如何生成分布式全局唯一ID

1,163 阅读9分钟

背景

在大多数业务场景中,我们通常需要对每条数据分配一个唯一ID作为标识。大部分关系型数据库提供了自增键功能来支持该需求。若数据量较大,则需要分库分表的场景,业务中的数据库跟进路由策略进行分片处理,此时就不能使用每个数据库实例提供的自增键功能,因为不能保证在所有实例中唯一,分布式全局唯一ID的需求应运而生

分布式全局唯一ID往往需要满足以下特性:

  1. 全局唯一性:在某个业务场景下唯一,避免数据冲突
  2. 高性能:生成速度要快,不能阻塞业务流程
  3. 趋势递增:通常会将该ID作为数据库主键,由于mysql innoDB采用聚集索引,若新增的记录主键无序,可能造成叶分裂和空间利用率不高的问题,降低写入性能
  4. 严格单调递增:适用于需要严格单调递增的场景,例如根据id进行排序
  5. 安全性:防止泄露业务信息,例如若生成的ID严格递增,在电商场景可根据一段时间的id差算出订单量

下面介绍几种常见的ID生成策略:

UUID

UUID(Universally Unique Identifier)即通用唯一识别码。是一个128比特的数字,格式为:

// 十六进制表示

xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxxxxxx

历史上有多个算法版本,可以看作能保证唯一性

某些版本基于命名空间能保证唯一性,某些版本基于随机数生成,不保证唯一性,但出现相同UUID的概率非常小,以java.util.UUID为例,每秒产生10亿笔UUID,100年后只产生一次重复的机率是50%

且都是本地生成,因此效率较高。其缺点为

  1. 没有趋势递增特性:作为数据库主键时插入性能不高
  2. 数据较宽:通常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的唯一性,严格单调递增。缺点为:

  1. 性能问题:每次生成id需要一次数据库远程IO,在高并发场景下可能成为性能瓶颈
  2. 可用性问题:只用到一台实例,存在单点为题,可用性没有保障

针对问题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序列总体为趋势递增,既减少了单台实例的访问压力,也提高了可用性。该策略缺点如下:

  1. 性能问题: 每次生成ID还是有一次远程数据库IO
  2. 伸缩性问题:当需要扩展更多的机器时,需要调整之前所有实例的步长,且需要保证再次期间生成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,说明有其他实例获取了这个号段,因此需要重试再次获取

号段模式的优点如下:

  1. 性能较高:大部分情况下直接在内存发号,无需远程请求,号段范围越大,远程请求的比例越低
  2. 可用性较高:若数据库宕机,可以使用之前获取的号段进行发号,号段范围越大,能撑的时间越久
  3. 趋势递增

其缺点如下:

  1. 号段浪费:若某台实例号段没用完就重启,则其号段剩余的ID就浪费了。解决方案为较小号段长度,但根据优点中的描述,这会降低性能和可用性,因此需要选择一个适中的号段长度
  2. 不够平滑:当号段用完时,会请求一次数据库,如果此时网络抖动,会使得该次请求响应较慢。为了解决这种情况,可以在号段即将用完时异步请求数据库获取下一个号段,而不是等到要用完再请求。这样使得请求数据库获取下一个号段发放当前号段的操作并行,降低出现慢请求的概率
  3. 可用性问题:和数据库自增键策略一样的单点问题。解决方位为使用多台实例,这里还是以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]位不一样

其优点为:

  1. 不依赖数据库,性能可用性较好

缺点为:

  1. ID的生成强依赖于服务器时钟,如果发生时钟回拨,则可能和以前生成过的ID产生冲突

时钟回拨:硬件时钟可能会因为各种原因发生不准的情况,网络中提供了ntp服务来做时间校准,做校准的时候就会发生时钟的跳跃或者回拨的问题

  1. 10位的机器号较难指定,最好不要手工指定,而是实例去自动获取

针对时钟回拨问题,可分两种情况讨论:

  • 实例运行过程中发生时钟回拨:此时可以在内存中记录上次时间戳,若这次获取的时间戳比上次小,说明发生了时钟回拨,可以等待一段时间再进行ID生成,若回拨幅度较大,则可选择继续等待,或给上层报错,因为在短时间内无法生成正确的ID。

也可以完全不依赖系统时间,例如百度的uid-generator使用一个原子变量,每次加一来生成下一个时间位

  • 实例重启过程中发生时钟回拨:此时没办法从内存中获取上次的时间戳,因此需要将上次时间戳放到外部存储中。美团leaf的方案为,每3s往zookeeper上报一次当前时间戳,这样在实例重启时,也能判断出是否发生了时钟回拨

针对机器号生成困难问题:有以下几种解决方案:

使用zookeeper:每次实例启动时,都去zookeeper下创建一个节点,利用其节点编号当做机器id,zookeeper保证每次生成的节点编号唯一

使用 mysql:也可以在实例启动时,去数据库的表插入一条记录,利用自增主键当做机器ID,同样能保证机器ID的唯一性

总结

本文介绍了几种场景的分布式全局唯一ID的方案,分析了每种方案的优缺点,并针对缺点说明其解决方案