文章类型技术长文 所属专栏登神之路 预计阅读98 分钟 文档状态已发布
返回

登神之路 01:美团 Leaf,生产级分布式 ID 服务的架构与实现

从号段预分配、双缓冲与动态步长,到 Snowflake 的 Worker ID 协调、时钟回拨与订单幂等,系统拆解美团 Leaf 的生产级分布式 ID 架构与正确性边界。

开始阅读全文19604 字 · 98 分钟 查看系列目录登神之路
关键词 分布式系统分布式 IDLeafSnowflakeJava高可用
栏目 Backend;专栏 登神之路;标签 分布式系统、分布式 ID、Leaf、Snowflake、Java、高可用

从号段预分配、双缓冲到 Snowflake 的机器协调与时钟处理。

生成一个不重复的数字并不难。难的是把它做成一项公共基础能力:许多业务同时调用,服务实例不断扩缩容,数据库可能暂时失联,机器可能重启,系统时间也未必始终向前走。在这些条件下,发号服务既要足够快,也必须知道什么时候应该停止,而不是继续返回一个“看起来正常”的数字。

美团 Leaf 值得研究的地方,不在于它发明了一种完全不同的整数编码方式,而在于它把两个朴素的思路做成了可服务化的实现:号段模式把逐个协调改为批量协调;Snowflake 模式把机器身份协调与日常 ID 计算分开。 官方开源项目分别提供了这两条路径。3

本文围绕一次 ID 请求的正常执行、并发竞争和故障恢复展开。号段模式是主线,Snowflake 部分重点分析机器编号与时间管理,最后用订单创建场景与可复现验证收束。读懂全文,可以沿着三个问题推进:号码的使用权在哪里获得,已经获得的使用权怎样被安全消费,恢复时怎样避免重复授权。

资料与版本说明: 历史背景参考美团技术团队在 2017 年 4 月 21 日、2019 年 3 月 7 日发布的文章;源码分析固定到官方仓库提交 86a6441d263497b9f9ee321de13422b9c63f0c06。文中“源码”均指这一提交,不代表美团当前内部部署。原生实现、假设改造与扩展设计分别说明;计算示例和订单链路是教学分析。文末明确列出本次实际执行的最小机制验证与尚未执行的集成测试,不把推演当成生产事故或性能测量。123

一、为什么生成 ID 值得做成一个独立服务#

1.1 从单库自增到分布式发号#

在一个简单的订单系统中,数据库自增主键已经能满足基本需求:应用插入记录,数据库生成一个新的主键,其他表使用这个主键建立关联。

问题通常不是从“应用有两台机器”开始的。多台应用连接同一个数据库,并不会自动导致自增主键冲突。 真正需要重新考虑的是唯一性的范围发生了变化,例如订单被拆到多个独立数据库,或者多个业务系统需要在数据落库前就拿到统一的标识。

设想订单被分到两个数据库,每个数据库各自生成 1、2、3……。两个数据库中的 id=100 都合法,但一条只携带 order_id=100 的消息到达其他服务时,就不一定能够唯一定位订单。

当然,也可以使用“分片标识 + 库内自增值”的复合键。独立发号服务并不是唯一答案。它提供的是另一种职责划分:把标识分配从具体业务数据库中抽出来,让创建记录、组织调用链和分配主键可以分开进行。

美团早期公开材料将分库分表,以及订单、骑手、优惠券等业务对象的唯一标识,列为 Leaf 的需求背景。这里需要的是跨服务复用的基础能力,而不是把某一张表的自增字段换一个名字。1

1.2 唯一、趋势递增、严格递增和连续,是四件不同的事#

讨论 ID 时,首先要说明“在哪里唯一”以及“按照什么顺序递增”。否则,“全局唯一、递增 ID”很容易变成一个包含多种不同承诺的口号。

性质实际含义不能顺带推导出的结论
唯一在约定的命名空间和有效范围内,两个不同对象不使用同一个 ID不代表数字连续,也不代表包含业务含义
趋势递增随着整体运行,分配的数值范围通常向更大的方向推进不代表每个客户端后收到的 ID 都更大
严格单调递增按某个明确定义的先后关系,后生成的值必须更大不代表号码没有空洞;跨节点还需要定义共同的顺序
连续无空洞相邻有效记录之间没有被跳过的号码不代表发号顺序等于事务提交顺序

例如,实例 A 持有 10001~11000,实例 B 持有 11001~12000。客户端先请求 B,再请求 A,可能得到 11001、10001。这不违反两段互不重叠的唯一性,却显然不是该客户端观察到的严格递增。

进一步说,A 先生成较小的 ID,业务事务却因为网络或锁等待后提交;B 后生成较大的 ID,其业务事务先提交。由此可见,ID 大小不能直接替代业务提交顺序。 号段预分配的多实例行为在美团的演进文章中也有说明。2

Snowflake 同样不能自动提供跨机器的严格全局顺序。它按照时间字段组织数值,但各机器时钟、请求执行和网络传递并不形成一个天然的共同序列。

1.3 发号服务为什么会成为关键依赖#

把 ID 分配独立出去以后,业务数据库的压力可能下降,但创建业务对象的链路也多了一个依赖。

一个订单创建请求即使完成了鉴权和价格校验,只要它必须依赖远程发号服务取得主键,发号失败就可能阻止后续写入。因此,评估 ID 服务不能只看平均发号速度,还要看号段切换时的尾延迟、依赖故障期间的成功率,以及冷启动时能否真正获取 ID。

这里还有一项比“继续返回结果”更重要的要求:在无法确认唯一性时,错误必须显式暴露。 两个订单拿到相同主键,可能造成插入失败,也可能在设计不当的下游引发关联错误。让调用方接收到可识别的失败,通常比把潜在冲突隐藏在成功响应中更容易处理。

下文讨论 Leaf 的每一项优化,都会回到这个标准:它降低了什么成本,又保留了哪些正确性前提。

二、Leaf 总体架构:两条不同的发号路径#

2.1 Leaf Core 与 Leaf Server 的职责#

官方项目将生成逻辑和接入层分开。leaf-core 包含号段生成器、Snowflake 生成器及相关状态管理;leaf-server 提供基于 Spring Boot 的 HTTP 接入。项目也说明,可以把核心 API 封装到所需的 RPC 框架中。3

这意味着要分清两个位置:

业务服务内的调用代码
→ 通过 HTTP 或 RPC 请求 Leaf 服务
→ Leaf 服务内的生成器在本地内存中执行发号逻辑

“内存发号”主要描述 Leaf 实例内部的操作,不意味着独立部署后,业务服务获取 ID 就没有网络开销。端到端延迟仍然包含连接、网络传输、服务端排队和序列化等成本。

两个模式提供不同的入口,可以独立启用,也可以同时开启。但同时开启只是暴露两条生成路径,并没有构成一个已证明安全的自动故障切换协议。 不能在号段失败后随意返回一个 Snowflake ID,然后默认两种数字空间永远不会相交。349

Leaf Segment 与 Snowflake 双模式总体架构

图 01:Leaf 的 Segment 与 Snowflake 两条生成路径共享服务入口,但依赖和正确性前提不同。

2.2 Segment:先分配范围,再在本地发号#

号段模式可以理解为两个层次。

范围分配层由数据库维护每个业务命名空间的分配边界。某个 Leaf 实例需要补充号码时,通过数据库事务领取一段尚未分配的范围。

范围消费层位于 Leaf 实例内部。实例持有号段的边界和一个内存计数器,请求到达后,从这个范围中取出一个数字,不必每次都访问数据库。45

正常的热路径因此变成:

请求进入 Leaf
→ 定位业务 Key 的内存缓冲区
→ 在当前号段内原子递增
→ 返回 ID

数据库仍然是范围分配的协调者,只是不再参加每一次常规发号。后面介绍的双缓冲,则进一步让补充号段的数据库访问尽量发生在后台。

2.3 Snowflake:先协调身份,再在本地计算#

Snowflake 不需要数据库持续提供数字范围。它把时间、机器编号和毫秒内序列号组合为一个整数。只要相关字段满足约束,不同实例就能独立生成互不冲突的 ID。9

