分布式ID需要满足的条件:
- 全局唯一:不能出现重复的ID
- 高性能,高可用:生成ID的速度快,以及需要接近100%可用
- 趋势递增:由于大多数的数据库使用B-树按索引有序存储数据,主键ID递增可以保证新增记录时不会发生页分裂,保证写入性能
- 信息安全:如果ID连续或者规则明显,恶意用户或竞争对手爬取信息会很方便。因此一些场景,如订单会要求id不规则。
分布式ID的几种实现:
1. UUID
UUID是一个128位
的全球唯一标识符,使用32个16进制位,用-分为五段,形式为8-4-4-4-12。通常格式:xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
;M:版本号(如 1、3、4、5);V2几乎不用,较冷门。
- UUID v1:
f81d4fae-7dec-11d0-a765-00a0c91e6bf6
(时间 + MAC) - UUID v3:
3c5e56b7-0c39-3a76-8575-b8f6efdb01b0
(MD5 哈希) - UUID v4:
550e8400-e29b-41d4-a716-446655440000
(随机生成) - UUID v5:
21f7f8de-8051-5b89-8680-0195ef798b6a
(SHA-1 哈希)
优点:本地生成,性能非常高
缺点:V1版本可能会导致mac地址泄漏;UUID太长,不适合作为数据库主键(主键应当越短越好);UUID无序,新增记录导致索引变动频繁,严重影响写入性能。
2. 雪花算法(SnowFlake)
Twitter公司采用并开源的一种算法,能在分布式系统中产生全局唯一且趋势递增的64位ID。
| 1 | 41位时间戳 | 10位机器ID | 12位序列号 |
部分 | 位数 | 说明 |
---|---|---|
符号位 | 1 | 始终为 0 |
时间戳 | 41 | 当前时间戳(一般是相对某个起始时间的毫秒数) |
机器 ID | 10 | 数据中心 + 机器编号 |
序列号 | 12 | 同一毫秒内的并发序列(0~4095),故理论QPS可达到409.6w/s |
优点:本地生成,生成的ID趋势递增,高性能
缺点:强依赖于机器时钟,如果发生时钟回拨,将导致ID重复或服务不可用。
注意:机器ID需要唯一分配,防止冲突
3. 数据库自增ID实现
**用一个专门的表生成自增ID,提供给其他表使用。**以MySQL为例,创建下面的这张表,当需要一个ID时,向表中插入一条记录返回主键id即可。
CREATE TABLE generate_id {
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
content CHAR(1) NOT NULL DEFAULT '' COMMENT '无实际意义',
PRIMARY KEY (id),
} ENGINE=INNODB;
缺点:依赖数据库服务,有单点故障的风险,且有非常明显的性能瓶颈。
解决方式有两个:采用数据库集群、号段模式(后面的美团组件就有用到)
数据库集群:
节点1生成的ID:1、4、7、10......
节点2生成的ID:2、5、8、11......
节点3生成的ID:3、6、9、12......
约定好起始值和步长,步长就是机器的个数。
缺点:水平扩展比较困难,因为起始值和步长都是预先设定好的。
号段模式:
REATE TABLE `segment_id` (
`biz_tag` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型',
`max_id` BIGINT(20) NOT NULL DEFAULT '1' COMMENT '当前最大id',
`step` INT(11) NOT NULL COMMENT '号段步长',
`version` INT(20) NOT NULL COMMENT '版本号',
`update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`biz_tag`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;
一次请求将从数据库获取一批自增ID,减小访库次数,降低数据库读写压力。当需要ID时,先发起查询,然后更新max_id
,更新成功则表示获取到新号段[max_id, max_id+step)
。
4. 开源组件-美团leaf
leaf
是美团基础研发平台推出的一个分布式ID生成服务,有Segment
(号段)和SnowFlake
两种模式
Segment模式
Leaf 启动时,从 DB 中取出一段 ID 区间(如 1000~1999);本地内存生成 ID;当接近用完时,后台异步去 DB 预取下一段。数据库表结构如下:
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) NOT NULL PRIMARY KEY,
max_id BIGINT NOT NULL,
step INT NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
每种业务一个 biz_tag(如订单、用户),分开管理。
Leaf采用了双Buffer异步更新的策略,保证无论何时,都能有一个Buffer的号段可以正常对外提供服务。
动态调整step
:T位号段的更新周期
T < 15min,nextStep = step * 2
,对应高QPS
15min < T < 30min,nextStep = step
,对应正常QPS
T > 30min,nextStep = step / 2
,对应低QPS
SnowFlake模式
基于上面的雪花算法实现,做了一些改进
使用zookeeper来分配机器ID
Leaf依靠zookeeper
为各节点生成递增的workerId
:
当leaf
节点首次启动时,会连接zookeeper
并在/snowflake/{leaf.name}/forever
下创建永久的有序节点,节点序号是workid
;是一个key-value
对,key
为ip+port+workid
,value
为是一个json
,记录ip
,port
以及时间戳。
若不是首次启动,leaf
节点会连接zookeeper
读取/snowflake/{leaf.name}/forever
的所有节点,根据ip+port
找到对应的key,再从key
中获取workid
。
一旦获取到workid
,便会存储到本地文件中;当启动leaf
节点时zookeeper
故障了,那么可以从本地文件中读取workid
。
应对时钟回拨
启动时检查:leaf
节点启动后,会每隔3秒更新节点数据(更新key-value
中value
的时间戳),上传当前服务器的时间,该时间用来leaf
节点启动时与机器的本地时间作比较;若本地时间小,说明发生时间回拨;则抛异常,启动失败
运行时检查:每次生成ID
都会将当前时间与上一个ID
的时间进行比较,若当前时间小,说明发生时钟回拨,若回拨时间小于5ms
,说明系统时间只是轻微不一致,采取容忍策略,让线程等待一段时间后重试,等待时间为回拨时间的两倍;若大于5ms
,出于安全考虑,返回负数,组织生成ID
。
总结:
虽然做了一些努力,但是Leaf并没有完全解决时钟回拨问题。我们看下面两个场景:
启动前,服务器时间进行了回拨;启动时连接Zookeeper
失败,会使用本地文件中保存的workerId
,此时跳过了时间检查将启动成功,可能会造成ID
重复。
Leaf
节点上报给zookeeper
的时间戳是2024-01-16 08:15:00.000
,最后一次生成的ID时间戳是2024-01-16 08:15:02.999
,还没来得及再次上报zk本地时间,该节点宕机了。在启动之前,发生了时钟回拨,该节点重启时本地时间为2024-01-16 08:15:01.000
;大于zookeeper中记录的时间戳,允许启动。但是,接下来两秒内生成的ID,都可能是之前已经生成过的。(时钟回拨后的时间小于最后一次生成ID的时间,但是大于上报给zookeeper
的时间)
Leaf运行中发现回拨超过5ms,会返回负数。
使用方式:
拉取源码或使用 jar 包;
部署数据库(Segment 模式)或 ZooKeeper(Snowflake 模式);
配置业务标签;
通过 API 获取 ID:
HTTP 接口:GET /api/segment/get/{key}
Java SDK 调用