ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

分布式ID生成算法全解析:九种方案与选型指南

分布式ID生成算法全解析:九种方案与选型指南

做后端这几年,几乎每个项目都会碰到ID生成这件事。单机时代一个自增主键就能打天下,但服务一旦拆成多个实例,数据库分库分表之后,事情瞬间就变复杂了。分布式ID生成算法这个词听起来偏理论,实际上它是每次接口联调、每次订单落库都可能踩到的门槛。这篇文章我会把九种主流方案从头到尾捋一遍,包括它们的基本原理、优缺点、适用场景,以及我在真实项目里的取舍思路,给正在做技术选型的你一个参考。

1. 分布式ID的评判维度:五个指标决定选型

1.1 硬指标:全局唯一、趋势递增、高性能、高可用

先看第一个问题:为什么单机自增在分布式环境下会失效?当你把一张订单表拆到多个数据库实例,每个库各自维护自增主键时,会出现不同库生成相同ID的情况,数据合并后必然冲突。所以分布式ID的第一条硬指标就是全局唯一:无论多少节点并发生成,都不能重复。

光唯一还不够,还要考虑数据库索引的物理存储特性。MySQL的InnoDB聚簇索引在插入数据时,如果ID是趋势递增的,新记录几乎总是追加到索引末尾附近,页分裂的概率很小,写入性能稳定。如果ID是完全随机的,每一次插入都可能落在B+树的中间位置,导致频繁页分裂、随机IO和碎片化,写入性能会明显下滑。所以,趋势递增往往关系到系统在数据量增长后的写入表现。

然后是高性能和高可用。ID生成通常处在核心业务链路的必经路径上,生成一次ID延迟几十毫秒肯定是不能忍的。高可用指的是某个节点挂了或某个组件抖动时,系统依然能正常发号,而不是让整个业务跟着停摆。这两条指标直接决定了你不能把“发号”做成一个脆弱环节。

1.2 隐藏指标:安全性、可读性与可解析性

除了上面四条,实践中还有两个隐藏指标需要提前想清楚。第一个是安全性。如果你生成的ID是连续递增的,比如订单号从1001到1002再到1003,竞争对手或用户就能推断出你的订单量、日活、增长速度,这属于业务信息泄露。很多电商场景宁愿用一个看起来没有规律、甚至包含随机位的ID,也不愿意暴露真实规模。

第二个是可解析性。雪花算法生成的ID内部其实包含时间戳和机器编号,拿到一个ID就能反推出大约生成时间和由哪台机器生成。这在排查问题时非常有用,但也会带来隐私风险。反过来,纯随机的UUID虽然完全无法反推信息,但排序、分页、范围查询会很麻烦。

也就是说,真正选型时你往往是在“趋势递增、信息安全、可解析性、复杂度”这四者之间做权衡。不存在一个在所有维度都碾压其他方案的算法,只有最匹配你业务约束的方案。

2. 九种主流算法原理与适用场景详解

2.1 UUID:最“懒”的本地生成方案

UUID严格来说不算分布式组件,它完全在本地生成,不需要请求任何外部服务,所以一上来就满足高可用和高性能。UUID的标准形式是8-4-4-4-12的36位字符串,128bit。常用的是UUID v4,内部由122bit随机数组成,理论上重复概率极低;UUID v1则包含时间戳和机器MAC地址,可解析性稍强一点。

它的优点是简单到不需要思考,任何语言都有现成实现,一行代码就能拿到一个全局唯一ID。缺点也非常直接:它是一个非常长的字符串,而且不具备趋势递增性。

假如你用UUID作为MySQL主键,前面说过,完全无序的插入会让聚簇索引产生大量页分裂,写入放大明显。同时36字符的字符串做主键,每个二级索引都会冗余一份主键,存储和内存开销比8字节的bigint大好几倍。因此我的习惯是:UUID更适合用在链路追踪、日志traceId、临时文件名、会话token这类不参与数据库主键索引、不需要排序的场景。如果数据库主键你想用类似思路,建议优先考虑后面会提到的雪花或号段方案。

2.2 数据库自增与多主步长:最简单但有硬伤

单库自增ID是最原始的方式,就是MySQL的AUTO_INCREMENT。它满足唯一、趋势递增、简单可读。问题在于单库写入能力和可用性都有限,数据库一旦主从切换或压力过大,发号也跟着出问题。