在 Leaf 的实现中,ZooKeeper 主要参与 Worker ID 的分配与恢复,并接收周期性的时间信息。日常取号则读取本机时间,更新本地序列,再完成位运算。910

身份准备阶段:Leaf 实例 → ZooKeeper / 本地身份缓存
日常取号阶段:读取本机时间 → 更新序列 → 拼接整数 → 返回

由此可见,两种模式各自依赖不同的“不会重复”的基础:号段模式要求已分配的范围不被再次分配;Snowflake 要求同一编码空间中的机器身份与时间、序列组合不重复。

三、号段模式:数据库如何一次分配一批 ID#

3.1 号段表保存的不是每一个 ID#

Leaf 使用 leaf_alloc 表维护业务维度的分配状态。下面是保留核心结构的示意建表语句,字段与官方说明对应;这里省略了整数显示宽度等非关键声明。3

CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) NOT NULL,
max_id BIGINT NOT NULL DEFAULT 1,
step INT NOT NULL,
description VARCHAR(256),
update_time TIMESTAMP NOT NULL
DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (biz_tag)
) ENGINE = InnoDB;

biz_tag 是业务命名空间,比如 order-internalmax_id 记录该命名空间已经预留到的边界;step 是基础号段长度。一个业务 Key 只需要一行分配状态,而不是给每个已经发出的 ID 保存一条记录。36

为避免后面出现边界歧义,本文统一按照固定版本源码使用的左闭右开区间解释号段。

假设事务更新前:

biz_tag = order-internal
max_id = 10001
step = 1000

更新后得到 max_id=11001。本次实际可发放的是:

[10001, 11001)
即 10001、10002、……、11000,共 1000 个数字。

源码初始化游标时使用 newMax - actualStep,返回 ID 时检查 value < max,因此 max_id 在此实现中应理解为已经预留号段的排他上界,不是最后一个可以返回的数字。历史说明中常用闭区间表达示例,阅读时需要统一到同一种边界约定。4

这行数据也不是“已经成功创建多少个订单”的计数器。号码可能提前预留、响应丢失、业务回滚,甚至随实例退出而被放弃。因此,分配边界与业务记录数量并不存在一一对应关系。

3.2 多个 Leaf 实例如何避免领到相同号段#

核心操作并不复杂。固定步长下,申请过程可以整理为下面的事务。:tag 是预编译参数占位,表示需要申请的业务 Key。56

BEGIN;
UPDATE leaf_alloc
SET max_id = max_id + step
WHERE biz_tag = :tag;
SELECT biz_tag, max_id, step
FROM leaf_alloc
WHERE biz_tag = :tag;
COMMIT;

关键不是某条 SQL 很特殊,而是更新和读取处在同一个数据库事务、同一个连接中。DAO 源码使用一个 SqlSession 执行更新与查询,提交完成后才返回结果。5

假设 A、B 同时领取 order-internal 的号段,初始上界为 10001,每段长度为 1000。一种正常执行次序是:

A 更新该行,边界推进到 11001,并持有该记录的排他锁。
B 尝试更新同一行,等待 A 结束事务。
A 读取自己的更新结果,提交事务,取得 [10001, 11001)。
B 随后把边界推进到 12001,取得 [11001, 12001)。

对于按唯一索引精确定位的更新,InnoDB 会锁定相应的索引记录;事务提交或回滚后释放其持有的锁。这里利用的是数据库原有的事务并发控制,不需要再在每个业务请求外面增加一把分布式锁。11

Leaf 实例通过数据库事务分配不重叠号段

图 02:同一 biz_tag 的更新在数据库事务中串行化,使不同实例取得互不重叠的号段。

为什么不能把更新和读取拆开,甚至让读取走只读副本?可以构造一个反例:A 更新并提交,B 随即更新并提交,然后 A、B 都读取了 B 更新后的同一个边界。如果两者都据此推导“自己刚刚获得的号段”,就可能把同一范围当成自己的分配结果。

所以,申请号段是一项完整的分配事务,不是一个可以随意读写分离的普通查询。更新结果的归属必须与执行它的事务绑定。

这项机制可以用一个不变量概括。设分配前的边界是 H,本次正步长是 S,事务持久提交后,申请者获得 [H, H+S),新的边界成为 H+S。下一次申请只能从新边界继续向前。因此,只要事务串行化同一行的更新、已生效的边界不倒退、步长为正且数值不溢出,每次得到的范围就与之前的范围不相交。

数据库提交确认的是一段号码的使用权,不是这些号码已经被业务成功使用。 这也是为什么提交响应丢失时应重新申请,而不是凭猜测使用某个范围。

不同 biz_tag 使用不同的记录,同一个 Key 的分配操作则仍然需要在其分配点上协调。Leaf 没有让这个协调点消失,而是减少了访问它的频率。

在忽略启动预取、失败重试等额外操作的稳态近似中:

号段分配事务频率 ≈ 发号速度 ÷ 号段长度

例如,一个实例每秒发出 10000 个 ID,每段有 100000 个 ID,那么平均每秒约需要一次分配事务,而不是每秒一万次。这个计算说明的是批量化效果,不是数据库的性能承诺。

3.3 从数据库号段到内存计数器#

Leaf 并不把一万个号码展开成一个包含一万个元素的列表。Segment 主要保存当前游标 AtomicLong value、排他上界 max、该段实际长度 step,以及所属缓冲区的引用。7

一个号段可以表示为:

Segment
value = 10001 下一次尝试取出的数字
max = 11001 排他上界
step = 1000 本次号段的实际长度

忽略切换与预加载,单段取号的核心行为可以用以下说明性代码表达:

long candidate = cursor.getAndIncrement();
if (candidate < upperExclusive) {
return success(candidate);
}
return segmentExhausted();

这是对行为的简化,不是可直接替代 Leaf 的完整实现。真正源码还需要控制当前段切换、备用段加载和异常返回。4

getAndIncrement() 返回递增前的值,并原子推进游标。它解决同一 JVM 中线程竞争同一个计数器的问题;数据库事务解决不同 JVM 领取范围的问题。跨实例正确性与实例内正确性来自两个不同层次,不能只看到 AtomicLong 就以为分布式问题也解决了。

在边界处,线程可能原子取到一个已经等于或超过 max 的数字。这个数字不能返回给业务。游标暂时超过上界并不意味着号段可以继续使用,也不意味着可以把超出的值算进下一个号段。源码会进入后续的切换逻辑。4

四、双缓冲:如何把申请号段的延迟移出常规请求路径#

4.1 当前号段与备用号段如何协作#

只有一个号段时,绝大多数请求只操作内存,但当前段用完后的请求必须等待数据库分配新段。平均延迟可能很好看,尾延迟却会在耗尽点出现尖刺。

Leaf 为每个业务 Key 维护一个 SegmentBuffer,其中包含两个 Segment。一个被选为当前段,另一个在后台准备;当前段用尽时,尝试切换到已经准备好的备用段。48

阶段 A:消费 Segment 0,Segment 1 尚未准备。
阶段 B:继续消费 Segment 0,后台加载 Segment 1。
阶段 C:Segment 1 已准备,继续消费 Segment 0。
阶段 D:Segment 0 用尽,切到 Segment 1。
下一轮:消费 Segment 1,重新准备 Segment 0。

这里的双缓冲不是把同一份数据复制两份。两个缓冲保存的是两段不同的、均已在数据库中预留的号码范围。因为其他实例也会申请号段,本实例前后拿到的两个范围还不一定数值相邻。

Leaf 双缓冲预加载与号段切换时间线

图 03:当前段消耗到阈值后异步预取备用段,待当前段耗尽再原子切换。

双缓冲的意义是让“消耗已获得的号码”和“准备未来要用的号码”重叠进行。这是一种提前承担 I/O 延迟的办法,而不是消除数据库耗时本身。

4.2 什么时候启动异步预加载#

固定版本源码在取号路径上检查三个条件:备用段尚未就绪、当前段剩余量低于其长度的 90%,以及当前没有该缓冲区的加载任务正在运行。满足条件后,通过原子标记竞争启动后台加载任务。4

