为什么你不能用自增ID了:分布式ID生成的红海战争

2026-10-04 2 0

上周三凌晨两点,报警邮件响了。

生产环境的MySQL主库CPU打满,原因很简单——有个憨批(就是我)在一个千万级订单表上做了全表扫描。处理方案很简单,加个索引。

结果加索引的时候,订单号和本地数据库的ID对不上了。

然后我被财务追着跑了三天对账。

这件事让我彻底想明白了一件事:分布式ID生成,不是你觉得需要的时候才去考虑,而是从系统第一天就要想清楚。今天把这事讲透。

先说自增ID为什么在单机时代是完美的

MySQL的自增ID是什么?AUTO_INCREMENT。

每insert一行,数据库自动给你+1,永远不重复。顺序递增,占用空间小,查询友好。

完美。MySQL帮你做了所有事情,你只需要拿来用就行了。

唯一的问题是——维护数据库的那个DBA可能半夜被叫醒,或者你对你的数据库做了什么不可描述的事情。更重要的是,你的表结构已经撑不住了,分库分表了。

分布式时代,自增ID的三个崩溃瞬间

当你的系统从单机扩展到多节点集群时,自增ID就开始出问题了。

第一:多主架构冲突。MySQL主从复制的时候,主主架构下两个库同时写入,各自独立递增,ID冲突是必然事件。听说过双主同步的坑吗?数据覆盖,丢数据,找bug找到天亮。

第二:没法排序。自增ID在单机上天然支持按创建时间排序。分库分表后,ID只是逻辑递增,不同库的ID根本没法对比谁先谁后。你的订单统计功能直接报废。

第三:外部暴露风险。订单号12345678,用户猜到下一个是12345679。这个问题不大,但如果你的ID能推算出业务量,那竞争对手可以知道你每天有多少订单。这不是开玩笑的。

Snowflake:时间戳+机器号+序列号的68年史诗

先说最主流的方案:Snowflake。

这个算法是Twitter在2010年前后开源的,核心思想很简单——把一个64位整数拆成三段,分别记录时间、机器、序列。

具体怎么拆:

|-41位时间戳(ms)-|-10位机器ID--|-12位序列号-|

41位时间戳:2^41毫秒 ≈ 69年。从某个固定时间开始算,69年内不会用完。

10位机器ID:2^10 = 1024,可以给1024个节点分配唯一编号。

12位序列号:每毫秒内递增,2^12 = 4096,同一毫秒最多生成4096个ID。

理论 TPS:1024节点 × 4096序列 = 约420万/秒。够用了。

但是!理论归理论,生产实践里这个算法有三个让你血压飙升的坑:

坑一:时钟回拨

这是Snowflake生产环境的头号杀手。

服务器时钟回拨,就是NTP同步的时候把时间往回拨了几毫秒甚至几秒。你的ID生成逻辑会认为这是「过去的时间」,于是:要么等待时间追上,要么序列号溢出。

实际场景:虚拟机热迁移、容器重启时时钟抖动、服务器休眠后恢复。

我经历过一次,Pod重启后时钟往回拨了800ms,结果生成的ID比上一条记录还小。主键冲突,insert直接报错。整个服务在2秒内雪崩。

坑二:机器ID分配是个运维噩梦

Snowflake要求机器ID全局唯一,不能重复。但谁来分配?谁来记录?

Twitter当年的解决方案是:写死在配置文件里,运维人员手动分配。

在内网物理机时代,这还行。但在Kubernetes里,Pod每次重启IP和hostname都变,你还手动配置?配一个Deployment试试,Pod扩缩容的时候机器ID直接冲突。

所以有了各种变种。

坑三:Worker ID 注册与续约

单机手动配置不现实,就得用注册中心。于是你的系统多了一个依赖:ZooKeeper、etcd、或者Redis。

然后你得处理:节点启动时注册、心跳续约、节点挂了要释放ID、极端情况下网络分区导致多个节点拿到同一个ID……

恭喜你,你用一个ID生成问题,换来了一个分布式协调问题。

实战派方案:美团Leaf的双号段模式

