一、引言
在业务开发中,唯一标识符(ID)非常常见,常见的ID生成器有几种方式:
- 数据库自增ID:这是最简单的一种生成器,依赖数据库自增字段,但随着分布式系统的广泛使用,多个数据库实例之间同时生成ID容易冲突。
- 雪花算法:非常高效并且能解决分布式系统的问题,但依赖机器标识和服务器时间戳,这两点需要拉运维参与,如果两者配置不正确,容易导致ID不唯一。
- UUID生成器:各语言都有提供,32个字符组成的唯一标识符,生成也非常高效,但字符串ID占用空间大,索引效率也不如整数。
上面的算法都有它们各自的适用场景和规模,但不是很适合我们,我们想要的是一款简单、高效、稳定的ID生成器,本文就来介绍一下我们自己设计的、并在业务系统中大面积使用的一种ID生成方法。
二、设计原理
设计一个ID生成器时,需要考虑以下几个关键因素:
- 唯一性:当并发执行时,需要采取适当的措施以确保生成的ID不会冲突。
- 可扩展性:随着需求的不断变化,可能需要为更多业务生成ID。
- 性能:ID生成器应该能够快速生成ID,以减少对系统性能的影响。
- 稳定性:在生成ID时,应尽量减少对不稳定因素的依赖,如机器标识。
- 去中心化:一个系统的ID生成不要都放在一处,避免一个点出问题整个系统都瘫痪。
2.1 分段Cache
之前就有好多服务使用DB中一张表来管理ID分配,每次需要时将ID加1并返回,只是这个生成方式比较低效,每次获取ID都伴随着数据库I/O操作。
我们只需在这个基础上稍加改进,引入分段Cache机制,大概可以理解为:一次将一个区间段的ID预先分配到内存中,等待应用来获取。具体如下:
-
表设计:需要设计一张ticket表,用于存储每个业务的ID分配信息。
- biz_tag:表示需要生成ID的业务名称,需要唯一;
- max_id:表示已经分配过的ID最大值;
- step:表示步长,每次分配多少个ID;

-
加载ID:放在事务中执行下面两条SQL,从DB分配并返回一段ID。
updateSQL := "UPDATE ticket SET max_id=max_id+step WHERE biz_tag='user'"
querySQL := "SELECT biz_tag, max_id, step FROM ticket WHERE biz_tag='user'"
# ID分段数据结构
type Ticket struct {
bizTag string
maxId int64 // 当前这段ID可用的最大值,小于maxID时直接从内存中返回,大于maxID时需要从数据库中加载下一段
step int // 一次Cache的ID段大小,默认50
cur int64 // 已经分配过的ID值,刚加载完一段ID时 cur = maxId - step
lock sync.Mutex // 锁保护,确保分配ID时线程安全
}
- 应用获取ID:只需让cur自增1返回,并加锁保护下
// 用于分配一个ID
func (this *Ticket) Next() (int64, error) {
this.lock.Lock()
defer this.lock.Unlock()
this.cur++
return this.cur, nil
}
- 如果没有ID可以获取,则从DB加载一批。
// 需要时从DB加载下一段
if this.maxId < this.cur+int64(1) {
segment, err := LoadIDSegment(this.bizTag)
if err != nil {
return 0, err
}
this.maxId = segment.MaxId
this.step = segment.Step
this.cur = this.maxId - int64(this.step)
}
这里估计会有个疑问:step是个关键参数,设多大合适?
可能得区分场景:
- 如果业务消耗ID比较快,可以调大点,性能优先;
- 如果业务消耗ID比较慢,建议调小点或者维持默认值50不变,减少因服务重启而可能浪费的ID数量;
2.2 预加载
上面提到,分段Cache机制主要为了改善性能,Cache的这一段ID没有用完之前获取ID是非常快的。但是当用完后不可避免的得走一次数据库的I/O, 这一步对性能的损耗依然不小,下面用一个图示意下:
- 假设每段ID缓存20个,获取一次加载好的ID用时 1ms(实际上用不了,这里只为说明方便);
- 当获取到第21次时(对应时间轴上第20ms),分段Cache的ID已经用完,需要启动下一段的加载;
- 假设从DB加载一段ID耗时10ms,则这10ms期间程序是阻塞的,直到下一段ID加载完毕,刚阻塞的第21次获取ID调用才成功返回;
- 后续不断重复循环上面的步骤:获取->阻塞->获取->阻塞->……