其判断可整理为:

备用段未就绪
且 当前段剩余量 < 当前段长度 × 0.9
且 成功将“加载中”标记从 false 改为 true
→ 提交后台加载任务

因此,不是只剩 10% 才加载,而是已经消耗超过约 10% 就开始尝试加载。 这一判断发生在请求处理过程中,不是某个定时器到点后强制执行。

设当前号段长度为 100000。在消耗刚超过约 10000 个 ID 后,后续请求就可能触发预加载,此时尚有接近 90000 个号码作为数据库访问的缓冲余量。

在近似匀速的前提下,留给后台加载的时间可以估算为:

预加载余量时间 ≈ 当前段剩余号码数 ÷ 当前实例该 Key 的发号速度

如果每秒消耗 3000 个号码,90000 个剩余号码大约提供 30 秒余量;如果流量突然增加十倍,同一余量便只剩约 3 秒。阈值本身不会让系统免疫突发流量。

后台加载失败时,源码会清除正在加载的标记。只要条件仍然满足,后续请求可以再次尝试触发加载。这里是随请求驱动的重试机会,并不是内置了一套具有指数退避和重试预算的调度器。4

4.3 并发取号与号段切换如何保持正确#

双缓冲增加了并发状态,需要防止的不只是两个线程取到相同数字,还包括读取未准备好的备用段、多个线程重复切换,以及同一个缓冲区被重复补段。

机制主要职责
AtomicLong value原子取得候选值,不让同一计数器位置被正常重复领取
读锁允许多个请求并发取号,阻止当前段在取值与边界判断之间被切换
写锁互斥切换当前段,并发布备用段就绪状态
threadRunning竞争单个业务缓冲区的后台加载资格
currentPosnextReady表示当前槽位,以及另一个槽位是否已经可以消费

上述结构与使用方式分别见 SegmentBufferSegmentIDGenImpl48

正常路径保护的是一组相关操作,而不只是一次加一。 请求持有读锁时,先取得当前段引用,再递增游标,最后检查候选值是否小于这段的排他上界。多个读线程仍然可以并行执行,真正分配候选值的原子性由计数器提供。

后台加载器则填充非当前段。填充结束后,它获得写锁,设置 nextReady=true,清除加载标记并释放写锁。请求随后取得锁、观察就绪状态,再把这个段切成当前段。

这里有两个层次的保证:互斥关系避免不合时宜的切换;锁的内存同步语义让发布之前的初始化结果,对之后取得对应锁的线程可见。备用段的数据是在发布之前写好的,并不是拿到写锁后才开始数据库查询。Java 的锁语义为这种先初始化、再发布的协作提供基础。41417

Leaf 请求线程后台补段线程与段切换流程

图 04:请求线程负责消费与触发预加载,后台线程发布备用段,耗尽后的切换在写锁内完成。

当请求发现当前段耗尽时,它先释放读锁,短暂等待加载器,再取得写锁。这里不能直接在读锁内升级成写锁,ReentrantReadWriteLock 不支持这种升级。14

取得写锁后,必须重新检查当前段。 例如,A 发现旧段耗尽并释放读锁;B 先拿到写锁,完成切换;A 随后取得写锁时,缓冲区可能已经有可用号码。源码因此会重新读取当前段并再次尝试取号,确实仍不可用才考虑切换,而不是执行一个基于旧观察的切换决定。4

如果备用段仍未就绪,源码不会越界发号。waitAndSleep 包含短暂自旋和可能的一次 10 毫秒休眠,随后再次检查;没有可用段就返回异常。它不是无期限等待,也不是端到端延迟一定只增加 10 毫秒的保证。4

还要区分安全性可用性:备用段没赶上时明确失败,可能牺牲这次请求的成功率,却守住了“不使用未获得的范围”。正确的实现不需要靠越界成功掩盖资源不足。

4.4 已经有 AtomicLong,为什么还需要读锁#

理解读锁的价值,最直接的办法是分析删掉它之后会发生什么。

下面是一个故意移除请求侧读锁的教学反例,不是原始 Leaf 在这条路径上存在重号问题。假设实例 A 的两个槽位已经对应 [100, 200)[300, 400),中间的 [200, 300) 已经分给实例 B。

时刻请求线程 A1其他请求与后台加载器
T1从旧 Segment 0 的计数器取出候选值 200,尚未判断边界就暂停旧段已耗尽,原上界是 200
T2仍持有旧 Segment 0 的对象引用其他请求把当前槽位切到 Segment 1
T3仍暂停消费 Segment 1 后触发下一轮加载,把同一个 Segment 0 对象复用为 [400, 500)
T4恢复,读取这个对象的新上界 500,错误判断 200 < 500200 原本不属于 A 的旧段,可能已由 B 发出

AtomicLong 没有失效:候选值确实通过一次原子操作获得。问题在于,候选值属于旧一轮号段,而上界属于新一轮号段。单字段原子性并不能保证一组字段来自同一个逻辑版本。

缺少读锁时旧候选值与新号段上界混用

图 05:缺少代际保护时,线程可能把旧号段候选值与复用对象中的新上界组合判断。

保留原来的读锁时,A1 在完成候选值检查前不释放读锁,其他线程就不能取得写锁完成当前段切换;旧对象也就不能沿着“退为备用段、重新装载”的正常生命周期被复用。于是 200 只能和旧上界 200 比较,结果是耗尽,而不是成功。4

maxcurrentPos 声明为 volatile 也不能替代这项保护。可见性使线程看得到更新,却不会自动把多次读取冻结成同一份状态快照。

并不是所有发号器都必须采用读写锁。也可以重新设计不可变号段描述、带版本的状态转换和原子发布协议。但那是另一套需要证明的并发算法,不能在保留原有可变槽位复用方式的同时,只删掉一把锁就声称完成了优化。

4.5 后台补段失败:线程、连接和状态也需要预算#

把数据库访问放进线程池,不等于数据库访问的成本消失了。固定版本的后台加载线程池可概括为:4

corePoolSize = 5
maximumPoolSize = Integer.MAX_VALUE
workQueue = SynchronousQueue

核心线程数为 5,不代表最多只有 5 个加载线程。SynchronousQueue 不储存排队任务;没有空闲线程接收任务时,线程池可以继续创建线程,直到最大线程数约束。这里的配置因而没有设置一个适合解释为业务预算的有限线程上限。15

当许多不同 Key 同时补段,而数据库连接获取或查询开始变慢时,可能出现“任务停留更久、更多线程等待连接、调度和内存负担上升”的压力链路。每个 Key 的 threadRunning 只能避免自己的重复加载,不能充当所有 Key 的全局并发上限。这是配置与控制流的风险推演,不是特定生产事故的披露。4

更深入的问题是:限制资源之后,失败必须落到一个完整的状态转换上。

取得加载资格:threadRunning 从 false 变成 true
→ 提交任务
→ 被接收:执行加载,成功发布或失败清理
→ 被拒绝:任务根本不会执行,提交方负责恢复状态

源码先设置加载标记,再调用 execute。如果提交抛出拒绝异常,任务内部的 finally 没有机会运行。单纯把线程池改成有界池,却不在提交失败路径清理标记,会让缓冲区继续被误判为“已有加载器在运行”。同样,静默丢弃任务也不能用来解决这种有完成依赖的后台操作。415

Leaf 补段任务提交发布与异常状态恢复

图 06:补段状态必须覆盖任务提交、执行、发布与拒绝等全部失败窗口。

另一个不能直接套用的方案是 CallerRunsPolicy。这项策略在拒绝时可能让提交线程自己执行任务,而 Leaf 在持有读锁时提交补段任务;补段成功后又要取得同一把锁的写锁。415

其假设改造后的路径是:

请求线程持有读锁
→ execute 因线程池饱和而转为调用者执行
→ 同一个线程执行数据库加载
→ 同一个线程仍持有读锁,却等待写锁
→ 无法完成读锁到写锁的升级

因此,“给线程池加背压”并不是只改一个策略名。这里若直接使用无超时的写锁获取,会卡在不支持的锁升级上;原始配置并不是 CallerRunsPolicy,不能把这个假设改造的后果写成原代码必然死锁。14

