分布式ID生成器:从Snowflake到Leaf,我们为什么最终选择了数据库号段模式

🔑 关键词:分布式ID,Snowflake,号段模式,时钟回拨,数据库号段

📖 摘要:一次凌晨三点的订单ID冲突事故,让我们重新审视了分布式ID生成方案。本文对比了UUID、Snowflake、Redis INCR和数据库号段模式在真实生产环境中的表现,包含具体的QPS、延迟数据和踩坑细节。

分布式ID生成器:从Snowflake到Leaf,我们为什么最终选择了数据库号段模式

图片

去年双十一大促前两周,凌晨三点我被电话叫醒。订单系统出现了ID冲突——两个不同的订单生成了完全相同的订单号,导致其中一个订单的支付状态被覆盖。事后排查发现,我们使用的Snowflake算法在两台机器的时钟回拨了8毫秒后,产生了重复ID。那天晚上我们紧急回滚了版本,用了一个极其丑陋的临时方案:在ID生成后加机器IP的后两位做哈希。这个方案当然不能长期用,但它给我们争取了三天时间来做真正的技术选型。

我们测试过的四种方案,以及它们真实的性能数据

先说结论:我们最终选择了基于数据库号段模式的Leaf方案,但不是因为它的性能最好——它的单次获取ID的P99延迟是2.3毫秒,而Snowflake是0.4毫秒。选择它的原因很复杂,我先把我们实测的数据摆出来。

图片

方案 单机QPS(实测) P99延迟 时钟回拨敏感 运维复杂度
UUID v4 120万 0.02ms 否 极低
Snowflake 38万 0.4ms 是 中
Redis INCR 8.2万 1.8ms 否 中
数据库号段 5.6万 2.3ms 否 低

注意这里的QPS是在我们特定的机器配置下测的(8核16G,SSD,MySQL 8.0)。UUID的QPS看起来很高,但它的存储成本是灾难性的——我们订单表用UUID做主键后,索引大小膨胀了4.7倍,范围查询性能下降了60%以上。而且UUID作为订单号发给用户,客服在电话里念都念不清楚,这是一个被很多技术文章忽略的业务现实。

Snowflake的问题在于时钟回拨。我们的IDC在2023年总共发生了17次NTP同步导致的时钟回拨,最大回拨幅度是23毫秒。Snowflake官方的解决方案是等待时钟追上或者抛异常,但在大促期间,你不可能让订单系统等待23毫秒,更不可能抛异常。我们试过用ZooKeeper来协调workerId,但ZK的会话超时和网络抖动又引入了新的不确定性——有一次ZK集群切换,导致3台机器的workerId重复了整整40秒。

数据库号段模式的真实实现细节,以及我们踩过的两个坑

图片

Leaf的号段模式原理很简单:每次从数据库取一批ID到内存,用完再取下一批。但实际落地时,有两个参数需要反复调整:号段长度和双Buffer的预加载阈值。

我们最初的配置是step=1000,预加载阈值=10%。结果在压测时发现,当QPS达到8000时,每125秒就要访问一次数据库,而数据库的更新操作(UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = 'order')在并发场景下会产生行锁等待。我们观测到的最长等待时间是340毫秒,直接导致那次请求的P99延迟飙升到350毫秒以上。

后来我们把step调整为50000,预加载阈值调整为20%。这样每6.25秒才访问一次数据库,行锁等待的概率降低了90%以上。但新的问题来了:如果服务重启,内存中未使用的ID就会丢失,导致ID空洞。我们算过一笔账,按每天重启2次计算,每天会浪费大约10000个ID。对于订单ID来说,这不是问题——订单ID本来就不要求连续。但如果你的业务需要ID连续(比如某些财务凭证),号段模式就不适合。

图片

第二个坑是数据库的高可用。我们用的是MySQL主从架构,Leaf默认配置是从主库读取号段。有一次主库故障切换,从库升为主库,但号段表的max_id没有同步到最新值。Leaf在从库上取到了已经分配过的号段,导致ID重复。解决方案是给号段表的更新操作加一个版本号,Leaf在每次获取号段时会校验版本号,如果发现版本号回退就拒绝使用。这个逻辑Leaf本身没有提供,是我们自己加的。

为什么最终没有选择美团Leaf的Snowflake模式

美团Leaf同时提供了Snowflake模式和号段模式。我们仔细读过它的源码,Snowflake模式的实现确实比我们自己的版本更完善——它用了ZK来分配workerId,并且处理了时钟回拨的问题(等待或抛异常)。但我们最终没有选择它,原因有两个。

图片

第一,我们不想引入ZooKeeper。我们的技术栈里没有ZK,为了一个ID生成器去维护一个ZK集群,运维成本太高。Leaf的Snowflake模式虽然也支持基于MySQL的workerId分配,但美团官方文档明确说这种方式的可靠性不如ZK。

第二,也是最关键的一点:大促期间的稳定性要求。Snowflake模式在时钟回拨时的处理策略是“等待时钟追上”或“抛异常”。等待意味着请求被阻塞,抛异常意味着订单创建失败。无论哪种,在大促期间都是不可接受的。而号段模式没有这个问题——它不依赖系统时钟,只要数据库可用,它就能持续生成ID。我们大促期间数据库的可用性是99.99%,这比系统时钟的稳定性高得多(NTP同步、虚拟机迁移、闰秒调整都会影响时钟)。

当然,号段模式也不是没有缺点。它生成的ID是趋势递增的,但不是严格递增的。如果你需要严格递增的ID,那只能选择Redis INCR或数据库自增。但Redis INCR的QPS只有8.2万,而且Redis的持久化配置(AOF everysec)在故障时可能丢失最多1秒的数据,这会导致ID回退。数据库自增的QPS更低,而且扩展性差。

一个反直觉的结论:ID生成器的性能瓶颈往往不在生成器本身

图片

我们做压测时发现一个有趣的现象:当QPS从1万提升到5万时,号段模式的P99延迟只从2.3毫秒增加到了2.8毫秒。但整个订单系统的P99延迟却从45毫秒飙升到了210毫秒。后来定位发现,瓶颈不在ID生成,而在ID生成之后的订单号格式化——我们用了String.format来生成带前缀的订单号,这个操作在高并发下产生了大量的临时对象,导致Young GC频率从每分钟2次增加到了每分钟17次。

改成用StringBuilder手动拼接后,订单系统的P99延迟降到了52毫秒。这个经历让我意识到,很多团队在选型分布式ID生成器时,过度关注生成器本身的性能,却忽略了ID在业务代码中的使用方式。一个P99延迟0.4毫秒的Snowflake,如果后面跟着一个P99延迟5毫秒的格式化操作,整体性能还不如一个P99延迟2.3毫秒但格式化只要0.1毫秒的号段模式。

最后说一个可能有点争议的观点:对于90%的中小型系统,数据库号段模式就足够了。它的QPS上限(单机5.6万)远高于大多数系统的实际需求。Snowflake更适合超大规模、对延迟极其敏感的场景,但它带来的时钟回拨问题和运维复杂度,往往被低估了。我们踩过的坑告诉我,在系统设计里,简单性和可预测性往往比极致的性能更重要。