上周三凌晨两点,报警邮件响了。
生产环境的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修好了没有。