分布式ID选型踩坑记:雪花算法时钟回拨、号段模式和UUID到底怎么选

🔑 关键词:分布式ID,雪花算法,时钟回拨,Leaf号段模式,UUIDv7

📖 摘要:从一次 NTP 校时导致订单号重复的线上事故出发,拆解雪花算法 64 位的位分配、时钟回拨的真实成因与三种兜底方案,再把号段模式的双 buffer、step 取值、UUID/ULID/UUIDv7 的参数摆在一起对比,最后给出我自己在选型时会问的几个问题。

上个月我们订单服务报了一串 Duplicate entry 主键冲突,半小时里 40 多条重复订单号。第一反应是并发写重了,把发号那段代码翻了个底朝天,加了日志、加了断言,还是复现不了。最后是在监控里看到机器时间往回跳了 1.2 秒——机房那边做了次 NTP 校时。雪花算法那 41 位毫秒时间戳一退,后面的序列号原地踏步,正好撞上前一毫秒已经发出去的号。这事之后我把分布式 ID 这块重新捋了一遍,下面不讲「三种方案各有优劣」那种八股,只说我自己踩过的坑,和选型时真正会盯的几个数字。

图片

雪花算法那 64 位,每一刀切在哪

先摆参数。Twitter 原版 Snowflake 是 64 位整数:1 位符号位固定 0,41 位毫秒时间戳,10 位机器 ID,12 位序列号。41 位能撑 2^41 毫秒,大概 69.7 年,起点是自定义 epoch,Twitter 当年用的是 1288834974657。10 位就是最多 1024 个节点,12 位就是单节点单毫秒 4096 个 ID,折算下来单节点理论上限 409.6 万 QPS。纸面上富余得离谱,问题是这三个数字里只有时间戳是自动的,另外两个都得你人工保证不重复。

10 位机器 ID 在 K8s 里基本是个灾难。Pod 重启 IP 就换,你拿 IP 末段算 workerId,Deployment 一扩容就可能撞车。我们后来走的是 Leaf-snowflake 那套思路:启动时去 ZooKeeper 建临时顺序节点,把序号当 workerId,本地再落一个文件做缓存,ZK 连不上就先用本地缓存的。代价是启动多了个依赖,但总比半夜被主键冲突叫起来强。

图片

还有个细节很少有人提:12 位序列号是按毫秒重置的。如果你发号逻辑里既加了 synchronized 又套了个 ReentrantLock,单机实际 QPS 能压到两三万,那 4096 这辈子都用不满。我见过有人为了「用满序列号」去搞无锁队列,最后收益还不如把一次批量取号做好。

时钟回拨这事,别指望它不发生

图片

回拨的来源比你想的多:NTP 校时是最常见的,ntpd 默认的 step 阈值是 128ms,超过就直接跳而不是慢慢 slewing;chrony 的 makestep 策略默认只在开机头几次更新时允许跳变,之后也是慢慢调,但容器场景下你不一定控制得了宿主机的配置。另外虚拟机挂起恢复、宿主机热迁移、闰秒(2012 年 6 月那次闰秒把 Reddit、Mozilla 一批服务都搞挂了,Linux 内核 hrtimer 和 Java 的 System.currentTimeMillis() 都有份)都会让时间不单调。

处理方案我试过三种。最土的是直接抛异常,回拨了就让这次请求失败,上游重试——听着糙,但对账最干净。第二种是等待,回拨多少毫秒就 sleep 多少毫秒,我给它设过 5ms 的阈值,超过就抛异常,5ms 以内等一等。第三种是百度 uid-generator 那种 RingBuffer 思路,提前把未来一段时间的 ID 生成好放进双 ring 里交替消费,本质上是用「预支未来时间」换掉等待。第三种吞吐最好,但代码复杂度上一个台阶,我们最后没上。

不管选哪种,检测得做两层:启动时检查一次,运行时在发号的那个临界区里再检查一次。只做启动检查等于没做,回拨是运行期发生的事。另外千万别静默地「借用未来时间」又不做记录,我见过一个实现,回拨时把 lastTimestamp 强行 +1,结果每回拨一次就永久性地把时间轴往前推,跑几个月之后 ID 里的时间戳比真实时间快了好几分钟,做时间范围查询全乱套。

图片

号段模式被我低估了很久

Leaf-segment 的原理简单到不像话:数据库一张表,字段就 biz_tag、max_id、step、description,服务启动时按 biz_tag 取一批号放在内存里发,用完了再取下一段。双 buffer 的意思是,当前号段消耗到某个比例(Leaf 里是 10%)就异步去拉下一段,这样拉 DB 的那一下不会顶在请求路径上。

step 取多少是个真问题。Leaf 的 wiki 里建议按 QPS 调,举的例子大概是 QPS 两千上下把 step 从 1000 提到 60000,拉 DB 的频率从每秒两次降到每半分钟一次,TP999 明显好看。但步长不是越大越好,服务一次重启、一次宕机,没发完的那一段号就直接浪费了,如果你的 ID 需要连续(比如给财务做流水号),跳号是要解释的。我们订单表用的 step 是 1000,日均 300 万单,一天也就拉三千次库,对主库毫无压力。

图片

号段模式还有个隐藏好处:ID 是连续递增的。InnoDB 聚簇索引按主键顺序插入,页分裂少,范围查询和归档也方便。这一点在对比 UUID 的时候特别明显——UUIDv4 是 122 位随机数,作为主键写入时页分裂严重,我们当年做压测,同样的表结构换成 UUID 主键,写入 TPS 大概掉了三成多(具体数字看行宽和机器,别当基准)。

现在我做选型会先问三个问题

图片

顺序是这样的:这个 ID 会出现在 URL 里吗?会被用户看到、被爬虫抓吗?会当分库分表的分片键吗?

只做内部主键、不上 URL、要范围查询的,我直接选号段模式,最省心。要对外暴露的,连续 ID 等于把「我们一天多少单」写在脸上,这时候雪花或者 UUIDv7 更合适。2024 年 5 月 RFC 9562 正式收了 UUIDv7,前 48 位是 Unix 毫秒时间戳,后面是随机位,字典序就是时间序,能当主键用;ULID 是另一个路子,48 位时间戳加 80 位随机,Crockford Base32 编成 26 个字符。这俩库的生态都还不算成熟,但值得盯。

最后一句忠告:别在同一张表里混两种 ID 语义。我们犯过这个错,订单主键是雪花,业务单号又用「日期 + 自增」,出了问题时两边对不上,两个人在会议室里对了两小时日志。分布式 ID 真正难的从来不是「怎么保证唯一」,是「出了事你怎么快速证明它唯一」。

🏷️ 标签: