如何实现一个高性能的全局唯一 ID 生成服务(一)
如果觉得对你有帮助,能否点个赞或关个注,以示鼓励笔者呢?!博客目录 | 先点这里
- 设计准备
- 需求与目的
- 设计目的与约束
- 相关预研
- ID 生成服务设计
- 基于 MySQL 的号段机制
- 数据库设计
- ID 缓冲区
- ID 分配流程
- 客户端分配策略
设计准备
需求与目的
迫切性
- 满足需要全局 ID 的业务场景
- 单机架构中的 ID 生成策略难以满足分布式系统的 ID 标识需求
需求
- 一次支持生成单个/多个唯一 ID
- 支持设置 ID 初始值
- 支持多业务场景使用
- ID 趋势递增,区间有序
设计目标与约束
- ID 标识满足基本两个特性
- 唯一性
- 趋势有序性
- ID 生成服务需要满足以下特性
- 支持多业务场景,可根据 tag 隔离 ID 生产线,互不干扰
- 至少保障 99.9 的服务可用率
- 在高性能,高并发场景下,具有优秀的 ID 生成速率
- 支持水平扩展
相关预研
由上文设计准备的需求和目标可知,我们需要一个满足 “可水平扩展”,“支持高并发高性能”,“ID 趋势有序”,“实现简单,维护成本低” 的 ID 生成方案,而
常见方案
- 关系型数据库自增
- 优点
- ID 有序
- 实现简单,可用性高
- 缺点
- ID 生成速率天花板低
- 无法水平扩容 ❌
- 优点
- 关系型数据库号段
- 优点
- ID 趋势有序
- 实现成本低,可用性高
- 可支撑较大速率的 ID 生成
- 支持水平扩展
- 缺点
- 机器重启,会丢失号段,造成浪费
- 优点
- Redis 自增
- 优点
- ID 趋势有序
- 基于内存计算,可支撑较大速率的 ID 生成
- 支持水平扩展
- 缺点
- 需要设计持久化方案,否则会丢失数据,造成重复 ❌
- 优点
- UDID
- 优点
- 实现简单,可单机实现
- 可支撑较大速率的 ID 生成
- 缺点
- ID 无序 ❌
- 优点
- 雪花算法
- 优点
- ID 趋势有序
- 可支撑较大速率的 ID 生成
- 缺点
- 时间回拨
- 获取机器号
- 实现复杂,依赖较多中间件 ❌
- 优点
综上对比,关系型数据库号段方案目前是落地性价比比较优秀的方案,唯一的缺点仅仅是在机器重启时,会丢失号段,造成资源浪费。这在常见的业务场景都是可接受的
ID 生成服务设计
基于 MySQL 的号段机制
在设计前准备中,我们述说了很多关于全局 ID 设计选型的方案,最终经过对比和比较,选择了一个落地性比较强的基于 “关系型数据库号段” 的方案,那么这个基于号段的方案到底是怎么样的呢?
号段方案?
发号段的思想说白了就是基于 “关系型数据库 ID 自增方案” 的升级版或优化版,采用了分而治之的思想。我们可以这么理解,假设关系型数据库自增方案只能支撑每秒1000 个 ID 的生成,那么瓶颈就在于数据库的读写性能上。那我们就会想,有什么办法可以降低数据库的读写性能,还能提供相同或是更强的 ID 生成速率呢?
这就是号段方案要去解决的问题,假设数据库的每个写操作从只能提供一个 ID,升级到提供 1000 个 ID, 那么原先的 ID 生成速率将从 1000/s 提升到 1000000/s,这将是一个巨大的提升,基本上满足市面上大多的分布式 ID 的要求。
那么如何降低 IO ,提升 ID 的生成速率呢?只要我们将生成一个 ID 的行为变成生成一段 ID ,然后将分配 ID 的操作放在应用层上实现。即把数据库性能瓶颈的部分压力释放,把分配 ID 的工作交给应用层上水平扩容去保证生成速率。
怎么做到的?
设计核心就是在关系型数据库中持久化一张记录表,这个表的主要作用就是记录当前的 ID 号段分配到哪了?
客户端每次都会根据这张记录表,获取一块独立无二的号段,即每个客户端的号段都不相同。然后客户端再根据自己获取到的号段,进行真实的 ID 的分配。
因为每个客户端可分配的 ID 都处在不同的段落,如图,所以我们只要保证客户端不会拿到相同的号段,那么就不可能出现 ID 相同的情况。那么自然也满足了全局 ID 的唯一性和趋势增长的特性,同时在降低读写 IO 的情况下,大幅提升了 ID 的生成速率
以上我们了解了号段机制的基本思想,后续我们将会分享一些具体的实现细节
数据库设计
首先整个 ID 生成服务核心只需要一张表 IDGen
即可实现,即 ID 生产表 ,也就是上面所说的记录表,当然叫什么不重要。它的作用就是存储 ID 生产线的元数据内容。怎么理解呢?举个通俗的粟子,记录某条生产线在 为谁生产
,从什么位置开始生产
,当前生产到了那
, 每批次生产多少
/**
* @author snailmann
*/
@Data
@Entity
@Table(name = "IDGen", indexes = {@Index(name = "tag", columnList = "tag", unique = true)})
@EntityListeners(AuditingEntityListener.class)
public class IDGen {
/**
* ID
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 业务组
*/
@Column(columnDefinition = "VARCHAR(64) NOT NULL DEFAULT 'GLOBAL'")
private String tag;
/**
* 步长
*/
@Column(columnDefinition = "INT(11) NOT NULL DEFAULT '100'")
private Integer step;
/**
* 初始 ID
*/
@Column(columnDefinition = "BIGINT(20) NOT NULL DEFAULT '1'")
private Long sid;
/**
* 当前可分配的极限 ID(不包含)
*/
@Column(columnDefinition = "BIGINT(20) NOT NULL DEFAULT '1'")
private Long mid;
/**
* 乐观锁版本
*/
@Column(name = "`version`", columnDefinition = "BIGINT(20) NOT NULL DEFAULT '1'")
private Long version;
/**
* 编辑者
*/
@Column(columnDefinition = "VARCHAR(64) NOT NULL DEFAULT 'SYSTEM'")
private String editor;
/**
* 记录创建时间
*/
@CreatedDate
private Date creation;
/**
* 记录修改时间
*/
@LastModifiedDate
private Date modification;
}
CREATE TABLE `IDGen` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`tag` varchar(64) NOT NULL DEFAULT 'GLOBAL',
`creation` datetime(6) DEFAULT NULL,
`maxId` bigint(20) NOT NULL DEFAULT '1',
`version` bigint(20) NOT NULL DEFAULT '1',
`modification` datetime(6) DEFAULT NULL,
`startId` bigint(20) NOT NULL DEFAULT '1',
`step` int(11) NOT NULL DEFAULT '1000',
PRIMARY KEY (`id`),
UNIQUE KEY `tag` (`tag`)
) ENGINE=InnoDB AUTO_INCREMENT=1
如上就是 IDGen
表的全部信息,非常简单, 但是我们还是要过一下核心的字段
tag
业务组- 作用让服务可以支撑多种业务场景,隔离各自所需的 ID 生产线
sid
初始 ID- 让业务方自定义 ID 生产线的初始 ID , 比如有的业务方需要从 1 开始递增,有的业务方需要从 100000 开始递增
mid
可分配的最大 ID (非包含)- mid 实际就是下一个号段区间的最小值(包含)
[mid, mid + step)
,当前区间的最大值 (不包含)[mid - step, mid)
- mid 实际就是下一个号段区间的最小值(包含)
step
步长- 步长就是每一个号段的大小,即号段是以一个 step 为单位,来分配下一个号段的
我们了解上面的核心字段, 我们就来过一下发号段的过程, 假设有一条 ID 生产线配置,如 tag = test, sid = 1, mid = 11, step = 10
, 那么简简单单的几个属性,它是如何与号段机制相联系起来的呢 ?
(一)根据配置,先确认什么意思?
tag = test
代表该生产线属于test
业务线,在test
tag 下,所生产的 ID 都是唯一并趋势递增的sid = 1
代表这条 ID 生产线的初始值是 1, 从 1 开始递增mid = 11
代表当前可以分配的号段范围是 [mid-step, mid), 即[1, 11)
step = 10
代表每个号段的大小是 10 , 即相隔 10 个 ID 会构成一个新的号段
(二)根据配置,我们先分配第一个号段,第一个号段是什么?
- 首先判断该生产线是否第一次分配号段,怎么判断?
- 如果
mid - step = sid
为真,那么属于第一次分配号段,第一个号段则持有 [1,11) 的号段生产所有权 - 如果
mid - step = sid
不为真,那么不属于第一次分配号段,比如分配第二个号段时,IDGen 配置已变成tag = test, sid = 1, mid = 21, step = 10
, 那么此时分配第二号段,第二号段则拥有 [11,21) 之间号码的生产所有权
- 如果
(三)当配置为 tag = test, sid = 1, mid = 1001, step = 10
时,当前可分配的号段的 ID 区间是?
mid = 1001, step = 10
所以当前可以分配的号段区间是[991,1001)
相信上面的描述已经足够清晰明了,我们就切到下一主题
ID 缓冲区
由数据库的设计可知,我们需要根据 IDGen 的记录信息,才能知道某条业务线,当前号段分配到哪?下一个号段分配什么?那么这就引出了几个疑问,号段要分配给谁? 什么时候分配? 在此,我们引出了 ID 缓冲区
的概念,来回答大家的疑问
什么是 ID 缓冲区?
首先回答第一个问题,号段分配给谁?答案就是分配给 ID 缓存区
。ID 缓存区
就是用于承接经过 IDGen 分配出现的号段实体,用于存放当前分配到的号段。
那么 ID 缓存区
获取到分配的号段后,又有什么功能?可以承担什么样的能力?
- 具备分配 ID 能力,可提供方法供使用方使用
- 具备号段监控能力,知道号段资源什么时候消耗完了,知道当前号段消耗到什么部分
- 具备自主加载号段的能力,并在当前号段消耗完后,可自主加载下一批号段
IDBuffer 的定义 ?
为了满足以上的几种能力,那么 IDBuffer 是怎么实现的呢?我们先来看下 IDBuffer 的定义
/**
* @author snailmann
*/
@Slf4j
public class IDBuffer
private String tag;
private final Segment[] segments = new Segment[]{new Segment(), new Segment()};
private volatile int cpos = 0;
private volatile boolean ready = false;
private IDBFFactory idbfFactory;
public IDBuffer(String tag, IDBFFactory idbfFactory) {
log.info("build id buffer, tag: {}", tag);
this.idbfFactory = idbfFactory;
this.tag = tag;
allocSegment(this.cpos);
this.ready = true;
}
...
}
tag
我们需要知道该 IDBuffer 属于哪个业务场景?这样才能知道 “我” 应该为谁生成 IDSegment
ID 缓存区中具体承接号段的实体,一个 IDBuffer 中具有两个 Segmentcpos
当前 IDBuffer 正在使用的 Segment 的索引ready
当前 IDBuffer 是否准备就绪?idbfFactory
ID 缓冲区工厂,获取 ID 生产信息和获得下一个号段
好的,那么了解完 IDBuffer 的定义,我们再看一下号段 Segment 的数据结构
Segment
/**
* id section [min,max)
*
* @author snailmann
*/
@Data
@NoArgsConstructor
public class Segment {
/**
* offset
*/
private AtomicLong offset;
/**
* min id
*/
private Long min;
/**
* max id (no contains)
*/
private Long max;
/**
* step
*/
private Integer step;
/**
* consumed or uninitialized
*/
private boolean ready = false;
private Date updateTime;
/**
* get id
*
* @return id
*/
public Long offsetAndIncr() {
return offset.getAndIncrement();
}
/**
* score20
*/
public Long offsetOfScore20() {
return this.min + this.getStep() / 4;
}
/**
* idle size
*/
public Integer idle() {
return (int) (this.max - this.min);
}
}
Segment 就是 IDBuffer 中存储 ID 号段的实体实现,用于存储即将被分配的一串 ID 号,每个 Segment 的 ID 区间范围都在 [mid - step, mid)
之间。
刚开始也许我们不好理解字段的含义,那么我们先思考一个问题,如何简单的表示一串 ID 号码,比如简单的使用一个数组去表示 [mid - step, mid - step + 1, mid - step + 2, ... , mid)
,当然现在有更节省空间,更加优秀的方式去实现,我们又何乐而不为呢?
mid
属于当前 Segment 的最大值 (不包含)offset
即当前 Segment 的偏移量,初始值是mid - step
, 在调用offsetAndIncr()
的过程中,会逐渐自增,step
即每个 Segment 的大小
即通过 mid
, offset
,step
三个字段,我们就可以代替存放 ID 号段的数组实现啦
为什么需要两个 Segment ?
ID 缓冲区的双 Segment 设计参考美团 Leaf 和滴滴的 TinyID,属于技术优化的方案。优点是可以让 ID 分配在高并发环境下变得更加平稳顺滑,缺点就是需要预分配多一段号段,当机器重启时,会造成多一段号段的浪费
-
ID 缓存区由两个
Segment
组成,每个Segment
将保留一个完整的号段;如某个业务组 tag 的初始 ID 为 1,step 步长为 10 ,那么 ID 环的Segment[0]
将预分配 [0,10] 的号段,Segment[1]
将预分配 [11,20] 号段 -
ID 生成将在 IDBuffer 中生成,ID 的具体生成则由 IDBuffer 的其中的一个
Segment
分配,并有且同时只有一个 Segment 处于分配状态 -
ID 缓存区的两个
Segment
互为备份,在Segment[0]
号段将 ID 分配完毕后,将由Segment[1]
继续分配,以此循环 -
ID 缓存区初始化阶段,只有一个
Segment
分配号段,只有处于分配状态的Segment
的 ID 分配数达到其所持 ID 数的 20%,才会触发后置 Segment 的加载
ID 分配流程
(一)获取 ID
首先我们从 IDBuffer 的 nextId() 入口开始分析流程,因为这是 ID 缓冲区提供 ID 的入口
/**
* 获得 ID
*/
public long nextId() {
while (true) {
final Segment segment = avaliableSegment();
Long offset = segment.offsetAndIncr();
Long max = segment.getMax();
// double check
Long score20 = segment.offsetOfScore20();
if (!nextSegment().isReady() && offset >= score20) {
synchronized (this) {
if (!nextSegment().isReady()) {
allocSegment(nextPos());
}
}
}
if (offset < max) {
return offset;
} else {
// id resources are exhausted
segment.setReady(false);
}
}
}
步骤
- 第一步,调用 avaliableSegment () 方法得到一个可用的
Segment
- 什么是可用状态?即 segment 是 ready 的,可以对外分配 ID 的
- 第二步,从可用的 Segment 中分配 ID ,获得本次调用需要返回的 ID 结果
- 第三步,判断当前 Segment 分配的 ID 数已经超过了号段的百分之 20 ?
- 如果超过了,则触发是否加载另一个号段
- 如果没有超过,则跳过
- 第四步,判断当前获得的 ID 是否小于当前
Segment
的 ID 最大值mid
- 如果
offset < mid
, 则认为该 ID 可被当前Segment
生产出来,可被返回 - 如果
offset >= mid
, 则认为该 ID 的生产权不属于当前Segment
,属于越界生产,同时证明该 Segment 号段已经消耗完毕,需要获取下一个可用号段
- 如果
为什么采取 20 分位值?
- 至于为什么采取 20 分位值,纯属拍脑袋决定。可以根据自己的需求调整 10~50,但不建议过大或过小
为什么要加锁?又为什么要使用双重检查锁?
if (!nextSegment().isReady() && offset >= score20) {
synchronized (this) {
if (!nextSegment().isReady()) {
allocSegment(nextPos());
}
}
}
- 为什么要加锁?nextId 是一个处于高并发环境的方法,如果不加锁,会导致多个线程同时预加载 Segment,最终导致分配了多个号段,但是只有一个号段能被使用
- 为什么采用双重检查锁?因为双重检查锁实现简单,还可以避免大量后续线程进入获取锁的环节,相比直接加锁可以大大提升性能
为什么不异步实现?
- 当然可以使用异步分配,但是异步分配会增加代码逻辑的复杂度,更加考验实施人的技术功底和维护成本
- 此处采用异步分配的收益不明显,因为异步分配的核心目的是不阻塞主线程进行 ID 的分配。在维护成本和易读写性的考虑下,最终采取简单的同步方案,牺牲一丢丢的性能,而且同步分配也可以达到相同类似的目的(本文没有实现,在优化环节会提及)
(二)获取可用的 Segment
/**
* 获取可用的 Segment
*/
public Segment avaliableSegment() {
Segment segment = segment();
// alloc segment if current segment no ready
// under the synchronization mechanism, this problem will not occur
if (!segment.isReady()) {
// next segment also no ready, alloc current segment
if (!nextSegment().isReady()) {
allocSegment(this.cpos);
} else { // or switch next segment
switchPos();
}
segment = avaliableSegment();
}
return segment;
}
这里采用递归的方式去获取一个可用的 Segment
- 第一步,我们要获取当前
segments[this.cpos]
- 第二步,判断当前 segment 是否准备就绪
ready = true
- 如果
ready = true
,则直接将当前 segment 返回 - 如果
ready = false
, 则进入第四步
- 如果
- 第四步,判断下一个 segment 是否准备就绪
- 如果
ready = true
,则代表下一个号段是可用的,则进行 switchPos(), 更换当前 cpos, 进入第五步骤 - 如果
ready = false
,则代表两个 segment 都是不可用的,则直接分配 segment, 进入第五步骤
- 如果
- 第五步,递归
avaliableSegment()
,获取可用的 segment
当然这里要注意的是
switchPos()
和allocSegment()
都是线程安全的allocSegment()
是同步分配的
(三)加载 Segment
IDBean bean = idbfFactory.alloc(tag);
// alloc new segment object
Long min = Predicates.trueOr(bean.firstUse(), bean::getSid, bean::minOfSegment);
final Segment segment = new Segment();
segment.setXXX();
...
this.segments[pos] = segment;
什么时候会触发 Segment 加载号段?
- IDBuffer 首次初始化时
- 前置 Segment 消耗完毕时,会触发加载后置 Segment, 以此循环
- 前置 Segment 消耗到 xx 分位时,会触发加载后置 Segment, 以此循环
allocSegment 做了什么?
- 从 idbfFactory 获取到下一号段元数据信息,从而更新 Segment
客户端分配策略
ID 生成服务会提供两种生成 ID 的方式
- 集中分配
- 本地分配
集中分配
由 ID 生成服务管理 ID 缓冲区,并对外提供 ID 生成,可用性与风险压力皆有 ID 生成服务集中承担
- 优点:
- 只有 ID 生成服务重启才会导致号段浪费,号段浪费的可能性更低
- 实现简单,集中式管理,易排查问题
- 缺点:
- 有 HTTP 延迟
- ID 生成速率较慢
本地分配
由 ID 生成服务对外提供号段,调用服务拿到号段, 在本地分配 ID
- 优点:
- 无网络延迟
- ID 生成速率块,ID 生成速率瓶颈不在于 ID 生成服务
- 缺点
- 调用方频繁重启,导致号段接口不稳定,并引起资源浪费
压测报告
压测工具: ngrinder
实例规模: 4 核心 8 G * 1 (AWS ECS Spot 实例)
服务: Java 11, G1, SpringBoot, Tomcat
接口: 集中式分配接口,一次分配一个 ID
60 并发用户, 4 tag, step = 1000
TPS
= 10000 ,tp avg
= 5.5 ms,tp 99
= 20 ms
150 并发用户, 4 tag, step = 1000
TPS
= 10000 ,tp avg
= 14 ms,tp 99
= 96 ms
300 并发用户, 4 tag, step = 1000
TPS
= 10000 ,tp avg
= 27 ms,tp 99
= 100 ms
600 并发用户, 4 tag, step = 1000
TPS
= 10000 ,tp avg
= 57 ms,tp 99
= 100 ms
综上报告,我们可以得出结论,如果我们采用集中式分配的方案,单台 4C8G 实例,一个 SpringBoot Tomcat 服务可以支撑1w
TPS 的吞吐量。如果我们采用本地分配的方案,在步长依然是 1000 的情况下,本地分配 TPS = 集中式分配 TPS * 1000 = 1000 w,基本上可以满足大部分的业务场景
完整的代码可以参考 myid - @github
参考
- 如果觉得对你有帮助,能否点个赞或关个注,以示鼓励笔者呢?!
- Leaf:美团分布式ID生成服务开源 - @美团
- didi/tinyid - @滴滴