分库分表后,这几种全局 ID 方案到底谁更扛得住
我们直接说结论:在 2024 年的分布式数据库场景下,基于时间序列的 Snowflake 变体(如百度 UidGenerator、美团 Leaf-segment)在性能和可靠性上最均衡,是大多数高并发业务的首选;而号段模式(号段发号)在不频繁扩容、能容忍少量 ID 不连续的场景下,QPS 上限最高。UUID 和数据库自增主键,一个输在存储和索引性能,一个输在可用性,基本可以排除在生产环境的主键方案之外。
我写过 3 年支付系统,经历过从单库单表到 128 库 1024 表的拆分,ID 生成方案换过 4 次。踩过的坑包括:ID 冲突导致对账不平、发号器自身瓶颈拖垮整个交易链路、时钟回拨让订单全部写入失败。下面我按业务压测数据和生产故障案例来拆解每种方案的真实表现。
Snowflake 及其变体:性能上限不是算法,是部署架构
业内压测的一个基准数据:单机部署的 Snowflake 算法(美团 Leaf 的号段模式不算在内),纯内存生成 ID,MacBook Pro M1 上单线程 QPS 轻松跑到 400 万/秒 以上。这远超过绝大多数业务的需求。瓶颈从来不在算法本身,而在部署方式、时钟依赖和 workerId 分配这三个工程问题上。
2018 年我们用的是 Twitter Snowflake 的原生 Java 实现,直接嵌入业务服务。很快遇到两个问题:一是容器化部署后,workerId 自动分配没有可靠方案,靠 ZooKeeper 临时节点注册,但 ZK 会话超时后 workerId 回收逻辑有 bug,导致两台机器拿到同一个 workerId,产生了 3 秒的 ID 冲突窗口。二是某次机房交换机重启导致 NTP 时钟同步延迟 50ms,一台机器时钟回拨了 43ms,直接抛出异常,所有交易创建失败。
后来切换到百度 UidGenerator 的 CachedUidGenerator,它的核心改进是:不再每生成一个 ID 就检查时钟,而是用 RingBuffer 预生成一批 ID,当前时间戳只作为 RingBuffer 填充的触发条件。即使发生小幅时钟回拨(100ms 以内),只要 RingBuffer 里还有可用 ID,服务就不会中断。我们在 2020 年双十一压测时,32 核物理机单实例跑到 800 万 QPS,CPU 占用不到 20%,而且全程零时钟异常。
但 UidGenerator 也有弱点:它强依赖数据库表 WORKER_NODE 来分配 workerId,服务启动时要读写一次 MySQL。如果这个表所在库挂了,发号器就起不来。我们的解法是把这张表放在一个独立的高可用 RDS 实例上,跟业务库彻底分离。
号段模式:QPS 最高,但扩容像拆炸弹
号段模式(如美团 Leaf-segment)的原理很简单:从数据库一次取一个号段(比如 step=1000),缓存在内存里,业务线程拿 ID 时只做 CAS 递增,用完再取下一段。因为不依赖时间戳,也没有时钟回拨问题,单机 QPS 理论上无限高——我们实测用 AtomicLong 递增,32 核机器跑到 1200 万 QPS 才触到内存带宽瓶颈。
但号段模式的问题全在运维侧。2021 年我们做分库分表扩容,从 64 库 512 表扩到 128 库 1024 表,路由规则从 hash % 512 变成 hash % 1024。号段模式下 ID 是绝对递增的,但新旧两张表的 ID 范围出现了重叠。迁移数据时,老表里已经生成了 ID 到 900 万的订单,新表从 0 开始发号,导致新写入的订单 ID 跟老数据冲突。那一次我们被迫停服 4 小时,用脚本把新表的所有 ID 偏移了一个亿。
另一个隐患是号段耗尽时的「突刺」。当 QPS 突然从 1 万跳到 10 万,号段消耗速度远超预期,取号段的数据库请求会把发号库打挂。我们设置了动态 step(根据消耗速率自动调整号段大小),但这又引入了新的复杂度:号段越大,服务重启后浪费的 ID 越多。一次发版重启,直接跳过了 50 万个 ID,下游对账系统报了大量「ID 空洞」告警。
UUID:存储层的隐性成本会吃人
很多人觉得 UUID 简单、无中心化依赖,适合分布式。但一旦表超过 5000 万行,UUID 主键的代价就非常具体了。
我们用 MySQL 8.0 + InnoDB,一张用户事件表用 UUID v4 做主键,写入 2 亿行后,INSERT 的 99 分位延迟从 2ms 涨到了 45ms。原因是 InnoDB 的聚簇索引按主键顺序存储数据,UUID 的随机性导致每次插入都会引起页分裂。SHOW ENGINE INNODB STATUS 显示,该表的页分裂频率是自增主键表的 300 倍 以上。而且 UUID 占 36 字节(字符串存储),自增 bigint 只占 8 字节,二级索引的空间膨胀也非常明显——同样 2 亿行,UUID 表的索引总大小是 bigint 表的 4.7 倍,直接导致 Buffer Pool 命中率从 99.8% 掉到 91%。
UUID v7(基于时间戳的 UUID)能缓解插入乱序的问题,但生态支持还很弱。2024 年初 MySQL 8.0 还没有原生的 UUID v7 生成函数,需要应用层用 Java 库 uuid-creator 生成,而且各种中间件(Canal、DTS)对 v7 的解析兼容性参差不齐。
数据库自增主键:分库分表后的天然死局
单库自增主键在拆分后,多个库各自从 1 开始自增,ID 必然冲突。设置不同起始值和步长可以错开,例如 auto_increment_offset = 1, auto_increment_increment = 128,但这条路走不远:扩容时改步长要锁表,DDL 在亿级表上执行时间是分钟级,业务根本等不起。而且这种方案把分片逻辑耦合到了 ID 生成上,一旦步长不够用(比如从 128 库扩到 256 库),所有表都得 ALTER,是个运维噩梦。
最终选择:一条决策树
如果你现在要做技术选型,我建议按这个顺序决策:
- 业务 QPS 低于 5 万,且未来一年不扩容:直接用美团 Leaf-segment 号段模式,部署简单,性能足够,唯一代价是 ID 不连续。
- 高并发(10 万 QPS 以上)且对时钟回拨零容忍:用百度 UidGenerator 的 CachedUidGenerator,RingBuffer 预生成机制能扛住 100ms 以内的回拨。workerId 分配可以接 ZooKeeper 或 etcd,但要做好降级方案——我们加了一个本地配置文件的 fallback,ZK 不可用时读取本机 hostname 映射的固定 workerId。
- 跨机房、跨云部署,无法保证 NTP 同步:放弃所有时间戳方案,直接用号段模式 + 多号段提供者。每个机房部署独立的发号服务,号段从各自的数据库取,ID 带上机房标识位来避免冲突。
- UUID 只在一种情况下可以用:表数据量明确不会超过 1000 万,且没有范围查询需求(比如日志归档表)。如果非要用,务必用 UUID v7 并存储为 binary(16),不要存成字符串。
这些方案没有银弹,选哪个取决于你能接受什么样的故障模式:号段模式怕扩容,Snowflake 怕时钟回拨,UUID 怕数据量大。想清楚你的业务最不能忍受什么,答案就出来了。
常见问题
Snowflake 的时钟回拨到底怎么处理最稳妥?
别信那些「等 5 秒再试」的方案,生产环境一秒都等不起。百度 UidGenerator 的 RingBuffer 机制是目前最成熟的解法:预生成一批 ID 放在内存里,回拨时只要回拨幅度小于 RingBuffer 的缓冲时长(通常设置 60 秒),就直接从 Buffer 取,不检查时钟。如果回拨超过缓冲时长,立即切到备用号段模式(此时会损失单调递增性,但不会停服),同时触发告警让人工介入。
号段模式的服务重启,真的会浪费很多 ID 吗?
取决于你的 step 设置和重启频率。假设 step=10000,每天重启 3 次,最多浪费 3 万个 ID,对于 bigint 的范围(2^64)来说可以忽略不计。但如果 step 设成 100 万,且频繁做滚动发布,浪费量就很可观了。我们后来在服务里加了一个优雅关闭的逻辑:收到 SIGTERM 后,把当前号段的剩余 ID 写回数据库的 max_id 字段,下次启动接着用。这需要数据库层面支持 CAS 更新,实现不复杂。
分库分表后,用 Snowflake 生成的 ID 做分片键,数据分布会均匀吗?
不会完全均匀,但足够用。Snowflake 的 ID 是时间趋势递增的,低位的序列号随机性不够强。如果你的分片算法是 id % 分片数,新写入的流量会集中在最后几个分片上,造成热点。解决办法是用高位取模:把 ID 右移 12 位(去掉序列号部分),再对分片数取模,或者直接用一致性哈希。我们用的是 (id >> 12) % 1024,实测 1024 张表的写入 QPS 偏差在 8% 以内。