一种更完整的扩展设计,是在短临界区中取得加载资格与目标段身份,释放读锁后再提交任务;使用明确拒绝而非静默丢弃,并在提交失败时归还资格、设置下一次可重试时刻;后台发布前再确认任务仍属于有效的缓冲区生命周期。这是设计方向,不是原版已经实现的有界调度器。

还有一个容易遗漏的条件:请求等待超时不等于数据库任务已经停止。 不能因为调用方等不及,就随意清除仍在执行的任务标记,放进第二个加载器。否则两个任务可能同时操作备用槽位。清理、取消与发布必须有明确的归属关系。

对双缓冲而言,真正的时间预算是:

任务排队 + 获取连接 + 分配事务 + 发布就绪
< 当前段剩余号码可支撑的时间

有限线程池保护进程,却可能增加排队;提前预取提供余量,却不能覆盖任意长的依赖故障。要分析的是整条补段链路,而不是把某一个参数单独设大。

五、动态步长:为什么号段不能永远固定大小#

5.1 号段长度调节的究竟是什么#

号段长度并不直接改变一个数字在内存中递增的成本。它主要改变三件事:数据库申请频率、已经预留但尚未使用的号码数量,以及数据库失联后剩余号码可支撑的时间。

设某实例上某个业务 Key 的发号速度为 q,一个号段长度为 S,在流量近似稳定且没有其他损失时,该号段的消耗时间为:

T ≈ S / q

如果长度固定为 100000,发号速度从每秒 100 个增加到每秒 10000 个,一个号段的理论寿命就从 1000 秒降到 10 秒。数据库还没变慢,系统对数据库短暂故障的承受空间却已经大幅缩小。

反过来,如果为了高峰流量把每段配置得极大,低流量实例可能长期持有一段旧范围。其他实例已经向很大的数字区间推进,它仍然在慢慢发放较小的数字;重启时也可能放弃大量号码。这些问题促使 Leaf 引入动态步长机制。2

动态步长不是在寻找一个永远正确的固定数字,而是在依据近期消费节奏调整批量大小。

5.2 Leaf 如何根据更新间隔调整步长#

固定版本源码以 15 分钟作为一个时间尺度,结合上次号段更新以来的间隔决定下一次申请的长度。忽略初始化分支后,可以整理为以下规则。4

观测到的更新间隔 T下一次步长的处理
T < 15 分钟尝试扩大为两倍;若两倍超过放大限制则保持
15 分钟 ≤ T < 30 分钟保持当前步长
T ≥ 30 分钟尝试减半;若减半低于记录的基础下限则保持

源码中的放大限制常量 MAX_STEP1000000。它在尝试翻倍的分支中发挥作用,不应该被误读为对任意数据库配置都进行了全面合法性校验,也不意味着每个 Key 最终都会增长到恰好一百万。4

例如,某个 Key 的初始步长是 2000。如果它连续多次很快消耗号段,后续申请可能从 4000、8000、16000 逐步扩大。系统不是一看到流量增大,就直接计算出一个精确的新步长。

Leaf 根据号段消耗时间动态调整步长的反馈流程图

图 07:Leaf 依据上一段的消耗时间放大、保持或缩小运行态实际步长。

源码还有三个值得单独说明的细节。

第一,数据库中的 step 与运行态的实际申请步长并不总是相等。 最初加载时使用表中的基础步长;进入动态调整阶段后,生成器通过自定义步长更新 max_id,把实际步长保存在当前实例的缓冲区和新号段中。它并不是每次都把调整结果写回表中的 step 字段。46

第二,第一次初始化和用于建立更新时间基准的下一次加载,不直接走后续的反馈调整分支。 因此,不能只看一次冷启动请求,就判断动态步长已经进入稳定状态。4

第三,计算新的起点时必须减去本次实际分配的长度。 如果本次用 8000 推进数据库上界,却仍然按表中的 2000 推导起点,就会把分配区间解释错。源码通过缓冲区中的实际步长设置 valuemax 和该段 step4

这也说明为什么动态步长可以是每个实例、每个业务 Key 的本地决策:不同实例即使申请不同长度,只要数据库分配事务正确、取得的区间不重叠,就仍然可以共同发号。

5.3 动态调整为什么不能承诺固定容灾时间#

15 分钟是反馈算法使用的时间尺度,不是“数据库故障后必定还能服务 15 分钟”的承诺。

首先,算法依据的是过去的更新间隔,流量突然增加后,手头已有号段不会自动变长。其次,步长放大存在限制;即便按一百万个号码估算,在每秒消耗十万个号码的教学假设下,一个满号段也只够约 10 秒。

此外,故障发生时,当前段可能接近耗尽,备用段也可能尚未加载成功。真正能支撑多久,应从已经成功获得、仍在可用实例内的剩余号码出发计算。

对于单个实例、单个业务 Key,可以使用一个粗略估算:

可用剩余量 = max(0, 当前段上界 - 当前游标)
+ 已就绪备用段的未消费数量
可支撑时间 ≈ 可用剩余量 / 故障期间的实际发号速度

如果当前段还有 60000 个号码,备用段已就绪且有 100000 个号码,故障期间每秒消耗 2000 个,则约有 80 秒余量。这个数值会随流量变化、实例故障和路由分配而变化,不能只把所有实例剩余号码相加,就当成每个请求都能访问的共享池。

因此,动态步长调节的是批量规模,故障分析仍应回到实时余量与实际负载,而不能只看配置里的 15 分钟。

六、号段模式的故障恢复:哪些故障只是丢号,哪些可能导致重号#

6.1 Leaf 实例宕机:未用完的号段怎么办#

假设实例 A 已经成功获得 [10001, 11001),对外发放到 10300 后宕机。它的内存游标丢失,但数据库中的分配边界已经在此前的事务中推进。

重启后,A 不应简单从 10301 恢复,更不能从 10001 重新开始。数据库只有分配边界,没有逐个 ID 的交付记录;它不知道响应是否到达调用方,也不知道业务记录是否成功提交。

Leaf 的实现不会把当前号段游标作为持续恢复点写回数据库。重新初始化时,它再次申请新的号段。于是,旧范围中没有被业务使用的号码可能被永久跳过。这是号段预分配下可理解的结果,不是唯一性故障。45

考虑一个更隐蔽的情况:数据库已经提交号段分配,提交响应却在网络中丢失。Leaf 无法确认本次申请结果,后续重新申请,可能又推进一次边界。只要数据库保留了前一次提交,代价通常是放弃一段号码,而不是把旧范围重复发出。

由此可以得到一条恢复原则:

对于交付结果不确定的号码,跳过通常比回收安全。

“这段号码似乎没有被使用”不是回收依据。要支持严格回收,就需要额外记录使用状态、约束迟到请求与旧实例,并解决更多并发问题,已经超出这里的号段机制。

6.2 数据库不可用:已有实例和新实例有什么区别#

当数据库暂时不可用时,号段模式不是简单的“全部可用”或“全部不可用”。不同实例、不同 Key 可能处于不同阶段。4

故障发生时的状态可能的表现
当前号段仍有剩余常规发号可以继续,后台补段可能失败
当前号段耗尽,备用段已经就绪可以完成切换并继续消费备用段
两段都没有可用号码无法再发号,返回异常
某 Key 从未完成首段初始化首次取号依赖数据库,可能直接失败
新实例启动,且没有取得任何号段没有现成的号码储备,不能凭空参与发号

因此,“数据库故障时再临时扩出十个 Leaf 实例”未必有用。这些实例没有已经领取的范围,数据库又不可访问,新增进程不能自动创造可用的号码储备。

Leaf 数据库失联时当前号段备用号段及新实例的分阶段可用性

图 08:数据库失联后,已有实例能否继续取决于当前段和备用段的剩余储备。

假设 A 剩余十万个号码,B 只剩一百个。若调用方仍均匀请求 A、B,B 很快出现错误,而 A 还可以继续提供结果。双缓冲保证的是本地状态可以提前准备,不是所有实例自动共享号码,也不是自动实现了按剩余量调度。

