Leaf : 美团分布式ID生成服务
There are no two identical leaves in the world.(世界上没有两片相同的树叶。) — 莱布尼茨
现有分布式ID生成方案
在探究美团的 Leaf 服务之前,我们不妨先了解下市场上现有的几种分布式 Id 生成方案。
- UUID
- 数据库自增ID
- 号段模式
- Redis
- 雪花算法(SnowFlake)
- 滴滴出品(TinyID)
- 百度(Uidgenerator)
- 美团(Leaf)
UUID
在 Java 中,如果你想在分布式系统中获得一个具有唯一性的ID,UUID 一定是首先被想起的几种方案之一,毕竟它有着全球唯一的特性,实现起来也十分简单。
public static void main(String[] args) {
String uuid = UUID.randomUUID().toString().replaceAll("-","");
System.out.println(uuid);
}
UUID 在你的项目中使用简单到只需要一行代码,输出结果则为一串无序且唯一的64位字符c2b8c2b9e46c47e3b30dca3b0d447718
。
如果我们仅仅想要的是一个全球唯一的 ID 号,那 UUID 的确是一个十分不错的解决方案。
但在实际生产环境中,UUID 作为 ID 最终是要被存入数据库之中并作为主键
的。但 UUID 64位的臃肿身材注定使得其在数据库中的存储性能差查询也会相当耗时。
另一个问题则是 UUID 其完全无序的生成策略导致其若作为流水号无法看出其与业务的联系,其不包含时间戳、机器ID,更无法满足趋势递增性的特点。注定其不太适合作为分布式ID
的生成方案。
优点:
- 生成足够简单,在本地生成没有网络消耗也没有中心化导致的单点故障问题,且具有唯一性
缺点:
- 无序的字符串,不具备趋势递增的特性
- 没有具体的业务含义
- 长度过长,存储查询对于MySQL数据库性能消耗大,MySQL官方也明确建议主键长度尽量越短越好,作为数据库主键
UUID
的无序性会导致数据位置频繁变动,严重影响性能。
数据库自增ID
基于数据库的auto_increment
自增ID完全可以当分布式ID
,具体实现:需要一个单独的MySQL实例用来生成ID,建表结构如下:
CREATE DATABASE `SEQ_ID`;
CREATE TABLE SEQID.SEQUENCE_ID (
id bigint(20) unsigned NOT NULL auto_increment,
value char(10) NOT NULL default '',
PRIMARY KEY (id),
) ENGINE=MyISAM;
insert into SEQUENCE_ID(value) VALUES ('values');
当我们需要一个ID时,向表中插入一条记录返回主键ID
,但这种方式有一个比较致命的缺点,就是当QPS激增时,MySQL 数据库可能由于自身性能的瓶颈限制(500-700并发量/秒)。且由于 MySQL 作为 ID
生成服务,一旦 MySQL 宕机出现单点故障,将可能导致系统后续所有服务不可用,风险较大。
优点:
- 实现简单成本低,ID 单调递增,数值类型查询性能高
缺点:
- 单例 DB 存在宕机风险,无法应对高并发场景
数据库集群模式
在对上述方案进行优化,对数据库进行集群化部署方式拓展,使用主主模式集群。同样为了防止多主数据库进行数据同步时出现主键冲突,我们需要预先设置 ID 的起始值与步长。
MySQL配置:
set @@auto_increment_offset = start; -- start 起始值:数据库序号
set @@auto_increment_increment = step; -- step 步长:master实例数量
这样两个MySQL实例的自增ID分别就是:
1、3、5、7、9
2、4、6、8、10
但如果集群后性能仍然无法应对高峰时的并发量,此时则需要对集群进行扩容,有利于解决每个数据库实例的并发压力。
但是当增加多台MySQL
,此时付出的部署成本以及维护成本也是较高的,当每扩容一次 MySQL 集群时,就需要人工修改之前部署的MySQL
实例的步长,将扩容机器的起始生成位置设定在比现有最大自增ID
的位置远一些,并且在实例ID还未增长到扩容实例的起始ID值的时候部署完成,否则可能出现自增ID冲突
,必要时可能需要停机修改。
优点:
- 解决DB单点问题
缺点:
- 后续扩容麻烦,数据库自身支持的并发数量有限,数据库压力还是很大,部署成本与维护成本巨大,扩容时若设置步距、起始值等操作不当,可能引起数据库主键冲突,导致系统暂时不可用。
号段模式
号段模式是当下分布式ID生成服务的主流解决方案之一,Leaf 也在其基础上进行优化从而推出了 Segment 模式。号段模式的本质是业务系统从数据库批量的获取 Id 并缓存在本地内存中,提升效率。
例如每次从数据库获取 ID 时,就获取一个号段。如 [1, 1000],表示1000个ID,业务系统在需要生成ID时,只需要本地从1开始自增并返回,而不需要每次都去请求数据库,一直到本地自增到1000时,也就是当前号段已经使用完毕,才去数据库重新获取下一个号段。
DDL
CREATE TABLE id_generator (
id int(10) NOT NULL,
max_id bigint(20) NOT NULL COMMENT '当前最大id',
step int(20) NOT NULL COMMENT '号段的布长',
biz_type int(20) NOT NULL COMMENT '业务类型',
version int(20) NOT NULL COMMENT '版本号',
PRIMARY KEY (`id`)
)
biz_type:代表不同业务类型
max_id:当前最大的可用id
step:代表号段的长度
version:是一个乐观锁,每次都更新version,保证并发时数据的正确性。
id | biz_type | max_id | step | version |
---|---|---|---|---|
1 | 101 | 1000 | 2000 | 0 |
这个数据表是用来记录自增步长,以及当前自增 ID 的最大值(也就是当前已被申请号段的最后那个值),而自增逻辑则通过业务系统实现。所以数据库不需要这部分逻辑。当号段使用完毕,再次向数据库申请新号段,对max_id
字段做一次update
操作。如果该次 update
操作成功则说明新号段获取成功。
update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX
优点:
- 非常简单,利用现有数据库系统的功能实现。
- 相较于数据库Id自动递增方案,其对于数据库的写入及查询操作大大减少,减缓了数据库的压力,减少数据库宕机的风险。
缺点:
- 如果业务系统宕机或重启,就会丢失一段ID,导致ID空洞。
Snowflake(雪花算法)
雪花算法(Snowflake)于 Twitter 公司 2010 年在 GitHUb 上开源后,一致受到国内外公司的好评,它的结构如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-27szmqkJ-1624865858005)(E:\坚果云\note\美团-Leaf\assets\13382703-b64e38457ddd13e2.webp)]
- 1bit,不用,因为二进制中最高位是符号位,1表示负数,0表示证书。生成的id一般都是用正整数,所以最高位固定为0。
- 41bit-时间戳,用来记录时间戳,毫秒级。41 位可以存储 241-1 个数字,即可存储241-1个毫秒的值,转化为单位年则为 (241-1) / (1000 * 60 * 60 * 24 * 365) = 69 年
- 10bit-工作机器id,用于记录工作机器的id,可部署 210 = 1024 个节点,其中包括 5 位 datacenterId 和 5 位workerId
- 12bit-序列号,序列号,用来记录同毫秒内产生的不同 id。即单个机器一毫秒内可以产生 212 = 4096 个序列号。
tips:由于在 Java 中 64bit 的整数是 long 类型,所以在 Java 中 Snowflake 算法生成的 id 就是 long 来存储的。
算法实现
-
Twitter官方的算法 是通过 Scala 语言实现的,可自行查看。
-
Java 版算法实现,摘自 GitHub beyondfengyu。
优点:
- 时间戳和工作id在高位,序列号在低位,所有生成的 ID 按时间趋势递增。
- 通过 10bit 的工作机器 id 保证了整个分布式系统不会产生重复的 id,且同作为分布式服务,后期集群扩容过程无需像
数据库集群模式进行大量修改操作。 - 对有特殊需求的 id ,如流水号,必须向外暴露,且具有可读性,并且竞争对手难以通过流水号猜测项目的流水量。
缺点:
- 对于机器时钟有强依赖性,存在时钟回拨问题,会导致重复ID生成。
tips:对于时钟回拨问题,业内已有完整的解决方案:
- 百度开源的分布式唯一ID生成器UidGenerator
- Leaf——美团点评分布式ID生成系统
Redis
使用Redis
作为分布式ID生成方案同样是一个不错的选择,得益于 Redis 为高性能(10WQPS)单线程应用,可以用 Redis 的原子操作 INCR 和 INCRBY 来实现。
Redis 同样可以通过初始化每台Redis实例的起始值以及设置步长的方式部署 Redis 集群以保证数据库层的容灾操作。另外比较适合使用Redis
来生成每天从0开始的流水号。比如订单号 = 日期 + 当日自增长号。
但 Redis 由于其基于内存存储数据的特性,必须考虑其持久化的问题。Redis 自身提供了两种解决方案分别为AOF
与RDB
RDB
本质为一个定时快照机制,假如Redis
连续自增但没有及时持久化,而此时 Redis 宕机了(例如1:00进行的快照,在1:10又要进行快照的时候宕机了,这个时候就会丢失10分钟的数据),重启后 Redis 通过dump.rdb
文件恢复数据会因为部分 id 丢失导致 id 重复生成的情况。AOF
会对每条写操作进行持久化,即使Redis
突然宕机,AOF
策略也可以很好的保护数据不丢失,但由于AOF
其在后台一般配置每秒fsync操作,其消耗相对更高导致使用AOF
相较于RDB
的 QPS 要更低,并且由于对同一时刻的 RedisAOF
文件比RDB
要大,所以宕机后使用其进行数据恢复会导致恢复时间过长。
优点:
- 性能优异于传统关系型数据库
- 基于自身
INCR
和INCRBY
命令的原子性操作,数字ID天然排序,对分页或者需要排序的结果很有帮助。
缺点:
- 如果项目本身没有使用
Redis
, 仅仅为了一个 ID 生成服务去部署维护一个Redis
集群,成本较高。
Leaf:美团开源分布式ID生成服务
分布式ID生成的方案有很多种,Leaf开源版本提供了两种ID的生成方式:
- 号段模式:低位趋势增长,较少的ID号段浪费,能够容忍MySQL的短时间不可用。
- Snowflake模式:完全分布式,ID有语义。
项目下载地址:美团-Leaf
Leaf特性
Leaf在设计之初就秉承着几点要求:
- 全局唯一,绝对不会出现重复的ID,且ID整体趋势递增。
- 高可用,服务完全基于分布式架构,即使
MySQL
宕机,也能容忍一段时间的数据库不可用。 - 高并发低延时,在CentOS 4C8G的虚拟机上,远程调用QPS可达5W+, TP99在1ms内。
- 接入简单,直接通过公司RPC服务或者HTTP调用即可接入。
Segment(号段模式)
如果使用号段模式,同样需要基于数据库建立DB表,并配置leaf.jdbc.url, leaf.jdbc.username, leaf.jdbc.password
如果不想使用该模式配置leaf.segment.enable=false即可
创建数据表
CREATE DATABASE leaf
CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '',
`max_id` bigint(20) NOT NULL DEFAULT '1',
`step` int(11) NOT NULL,
`description` varchar(256) DEFAULT NULL,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
insert into leaf_alloc(biz_tag, max_id, step, description) values('leaf-segment-test', 1, 2000, 'Test leaf Segment Mode Get Id')
tips:Leaf
没有使用数据库中添加version
字段通过乐观锁保证 id 被不同Leaf-Server
重复分发的方案,而是在Mybatis
中通过开启事务保证了读写操作过程中数据的一致性。
在Leaf
早期版本,由于实现比较简单,在实际生产环境中发现了两个较为严重的问题:
- 的更新DB的时候会出现耗时尖刺,系统最大耗时取决于更新DB号段的时间。
- 当更新DB号段的时候,如果DB宕机或者发生主从切换,会导致一段时间的服务不可用。
- 号段长度始终是固定的,假如Leaf本来能在DB不可用的情况下,维持10分钟正常工作,那么如果流量增加10倍就只能维持1分钟正常工作了。
- 号段长度设置的过长,导致缓存中的号段迟迟消耗不完,进而导致更新DB的新号段与前一次下发的号段ID跨度过大。
针对以上问题,Leaf 针对其进行了相关优化:
Leaf双Buffer优化
在原有方案中,当号段使用完毕时再去DB中取下一个号段,如果此时网络发生抖动,或者DB发生慢查询,业务系统拿不到号段,就会导致整个系统的响应时间降低甚至出现超时,从而出现性能毛刺,而Leaf
的解决方案是将该步骤提前并异步化,从而达到在DB中取号段的过程中做到无阻塞的需求。
Leaf动态调整Step
假设服务QPS为Q,号段长度为L,号段更新周期为T,那么Q * T = L。最开始L长度是固定的,导致随着Q的增长,T会越来越小。但是Leaf本质的需求是希望T是固定的。那么如果L可以和Q正相关的话,T就可以趋近一个定值了。所以Leaf每次更新号段的时候,根据上一次更新号段的周期T和号段长度step,来决定下一次的号段长度nextStep:
- T < 15min,nextStep = step * 2
- 15min < T < 30min,nextStep = step
- T > 30min,nextStep = step / 2
至此,满足了号段消耗稳定趋于某个时间区间的需求。当然,面对瞬时流量几十、几百倍的暴增,该种方案仍不能满足可以容忍数据库在一段时间不可用、系统仍能稳定运行的需求。因为本质上来讲,Leaf虽然在DB层做了些容错方案,但是号段方式的ID下发,最终还是需要强依赖DB。
Snowflake(雪花算法)
Leaf
的Snowflake
模式,其核心算法便来源于Twitter的Snowflake算法,其64位构成与Twitter的雪花算法完全一致。
Leaf-snowflake
不同于原生snowflake算法的地方,主要是在两个方面:
-
10bit位
workId
的生成,Leaf-snowflake
依靠Zookeeper
生成workId
。每个Leaf
应用在使用Leaf-snowflake
时,启动时都会在ZooKeeper
中生成一个顺序Id,相当于每个实例对应一个顺序节点,也就是一个workId。 -
Leaf-snowflake
针对时钟回拨问题提供了解决方案。// synchronized保证线程安全问题 public synchronized Result get(String key) { long timestamp = System.currentTimeMillis(); // 如果时钟发生了回拨 if (timestamp < lastTimestamp) { long offset = lastTimestamp - timestamp; if (offset <= 5) { // 如果回拨的时间在5ms以内,那么直接等待 wait(offset << 1); timestamp = System.currentTimeMillis(); } else { // 如果超过5ms,那么直接抛出异常 return new Result(-3, Status.EXCEPTION); } } // 如果和上一次请求是同一毫秒以内,那么sequence+1 if (lastTimestamp == timestamp) { sequence = (sequence + 1) & sequenceMask; if (sequence == 0) { //sequence为0的时候表示这一毫秒请求量超过1024,那么自旋等待下一毫秒 sequence = RANDOM.nextInt(100); timestamp = tilNextMillis(lastTimestamp); } } else { //如果是新的一毫秒,那么从一个[0, 100)的随机数开始,之所以不是每次都从0开始,是因为防止低并发时获取的唯一ID都是偶数,如果用唯一ID作为分片键,可能导致数据倾斜 sequence = RANDOM.nextInt(100); } lastTimestamp = timestamp; // 通过位运算计算此次生成的唯一ID long id = ((timestamp - twepoch) << timestampLeftShift) | (workerId << workerIdShift) | sequence; return new Result(id, Status.SUCCESS); }
由这段源码我们可知,leaf的Snowflake模式并没有彻底解决时钟回拨的问题。当运行过程中,如果时钟回拨超过5ms,依然会抛出异常。