Leaf 分布式 ID 生成实战——美团 Leaf 从原理到落地
Leaf 分布式 ID 生成实战——美团 Leaf 从原理到落地
分库分表后自增主键废了、雪花算法机器号怎么分配、两步一个 ID 的数据库方式扛不住高并发——美团 Leaf 专治这些。号段模式用双 Buffer 预加载把 DB 查 ID 从两步优化到近零步,雪花模式用 ZooKeeper 自动分配 Worker ID 解决机器号冲突。本文从两种模式的原理讲起,到 DB 建表、部署配置。
为什么需要分布式 ID
单机 MySQL 时代,AUTO_INCREMENT一把梭搞定。微服务分库分表后,一张表拆成 16 张分片——自增主键没法用了,因为 16 张表各自从 1 开始递增,全局一定出现重复 ID。
分布式 ID 的核心要求就两条:
- 全局唯一
- 高性能(每秒能生成几万到几十万个)
常用的方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 简单,本地生成 | 太长、无序,B+Tree 索引不友好 |
| 自增主键 + 步长(DB 号段) | 有序、全局唯一 | 每次取号要查 DB,不够快 |
| Redis 自增 | 快 | Redis 持久化非强项,挂了丢数据 |
| 雪花算法 | 本地生成,极快 | 强依赖机器时钟,时钟回拨可能产生重复 ID |
美团 Leaf 把 DB 号段和雪花算法两个方案都收了进来,各取所长:
- 号段模式(Segment):DB 批量取号,服务端缓存 + 双 Buffer 预加载,DB 一跳抵一万次
- 雪花模式(Snowflake):纯本地生成,用 ZooKeeper 自动分配 Worker ID,解决时钟回拨
号段模式——怎么做到又快又唯一
思路
号段模式的核心就是:一次从 DB 取一批 ID,在内存里慢慢用,用完了再取下一批。
这样一来,取 1000 个 ID 才查一次 DB。step越大,DB 压力越小,但万一 Leaf 服务挂了,没发出去的那批 ID 就丢了(不过问题不大,ID 不要求连续,跳号无所谓)。
双 Buffer——用的时候后台已经在准备下一批了
上面的方案还有一个问题:每次号段用完时,业务请求要等着 Leaf 去查 DB——这 10ms 左右的延迟对高并发场景来说还是有影响。
Leaf 的解法是双 Buffer:每个 key 维护两个号段,当前号段快用完(比如用了 90%)时,后台异步线程去 DB 把下一个号段拉回来备着。当前号段用完后直接切到备用的,毫无停顿。
DB 表结构
CREATEDATABASEleaf;USEleaf;CREATETABLEleaf_alloc(biz_tagVARCHAR(128)NOTNULLDEFAULT''COMMENT'业务标识,比如 user、order',max_idBIGINTNOTNULLDEFAULT1COMMENT'当前已分配的最大 ID',stepINTNOTNULLDEFAULT1000COMMENT'每次分配的号段长度',descriptionVARCHAR(256)DEFAULTNULLCOMMENT'业务描述',update_timeTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,PRIMARYKEY(biz_tag));插入一条初始数据:
INSERTINTOleaf_alloc(biz_tag,max_id,step,description)VALUES('user',1,1000,'用户表 ID');号段模式的原理就这一条 SQL:
-- 取号的时候:max_id = max_id + step,然后返回新的 max_idUPDATEleaf_allocSETmax_id=max_id+stepWHEREbiz_tag='user';-- 再查出来当前的 max_id 是多少SELECTbiz_tag,max_id,stepFROMleaf_allocWHEREbiz_tag='user';Leaf 服务拿到max_id=1001,那就知道这批号段是[max_id - step + 1, max_id]=[2, 1001],一共 1000 个 ID 可用。
雪花模式——纯内存生成,用 ZooKeeper 管 Worker ID
号段模式需要一个独立部署的 Leaf 服务,雪花模式不需要——它是在业务服务本地直接生成 ID,性能天花板更高。
雪花算法原理
一个 64 位 long 型 ID,按位分成四段:
┌─┬──────────────────────────────────────┬────────────┬──────────┐ │1│ 41 位时间戳 │ 10 位机器号 │ 12 位序号 │ │ │ (毫秒级) │(Worker ID)│ │ │0│ │ │ │ └─┴──────────────────────────────────────┴────────────┴──────────┘ 示例:71022798411251713 拆解: 时间戳部分 = 1692625699(相对时间) 机器号 = 10 序号 = 1- 1 位:固定为 0(保证生成的 ID 是正数)
- 41 位时间戳:毫秒级,可以支撑 69 年(从起始时间算起)
- 10 位 Worker ID(机器号):最多支持 1024 个节点
- 12 位序号:同一毫秒内最多生成 4096 个 ID
先解释一下Worker ID 到底是干嘛的。
你部署了 3 台服务器,每台都在跑雪花算法生成 ID。假设第一台和第二台在同一毫秒同时生成了一个 ID——如果两台服务器没有区分标识,时间戳一样、序号一样,生成的 ID 就一模一样。
服务器 A:时间戳=1692625699000 序号=1 → 生成的 ID = 71022798411251712 服务器 B:时间戳=1692625699000 序号=1 → 生成的 ID = 71022798411251712 ↑ 一模一样!所以雪花算法留了 10 位给每台机器一个唯一编号——Worker ID。同一毫秒不同机器生成的 ID,Worker ID 不同,整个 ID 就不同:
服务器 A(Worker ID=1): 时间戳 + 0000000001 + 序号 → ID = xxx...1...001 服务器 B(Worker ID=2): 时间戳 + 0000000010 + 序号 → ID = xxx...2...001 ↑ 不同机器,全局唯一单机部署:Worker ID 写死为 0 就行,永远不冲突。
多机部署:每台机器的 Worker ID 必须全局唯一。问题就变成了——这 1024 个编号,谁来分配?怎么保证不重复?
Leaf 的做法是用 ZooKeeper 自动分配。每台机器启动时去 ZK 注册,ZK 给它一个唯一的编号。省掉了手动改配置、担心配重的麻烦。
所以理论峰值 QPS = 4096 × 1000 = 409 万/秒。一个机器每毫秒 4096 个 ID,日常够用。
雪花算法最大的坑——时钟回拨
服务器 NTP 校时,把系统时间往回拨了 2 秒。雪花算法依赖时间戳递增来保证 ID 不重复,时间往回走了→可能生成跟上一秒一模一样的 ID→数据库插入冲突。
Leaf 的应对:
- 回拨不超过 5ms:sleep 等待,等时间追上之前的时间戳
- 回拨超过 5ms:直接拒绝生成,返回异常,不停留在"可能出错"的状态
- 用 ZooKeeper 维护 Worker ID:每次启动时去 ZK 注册,ZK 会分配一个全局唯一的 Worker ID。同时 Leaf 会往 ZK 周期性上报当前时间戳,如果重启后发现本地时间比 ZK 记录的值还小→抛出异常
ZooKeeper 怎么分配 Worker ID
项目结构
参考 Leaf 官方,把项目下载下来,下面目录结构中的 core 从 leaf-core 模块中拷贝,其他 controller、service、constant 从 leaf-server 模块拷贝。
完整拷贝过来之后有些包路径改一改就行,改成自己的。
另外我们还需要建个数据库,mysql 数据库的
CREATETABLE`leaf_alloc`(`biz_tag`varchar(128)NOTNULLDEFAULT'',`max_id`bigint(20)NOTNULLDEFAULT'1',`step`int(11)NOTNULL,`description`varchar(256)DEFAULTNULL,`update_time`timestampNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,PRIMARYKEY(`biz_tag`))ENGINE=InnoDB;insertintoleaf_alloc(biz_tag,max_id,step,description)values('leaf-segment-test',1,2000,'Test leaf Segment Mode Get Id')之后,在 pom.xml 里面,因为这个项目比较老了,所以我们包的依赖管理就单独在这个 biz 模块就可以了,不放在父工程包依赖管理了
<properties><common-io.version>2.4</common-io.version><perf4j.version>0.9.16</perf4j.version><druid.version>1.0.18</druid.version><mybatis.version>3.3.0</mybatis.version><curator-recipes.version>2.6.0</curator-recipes.version><zookeeper.version>3.6.0</zookeeper.version></properties><!-- 如果要服务注册,feign 调用等自己加,这里不多介绍 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>commons-io</groupId><artifactId>commons-io</artifactId><version>${common-io.version}</version></dependency><dependency><groupId>org.perf4j</groupId><artifactId>perf4j</artifactId><version>${perf4j.version}</version></dependency><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid</artifactId><version>${druid.version}</version></dependency><dependency><groupId>org.mybatis</groupId><artifactId>mybatis</artifactId><version>${mybatis.version}</version></dependency><!-- zk --><dependency><groupId>org.apache.curator</groupId><artifactId>curator-recipes</artifactId><version>${curator-recipes.version}</version><!-- 为防止日志冲突,添加以下排除项 --><exclusions><exclusion><artifactId>log4j</artifactId><groupId>log4j</groupId></exclusion><exclusion><artifactId>org.slf4j</artifactId><groupId>slf4j-reload4j</groupId></exclusion><exclusion><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId></exclusion><exclusion><groupId>org.apache.zookeeper</groupId><artifactId>zookeeper</artifactId></exclusion></exclusions></dependency><dependency><groupId>org.apache.zookeeper</groupId><artifactId>zookeeper</artifactId><version>${zookeeper.version}</version><!-- 为防止日志冲突,添加以下排除项 --><exclusions><exclusion><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId></exclusion><exclusion><groupId>org.slf4j</groupId><artifactId>slf4j-log4j12</artifactId></exclusion><exclusion><groupId>log4j</groupId><artifactId>log4j</artifactId></exclusion></exclusions></dependency>还要在 /service 包下修改一下 SegmentService,因为我用的是 mysql8.x,如果你不是可以不用改
如果你想为某个业务添加全局ID,比如用户ID、帖子ID,要在数据库里面执行一个插入SQL
INSERTINTO`leaf`.`leaf_alloc`(`biz_tag`,`max_id`,`step`,`description`,`update_time`)VALUES('user-id',10100,2000,'用户 ID',now());# 分别是业务唯一表示,biz-tag# ID 起始值# 步长# 描述信息
另外,第二种雪花算法是基于 Zookeeper,所以如果要用的话,就得配置上
leaf.name=idle-store leaf.segment.enable=true leaf.jdbc.url=jdbc:mysql://127.0.0.1:3306/leaf?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai leaf.jdbc.username=root leaf.jdbc.password=Hliu1026. leaf.snowflake.enable=true leaf.snowflake.zk.address=127.0.0.1:2181 leaf.snowflake.port=2222默认两个都是关闭的,用哪个就要配置哪个。