从这个过程还能看出,判断服务健康不能只检查端口是否存活。至少在理解系统行为时,需要区分“进程活着”“能够服务某个 Key”“能够继续补充该 Key 的号段”这几层含义。它们并不等价。

6.3 数据库切换:为什么分配记录不能倒退#

实例宕机导致号码浪费,通常是可控问题;分配状态倒退则可能破坏唯一性。

下面构造一个教学故障时间线。假设复制链路没有让被选为新主库的副本保留最近一次已对外生效的分配记录:

T1:主库的分配边界为 10001。
T2:A 申请号段,主库提交,边界变为 11001。
T3:A 已经发出 [10001, 11001) 中的一部分号码。
T4:主库故障,仍停在 10001 的副本被提升为新主库。
T5:B 向新主库申请,边界再次从 10001 变为 11001。
T6:B 也取得 [10001, 11001),与 A 已持有的范围重叠。

无论 A 此时仍在运行,还是先前已经将一些号码交付业务,重新分配这个范围都可能造成重复。

数据库切换导致分配边界回退和号段重叠示意图

图 09:新主库若缺少已经提交的高水位,后续申请可能再次分配旧实例仍持有的范围。

这不是 AtomicLong、双缓冲或重试次数可以修复的问题。它发生在更底层:系统忘记了自己已经授权给其他实例使用的数字范围。

美团早期设计文章讨论过复制和主从切换中的数据一致性风险。这里需要记住的不是某个历史部署组合,而是背后的不变量:已经对外生效的分配边界,不能在恢复后退回更小的值;旧的分配者也不能在失去资格后继续与新的分配者并行授权相同范围。1

从旧备份恢复分配表、手工调小 max_id、误删并重建同名 biz_tag,都需要按同样的逻辑审视。它们不是普通配置调整,而可能是在重新开放已经使用过的命名空间。

业务表上的唯一索引能够帮助发现某些冲突,但它不是发号服务可以依赖的补救证明。ID 可能先进入消息、缓存或其他系统;让下游报错不能替代上游正确分配。

号段模式的故障边界可以归纳为:允许已经分配的号码被浪费,不允许已经分配的范围被遗忘后再次授权。

6.4 美团的实际演进:谨慎分配,让缓冲吸收等待#

前面的机制不是几个独立优化点的堆叠。美团在 2019 年的公开文章中介绍了从单段同步补充到双缓冲,再到动态步长的演进:先缓解补段时的请求延迟,再处理固定号段难以适应流量变化的问题。2

在数据库层面,该文章记录了当时使用半同步复制,并通过 Zebra 与 MHA 处理主从切换;还描述了跨机房复制、把半同步超时设置得极长以避免退化为异步的措施。这里仅复述当时的公开实践,不代表当前美团内部架构,也不构成可以直接照抄的高可用配置。2

为什么这与发号器设计有关?MySQL 半同步复制等待的是所需副本对日志接收的确认,不等于所有副本都已执行事务;未收到确认而超时后,还可能退化为异步复制。因此,切换时选用哪个副本、日志是否保留并应用,以及旧主是否被隔离,仍然需要明确约束。16

结合 Leaf 的范围分配模型,可以得到一项架构取舍:

无法可靠确认的新号段,不急着授权使用;已经可靠取得的旧号段,则继续在内存中消费。

这是对机制组合的解释。分配层可以选择更谨慎地等待确认,双缓冲则用已取得的号码余量暂时吸收等待。确认一直无法恢复时,号码会耗尽,服务仍会失败,但不会靠遗忘已经分配的范围换取表面上的成功。

首次取号的懒加载也有取舍。进程启动时加载 Key 列表,不意味着所有 Key 都已有号码。2019 年文章解释过,不同节点未必服务全部 Key,启动时给每个 Key 都领取号段,会造成范围浪费;按历史活跃 Key 选择性预热则被列为当时的后续方向。24

所以,不能只把“首个请求同步查库”视为遗漏优化。它是在冷启动延迟、数据库访问和预留号码浪费之间做了选择。这里真正值得迁移到其他中间件的思路,是把协调的可靠性、消费的快速路径和资源的提前准备放在一起设计。

七、Snowflake 模式:唯一性如何被编码进一个整数#

7.1 时间、机器编号与序列号的分工#

如果能够给每个发号实例分配一个互不冲突的身份,再让实例区分不同时间、同一时间内的不同请求,就不必持续向数据库领取数字范围。

Leaf 的 Snowflake 实现使用如下布局:9

最高 1 位 41 位 10 位 12 位
约定为 0 相对起点的毫秒时间 Worker ID 毫秒内序列

对应的组合方式为:

ID = ((timestamp - epoch) << 22)
| (workerId << 12)
| sequence

Snowflake 64 位 ID 的时间戳 Worker ID 与序列号字段布局

图 10:Snowflake 用时间、Worker ID 与毫秒内序列三个字段共同区分请求。

三个有效字段分别解决不同维度上的区分:时间字段区分不同毫秒,Worker ID 区分不同生成器,序列号区分同一生成器在同一毫秒内的多次发号。

对这三个字段的容量,可以直接由位数计算:10 位有 1024 种机器编号,12 位有 4096 种序列值,41 位毫秒时间覆盖约 69.7 年。时间容量从约定的 epoch 起算,不是从每次服务启动时重新获得 69.7 年。

固定版本默认起点为 1288834974657L。调整起点不是可以随意进行的热配置变更,因为它参与每个 ID 的编码;同一唯一性空间中的历史数据和其他实例都需要被纳入考虑。9

只有在 0 ≤ timestamp - epoch < 2^41 时,时间差才完整落在 41 位字段内,最高位也才能保持为零。固定版本在构造时检查当前时间晚于 epoch,但每次发号并未额外校验 41 位时间上界。因此,位数只是规定允许的编码范围,代码并没有自动提供永不溢出的保证,长期使用仍然要尊重时间和机器编号边界。

7.2 同一毫秒内的并发请求如何处理#

固定版本的生成入口 get(String key) 使用 synchronized,围绕 lastTimestampsequence 更新状态。正常路径可按以下过程理解。9

首先读取当前时间。如果仍然处于上一条 ID 所在的毫秒,就推进序列号;如果进入了新的毫秒,就设置新的起始序列。随后记录本次时间,并将各字段组合为 ID。

序列号只能占用 12 位。当同一毫秒的序列空间耗尽时,源码会等待时间进入下一毫秒,而不是继续让序列溢出到 Worker ID 字段中。9

这条等待路径也有自己的边界:tilNextMillis 循环读取系统时间,没有单独的等待超时。如果等待过程中时间异常,不能把入口处的回拨检查理解成已经覆盖所有时间等待路径。9

一个容易被通用 Snowflake 介绍掩盖的细节是:这个 Leaf 版本在进入新毫秒时,会在 0~99 中选择一个序列起点,而不是始终从零开始。 因此,4096 表示字段可表达的序列数量,并不表示该实现每一毫秒都一定从零使用满 4096 个值。9

把 4096 乘以每秒的毫秒数,只能得到一种理想编码容量估算。真实发号性能还受到同步竞争、时间读取、CPU、网络和服务端排队等因素影响,不能把位数计算直接当作 QPS 压测结果。

另外,虽然方法签名里有 key,该实现的 Snowflake 生成逻辑并未用它切分业务序列。多个 Key 调用同一个生成器,使用的仍是同一套时间、Worker ID 和序列状态;这与号段模式按 biz_tag 管理独立分配行的语义不同。49

7.3 算法成立需要哪些前提#

这个整数编码看上去没有中央数据库,却并不是“不需要协调”。它只是把协调移动到了其他地方。

至少需要保证:同一个编码空间中,不存在并行发号且持有相同 Worker ID 的生成器;同一 Worker ID 不会在重启或时钟异常后重新使用已经使用过的时间与序列组合;所有实例对字段布局和起点的解释一致。

在允许的位宽内,三个字段占据互不重叠的二进制位置。两条 ID 若相等,对应的时间差、Worker ID 和序列就必须分别相等。因此,正确性可以拆开检查:不同实例由机器字段区分,同一实例不同毫秒由时间字段区分,同一毫秒由序列字段区分。

