1. 从一次线上事故说起:为什么全局唯一ID值得单独开一篇
凌晨两点,订单系统告警群炸了。运维同事甩出一张截图,数据库主键冲突报错刷了满屏,紧接着是几笔订单状态错乱,用户侧已经有人反馈“付了钱订单查不到”。排查到天亮,根因并不复杂:分库分表之后,订单表用了数据库自增ID,但两个分片各自从1开始计数,跨分片写入时主键撞车了。更麻烦的是,出问题的那批数据已经落库,回滚和补偿花了大半天。
这件事之后,我把全局唯一ID方案重新梳理了一遍。这篇就聊聊高并发系统里,数据库优化绕不开的一环——全局唯一ID。它看起来只是“生成一个不重复的数字”,但真放到分库分表、高并发写入、多机房部署的场景里,方案选型直接决定了系统的稳定性、性能和后续扩展成本。如果你正在做分库分表、微服务拆分,或者单纯想搞清楚“为什么大厂不用自增ID了”,这篇内容应该能帮你少踩几个坑。
先说清楚这篇要解决什么:在分布式、高并发的数据库架构下,如何选出一套适合自己业务的全局唯一ID生成方案,并且能落地、能扛量、能排查问题。我会从需求拆解讲到方案对比,再讲到具体实现和踩坑记录,尽量把每个选择的“为什么”讲透。
2. 全局唯一ID到底要满足哪些硬指标
很多人一上来就问“用雪花算法还是号段模式”,其实应该先问:你的业务对ID的要求到底是什么。不同业务对ID的诉求差异极大,比如订单号要能抗高并发且趋势递增,而日志追踪ID可能只要求唯一、对顺序没要求。选型之前,先把指标列清楚。
2.1 唯一性是底线,但不是唯一要求
唯一性是必须的,但“唯一”的边界要说清楚。是单库唯一,还是全局唯一?是同一时刻唯一,还是永久唯一?比如分库分表后,每个分片的自增ID只在分片内唯一,跨分片就可能重复,这就是典型的“局部唯一”陷阱。
除了唯一性,还有几个常被忽略的指标:
- 趋势递增:ID如果完全随机,B+树索引插入会频繁页分裂,写入性能下降明显。趋势递增能让新数据尽量落在索引尾部,减少随机IO。
- 单调递增:严格单调递增在分布式环境下很难保证,通常退而求其次要“趋势递增”。
- 信息安全:自增ID会暴露业务量,比如订单ID是10001,别人就知道你大概有多少订单。有些场景需要ID不可预测。
- 高可用:ID生成服务不能是单点,否则整个写入链路都卡住。
- 高性能:高并发下,ID生成不能成为瓶颈,本地生成通常优于远程调用。
2.2 不同业务场景的ID诉求差异
拿几个典型场景对比一下,你会发现没有万能方案:
| 业务场景 | 唯一性范围 | 顺序要求 | 信息安全 | 性能要求 | 典型方案 |
|---|---|---|---|---|---|
| 订单主键 | 全局 | 趋势递增 | 中 | 极高 | 雪花算法/号段模式 |
| 日志追踪ID | 全局 | 无 | 低 | 高 | UUID |
| 用户ID | 全局 | 趋势递增 | 高 | 高 | 号段模式+混淆 |
| 分片路由键 | 分片内 | 无 | 低 | 极高 | 本地生成 |
| 消息队列消息ID | 全局 | 趋势递增 | 低 | 极高 | 雪花算法 |
这张表不是标准答案,而是提醒你:先明确业务诉求,再谈方案。比如订单ID如果要求严格递增,那雪花算法就不合适,因为不同机器时钟回拨或并发会导致ID不是严格递增的。
2.3 一个容易被忽视的指标:ID长度与存储成本
ID长度直接影响存储和索引大小。比如用UUID做MySQL主键,36个字符的字符串,比bigint多占不少空间,而且随机性导致索引效率低。假设一张表有1亿行,主键从bigint换成UUID,索引大小可能翻几倍,内存命中率下降,查询变慢。所以选型时要把ID长度算进去,能用64位整数就别用字符串。
提示:如果业务必须用字符串ID,尽量选有序的字符串方案,比如把雪花算法生成的long转成62进制字符串,既缩短长度又保持趋势递增。
3. 主流方案逐个拆:从自增到雪花再到号段
方案没有绝对好坏,只有适不适合。我把常见的几类方案拆开讲,重点说清楚各自的适用边界和坑。
3.1 数据库自增ID:单库时代的默认选择
单库单表时,自增ID简单可靠,插入性能好,趋势递增。但一旦分库分表,问题就来了:每个分片独立自增,跨分片必然重复。有人用“步长”方案,比如两个分片,一个从1开始步长2,另一个从2开始步长2,这样能保证全局唯一。但扩展性差,增加分片时要改步长,而且ID生成依赖数据库,高并发下数据库压力大。
还有一种“全局自增表”方案,单独建一张表专门发号,每次插入返回自增ID。这能保证全局唯一,但那张表就成了单点,并发高时扛不住,而且每次生成ID都要写数据库,性能瓶颈明显。
所以自增ID方案在分布式场景下基本被淘汰,除非业务量很小且不打算扩展。
3.2 UUID:简单但代价不小
UUID生成简单,本地就能算,不依赖任何中心服务,唯一性极高。但缺点也很明显:
- 无序:随机UUID导致B+树索引插入随机,页分裂频繁,写入性能差。
- 太长:36字符,存储和索引成本高。
- 可读性差:无法从ID看出任何业务信息。
UUID适合对顺序无要求、写入量不大的场景,比如日志追踪、临时令牌。但作为数据库主键,尤其是高并发写入的表,UUID通常不是好选择。
3.3 雪花算法:趋势递增的经典方案
雪花算法(Snowflake)生成64位整数,结构通常是:1位符号位+41位时间戳+10位机器ID+12位序列号。41位时间戳可用约69年,10位机器ID支持1024个节点,12位序列号支持每毫秒每节点4096个ID。整体趋势递增,本地生成,性能极高。
但雪花算法有几个经典坑:
- 时钟回拨:如果机器时钟回拨,可能生成重复ID。常见处理是回拨超过一定阈值就拒绝服务或等待。
- 机器ID分配:1024个节点怎么分配?手动配置容易冲突,需要配合配置中心或自动注册。
- 序列号耗尽:同一毫秒内超过4096个请求,需要等待下一毫秒,高并发下可能成为瓶颈。
我实测过,单节点雪花算法每秒能生成几十万ID,足够大多数业务。但机器ID管理是个运维活,小团队容易在这里翻车。
3.4 号段模式:批量获取,本地分配
号段模式的核心是:每次从数据库或中心服务获取一批ID(比如1000个),缓存在本地,用完了再取下一段。这样既保证了全局唯一,又减少了远程调用次数,性能接近本地生成。
实现上,通常用一张表记录当前最大ID和步长,每次更新步长并返回新号段。比如:
UPDATE id_segment SET max_id = max_id + step WHERE biz_type = 'order'; SELECT max_id FROM id_segment WHERE biz_type = 'order';号段模式的优点是趋势递增、性能好、对数据库压力小。缺点是如果服务重启,未用完的号段会浪费,导致ID出现空洞。另外,如果数据库不可用,号段获取会失败,需要做降级或重试。
3.5 各方案对比与选型建议
把几个方案放一起对比:
| 方案 | 唯一性 | 趋势递增 | 性能 | 可用性 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|---|
| 数据库自增 | 单库唯一 | 是 | 低 | 依赖DB | 低 | 单库小业务 |
| UUID | 全局唯一 | 否 | 高 | 高 | 低 | 日志、令牌 |
| 雪花算法 | 全局唯一 | 是 | 极高 | 高 | 中 | 订单、消息 |
| 号段模式 | 全局唯一 | 是 | 高 | 依赖DB | 中 | 用户ID、订单 |
| 全局自增表 | 全局唯一 | 是 | 低 | 依赖DB | 低 | 低并发 |
选型建议:如果业务量不大且不打算分库分表,自增ID够用。如果分库分表且要求趋势递增,雪花算法或号段模式是主流。如果对信息安全要求高,可以在ID生成后做混淆,比如异或一个固定值再转进制。
4. 落地雪花算法:机器ID分配与时钟回拨处理
雪花算法看起来简单,但落地时细节很多。我以Java实现为例,讲几个关键点。
4.1 机器ID的分配策略
机器ID占10位,最多1024个节点。分配方式有几种:
- 手动配置:在配置文件里写死,简单但容易冲突,适合节点少且稳定的场景。
- 配置中心下发:服务启动时从配置中心获取一个唯一ID,配置中心保证不重复。
- 自动注册:利用数据库或Redis的原子操作注册机器ID,比如用Redis的INCR生成一个序号,取模1024。
- 基于IP或主机名哈希:用IP最后一段或主机名哈希取模,可能冲突,需要检测。
我倾向于用配置中心或Redis注册,启动时获取并缓存,同时上报心跳,便于监控。
4.2 时钟回拨的几种处理方式
时钟回拨是雪花算法的经典问题。假设当前时间戳比上次生成ID的时间戳小,说明时钟回拨了。处理方式:
- 直接抛异常:简单粗暴,但会导致服务不可用。
- 等待时钟追上:如果回拨幅度小(比如几毫秒),可以自旋等待。
- 使用备用位:预留几位作为回拨计数,但会减少可用ID数量。
- 切换机器ID:回拨时换一个机器ID,但需要额外管理。
实际生产中,通常结合监控:回拨超过阈值就告警并拒绝服务,小幅度回拨则等待。同时,服务器开启NTP同步,减少回拨概率。
4.3 序列号耗尽与并发优化
12位序列号每毫秒最多4096个ID。如果同一毫秒内请求超过4096,需要等待下一毫秒。高并发下,可以用“预生成”或“批量生成”来缓解:一次生成多个ID缓存起来,减少对时间戳的依赖。但这样会牺牲一点趋势递增性。
另一个优化是减少锁竞争。雪花算法生成ID时通常要加锁保证序列号递增,可以用CAS或ThreadLocal来优化,但要注意线程安全。
4.4 一个可参考的雪花算法实现片段
public class SnowflakeIdGenerator { private final long workerId; private final long datacenterId; private long sequence = 0L; private long lastTimestamp = -1L; private final long twepoch = 1288834974657L; private final long workerIdBits = 5L; private final long datacenterIdBits = 5L; private final long maxWorkerId = -1L ^ (-1L << workerIdBits); private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); private final long sequenceBits = 12L; private final long workerIdShift = sequenceBits; private final long datacenterIdShift = sequenceBits + workerIdBits; private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; private final long sequenceMask = -1L ^ (-1L << sequenceBits); public synchronized long nextId() { long timestamp = timeGen(); if (timestamp < lastTimestamp) { throw new RuntimeException("Clock moved backwards"); } if (lastTimestamp == timestamp) { sequence = (sequence + 1) & sequenceMask; if (sequence == 0) { timestamp = tilNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - twepoch) << timestampLeftShift) | (datacenterId << datacenterIdShift) | (workerId << workerIdShift) | sequence; } // 省略 tilNextMillis 和 timeGen }这段代码是经典实现,注意synchronized保证了线程安全,但高并发下可能成为瓶颈。可以改用LongAdder或分段锁,但复杂度上升。
5. 号段模式的工程实现与数据库设计
号段模式在中小规模系统里很实用,我详细讲一下落地细节。
5.1 号段表设计与更新逻辑
通常建一张表:
CREATE TABLE id_segment ( biz_type VARCHAR(64) NOT NULL, max_id BIGINT NOT NULL, step INT NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (biz_type) );每次获取号段时,用事务更新:
START TRANSACTION; SELECT max_id INTO @current FROM id_segment WHERE biz_type = 'order' FOR UPDATE; UPDATE id_segment SET max_id = max_id + step WHERE biz_type = 'order'; COMMIT;返回的号段是[@current, @current + step)。服务拿到后缓存在内存,用AtomicLong分配。
5.2 双Buffer与预加载优化
号段模式的一个问题是:当号段用完时才去数据库取,取的过程中如果有请求进来,会阻塞。解决办法是双Buffer:当前号段用到一定比例(比如10%)时,异步去取下一个号段,这样切换时无感知。
实现上可以用两个AtomicLong,一个当前,一个备用,后台线程检测剩余量并预加载。
5.3 号段模式的容灾与降级
如果数据库挂了,号段取不到怎么办?可以本地缓存一个备用号段,或者降级到雪花算法。但降级要保证ID不重复,所以通常需要提前规划好ID空间,比如号段模式用高位,雪花用低位,避免冲突。
5.4 号段模式与雪花算法的混合使用
有些团队会混合使用:正常时用号段模式,数据库故障时切到雪花算法,但需要保证两种方式生成的ID不冲突。一种做法是给号段模式分配一个固定的高位前缀,雪花算法用另一个前缀,这样即使切换也不会重复。
6. 踩坑记录:那些文档里不会写的教训
这一节是我和同行交流时收集的真实问题,每个都值得注意。
6.1 雪花算法机器ID冲突导致重复ID
某团队用IP哈希分配机器ID,结果两台机器IP最后一段相同,哈希后冲突,生成了一批重复ID。排查时发现是机器ID重复,但日志里没打印机器ID,定位花了好久。教训:机器ID分配后要打印并监控,最好有唯一性校验。
6.2 号段模式步长设置不当导致ID空洞
步长设得太大,服务重启后未用完的号段浪费,ID出现大段空洞。比如步长10000,服务用了100个就重启,剩下9900个ID永远不会被使用。如果业务对ID连续性有要求,这就不合适。通常步长设为1000到10000,根据QPS调整。
6.3 时钟回拨导致服务不可用
某云厂商的服务器时钟回拨了5分钟,雪花算法直接抛异常,整个写入链路挂掉。后来改成回拨小于100ms等待,大于100ms告警并降级到备用方案。所以时钟同步和回拨处理必须做。
6.4 数据库自增ID在分库分表后的主键冲突
这就是开头那个事故。分片各自自增,跨分片写入时主键冲突。解决办法是改用全局唯一ID,或者用分片键+自增的组合主键。但组合主键在查询时麻烦,不如直接用全局ID。
6.5 ID生成服务成为单点
把ID生成做成独立服务,结果这个服务挂了,所有依赖它的业务都写不进去。所以ID生成要么本地化(雪花算法),要么做高可用集群(号段模式+多实例)。
7. 常见问题速查与排查思路
整理一个速查表,方便遇到问题时快速定位。
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 主键冲突 | 分片自增ID重复 | 检查分片ID生成逻辑 | 改用全局唯一ID |
| ID重复 | 雪花算法时钟回拨 | 检查服务器时钟同步 | 处理回拨,开启NTP |
| ID重复 | 机器ID冲突 | 检查机器ID分配 | 唯一性校验,配置中心 |
| 写入性能下降 | UUID随机插入 | 检查主键类型 | 改用趋势递增ID |
| ID生成慢 | 数据库自增表瓶颈 | 检查数据库负载 | 改号段模式或雪花 |
| 号段浪费 | 步长过大 | 检查步长配置 | 调整步长,双Buffer |
| 服务启动失败 | 机器ID获取失败 | 检查配置中心 | 降级到本地配置 |
排查时先看日志,确认是ID重复还是生成失败,再定位是哪个环节。建议在ID生成处打印机器ID、时间戳、序列号,方便追踪。
8. 我个人在实际操作中的几点体会
做了几个高并发项目后,我对全局唯一ID的选型有几个固定习惯。第一,优先考虑雪花算法,因为本地生成、性能高、趋势递增,适合大多数场景。但机器ID管理一定要做好,我通常用配置中心下发,启动时校验唯一性。第二,如果业务对ID连续性有要求,或者团队运维能力有限,号段模式更稳妥,数据库压力小,实现简单。第三,不管选哪种,都要做监控和告警,比如ID生成速率、时钟回拨次数、号段剩余量。第四,ID长度尽量控制在64位以内,能用long就别用字符串。
最后分享一个小技巧:如果担心雪花算法的时钟回拨,可以在生成ID时记录最近几次的时间戳,回拨时对比历史,判断是偶发还是持续。偶发等待,持续则切换备用机器ID。这个逻辑不复杂,但能避免很多线上问题。