为了扩展,有人会考虑多主步长:比如三台数据库分别设置auto_increment_offset为1、2、3,auto_increment_increment固定为3,这样各自生成的ID在取模意义上不会冲突。这套方案虽然解决了单点写瓶颈,但扩展性极差。假如业务量增长,你需要从3个节点扩到5个,步长改成5之后,旧ID规律全部作废,已经生成的ID和未来新生成的ID可能跨区间冲突,改造风险不小。ID的规律性也强,几乎等于把业务吞吐量写在脸上。

这类方案适合极低并发、对ID规律不敏感的后台管理系统,或者临时内部工具。真正一到核心高并发链路,数据库自增或多主步长几乎不会出现在长远的架构方案里。

2.3 号段模式(Segment):数据库自增的高并发升级版

号段模式的出现,是为了解决数据库自增方案性能和扩展性不足的问题。它的核心思路是:让数据库不再逐条生成ID,而是每次分配一批号段给应用节点。比如应用启动时执行一次更新,把某业务key的max_id从1000改成2000,那么应用就独占1001到2000这一千个ID,在本地内存中按顺序发放,用完再找数据库要下一批。

这里用一张简单的表就能实现:

CREATE TABLE id_generator ( biz_tag varchar(32) NOT NULL COMMENT '业务标识', max_id bigint NOT NULL COMMENT '当前已发放的最大ID', step int NOT NULL COMMENT '每次取号步长', version int NOT NULL COMMENT '版本号,用于乐观锁', update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (biz_tag) ); UPDATE id_generator SET max_id = max_id + step, version = version + 1 WHERE biz_tag = ? AND version = ?;

UPDATE语句执行后,用SELECT把新的max_id取回来,应用就得到了一个区间。因为一次更新能支撑上千次内存发号,数据库的访问压力被大大稀释。乐观锁的version字段可以避免并发取号时相互覆盖,防止多个节点拿到重叠区间。

更进一步,生产级实现往往会做“双Buffer”优化:维护两个号段,第一个号段使用到一定阈值时,后台异步去数据库加载下一个号段。这样即使取号耗时波动,本地也不会出现线程阻塞,发号吞吐能保持稳定。

它的优势很明显:ID趋势递增、性能远高于数据库自增、某个节点宕机只损失内存中一小段未发放的ID,不会影响全局。缺点是ID连续、可预测,数据库本身仍然是单点,一旦数据库不可用,新号段就取不回来。所以它特别适合订单号、用户ID、优惠券编号这类需要在数据库里整体规划、同时并发量又比较高的业务。

2.4 Redis INCR:原子自增的快速通道

Redis的INCR命令可以简单地实现发号器:SET一个业务key之后,每次调用INCR就返回一个递增的long值。因为Redis是单线程事件模型,INCR天然原子,多个应用节点同时调也不会重复。相比数据库自增,它把发号延迟降到极低,单机Redis每秒可以支撑十万级别的INCR操作。

但这里藏着几个坑。Redis如果使用RDB快照持久化,快照之间的数据丢失可能导致ID回退;主从切换时,从库可能落后于主库,新主库的counter值比旧主库小,重新发号就有重复风险。我自己就见过一个真实事故:发号key设置了24小时过期,第二天key被Redis淘汰重建,ID从1重新开始,直接把业务表里已有的主键撞了,线上报了一大堆DuplicateKey。所以用Redis发号,持久化策略要选AOF,并且绝对不能对发号key设置过期时间。

用量大的业务,更推荐用INCRBY一次取一批号,比如每次取1000,然后本地分配。这样既能减少网络RTT,也能降低Redis故障时对业务的冲击。总体来说,Redis发号适合短链接、流水号等对趋势递增有要求、但能接受Redis依赖和一定丢失窗口的业务。如果ID要求严格不重复且长时间不丢,建议还是用号段或雪花方案。

2.5 经典雪花算法:分布式ID的事实标准

Snowflake算法最早是知名社交平台内部设计的ID生成方案,后来业界广泛采用,几乎成了分布式ID的代名词。它生成的ID是一个64位的long,正好对应数据库的bigint。标准位分配是:1bit符号位(固定为0),41bit毫秒时间戳,10bit工作机器ID(通常拆成5bit数据中心ID加5bit机器ID),12bit同一毫秒内的自增序列号。

