系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL
- Kafka(上):吞吐的来源是顺序 IO
- Kafka(下):生产端、broker 与消费端的可靠性配置
- RPC 框架:像本地调用一样调远程的代价
- 消息语义:按 at-least-once 设计业务代码
- 熔断与限流中间件:把失败当作正常状态管理
- 唯一 ID 中间件 Leaf:号段模式与雪花模式(本篇)
Leaf 如何处理唯一 ID 生成的实现细节
(分布式锁与唯一 ID)记录过云盘选择唯一 ID 方案的过程。那篇列了 DB 自增、号段、UUID、雪花四条路,结论是云盘选号段:DB 已是强依赖,加一张发号表不引入新组件;雪花的机器位分配和时钟回拨处理方案当时未验证。那篇侧重业务侧的选型;号段空洞、时钟回拨和 workerId 冲突的处理尚未展开。每个业务自己处理,意味着每接入一个服务就要重写一遍发号逻辑和相关边界条件。
美团使用的 Leaf(2019 年开源)作为发号中间件。本文说明 Leaf 如何处理号段和雪花模式的实现细节。Leaf 提供 segment 与 snowflake 两种模式:segment 仍依赖 DB,但将访问频次降至较低水平;snowflake 在本地生成 ID,但需要处理时钟与机器位。
材料范围。 我没有在生产中使用 Leaf。下文关于双 buffer 与 snowflake 行为的内容,来自源码阅读和本地 Leaf 服务验证,未在生产环境确认;本文结论限于文档与源码描述。2016 系列第 12 篇讨论业务方案谱系与选型理由,本文讨论中间件的实现机制,不重复前文内容。
UUID 与 DB 自增的限制
UUID 和 DB 自增分别有不同限制,后文的号段和雪花模式分别处理其中一部分限制。
DB 自增主键在单库内提供严格单调递增的唯一值,实现简单。限制在于容量与可用性:每生成一个 ID 都要一次 DB 往返,发号速率受 DB 写入能力限制;DB 是单点,主从切换时需要重新定义序列语义(主库序列领先从库,切换后可能回退或重复)。当每条业务写入都要生成 ID 时,DB 的容量也限制全系统的发号能力。
UUID v4 在本地生成,没有中心服务,吞吐受 CPU 限制。其随机性带来索引写入问题:作为 InnoDB 聚簇主键时,随机值使 B+ 树插入位置分散,频繁触发页分裂,导致索引空间膨胀和写入放大。UUID 的 36 字符文本表示还占用更多存储。UUID 满足全局唯一和本地生成,但不具备有序性;主键索引的写入通常需要有序性。
号段模式仍由 DB 保证唯一性,但将访问频次从「每 ID 一次」降到「每段一次」。
号段模式的基本形态
号段模式使用一张发号表,每个业务分配一行:
-- leaf_alloc 发号表(最小结构)
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(64) PRIMARY KEY, -- 业务标识
max_id BIGINT NOT NULL, -- 当前已分配到的最大值
step INT NOT NULL, -- 每次领取的号段长度
update_time TIMESTAMP NOT NULL
);实例取号是一次原子更新加读取:
-- 把 max_id 推进一个 step,并读回更新后的值
UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = 'order';
-- 读回新 max_id,实例获得段 [old_max, new_max)拿到号段的实例在内存中递增生成 ID,段内发号不再访问 DB。DB 写入从「每 ID 一次」降到「每 step 一次」:step 取 1000 时,DB 写入次数减少三个量级。号段模式通过段内本地递增降低 DB 访问频次。
号段模式有三个限制。实例领到 [1000, 2000) 后发了 350 个就重启,剩余 650 个号不会再发出。空洞不影响唯一性,但会影响连续性。DB 故障期间,实例仍能使用段内剩余号发号;段耗尽而 DB 未恢复时,发号会阻塞。跨实例也不保证严格单调:两个实例分别持有 [1000, 2000) 和 [2000, 3000),按业务请求时间观察到的 ID 会交错,1300 可能晚于 2500 发出。号段模式保证号段不重叠和段内递增,无法保证全局严格递增。
Leaf segment 的双 buffer 预取
单 buffer 的号段模式在段耗尽时要同步等待 DB,DB 响应波动会直接影响发号路径。Leaf segment 模式通过双 buffer 减少这种影响。
两段缓冲交替使用。当前段(Buffer A)发号时,后台监控已发出量;按官方文档与开源版源码,当前段已下发约 10%(剩余量低于 90% × step)且下一段未就绪时,异步启动线程从 DB 领取下一段并填入 Buffer B。A 耗尽时切换到 B,B 成为新的当前段,原 A 的位置用于下一轮预取。切换是内存指针操作,不阻塞发号。预取在当前段刚开始使用后触发,为 DB 请求提供接近一整段的完成时间。
双 buffer 用于缓冲 DB 的短暂响应波动。预取是异步的,DB 响应变慢只会影响 B 的就绪时间,不影响 A 继续发号。A 耗尽前 B 已就绪时,发号路径无需等待 DB。实例重启时,两个 buffer 中未使用的号都会丢失,最坏情况下接近两个号段;因此,双 buffer 的空洞可能大于单 buffer。
本地验证中,已发量超过阈值后,预取线程异步访问 DB,发号主路径不阻塞。DB 停止几十秒时,A 段未耗尽仍可正常发号;A 耗尽而 B 未就绪时,发号线程会阻塞等待。该行为与源码描述一致,但未在生产负载下验证预取与发号并发的边界情况。
雪花模式的位分配与工程问题
雪花模式在每次发号时不访问 DB,实例在本地用时间戳、机器位和序列号组成 ID。Twitter 2010 年公开的原始方案将 64 位分成四段:1 位符号位、41 位时间戳、10 位 workerId、12 位序列号。
41 位时间戳按毫秒计,2^41 毫秒约 69 年。10 位 workerId 最多 1024 个实例。12 位序列号支持单实例每毫秒 4096 个 ID。同一毫秒内序列号递增,毫秒切换时序列号归零;时间戳高位在前,所以 ID 在单实例上按生成时间趋势递增。
该方案减少了对 DB 的依赖,ID 生成吞吐受本地 CPU 限制,但需要处理两个工程问题。
workerId 的分配。 10 位 workerId 必须在所有并发实例间不冲突,否则两个实例会生成相同 ID。机器频繁上下线时,手动配置需要同步更新,配置错误会重复发号。Leaf snowflake 模式使用 ZooKeeper 分配:实例启动时在 ZK 上创建持久顺序节点,节点序号作为 workerId,并定期上报时间戳进行时钟校验。每次发号仍由实例在本地完成,ZK 负责机器位分配与时钟校验的协调;持久顺序节点如何避免回收后重发等细节,见ZooKeeper/etcd:小数据强一致的协调服务。
时钟回拨的处理。 雪花模式依赖机器时钟单调递增。NTP 校时、虚拟机迁移、手动改时间都可能让时钟回退。回拨发生后继续发号时,时间戳会小于上一毫秒,新生成的 ID 可能与之前已发出的 ID 重复,或出现时间倒序。Leaf 的处理分三档:启动时校验当前时间不小于 ZK 上报的历史时间,回拨超阈值时拒绝启动;运行中短暂回拨(毫秒级)时等待时钟追平再发号;长时间回拨时拒绝服务。为避免重复 ID,长时间回拨时的拒绝服务会中断发号。
号段模式与雪花模式的区别
号段和雪花模式处理的约束不同。号段通过批量取号降低 DB 访问频次,DB 仍是发号的最终来源;雪花通过时间戳和机器位在本地生成 ID,运维需要处理时钟同步和 workerId 管理。
递增性的保证也不同。号段段内严格递增,跨实例按业务时间观察是趋势递增但不严格单调,相邻 ID 还可能有步长跳变(一个实例发到 1350,另一个实例从 2000 起)。雪花单实例按时间趋势递增,跨实例因时钟和 workerId 不同无法保证严格单调,但因时间戳在高位,整体仍可大致按时间排序。
选择号段还是雪花,需要评估可接受的依赖:号段依赖 DB 并会产生空洞,雪花需要维护时钟和 workerId。发号量有限、DB 已是强依赖的场景通常适合号段;发号量高且不希望将 DB 作为发号瓶颈的场景通常适合雪花。Leaf 在同一个中间件中提供两种模式,由业务按场景选择。
边界清单
- Leaf 生成的 ID 全局唯一且趋势递增。 限制:号段模式的实例重启会丢弃两个 buffer 中未使用的号,雪花模式跨实例不保证单调。把 ID 连续性作为业务前提会出错,例如用「上一个 ID 加一」做关联推断。
- 号段模式仍依赖 DB。 双 buffer 能缓冲 DB 的短暂响应波动,无法处理 DB 长时间故障。A 耗尽而 B 未就绪时发号阻塞。DB 恢复时间窗要和业务的发号 SLA 对齐。
- 号段 ID 用作分库分表键时注意分布形态。 段内 ID 连续,用作取模分片键时分布均匀;用作范围分片键时段内连续 ID 集中写入同一分片造成热点。雪花 ID 高位是时间,整体趋势递增,范围分片同样有时间局部热点。这一条呼应分库分表:用应用层复杂度换数据库容量分库分表键的选择。
- 雪花模式强依赖机器时钟。 部署环境必须确认 NTP 配置,回拨阈值要和业务的发号连续性要求对齐。虚拟机迁移、容器时钟漂移是常见诱因。Leaf 的启动校验和运行时回拨处理只覆盖它能检测到的回拨,检测不到的时钟异常仍会生成问题 ID。
- workerId 分配依赖 ZooKeeper。 ZK 不可用时新实例无法启动并分配 workerId,已运行的实例仍可发号。workerId 分配机制的细节(持久顺序节点回收与重发风险)见ZooKeeper/etcd:小数据强一致的协调服务。
- 雪花序列号每毫秒 4096 个是单实例上限。 单实例单毫秒发号超过 4096 会触发溢出处理(等待下一毫秒或抛异常)。单实例发号峰值应低于这个上限。
参考资料
- 美团技术团队《Leaf——美团点评分布式 ID 生成系统》(2017 年内部实践、2019 年开源相关公开资料)
- Leaf 开源版源码(segment 双 buffer 预取、snowflake ZK workerId 分配与时钟校验)
- Twitter snowflake 原始方案(2010 年公开,64 位时间戳加 workerId 加序列号位分配)
- 分布式锁与唯一 ID(2016 系列第 12 篇,本篇引用不复述)
- ZooKeeper/etcd:小数据强一致的协调服务(ZK 持久顺序节点分配 workerId)
- 分库分表:用应用层复杂度换数据库容量(ID 用作分库分表键的分布形态)