反过来,随机化少量序列起点不能弥补 Worker ID 冲突,也不能提供密码学上的不可猜测性。 两台机器若在同一毫秒使用同一 Worker ID,序列范围仍然可能重叠。

时间字段带来的数值趋势,与第一章所区分的业务提交顺序仍然是两回事。

因此,理解 Snowflake 的重点不应停留在最后一行位运算。真正的分布式问题,是如何在实例创建、重启、失联和替换时,持续满足上述前提。

八、Worker ID 管理:ZooKeeper 怎样参与,又怎样退出日常发号路径#

8.1 新实例注册与已有实例重启#

Leaf 使用 ZooKeeper 的持久顺序节点管理机器编号。顺序节点在创建时由 ZooKeeper 添加序号;持久节点不会因为创建它的会话结束就自动删除。这些性质适合用来保存可恢复的身份分配记录。1012

在固定版本中,相关路径由 leaf.name 构成,可以概括为:

/snowflake/<leaf.name>/forever/<ip>:<port>-<顺序编号>

实例以本机 IP 和配置端口组成地址标识。已有父路径时,启动逻辑读取子节点,查找是否已经存在同一地址的记录;如果存在,就取回该记录中的 Worker ID,并进行时间检查;如果不存在,就创建新的持久顺序节点,从返回的节点名称中解析编号。10

举一个用于理解编号关系的示例:

10.0.0.8:9000-0000000007 → Worker ID 7
10.0.0.9:9000-0000000008 → Worker ID 8

这里不是对 IP 地址做哈希,也不是根据当前有多少个在线实例临时编号。编号来自持久注册记录;地址是查找已有身份的依据。

Leaf Worker ID 通过 ZooKeeper 注册恢复并写入本地缓存的流程图

图 11:ZooKeeper 负责身份注册与恢复,本地文件则提供初始化异常时的缓存回退。

持久身份能够减少实例重启后反复分配新编号,但它也带来责任:必须确保一个已经恢复的身份没有被其他仍在发号的进程同时使用。持久节点存在,不等于它本身就是一把持续约束发号进程的租约锁。

还需要注意一个源码审查点:首次发现父路径不存在的分支,创建节点后使用默认 Worker ID 0,没有像后续新节点分支那样解析实际返回的顺序编号。因此,并发首次启动时的初始化假设需要专门验证,不能仅凭“ZooKeeper 顺序节点唯一”便推导进程最终装载的编号一定正确。这是基于该提交控制流的分析,不是本文复现过的生产事故。10

8.2 本地缓存如何降低对 ZooKeeper 的依赖#

Leaf 在注册或恢复 Worker ID 后,尝试把编号写入本地 workerID.properties。启动过程中发生异常时,会尝试从这个文件中恢复编号。固定版本的默认路径位于 java.io.tmpdir 下,并包含 leaf.name 和端口信息。10

这减少了部分重启场景对 ZooKeeper 的即时依赖,但不同场景必须分别讨论。

已经运行的实例持有 Worker ID、时间和序列状态。日常取号不要求每次访问 ZooKeeper,因此协调服务暂时失联,不必然让每次 ID 请求失败。910

具有正确历史缓存的重启实例可能从本地文件恢复编号。但是,“成功读到一个编号”只解决身份值从哪里来,不代表已经验证该身份没有被并发复用,也不代表恢复了上次发号的精确时间状态。

没有历史身份的新实例既无法从 ZooKeeper 获得身份,又没有合法缓存,就不能安全地猜一个编号加入系统。随机选择或者对地址取模,都不是上述协调逻辑的等价替代。

由此,弱依赖更准确的含义是:已有身份可以支持部分离线运行和恢复场景,而不是任意新实例都能在协调服务失效时自行建立身份。

8.3 身份复用与动态部署的边界#

在动态部署中,身份管理比“代码里有一个 Worker ID 字段”复杂得多。

首先,地址变化可能被识别为新实例,进而分配新的持久编号。官方 README 也提醒,需要关注 IP 变化引起的 Worker ID 消耗。3

其次,10 位字段只容纳 0~1023。持久顺序节点不是一个自动回收的 1024 槽位池。历史注册记录不断增加时,即使同时在线的实例数量很少,新编号仍可能越过可编码范围;生成器构造过程会检查这个范围。910

不能把超出的编号直接对 1024 取模。这样会把不同持久身份重新压到相同的编码值中,破坏原本依赖的唯一性前提。随意删除并重建父路径也不安全,因为可能让历史上使用过的编号重新出现。

再次,本地缓存不能被当作普通配置随镜像或运行快照复制给多个实例。两个进程读到相同 Worker ID,又同时使用相同时间范围,就有组合冲突的风险。默认文件位于临时目录,也不能直接等同于“身份信息已经可靠持久保存”。10

最后,不同的 leaf.name 会进入不同的 ZooKeeper 路径,但这个名称本身没有编码进 Snowflake ID。如果两个隔离的注册域各自分配 Worker ID 0,却使用同样的起点与位布局,那么只拿两个系统返回的裸整数混用,并没有获得跨注册域的唯一性保证。910

这些边界说明,真正要管理的是编号的完整生命周期:谁获得过它,谁现在可以使用它,历史实例是否仍可能恢复,以及不同注册域的结果是否会被混在一起。

九、时钟回拨:为什么有时必须暂停发号#

9.1 时钟回拨怎样造成 ID 冲突风险#

假设同一个 Worker ID 已经在毫秒 T 使用过一组序列号。时间随后推进,机器又因为时钟调整或恢复旧运行状态回到 T 附近。如果生成器再次使用相同序列,就可能拼出已经生成过的整数。

问题的本质并不是“时间显示得不准确”,而是已经使用过的编码组合被再次访问。机器的系统时钟因此成为 Snowflake 正确性的一部分。

固定版本源码在每次取号时比较 timestamp 与内存中的 lastTimestamp。如果当前时间更小,按回拨量进行处理:9

当前时间 < 上次发号时间
→ 计算回拨量 offset
→ offset ≤ 5 毫秒:尝试等待后重新读取时间
→ offset > 5 毫秒:返回异常

小幅回拨时,源码使用 wait(offset << 1),即把两倍回拨量作为等待超时参数。等待结束后再次比较时间;如果仍然落后,继续返回异常。等待被中断也会返回异常。9

Snowflake 运行时检测时钟回拨并等待或拒绝发号流程图

图 12:小幅回拨可短暂等待并复查,超过阈值或等待后仍回拨则拒绝发号。

这不是把时间强行改成过去的 lastTimestamp 后继续发号,也不是用一个随机序列绕开问题。它选择先等待时间追上;追不上时,显式停止这次发号。

这里的 wait 还有并发语义:它会释放当前对象的监视器,恢复执行前再重新获取。因此,等待后的重新检查很重要,不能把这段代码简单理解成“持有锁精确睡眠若干毫秒”。实际唤醒时机也不是精确定时保证。13

调用方必须检查异常状态,不能把错误码当作正常业务 ID 写入数据库。检测到回拨的意义,就是不再把一个无法确认安全的生成过程包装成成功结果。

9.2 运行期检查与启动期检查分别保护什么#

运行时的 lastTimestamp 存在内存中。只要进程重启,这个字段就不会自动保留旧值。因此,运行时检查不能单独覆盖跨进程重启的时间连续性。9

Leaf 会把包含本机地址与时间戳的信息写入 ZooKeeper,并定期更新。固定版本安排首次延迟约 1 秒、随后按固定延迟约 3 秒执行上报。已有节点重启时,会将当前时间与持久节点中保存的时间比较;当前时间更早时,触发启动检查异常。10

但是,周期性上报时间不等于逐次持久化“最后一个已经发出的 ID 所在的毫秒”。 可以构造下面的教学过程:

12:00:00.000:完成一次时间上报。
12:00:00.800:进程仍在发号,随后退出。
12:00:00.500:带有回退时间的实例重新启动。