41bit的时间戳如果从某个固定纪元开始算,大约可以使用69年而不溢出。12bit序列号意味着同一台机器在同一毫秒内最多生成4096个ID,单机每秒的理论生成量是409.6万。这个吞吐量对绝大多数业务绰绰有余。

一个简化版Java实现大概长这样:

public class SnowflakeIdGenerator { private final long epoch = 1609459200000L; // 自定义起始时间戳 private final long workerIdBits = 10L; private final long sequenceBits = 12L; private final long workerId; private long lastTimestamp = -1L; private long sequence = 0L; public SnowflakeIdGenerator(long workerId) { this.workerId = workerId; } public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) { throw new IllegalStateException("Clock moved backwards"); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & 4095; if (sequence == 0) { timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - epoch) << 22) | (workerId << 12) | sequence; } private long waitNextMillis(long lastTimestamp) { long timestamp = System.currentTimeMillis(); while (timestamp <= lastTimestamp) { timestamp = System.currentTimeMillis(); } return timestamp; } }

这段代码有两个关键点:时间戳、workerId、序列号通过位运算拼到一个long里;时间戳放在高位,所以从整体趋势上看生成的ID是递增的。序列号在毫秒内耗尽时,自旋等待下一毫秒即可。

雪花算法的优点是几乎所有能力都在本地完成,不依赖数据库、不依赖Redis,性能极高;ID趋势递增,排序友好;还能从ID中解析出生成时间,对排查问题时非常有帮助。缺点也很集中——它依赖系统时钟。如果机器的系统时间发生回拨,比如NTP校正导致时间往回跳,在重复的时间戳下就可能生成重复ID,且代码里通常只能抛异常或阻塞等待,搞不好就拖垮业务。workerId也需要额外管理,不能重复,否则会破坏唯一性。

即便有这些限制,雪花算法仍然是目前订单、交易、账户等核心链路里最主流的方案。后续很多方案,都可以看作在雪花算法基础上对位分配或协调方式做了优化。

2.6 增强型雪花变体:位分配可定制的UidGenerator思路

经典雪花算法的41bit时间戳加10bit workerId加12bit序列号是一种默认配置,但并不是唯一配置。不同业务场景对位分配的需求不同,比如有的业务并发极高,每秒千万级,12bit序列号在毫秒内很快耗尽;有的业务希望ID支持更长周期,希望时间戳单位从毫秒改成秒。于是业界出现了一批增强型雪花实现,核心思路就是把64bit按业务特征重新切分。

举一个常见的增强方案:1bit符号位 + 28bit序列号 + 22bit秒级时间戳 + 13bit工作节点ID。这样配置的好处是:序列号位宽大幅提升,单节点每个时间单位内可生成的ID数量比经典雪花多得多;时间戳使用秒级,相同bit数覆盖的年数更长;workerId位宽也更大,能支持更多节点。但这会牺牲ID的即时可解析性,你不能一眼知道ID的精确生成时间,只能定位到秒级。

另一种增强方向是在应用层用RingBuffer数组预生成一批序列号,线程在并发获取时通过无锁的原子指针消费。这样是为了降低并发竞争,从CAS角度提升吞吐。很多高并发日志系统、消息系统在用这类变体,因为它们不在乎ID是毫秒还是秒级排序,只在乎单节点每秒能爆出多少不重复的long。

这类方案适合并发写入压力极大、对时间精度要求不高、愿意自己做位配置和workerId管理的团队。代价是实现复杂度明显上升,如果并发量还没到那个量级,经典雪花反而更容易运维。

2.7 一体化发号器:号段与雪花结合的中间件思路

在真实的大型业务系统中,纯雪花要处理时间回拨问题,纯号段又绕不开数据库单点。于是业界出现了一类组合方案,常见的思路是:用一个数据库表负责分配“节点标识”,每个应用节点启动时从DB获取一个全局唯一的workerId或一段ID空间,之后在本地用雪花算法或号段内存发放生成ID。

这种组合的好处是把全局协调次数压到最低。节点启动后,发号主路径完全在本地完成,数据库只做低频的节点注册。相比DB号段,它不要求每次用完一批就回来取号;相比纯雪花,workerId由DB统一分配,不会出现两台机器配置了相同workerId的问题。另外,一体化方案普遍内置了时间回拨处理策略,比如小范围回拨时继续消耗序列号或切换到备用时间戳,大范围回拨时快速摘除节点,避免生成重复ID。

这种方案通常包含管理后台、监控面板和心跳上报能力,适用于订单、支付流水等需要高可靠、高可用、趋势递增的核心链路。但实话实说,要自己从零实现一套这种组件,工程量不小。选型时如果你所在团队已经有人维护这类发号服务,优先复用;如果没有,很多人会先用经典雪花加一个workerId配置文件,也能扛住前期业务量。

2.8 ZooKeeper顺序节点:强一致但重依赖

ZooKeeper的顺序节点天然可以当作ID生成器:每次创建一个持久顺序节点,比如在/id目录下创建seq_节点,ZK会自动在节点名称后面追加一个全局递增的序列号。多个客户端并发创建时,序号是严格递增且不重复的。

这个方案的优势是强一致,序号不会丢失,不会回退,纯粹的有序数字。但它的问题也很明显:ZK的写性能有限,单集群TPS能达到的量级远不如本地发号;发一次ID等于一次网络IO加一次持久化,在毫秒级高并发场景下完全顶不住;另外ID形式是路径字符串,直接用它当订单号还要再做转换。一个节点挂了或网络分区,客户端要处理Session过期,发号请求就会失败。

所以ZK顺序节点更适合低频但要求严格有序的场景,比如分布式任务调度的任务ID、审批流实例编号、全局配置的版本号。核心高并发链路不要把它放在发号主路径上。

2.9 MongoDB ObjectId:另一种组合思路

MongoDB的ObjectId常被忽略,但它也是分布式ID中很有代表性的一种:由12字节组成,其中4字节是Unix秒级时间戳,5字节是机器相关的随机值,3字节是自增计数器。客户端驱动在本地就能生成,不需要数据库参与。

它和雪花一样都是“时间 + 机器 + 计数”的组合思路,区别在于ObjectId的时间戳在最高位,且以秒为单位,因此多个客户端生成的ID整体趋势上有序,但同一秒内不同客户端由于随机值不同,ID之间没有严格的先后顺序。相比UUID它要短不少,但还是12字节,转成字符串算是24个十六进制字符。

ObjectId适合文档类NoSQL、日志存储这类以对象存储为核心的场景。如果团队主要在关系型数据库上做大批量事务,用它做业务主键的优势并不明显,更建议直接用雪花后的long型ID。

3. 九种算法横向对比与选型建议

3.1 一张表看清九种方案

算法唯一性来源趋势递增典型性能外部依赖推荐场景
UUID本地随机/时间+MAC否极高无链路追踪、日志traceId、token
DB自增数据库自增是低数据库低并发后台、内部系统
DB号段数据库分配号段是中高数据库订单号、优惠券、用户ID
Redis INCRRedis原子计数是高Redis短链接、流水号、验证码编号
经典雪花时间戳+机器ID+序列是极高本地时钟订单、交易、核心业务ID
增强雪花定制位分配+无锁序列是极高本地时钟+worker管理超大并发日志、消息ID
一体化发号器DB分配节点+本地发号是极高数据库+本地支付流水、高可靠核心链路
ZK顺序节点ZK顺序节点是(严格)低ZooKeeper任务ID、低频严格有序
ObjectId时间戳+随机+计数大致趋势高无文档型数据库、日志存储

3.2 按场景选型的三个示例

第一个场景是订单中心。订单量增长快,又需要给用户展示订单号,同时数据库是分库分表架构。这里我建议选经典雪花或一体化发号器,原因有三个:趋势递增能保证落库顺序好;long型存储紧凑;还可以从订单号反解出生成时间,方便排查。如果不想暴露订单量,可以在对外展示时对ID做一次可逆混淆编码。

第二个场景是内容社区的评论ID或私信ID。这类数据量很大,对排序要求不严格,就算ID中间存在空洞也无所谓。用DB号段方案就能获得很好的性能,而且ID趋势递增,分页查询很容易。数据库压力也不大,一个取号段操作能支撑上万次发号。

第三个场景是分布式任务调度。任务ID需要全局严格有序,以便判断执行顺序,但生成频率很低,远达不到每秒几千。这种情况下ZK顺序节点反而最合适,强一致、绝对有序,还顺便能借助ZK做分布式协调。

4. 实战中的坑与排查技巧

4.1 时间回拨:雪花算法最大的敌人

我真正在生产环境踩到过时间回拨的坑。某次一台应用服务器NTP校时后,系统时间往回跳了几十毫秒,雪花算法的代码立刻抛出异常。因为发号接口被核心链路调用,异常导致一批请求失败。这类问题最典型的两种表象:要么日志中出现大量时间回拨异常,要么数据库出现主键冲突。

处理时间回拨,业界有几种常见策略。简单粗暴的做法是发现回拨就抛异常或持续阻塞到时间追平,适合回拨频率极低的场景。更稳妥的做法是维护lastTimestamp,当检测到回拨时,先判断回拨幅度是否小于某个阈值,比如500ms以内就继续在当前时间戳内递增序列号,而不是退回到旧时间戳;如果回拨幅度过大,就从备用号段或Redis里申请临时workerId切换节点服务。最后一种是在架构层面做多节点冗余,单个节点触发时间回拨时直接把自身摘除,让其他节点接管发号,用户无感知。

4.2 号段模式与Redis模式的重复ID问题

号段模式一个非常隐蔽的坑在于应用节点内存中还有未使用的ID时发生了宕机,随后重启会从数据库再取一个新号段。如果取号逻辑写得不严谨,比如max_id在UPDATE和SELECT之间存在并发窗口,两个节点可能拿到重叠区间。解决方式是用乐观锁保证max_id的更新是原子的,UPDATE成功后再回读新值。注意UPDATE和SELECT必须在同一个事务里,或者由同一连接完成,才能避免读到旧值。

Redis发号重复的坑前面提过。要特别提醒的是,AOF的appendfsync如果配成everysec,极端情况下会丢最后一秒数据,恢复后counter可能回退。你可以接受你业务里出现“少量ID空洞”,但绝不能接受“ID重复”。所以如果系统对ID唯一性有强要求,Redis方案一定要额外做启动时的补偿检查,比如持久化一个已生成最大ID的快照,恢复时主动把counter拨回到快照值以上。

4.3 压测验证唯一性与稳定性

选型完成后,一定要做并发压测来验证方案。我自己常用的做法是写一个多线程测试,用200个线程同时调用发号接口,每个线程生成50万ID,最终收拢到Set里去重,检查是否有碰撞。同时会在压测过程中人为做一次NTP时间回拨注入,观察系统能否自动恢复。

测试中要特别留意几个指标:生成一定数量ID的耗时曲线是否平缓;数据库或Redis相关线程数量是否被打满;节点重启后是否出现ID回退或重复。只测单机吞吐没有意义,长时间高负载下的稳定性才是关键。靠运气,不如在测试阶段把异常场景全部模拟一遍。

4.4 不要忽略ID泄露问题

最后说一个经常被忽略的事:雪花算法生成的ID,理论上可以把时间戳部分解出来,从而推断系统创建时间和业务量级。如果你的ID会暴露到外部,比如充当订单号、优惠码或对外接口的资源ID,就需要额外考虑混淆。

有两个常见手段。一个是对外展示时用可逆编码方案,内部保留long型ID,出口处转成短字符串,入口再解析回来,优点是对已有系统改动小。另一个是在ID里混入随机位,破坏可预测性,但会让ID失去严格趋势递增的属性。如果业务对趋势递增和排序有硬要求,一般还是优先考虑在展示层做处理,而不是改底层ID结构。分布式ID算法解决的是“全局唯一、趋势递增、高性能”这些基本盘,不要把安全混淆也交给它一把抓。

九种算法没有银弹,“标准答案”永远是结合团队运维能力和业务特点做匹配。我这几年用过纯自增、踩过UUID主键的坑,也维护过雪花和号段结合的发号链路。回头看,比较稳妥的做法是先把方案复杂度压到最低,能用一个简单方案撑住半年业务增长就先上,等真的出现性能瓶颈或跨地域容灾需求时,再逐步演进到一体化发号器。发号看起来是个小点,但它趴在所有核心链路的底座上,值得你多花几天时间把方案选扎实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进