不想用Snowflake的复杂协调逻辑?还有一个实战中很靠谱的方案:双号段模式。

核心思路:不要每次insert都访问数据库,而是每次从数据库领一个号段,在本地内存里慢慢用。

实现大概是这个样子:

// 号段模型CREATE TABLE segment_table (
  biz_tag VARCHAR(64) PRIMARY KEY,  -- 业务标识
  max_id BIGINT NOT NULL,           -- 当前号段最大值
  step INT NOT NULL                 -- 步长(每次领多少)
);

// 领取号段逻辑
UPDATE segment_table 
SET max_id = max_id + step 
WHERE biz_tag = ?
RETURN max_id;  -- 返回更新前的值

本地缓存一个号段[max_id - step, max_id],每次取ID从这个区间拿,用完再异步从数据库领新的。

这个方案有几个显著优点:

• 数据库压力大幅降低——TPS从每次ID生成一次SQL,变成每领一次号段一次SQL。如果step=1000,压力降1000倍。

• 不依赖时钟——纯数据库自增,天然有序。

• 实现简单——不需要分布式协调,不需要注册中心。

缺点也有:ID是趋势递增,不是严格递增。两个号段之间可能有空洞(如果服务挂了,还没来得及用的号段就浪费了)。

美团内部用的Leaf就是这个思路,在他们的生产环境里验证过了。

机器ID争夺战:谁才是真正的解决之道

回到Snowflake变种这个话题。各路神仙各显神通,我来给你整理一下主流实现:

Snowflake原版:静态配置机器ID,内网物理机时代很稳,容器时代劝退。

美团Leaf:双号段模式,放弃Snowflake,纯数据库领号段,胜在稳定。

百度Uidgenerator:Snowflake变种,借用数据库一次性分配Worker ID,支持循环使用(如果时间回拨太多,就循环使用历史时间),对时钟回拨更宽容。

水滴ID(Sonyflake):用69年改成更细粒度的设计,机器ID只有8位,但序列号有16位,高并发场景下序列号用得更充分。

ULID:字典序递增的唯一ID生成方案,和UUID同样128位,但按字母排序比UUID快,而且时间部分在前面。缺点是不是64位,是128位,而且时间精度只到1ms。

我自己选型标准就两个:

1. 对外暴露的ID,用数据库号段,安全第一。

2. 内部流水ID,用Snowflake变种,性能强依赖时钟的稳定性。

血的教训:别混用ID体系

最后说个真实踩坑案例。

之前的交易系统,同时用了两套ID体系:Snowflake生成内部追踪ID,数据库自增ID作为外部订单号。结果有一天,RocketMQ的消费顺序和数据库写入顺序不一致,对账的时候发现有一批订单的内部追踪ID和订单号对不上。

排查了两天,发现是消息重试导致的消息乱序。

解决方案:Snowflake配合顺序消息机制,消息队列内部保证顺序,ID生成只负责唯一性,不要把时序问题寄托在ID上。

这件事给我的最大教训:分布式ID的核心问题不是「怎么生成」,而是「谁来决策,谁来保证唯一」。

理解了这一点,很多分布式问题都能想清楚了。

总结

分布式ID生成,没有银弹。

数据库号段方案:稳定、简单、但对时钟没要求,适合对ID本身没有语义的场景。

Snowflake系:高性能、趋势有序,但时钟问题是悬在头顶的剑,需要花精力维护。

选型之前,先想清楚你的业务场景:要不要严格递增?并发量多大?容许不容许时钟回拨?

想清楚这些,答案自己就出来了。

好了不说了,我去检查一下上次那个时钟回拨导致的ID冲突BUG修好了没有。

相关文章

别再被HTTP/1.1拖后腿了:我用血泪经验告诉你后端性能优化该怎么做
别再把API设计成一坨屎了:我的RESTful血泪史
你写的HTTP客户端,正在悄悄拖死你的服务
你写的API是不是一坨屎?——10个让后端开发者崩溃的瞬间
AI圈最近有点热闹!OpenClaw又整活了,以及那些让我欲罢不能的新玩具
写API这件事,我踩过的坑比吃过的盐还多

发布评论