启动时间 .500 大于已上报时间 .000,可以通过这项比较;但它仍小于退出前已经到达的 .800,存在重新走过一段历史发号时间范围的可能。是否真的产生相同 ID,还取决于该时间范围内的具体序列使用情况,不能直接断言每次都会冲突。

这说明两个检查观察的状态不同:运行时检查拥有本进程内较精确的最近发号时间;启动检查依据的是周期性持久化的时间样本。后者并不是前者的无损恢复。

运行期时间检查重启检查与本地缓存回退的安全边界

图 13:运行期检查、启动样本检查与身份缓存回退保护的是不同边界,不能相互替代。

这项缺口提示了一个后续设计问题:与其不断追记“刚才发到了哪里”,能否先可靠记录“接下来最多允许用到哪里”?9.4 节将在明确假设下展开这项思路。

9.3 设计文章与开源实现有哪些边界差异#

阅读 Leaf 时,很容易把早期设计文章里的机制,全部记成当前看到的开源实现。

2017 年文章描述过获取其他运行节点时间、计算平均值并检查本机偏移的方案。然而,在本文固定的 SnowflakeZookeeperHolder 中,正常启动过程没有实现那条完整的跨节点 RPC 校时路径。不能仅因为仓库中存在相关命名或辅助类型,就认定启动时真的执行了该流程。110

更重要的是启动异常后的处理范围。该实现将包括时间检查异常在内的启动异常统一交给外层 catch (Exception),然后尝试读取本地 Worker ID 文件。只要读取成功,初始化仍可能返回成功。10

因此,下面这句话并不准确:

只要启动时发现时钟回拨,Leaf 就一定拒绝启动。

准确的描述应当是:正常注册路径包含启动时间检查,但该版本存在统一异常后的本地身份缓存回退;实际启动结果还取决于是否进入回退以及缓存是否可读。 缓存只保存 Worker ID,并没有同时恢复精确的历史发号时间上界。10

这并不是说这些检查没有价值,而是说安全保证必须覆盖完整控制流。只分析正常路径上的 if,没有分析异常被谁捕获、之后是否继续运行,很容易得出比代码实际行为更强的结论。

在唯一性与持续发号发生冲突时,应该首先明确系统能够证明什么。暂停发号是一种可以处理的失败状态;未经证明地重用身份和时间范围,则可能把失败转化为难以追踪的数据冲突。

9.4 扩展推演:先持久化可使用的时间范围#

本节是根据前述缺口构造的扩展设计,不是 Leaf 原生功能,也不是不附带前提的完整实现。 它保留相同 Worker ID 和时间字段,尝试通过提前预留时间范围,而不是逐次持久化每条 ID,改善跨重启的时间重用问题。

先明确约束:同一 Worker ID 的旧实例已经停止且不能从快照重新参与发号;持久化存储能够可靠、单调地保存上界;所有采用这项协议的发号入口都检查范围;时钟值仍在位布局可编码的范围内。编号独占本身需要另外保证,不能因为保存了时间就认为身份冲突也解决了。

定义一个持久字段 reservedUntil,记为 H当前已经授权给该身份使用的最大毫秒值,含这个毫秒。 进程只允许在已确认的时间范围内发号,超过上界之前,必须先提交一个更大的上界。

正常运行可以按下面的逻辑组织:

即将用完已确认的时间范围
→ 在可靠存储中原子推进 reservedUntil
→ 获得持久提交的确认
→ 才允许发出新范围内的 ID

这与号段预分配的结构相似,但预留的对象变成了时间字段。请求通常在已获授权的范围内本地计算,上界扩展承担持久化成本。

重启则执行另一套规则。新进程读取旧上界 H_old,不再尝试猜测旧进程究竟发到哪个毫秒,而是把它当作已经可能使用过的最远边界。新进程只有在当前时间严格大于 H_old,并成功预留新上界之后,才允许发号。

在一个教学例子中:

旧进程已持久预留到 12:00:03.000。
旧进程实际只发到 12:00:00.800 就退出。
新进程带着 12:00:00.500 的时间启动。
新进程不在 .500 继续使用旧范围,
而是等待时间严格超过 12:00:03.000,
确认新的持久预留后,才开始新的发号区间。

持久化时间上界并在重启后启用不重叠时间范围

图 14:扩展设计先持久化时间上界,再让重启实例进入与旧进程不重叠的新范围。

其推导很直接:旧进程所有成功发出的时间戳满足 T_old ≤ H_old;重启后的所有新时间戳满足 T_new > H_old。只要两项约束持续成立,同一 Worker ID 的新旧运行期就不会使用相同时间字段,也就不需要恢复旧进程每个毫秒内的具体序列。

这里的“持续”很重要。新进程不能只在启动时检查一次,随后遇到回拨就重回旧区间。它应保留本次运行的下边界,并继续检查时间没有越过上下界、没有破坏毫秒内序列的递增规则。扩展上界成功之前,仍只能使用此前确认的范围。

持久化调用失败或提交结果不确定时,也不能直接使用计划中的新上界。可以继续消费此前已经确认、且仍满足时间约束的范围;越界前无法确认新授权,就必须停止。重启时读不到可靠的上界,也不能用仅含 Worker ID 的缓存绕过这一检查。

代价同样清楚:预留窗口越长,持久化次数通常越少,但崩溃后可能放弃更多时间,并等待更久;窗口越短,更新次数越多。大幅时钟回拨还会增加等待,不能把恢复时间承诺成固定的一段窗口。若多个窗口已经提前获批,就必须以最后确认的最远上界计算恢复边界。

还有两项不能省略的边界。第一,历史上未执行该协议的旧进程没有留下可信上界,迁移时不能凭空认为已有范围安全。第二,可靠存储回滚、活跃虚拟机快照复制、同一身份被同时复用,仍然会破坏前提;只给未参与校验的请求增加一个“代际编号”字段,也不会自动阻止它们发号。

这个扩展的价值不在于承诺免费容灾,而是提供一种可推导的交换:提前持久化授权范围,换取日常发号时不逐条落盘;恢复时放弃不确定的范围,换取新旧运行期不重叠。 它把 Snowflake 的时间边界重新连接到了号段模式已经讲过的设计原则。

十、用一次订单创建串起 Leaf 的职责边界#

10.1 订单服务如何获得并使用内部主键#

下面使用一个简化的订单创建系统作为教学案例。它用于串联前文机制,不代表美团内部订单系统的真实架构。

假设订单服务使用号段模式获取内部主键,业务 Key 为 order-internal。选择内部主键,是为了把数据库关联标识与向外展示的订单编号区分开;本例不让一个可推测的数字同时承担隐私保护或访问授权职责。

一次正常请求可分为以下阶段:

接收创建订单请求
→ 校验身份、参数与业务幂等键
→ 查询是否已有该请求对应的订单
→ 向 Leaf 的 order-internal 获取 ID
→ 在业务数据库中创建订单并提交
→ 返回订单结果

Leaf 只参与“获得 ID”这一步。它不知道购物车内容,也不判断用户是否已经点击过提交,更不会替业务系统管理订单事务。

为了说明并发幂等,业务表可以具有两个不同用途的约束:

CREATE TABLE orders (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
request_key VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_request (tenant_id, request_key)
) ENGINE = InnoDB;

这是教学表结构,省略了金额、明细和时间等真实订单字段。id 唯一表示记录身份;(tenant_id, request_key) 唯一表示一个约定范围内的创建意图。

前面的“查询已有订单”只是快速返回路径,不是并发正确性的充分保证。两个请求可能同时查不到,然后各自拿到不同的 Leaf ID。真正阻止两个请求都创建成功的,需要是业务数据库的唯一约束或等效的事务性命令去重机制。

订单创建流程中 Leaf 发号与业务幂等约束的职责分工

图 15:Leaf 负责分配内部主键,订单服务仍需用业务幂等键吸收超时与重试。

当发生业务幂等键冲突时,失败的一方应按业务协议读取已经提交的对应结果。还需要校验相同幂等键是否携带相同请求内容,不能让同一个键把两次不同购买意图混为一谈。

但如果冲突的是 Leaf 生成的主键 id,那属于另一个问题:它可能暴露发号空间或运行环境异常,不能简单当作“用户重复提交”忽略。