这样导致的结果是:对于业务使用方,获取ID的耗时是不均匀的,每过一段时间就有一个明显的耗时突刺,每当一段ID使用完,应用方获取ID的耗时会从开始不到1ms突然增长到几十ms甚至更长,进而影响业务请求的整体性能。
我们如果想解决这个问题,可以使用预加载机制,具体作法如下示意:
- 每段还是缓存20个,获取1次ID耗时1ms不变;
- 我们可以设一个预加载因子factor=0.75,表示当Cache的ID段消耗了75%时,就提前启动加载下一段;
- 以图中所示,当第16次获取ID时,触发了预加载因子的阀值,提前5ms启动了预加载;
- 当第21次获取ID时,发现ID已经消耗完,与之前一样也会阻塞,与之前不同的是,阻塞时长有区别;
- 由于提前启动了预加载,到第25ms下一段ID已经加载好,所以这次只阻塞了5ms;

对于不同场景,预加载带来的效果会有所区别:- 对于业务高峰期,预加载可以减少阻塞时间,但可能无法完全规避,主要原因是内存和DB的性能差距太大;
- 对于常规时间,只要加载因子设置合适,理论上是可以规避阻塞和耗时突刺的,毕竟正常的业务中是不可能像压测一样不停的获取ID。
2.3 去中心化
这一部分算是一个使用方式的推荐,区分代码和DB分别说明。
ID生成器的代码使用可以有两种方式:
- 将代码以独立服务的方式对外提供http接口,所有业务都访问此接口来使用ID生成器;
- 将代码以公共库的方式集成到各个业务中独立使用;
我们更推荐第二种方式,即使ID生成器出问题一般也只影响单个业务,不会影响到全局。
DB部署也支持两种方式:
- 所有业务共用一个ticket表,部署在一台DB中;
- 每个业务数据库独立维护自己的ticket表,里面只维护此业务用到的ID类型;
和上面类似,我们也推荐第二种方式,规避单台DB故障影响整个系统。
2.4 批量获取
有些场景需要一次生成大量ID,例如导入功能,可能有成百上千甚至更多,这种情况虽然有ID段的缓存,但调用Next()上千次也是会触发多次IO,开销不容忽视。
把step调大也能缓解,但不是很好,这可能会导致ID浪费加剧,而且也没有彻底解决导入可能会触发多次IO的问题。
单个和批量是两个不同的场景,所以我们有必要在设计时就支持这种场景。
- 第一步,我们需要在访问DB时支持分段长度可指定,只需要将step从方法参数中暴露出去即可:
func LoadIDSegment(bizTag string, step int) (*TicketSegment, err error) {
……
}
- 第二步,需要为批量场景单独开一个API,直接将加载出来的指定数量ID段一次性返回:
func (this *Ticket) NextNum(num int64) ([]int64, error) {
ret := []int64{}
this.lock.Lock()
defer this.lock.Unlock()
segment, err := LoadIDSegment(this.bizTag, num)
if err != nil {
return ret, err
}
for i := segment.MaxId - num + 1; i <= segment.MaxId; i++ {
ret = append(ret, i)
}
return ret, nil
}
2.5 存储层扩展
我们的系统中不仅使用了MySQL,还使用了mongoDB, 很多基于mongoDB的服务也需要使用ID生成器。为此,我们对ID生成器的存储层作了一层抽象。
type ITicketStore interface {
LoadIDSegment(bizTag string) (ticket *TicketSegment, err error)
LoadIDSegmentWithNum(bizTag string, num int64) (ticket *TicketSegment, err error)
InitScope(bizTag string, step int, maxId int64) (err error)
}
对这个接口作了两个版本的实现:
- SQLTicketStore: 就是上文2.1节描述的基于SQL的实现,这里不再描述;
- MongoTicketStore: 本节新增的基于mongoDB的实现;
mongo中有一个FindAndUpdate方法,可以同时完成查询和更新两步,并且这两步是在一个原子操作中完成的,我们直接用此方法就能保证并发安全。代码示例如下:
r := collection.FindOneAndUpdate(context.Background(),
&bson.M{ // 匹配条件
"biz_tag": bizTag,
},
bson.M{ // 更新语句,给max_id加上step指定的数字
"$inc": &bson.M{
"max_id": step,
},
},
&options.FindOneAndUpdateOptions{
ReturnDocument: &options.After, // 表示返回更新后的文档
}
)
if r.Err() != nil {
……
}
segment = new(TicketSegment)
err = r.Decode(segment)
以上就是实际工作中使用到的ID生成器大概实现原理,本篇就介绍到这里,关于使用说明和代码示例,请参考链接 ID生成器, 有疑问欢迎讨论和留言。
文章讲述了在业务开发中设计一种高效ID生成方法,涉及分段Cache、预加载策略以降低性能影响和提高稳定性,同时提倡去中心化部署以增强系统健壮性。

3462

被折叠的 条评论
为什么被折叠?



