
1. 为什么分布式 ID 是个真问题一次订单号引发的线上事故先讲一个我亲身经历的案例。几年前我在一家电商公司负责订单系统的重构当时订单表还是单库单表主键用 MySQL 自增 ID。业务量上来之后单表数据量破亿查询性能肉眼可见地下降于是决定分库分表。方案定了订单表按 order_id 哈希拆成 64 张表。结果上线当晚就出事了——用户下单后订单详情页偶尔 404排查半天发现是订单号重复了。原因很简单分库后每张表的自增 ID 都是从 1 开始的同一个订单号在 A 库和 B 库各存在一条按订单号查询时路由到了错误的库数据自然对不上。这个教训让我彻底明白了一件事分库分表之后数据库自增 ID 这套机制就失效了你必须有一套全局唯一的 ID 生成方案。这个需求听起来简单但深挖下去会发现里面全是讲究。全局唯一只是最底线要求一个合格的分布式 ID 还要满足趋势递增方便数据库索引写入、高可用、低延迟、尽可能短的长度。后面这几年我在不同项目中陆续用遍了市面上的主流方案UUID、数据库步长、Redis 自增、号段模式、雪花算法也自己动手改过雪花算法的源码。这篇文章就是把这些方案的原理、实现、坑点一次性讲清楚。如果你是刚接触分布式的开发这篇文章可以作为你的选型参考如果你已经在用某个方案文中的踩坑记录应该能帮你避开几个我当年绕过的弯路。2. 三套基础方案的真实面UUID、数据库步长、Redis 自增很多人在面试里能把这几种方案背得滚瓜烂熟但真到落地的时候每种方案都有课本上不会写的坑。2.1 UUID本地生成的无序长串索引性能的重灾区先说说最省事的方案——UUID。它的核心优势是本地生成、不依赖任何外部服务、全球唯一、生成速度极快JDK 自带UUID.randomUUID().toString()一行代码搞定。但它的缺点足以让你在绝大多数业务场景里直接放弃它。首先是长度标准的 UUID 是 36 个字符含连字符而一个 bigint 类型的自增 ID 只有 8 个字节。如果把 UUID 作为主键意味着你的每个二级索引都要存储这 36 个字符整个表的索引体积会膨胀好几倍内存和磁盘成本直线上升。更致命的是无序性。MySQL InnoDB 引擎的聚簇索引是按照主键顺序组织的UUID 完全随机插入时会造成大量的页分裂和随机 IO写入性能会明显下降。我实测过在单表千万级数据量下用 UUID 做主键的插入性能大约只有自增 ID 的 60% 左右。那 UUID 完全没用吗也不是。它适合那些对 ID 长度不敏感、写入并发极高、不需要按时间排序的场景比如日志流水号、消息队列的消息 ID。我在日志系统里就用的 UUID因为日志场景写入量极大而且基本没有按主键范围查询的需求UUID 的性能优势反而能发挥出来。另外要注意如果一定要用 UUID建议去掉连字符并转成BINARY(16)存储能省一半空间。2.2 数据库步长方案原理简单运维痛苦既然单库自增会撞车那多库各自设置不同的起始值和步长行不行这就是步长方案的核心思路。比如两台数据库实例实例 A 的 ID 从 1 开始、每次自增 21、3、5、7...实例 B 从 2 开始、每次自增 22、4、6、8...这样两台实例生成的 ID 理论上就不会重复。MySQL 的设置方式是启动参数或会话变量-- 实例 A SET auto_increment_offset 1; -- 起始偏移 SET auto_increment_increment 2; -- 步长 -- 实例 B SET auto_increment_offset 2; SET auto_increment_increment 2;这个方案的好处是简单直接完美利用了数据库自身的自增能力ID 也是严格递增的。但问题同样明显扩展性太差。步长在部署时就定死了如果线上是 2 台实例、步长 2某天流量暴涨需要扩到 4 台所有实例的配置都得改数据还可能错乱。缩小实例数也一样麻烦。只要规模一变这套配置就要重新规划。还有一个隐藏问题ID 重复不重复取决于取模结果是否冲突。假设步长是 1010 个实例的偏移分别是 0-9那么某个实例宕机后你临时加的替代实例配置稍微跟其他实例重叠立刻就会产生重复 ID。这种问题的排查成本非常高因为重复不是必然出现的而是概率性的。我不建议在核心链路用这个方案除非你的系统规模非常稳定、几年内都不会扩容缩容。2.3 Redis 自增高性能背面的持久化陷阱Redis 的INCR命令天然支持原子自增而且单机 QPS 能做到 10 万以上用 Redis 生成 ID 也是很多团队的首选方案。配合 Lua 脚本还能把取号 拼接业务前缀做成一个原子操作灵活性很高。这个方案最大的坑不在功能层面而在持久化策略。Redis 默认的 RDB 快照是定期落盘的如果你开启了 RDB 持久化在两次快照之间 Redis 宕机了重启后内存里的计数器会回退到上次快照时的状态。这意味着什么意味着之前已经发出去的 ID 会被再次生成造成重复。可能有人会说那我用 AOF 追加日志不就行了AOF 也有坑。如果 AOF 的刷盘策略是默认的everysec每秒刷一次宕机时最多丢掉 1 秒的数据仍然可能造成 ID 冲突。最稳妥的配置是appendfsync always每条命令都刷盘但这会大幅拉低 Redis 的写入性能等于牺牲了高并发优势。所以用 Redis 生成 ID 的正确姿势是接受它的高性能 可能重复的双刃剑特性然后在业务侧做兜底。比如我之前的做法是Redis 负责生成趋势递增的序列号但最终的主键里拼接一个时间戳前缀这样即使序列号回退了拼接出来的完整 ID 因为带了时间戳仍然全局唯一。代价就是 ID 变长到 20 多位看你能不能接受。3. 号段模式的工程智慧批量取号与双 buffer 机制如果说前三种方案是能用那号段模式就是好用的起点。它解决了数据库自增的扩展性问题又解决了 Redis 的持久化风险问题是目前很多中小团队落地生产的首选方案美团开源的 Leaf-segment 就是其中的代表作。3.1 为什么批量取号能避免每次请求都打数据库号段模式的核心思想特别朴素数据库还是那个数据库但我不再每来一个请求就去数据库自增一次而是一次性取一批号码到本地内存应用层在内存里分配用完了再去数据库取下一批。举个例子。我在数据库建一张这样的大号表CREATE TABLE id_allocator ( biz_tag VARCHAR(64) NOT NULL COMMENT 业务标识, max_id BIGINT NOT NULL COMMENT 当前已分配的最大ID, step INT NOT NULL COMMENT 号段的步长, version INT NOT NULL COMMENT 乐观锁版本号, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (biz_tag) ) ENGINEInnoDB;假设max_id 1000step 1000应用启动时会执行一次更新操作UPDATE id_allocator SET max_id max_id step, version version 1 WHERE biz_tag order AND version #{version};这次更新成功后应用就拿到了(1000, 2000]这个左开右闭区间里的一千个 ID全部缓存在本地内存中。在内存的号段耗尽之前应用不会再碰数据库。走到第 1001 个请求时才开始第二次取号。这个方案的精妙之处在于数据库只承受每隔一段时间取一次号的压力而不是每个请求都打一次。假设你设置了步长 2000生成一个 ID 的数据库操作次数直接降低到 1/2000数据库的压力几乎可以忽略不计。而且因为每个实例拿到的号段区间不同即使部署多个实例各实例之间也不会产生重复 ID。3.2 双 buffer 机制彻底消除号段用尽时的毛刺在讲双 buffer 之前先想想单号段模式有没有问题。有——号段耗尽瞬间的性能毛刺。假设你取了一个 1000 的号段高并发下每秒消耗 200 个5 秒就耗尽了。耗尽之后所有请求线程会同时阻塞在取号的数据库更新操作上表现出明显的延迟尖刺。这种平时毫秒级、每隔几秒就卡一下的现象在性能监控图上非常难看。美团的 Leaf 给出了教科书级的解法双 buffer。核心思路是用两个槽位轮换存储号段当一个槽位的号段使用量达到阈值默认 10%时后台就异步去数据库取下一个号段填充到另一个槽位保证内存里始终有备用号段。具体流程是这样初始化时槽位 A 装入号段 1-1000槽位 B 为空。应用从槽位 A 分发 ID。当槽位 A 的号码消耗到 10%即用掉 100 个还剩 900 个时触发一个后台线程去数据库取新的号段 1001-2000装入槽位 B。槽位 A 的号码彻底耗尽后立刻切换为从槽位 B 分发 ID。槽位 B 消耗到 10% 时后台线程再取号段 2001-3000 装入已经空出来的槽位 A如此循环。这套机制保证了在正常并发下应用永远不需要等到号段用尽才去取号数据库操作被异步化性能毛刺基本被抹平了。我自己实际部署时的配置经验是触发提前量建议设置在 20%-50% 之间。10% 的触发阈值在步长很大的时候问题不大但步长小、并发高的时候可能会来不及。比如步长只有 500 而 QPS 有 100010% 意味着 50 个 ID 消耗完只花 50 毫秒后台线程可能还没来得及更新数据库两个槽位就都空了照样会出现短暂阻塞。3.3 步长和提前量的计算这一步是很多人照着文档配置时最容易忽略的部分。步长 (step) 设置得太小取号频率高数据库压力大设置得太大号段里的 ID 又存在被浪费的可能服务重启时未消耗完的号段会被丢弃。提前量设置得太小异步更新赶不上消耗速度太大又会导致频繁的预取号后台线程忙个不停。我的习惯是拿峰值 QPS来做计算。步长 峰值 QPS 单机 × 目标取号间隔秒× 实例数。比如单机 QPS 是 200希望每 2 分钟取一次号有 3 个实例那步长就是 200 × 120 × 3 72000。取整到万设成 100000。提前量 步长 ÷ 峰值 QPS ÷ 实例数 × 安全系数。安全系数取 2-3保证异步线程有充足的时间完成取号。这样算出来的步长可能比你直觉设的要大很多但它是安全的。不要因为怕浪费 ID 就把步长调小bigint 的空间足够你挥霍真没必要在号段大小上精打细算。4. 雪花算法深度拆解位分布、容量计算与时钟回拨如果要在所有分布式 ID 方案里选一个最优雅的我会投雪花算法一票。它严格来说不是一套系统而是一种位运算算法最早是 Twitter 内部使用的 ID 生成方案用 64 位长整型同时编码了时间戳、机器 ID 和序列号生成的 ID 既是唯一的又趋势递增而且完全本地生成、不依赖外部存储性能极佳。我目前主力系统用的就是它改进了三处后跑了两年没出过任何问题。4.1 64 位里的每一个 Bit 都在干什么经典的雪花算法把 64 位1 个 long 类型分成了四段区间位数含义取值范围最高位1 bit符号位恒为 0保证 ID 为正数0时间戳41 bit毫秒级时间戳差值相对某个起始纪元约 69 年机器 ID10 bit区分不同的节点支持 1024 个节点0-1023序列号12 bit同一毫秒内的递增序列0-4095时间戳部分用的是当前时间 − 起始纪元的差值而不是完整的时间戳。Twitter 的起始纪元是 12888349746572010 年 11 月 4 日41 bit 能表示的最大数字是 2^41 - 1 2199023255551 毫秒约等于 69.7 年。也就是说从起始纪元开始算这套算法最多能用 69 年。你甚至可以自己定义一个更近的起始纪元把这段寿命往后推——比如你想让它到 2100 年再失效就把起始纪元设成 2024 年。序列号部分是应对同一毫秒内多个请求的关键。单机情况下同一毫秒最多能生成 4096 个 ID。换算一下单机 QPS 上限大约是 40960004096 × 1000远超绝大多数业务需求。机器 ID 部分的分配需要仔细规划。1024 个位置看起来很多但如果你不加以管理踩坑是迟早的事。最土但最有效的方式是建一张机器 ID 注册表新节点上线时去表里申请一个未被占用的 ID记录 IP 和归属业务。如果靠人肉配置、写死在配置文件里时间一长必然会出现两个节点配了同一个机器 ID 的情况——这种事故的破坏力比数据库 ID 冲突还高因为它是间歇性的两个节点轮换着生成 ID极难排查。4.2 容量与性能的极限在哪里很多人以为雪花算法只能在单机并发 4096/ms以内的场景使用其实这是误解。当同一毫秒的请求数超过 4096 时算法会自旋等待到下一毫秒再生成。所以它的正确理解是在保证不重复的前提下单机生成 ID 的速率上限取决于你能等多长时间。我写过一份无锁的 Java 实现核心逻辑是public long nextId() { long currentTime System.currentTimeMillis(); // 时钟回拨检测后面重点讲 if (currentTime lastTimestamp) { throw new RuntimeException(时钟回拨拒绝生成ID); } if (currentTime lastTimestamp) { // 同一毫秒内序列号自增 sequence (sequence 1) 4095; // 序列号溢出等待下一毫秒 if (sequence 0) { currentTime waitNextMillis(currentTime); } } else { // 新的一毫秒序列号归零 sequence 0; } lastTimestamp currentTime; // 拼接时间戳左移22位 | 机器ID左移12位 | 序列号 return ((currentTime - START_EPOCH) 22) | (workerId 12) | sequence; }这个实现用位运算把时间戳、机器 ID、序列号拼成一个 long本身没有任何阻塞和锁性能极高。我压测过单机每秒可以生成 300 万个以上的 ID这个量级基本不用担心性能瓶颈。4.3 时钟回拨雪花算法最容易翻车的场景时钟回拨是雪花算法最经典的坑也是面试高频题。它的触发场景很常见服务器通过 NTP 同步时间或者运维手动调整系统时间导致系统时间倒退。如果上一次生成 ID 时记录的时间戳是 15:30:00.100现在系统时间跳回了 15:29:59.500算法就会认为当前时间早于最后一次生成时间如果不处理就会生成一个比之前更小的 ID破坏趋势递增的特性甚至因为序列号重置产生重复。各家处理策略无非三类抛异常拒绝服务。最保守发现回拨就报错让调用方重试。优点是绝对安全缺点是回拨期间服务不可用。适合对 ID 唯一性极其敏感、不能接受任何风险的核心交易系统。等待追赶。记录回拨的时间差让生成请求自旋等待直到系统时间追赶上回拨前的时间戳再继续。如果回拨很小比如几十毫秒这是最优解但如果回拨了几秒甚至几分钟所有请求都会阻塞系统相当于短暂瘫痪。备用时间戳。当检测到时间回拨时用一个内部维护的经验时间比如上次生成时间 1ms替代系统时间。这个做法的风险在于如果系统时间永久落后于经验时间后续生成的 ID 时间戳就和真实时间脱节了。我的做法是检测到回拨小于 100ms 时自旋等待大于 100ms 时把系统切换到一个备用时间源——用上次生成时间 1ms 继续生成同时触发告警让人工介入。这样既不会拒绝服务又能保证回拨期间 ID 不重复。代价是 ID 里的时间戳不再是绝对时间但这个代价在绝大多数业务里是可以接受的。除了回拨还有一个很多人忽略的问题云服务器的时钟漂移。在虚拟化环境下宿主机负载过高时虚拟机的时钟走得会比真实时间慢这种漂移虽然不如回拨剧烈但日积月累也会导致 ID 的时间戳落后于真实时间。建议在部署了雪花算法的节点上配置 NTP 定时同步并开启时钟监控告警。5. 开源方案横向对比Leaf、UidGenerator、Tinyid 的取舍自己从零写一套分布式 ID 系统当然可以但绝大多数团队没必要重复造轮子。目前社区里最成熟的三套开源方案是美团的 Leaf、百度的 UidGenerator 和滴滴的 Tinyid它们分别代表了不同的设计取向。我用这三套方案都做过生产级部署说说真实差异。5.1 三家方案的核心设计差异美团 Leaf提供了两套模式号段模式Leaf-segment就是上面讲的双 buffer 机制和雪花模式Leaf-snowflake。它的雪花模式解决了两个在工程上很棘手的问题一是机器 ID 的自动注册与冲突检测二是时钟回拨时通过 ZooKeeper 记录上次生成时间来做兜底。Leaf 还提供了现成的 HTTP 和 RPC 接口接入成本极低。值得注意的是Leaf 的号段模式完全是美团的业务场景驱动的产物它在初始化时会加载所有业务标签的全量号段到本地内存把取号、切换、异步预取全部封装好了。如果你需要支持多个业务线各自独立的 ID 序列Leaf 的biz_tag设计可以直接满足。百度 UidGenerator本质上是雪花算法的优化版本但它有一个非常关键的改动把 41 位的时间戳和 10 位的机器 ID 都改成了动态配置。它的默认位分配是时间戳占 28 位秒级精度、工作节点占 22 位、序列号占 13 位。因为它用的是应用启动时间作为时间基准而不是固定纪元所以时间戳部分可以缩短机器 ID 部分可以扩大整体容量反而变大了。它还支持自定义位分配方案可以根据自己的并发模型调整各段位数。UidGenerator 的接入复杂度比 Leaf 高不少它默认是嵌入式的不像 Leaf 那样开箱即供 HTTP 接口。但它对避免时钟回拨处理得很彻底用的是环形数组RingBuffer缓存序列号预生成一批 ID 放在内存里取号时直接从数组里取规避了时间戳的计算。滴滴 Tinyid是这三家里最纯粹的方案只做号段模式不做雪花。它的设计哲学是分布式 ID 生成器就是数据库号段的调度器所以实现得非常轻。它支持多主多从部署、DB 故障时降级到本地内存缓存、HTTP 和 Java 客户端两种接入方式。如果你的业务只需要趋势递增的 long 型 IDTinyid 的学习成本和运维成本是最低的。5.2 部署复杂度和运维成本的实测对比维度美团 Leaf百度 UidGenerator滴滴 Tinyid依赖外部组件号段模式依赖 MySQL雪花模式依赖 ZooKeeper依赖 MySQL存机器 ID依赖 MySQL接入方式HTTP Java SDK RPC嵌入式 Java 库HTTP Java 客户端部署成本中等需额外部署 Leaf 服务低随应用内嵌部署低可独立部署也可内嵌时钟回拨处理ZooKeeper 记录上次时间戳回拨时拒绝环形数组预生成规避时间戳依赖不涉及无回拨问题运维关注点双 buffer 的稳定性、ZK 集群机器 ID 表的维护、RingBuffer 大小DB 可用性、号段表容量从我实际使用的体验来说如果你选号段模式Tinyid 的维护成本最低如果你想用雪花模式又不想自己处理时钟回拨Leaf 最省心UidGenerator 的性能最极致但需要团队对它的内部机制足够熟悉否则出了问题不好排查。这里有一个常被忽略的问题这些开源方案在引入前要评估它们和你团队的中间件栈是否匹配。比如 Leaf 的雪花模式强依赖 ZooKeeper如果你的团队根本没部署过 ZK为了一个 ID 生成器多维护一套分布式协调组件成本和风险都要算进去。这也是很多团队最终选择自己基于开源方案改造、而不是直接引用的原因。6. 选型决策框架不同业务场景到底应该用哪套方案每次有朋友问我分布式 ID 用哪个方案我的第一回答永远是先问业务场景再谈技术方案。没有一个方案是全能的脱离场景谈选型就是耍流氓。6.1 按业务属性分场景我把常见业务分成四类每一类对 ID 的需求差异非常大第一类核心交易链路订单、支付、流水。要求全局唯一、趋势递增、长度短、生成延迟低同时绝对不能接受生成冲突。这一类场景我推荐雪花算法或 Leaf-snowflake因为纯本地生成、无网络 IO延迟最低吞吐最高。时钟回拨问题做好策略兜底即可。第二类日志/埋点/消息队列。写入量极大需要极高的生成吞吐但对 ID 的排序性要求低接收方基本只会拿它做唯一标识。这类场景推荐 UUID 或 UidGenerator。UUID 简单可靠UidGenerator 吞吐更高。长度都不是问题反正不会当索引用。第三类多业务线的各类单据号。多个业务系统各自独立但都需要生成 ID而且互相之间不能串。这一类推荐号段模式Leaf-segment 或 Tinyid因为biz_tag天然支持业务隔离。而且号段模式生成的 ID 是递增的对后端列表分页查询很友好。第四类涉及外部可读的场景如用户 ID、邀请码、订单号。这里有个安全考量纯递增的 ID 容易被爬虫遍历比如通过订单号相减就能推测订单量。这类场景我建议在 ID 底层之上做一层混淆编码或者干脆用带随机特性的方案比如在 ID 后面补一个随机数因子。可惜很多人没有意识到这一点直接暴露了全递增的订单号被竞对抓数据抓了个底朝天。6.2 我的选型心得和几个说得上话的经验根据我自己的项目经验我给出一套比较省心的组合拳可以参考单个机房、规模不大几百台以内、对 ID 长度敏感雪花算法自己实现或直接用 Leaf。多机房部署、要求各机房独立生成且 ID 趋势递增Leaf-snowflake 机器 ID 按机房分段规划比如机房 A 使用机器 ID 0-255机房 B 使用 256-511这样不同机房的 ID 天然隔离。有现成的 MySQL 主从集群、不想引入额外的中间件、业务线多Tinyid。对 ID 生成性能要求到百万级 QPS、且集群规模固定UidGenerator。还有几个经验之谈都是我在生产环境里交了学费换来的第一不要过度设计。如果你的业务只有几千 QPS单库自增扛不住用 Redis 自增绰绰有余非得上号段模式和雪花算法反而增加了系统复杂度和故障点。做技术选型最大的忌讳就是拿着锤子看什么都是钉子。第二一定要给 ID 预留容量。不管是号段步长、雪花算法的机器 ID还是 Redis 计数器的初始值都要预留未来三年增长的空间。我见过有团队把雪花算法的时间戳起始纪元设成当前时间结果机器 ID 又规划得很小业务增长半年后机器 ID 就不够用了被迫停机扩容。第三监控和告警一定要跟上。ID 生成器是最容易被忽略却又最牵一发动全身的基础组件。我会对以下几个指标单独配置告警生成延迟的 P99、取号失败的次数、号段剩余可用量、时钟回拨次数。号段剩余可用量低于某个阈值时提前预警就能避免号段耗尽导致大面积请求阻塞的事故。第四ID 唯一性的最终保障兜底。无论用哪个方案都建议在数据库层面保留唯一索引或唯一约束。分布式 ID 生成器再可靠也可能因为配置错误、代码 bug、时钟漂移等意外原因产生重复数据库的唯一约束是最后一道防线。有了它就算 ID 生成器出了问题你收到的是插入失败的明确报错而不是悄无声息地把两条数据夯在一起。写了这么多核心还是那句话分布式 ID 没有银弹只有最匹配当前业务和团队现状的方案。希望这篇文章能帮你把几条主路都看明白选型的时候少走一些我走过的弯路。