10.2 获取 ID 超时,重试意味着什么#

先看第一种情况:Leaf 已经生成 50001,但响应在网络中丢失。订单服务只知道调用超时,不知道这个数字是多少,也不知道它是否曾经被生成。

再次请求 Leaf,可能获得 50002。原来的 50001 因为没有被用来写入订单,成为一个空洞。Leaf 没有承诺同一次业务意图重试时必须返回同一个数字;固定版本的两个生成入口也没有以请求幂等键查询历史发号结果的逻辑。49

再看第二种情况:订单服务成功取得 50002,但写入订单前进程退出。这个号码同样可能没有对应的订单记录。对于内部唯一标识,这通常不影响正确性。

第三种情况更重要:订单 50002 已提交,但返回给用户的响应丢失。用户再次提交相同 request_key 时,业务系统应该识别原来的订单并返回相同业务结果,而不是因为又获得一个新的 Leaf ID,就创建一张新订单。

因此:

发号唯一性:不同分配结果不冲突。
业务幂等性:同一个创建意图只产生一个约定的业务结果。

这两个目标相关,但互不替代。获得两个不同的 ID,并不能证明应该创建两张订单;一个 ID 被成功分配,也不能证明对应业务已经提交。

把这个边界明确下来,才能正确理解号码空洞、请求重试和订单去重,而不是试图让 ID 服务回答它从未记录过的业务问题。

10.3 明确唯一性的范围,而不是只写“全局唯一”#

最后回到整篇文章最基础的约束:所谓全局,必须有一个明确的边界。

在号段模式中,不同 biz_tag 对应独立的分配行。如果 order-internalcoupon-internal 都从同一初值起步,它们完全可能发出相同的裸整数。唯一性是在各自约定的分配空间中建立的,不是自动覆盖任意 Key 的数字并集。46

如果两类对象需要进入一个只允许使用裸 ID 的公共存储空间,就必须事先统一分配空间,或者把业务命名空间作为标识的一部分。不能先让各业务独立发号,之后再假定数字天然互不相交。

Snowflake 的边界则由 Worker ID 协调域、起点和字段布局共同决定。leaf.name、数据库环境名称、机房名称不会自动出现在返回的 64 位整数里。两套彼此隔离的系统,各自内部不冲突,也不代表把结果合并后仍然不冲突。910

第二章中两种模式不能任意互相降级,也可以归结为同一原因:接口返回类型相同,不代表发号空间已经隔离。

10.4 怎样验证,而不是只统计“跑出了多少个 ID”#

验证应围绕前文的不变量展开,而不是只在正常流量下生成一百万个数字、发现没有重复,就把所有故障保证视为已经证明。

下面列出四组适合在隔离测试环境中执行的检查。它们是集成验证方案,不是本文已完成的真实集群实验。

检查目标构造条件需要观察的结果
多实例分配不重叠两个生成器连接同一测试分配表,使用同一 Key,并发领取不同长度的小号段已提交区间两两不相交;进程失败或提交响应丢失后,不重新消费归属不明的旧范围
号段切换不混用状态使用小号段高频切换,并在取值、边界判断、发布就绪处设置可控暂停点成功值属于被授权的那一轮号段;耗尽值不越界返回;切换前后线程观察符合预期
依赖故障有清晰结果当前段有余量时暂停备用段加载,再逐步耗尽现有储备,最后恢复数据库有余量时继续;备用就绪时可切换;两段耗尽时显式失败;恢复后能够重新补段
身份与时间恢复满足前提使用可控时钟或测试接缝,覆盖运行回拨、重启、缓存存在/缺失,并发首次注册分别记录实际异常和回退路径;不能预设“时间异常一定拒绝启动”;检查恢复后是否重用身份与时间组合

针对第六章的边界倒退,也可在专用测试集群中保留一个落后的副本,模拟错误提升并观察号段重叠。这个实验用于验证故障前提,不能在业务分配表上通过手动回退 max_id 来演示。

本次写作实际执行的是四个更小的 Java 机制检查。 配套 LeafMechanismChecks.java 不连接 MySQL 或 ZooKeeper,不启动 Leaf Server,也没有替代上述集成测试。它使用受控线程交错与断言检查:

最小检查本次观察到的结果
去掉读锁的简化模型混用旧候选值与新上界旧候选值 200 被错误地与新上界 500 比较并接受,反例成立
在对应简化模型中保留读锁写线程在读锁释放前不能完成受保护的切换,耗尽候选值被拒绝
有界池饱和后触发 CallerRunsPolicy任务在请求线程执行;该线程持有读锁时,限时获取写锁未成功
设置加载标记后提交被拒绝任务未执行,其 finally 不能清理;提交方显式恢复标记后状态回到可重试

本次在 Java 17 环境中运行,四项断言通过;代码未使用版本限定特性,可直接在 Java 21 中编译执行。锁升级检查用限时 tryLock 避免测试自身永久挂住;拒绝检查通过关闭执行器稳定构造拒绝结果,不是对实际数据库慢查询的压力测试。

配套代码可以按以下方式运行:

Terminal window
javac LeafMechanismChecks.java
java LeafMechanismChecks

这些结果支撑的是指定的局部并发语义,不是整套 Leaf 已经获得全故障证明。对于分布式正确性,最有说服力的证据应同时包括:解释不变量、追踪完整控制流,以及在明确条件下复现关键交错。

回到 Leaf 的设计价值。 号段模式通过持久化分配边界,把高频数据库协调变成低频的范围申请,再用双缓冲和动态步长改善热路径延迟与负载适应能力;Snowflake 通过协调机器身份,让大部分发号工作变成本地计算。4910

这些优化都没有取消正确性的成本,只是重新安排了成本的位置。前者要求范围分配的历史不能倒退,后者要求身份、时间与序列组合不能被重用。

理解一个生产中间件,不仅要看正常情况下它怎样快速返回,还要看依赖失联时它凭什么继续、重启之后它依靠什么恢复,以及哪些条件一旦无法确认,就必须停止。Leaf 把这些问题浓缩在一个看似简单的整数里,也正是它值得被作为生产架构案例拆解的原因。

参考资料#

历史文章用于交代演进背景,具体参数、状态字段与控制流以本文固定提交的源码为准。新增 Java 文档用于解释锁与线程池语义,不表示 Leaf 原项目要求 Java 21。本文没有复测历史性能数据,也没有执行真实数据库切换或 ZooKeeper 故障注入;最小机制检查的范围见 10.4 节。

  1. 美团技术团队:Leaf,美团点评分布式 ID 生成系统,2017-04-21
  2. 美团技术团队:Leaf,美团分布式 ID 生成服务开源,2019-03-07
  3. Leaf 官方中文 README:模式、接入与配置说明
  4. SegmentIDGenImpl.java:号段初始化、双缓冲、动态步长与取号控制流
  5. IDAllocDaoImpl.java:号段分配事务
  6. IDAllocMapper.java:查询与边界更新 SQL
  7. Segment.java:单个号段的内存结构
  8. SegmentBuffer.java:双缓冲与并发状态
  9. SnowflakeIDGenImpl.java:字段布局、序列与运行时回拨处理
  10. SnowflakeZookeeperHolder.java:身份注册、时间检查与本地缓存回退
  11. MySQL 8.4 Reference Manual:InnoDB 中不同 SQL 语句设置的锁
  12. Apache ZooKeeper Programmer’s Guide:节点、顺序编号与会话语义
  13. Java SE 21 API:Object.wait 的监视器与等待语义
  14. Java SE 21 API:ReentrantReadWriteLock 的重入、发布与锁升级限制
  15. Java SE 21 API:ThreadPoolExecutor 的队列、线程上限与拒绝策略
  16. MySQL 8.4 Reference Manual:半同步复制的确认与退化行为
  17. Java SE 21 API:Lock 的内存同步语义
登神之路 01:美团 Leaf,生产级分布式 ID 服务的架构与实现
https://jupiter-ws.cn/posts/backend/road-to-god/01_meituan_leaf_production_id_architecture/
作者
Jupiter
发布于
2026-09-06
许可协议
CC BY-NC-SA 4.0