背景
在大多数业务场景中,我们通常需要对每条数据分配一个唯一ID作为标识。大部分关系型数据库提供了自增键功能来支持该需求。若数据量较大,则需要分库分表的场景,业务中的数据库跟进路由策略进行分片处理,此时就不能使用每个数据库实例提供的自增键功能,因为不能保证在所有实例中唯一,分布式全局唯一ID的需求应运而生
分布式全局唯一ID往往需要满足以下特性:
- 全局唯一性:在某个业务场景下唯一,避免数据冲突
- 高性能:生成速度要快,不能阻塞业务流程
- 趋势递增:通常会将该ID作为数据库主键,由于mysql innoDB采用聚集索引,若新增的记录主键无序,可能造成叶分裂和空间利用率不高的问题,降低写入性能
- 严格单调递增:适用于需要严格单调递增的场景,例如根据id进行排序
- 安全性:防止泄露业务信息,例如若生成的ID严格递增,在电商场景可根据一段时间的id差算出订单量
下面介绍几种常见的ID生成策略:
UUID
UUID(Universally Unique Identifier)即通用唯一识别码。是一个128比特的数字,格式为:
// 十六进制表示
xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxxxxxx
历史上有多个算法版本,可以看作能保证唯一性
某些版本基于命名空间能保证唯一性,某些版本基于随机数生成,不保证唯一性,但出现相同UUID的概率非常小,以java.util.UUID为例,每秒产生10亿笔UUID,100年后只产生一次重复的机率是50%
且都是本地生成,因此效率较高。其缺点为
- 没有趋势递增特性:作为数据库主键时插入性能不高
- 数据较宽:通常UUID为128bit,不能用mysql的bigint存储,需使用varchar,对性能有一定影响
数据库自增键
以mysql为例,我们可以专门建一张表,利用其自增键来生成唯一ID
表结构如下:
CREATE TABLE unique_id (
id bigint(20) unsigned NOT NULL auto_increment,
value char(20) NOT NULL default '',
PRIMARY KEY (id),
UNIQUE KEY unique_v(value)
) ENGINE=MyISAM;
并使用以下语句获取id
begin;
replace into unique_id (value) VALUES ('placeword');
select last_insert_id();
commit;
这里使用replace而不是insert,是为了保证整个表只有一条记录,因为不需要多余的记录也能生成自增的id
这种方式利用数据库的自增主键保证生成id的唯一性,严格单调递增。缺点为:
- 性能问题:每次生成id需要一次数据库远程IO,在高并发场景下可能成为性能瓶颈
- 可用性问题:只用到一台实例,存在单点为题,可用性没有保障
针对问题2,可以引入多台mysql实例,每台实例的表使用不同的初始时可递增步长
以2台mysql实例为例,分别做如下配置:
mysql1:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步长
mysql2:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步长
这样mysql1生成的id序列为:
1,3,5,7,9....
mysql2为:
2,4,6,8....
当请求进来时,采用随机或轮询的方式请求这些实例,这样得到的id序列总体为趋势递增,既减少了单台实例的访问压力,也提高了可用性。该策略缺点如下:
- 性能问题: 每次生成ID还是有一次远程数据库IO
- 伸缩性问题:当需要扩展更多的机器时,需要调整之前所有实例的步长,且需要保证再次期间生成ID不冲突,实现起来较麻烦
redis
为了解决数据库自增键遇到的性能问题,可以利用redis的incr命令来生成不重复的递增ID。该策略相较于数据库方案,从远程磁盘IO变为为远程内存IO,性能有一定提升,但为了保证唯一性需要花费一番功夫
依次讨论各种持久化策略:
若不开启 redis 持久化,则redis宕机后会丢失已生成的ID,再生成可能导致ID重复
若开启 RDB 或 AOF 中非AOF_FSYNC_ALWAYS模式的持久化,可能丢失最近一段时间的ID,一样会出现ID重复
若开启 AOF 中AOF_FSYNC_ALWAYS模式的持久化,能保证即使在当前的情况下也不会出现ID重复问题,但性能会下降,相较于数据库方案没有太大的优势
号段模式
为了解决数据库自增键和redis策略中,每次获取ID都需要远程请求的问题,这里引入号段模式,即每次从数据库获取一个ID范围,作为一个号段加载到内存,这样生成唯一ID时不需要每次都从数据库获取,而是从本地内存里获取,大大提高性能
以mysql为例,数据库表结构如下:
CREATE TABLE unique_id (
id bigint(20) NOT NULL,
max_id bigint(20) NOT NULL COMMENT '当前最大id',
step int(10) NOT NULL COMMENT '号段的范围长度',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT
每次获取一个号段时,执行如下sql:
update unique_id set max_id ={max_id} + step where max_id = {max_id}
当执行成功后,affectRows等于1,则能保证只有当前实例获得了 [max_id,max_id + step) 这个区间的号段,就能愉快地在内存发号了。若affectRows不等于1,说明有其他实例获取了这个号段,因此需要重试再次获取
号段模式的优点如下:
- 性能较高:大部分情况下直接在内存发号,无需远程请求,号段范围越大,远程请求的比例越低
- 可用性较高:若数据库宕机,可以使用之前获取的号段进行发号,号段范围越大,能撑的时间越久
- 趋势递增
其缺点如下:
- 号段浪费:若某台实例号段没用完就重启,则其号段剩余的ID就浪费了。解决方案为较小号段长度,但根据优点中的描述,这会降低性能和可用性,因此需要选择一个适中的号段长度
- 不够平滑:当号段用完时,会请求一次数据库,如果此时网络抖动,会使得该次请求响应较慢。为了解决这种情况,可以在号段即将用完时就异步请求数据库获取下一个号段,而不是等到要用完再请求。这样使得请求数据库获取下一个号段和发放当前号段的操作并行,降低出现慢请求的概率
- 可用性问题:和数据库自增键策略一样的单点问题。解决方位为使用多台实例,这里还是以2台实例为例进行说明:
每次生成一定范围的号段,生成完毕后将下次起始ID调为:
起始ID + (范围 * 实例数量)
例如mysql1当前起始ID为0,mysql2为1000,下次生成号段时,将mysql的起始ID调整为 2000,mysql2调整为3000,再下次分别为4000,5000
这样既保证了ID的唯一性,能降低了单台数据库实例的访问压力,提高了可用性
雪花算法
上面介绍的几种算法都依赖自增实现,但在某些业务场景下,需要保证生成ID的不规则性,这种情况下可以常常使用雪花算法来实现
雪花算法使用一个64位的数字来表示唯一ID,而这64位中的每一位怎么用,就是其精髓所在:
- [0:0] 1位符号位:ID一般为整数,所以该位一般为0
- [1:41] 41位时间:通常用来表示时间差(当前时间 - 业务开始时间),而不是相对于1970年的时间戳,这样能支持的时间更久,若41位时间戳的单位为毫秒,则能支持大约(1 << 41) / (1000 * 60 * 60 * 24 * 365) = 69年
- [42:51] 10位表示机器:当然可以用前几位表示机房,后几位表示每个机房内的机器
- [52:63] 12位自增序列号:表示某台机器上在某一毫秒(如果表示时间的单位为毫秒)内的生成的ID序列号
位的分配可以根据业务的不同进行调整,例如若机器数没那么多,不需要10位表示,可增大时间位,以支持更长的时间范围。或者业务并发量不高时,可将时间单位改为秒,将节省出来的位用于表示其他含义
只要每个实例的机器ID不同,则不同机器间生成的ID一定不同,因为其[42:51]位不一样
其优点为:
- 不依赖数据库,性能和可用性较好
缺点为:
- ID的生成强依赖于服务器时钟,如果发生时钟回拨,则可能和以前生成过的ID产生冲突
时钟回拨:硬件时钟可能会因为各种原因发生不准的情况,网络中提供了ntp服务来做时间校准,做校准的时候就会发生时钟的跳跃或者回拨的问题
- 10位的机器号较难指定,最好不要手工指定,而是实例去自动获取
针对时钟回拨问题,可分两种情况讨论:
- 实例运行过程中发生时钟回拨:此时可以在内存中记录上次时间戳,若这次获取的时间戳比上次小,说明发生了时钟回拨,可以等待一段时间再进行ID生成,若回拨幅度较大,则可选择继续等待,或给上层报错,因为在短时间内无法生成正确的ID。
也可以完全不依赖系统时间,例如百度的uid-generator使用一个原子变量,每次加一来生成下一个时间位
- 实例重启过程中发生时钟回拨:此时没办法从内存中获取上次的时间戳,因此需要将上次时间戳放到外部存储中。美团leaf的方案为,每3s往zookeeper上报一次当前时间戳,这样在实例重启时,也能判断出是否发生了时钟回拨
针对机器号生成困难问题:有以下几种解决方案:
使用zookeeper:每次实例启动时,都去zookeeper下创建一个节点,利用其节点编号当做机器id,zookeeper保证每次生成的节点编号唯一
使用 mysql:也可以在实例启动时,去数据库的表插入一条记录,利用自增主键当做机器ID,同样能保证机器ID的唯一性
总结
本文介绍了几种场景的分布式全局唯一ID的方案,分析了每种方案的优缺点,并针对缺点说明其解决方案