Java 中分布式 ID 的设计方案

Java 中分布式 ID 的设计方案

文章目录

  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。


系统环境:

  • JDK 版本:1.8

示例地址:

参考地址:

什么是分布式 ID

分布式 ID 是指,在分布式环境下可用于对数据进行标识且易存储的全局唯一的 ID 标识。

为什么需要分布式 ID

在互联网业务中,一套系统往往都是非常复杂的,很多时候都需要对大量数据进行处理与标识。例如,支付宝、美团、大众点评、京东商城、淘宝商城等等,都需要关联商品编号、用户下单单号、快递的快递号这样的数据,都需要使用一个唯一的 ID 进行标识。不仅如此,也随着分布式系统的流行,ID 号也是向这个方向分布式方向靠拢。

如何生成一个全局唯一的,且有序的 ID 对这些数据进行标识,成为分布式系统中很重要的考虑内容。

分布式 ID 需要满足的条件

分布式 ID 是我们在非常多的场景下用到的组件,对其要求比较高,其一般需要满足以下条件:

  • ① 唯一性: 必须保证 ID 是全局性唯一的,这是基本要求。
  • ② 高性能: 高可用低延时,ID 生成速度要快,否则反倒会成为业务瓶颈;
  • ③ 高可用: 尽量保证服务的可用性,多实例化,避免因一个实例挂掉影响整个业务应用的运行。
  • ④ 容易接入: 要秉着拿来即用的设计原则,在系统设计和实现上要尽可能的简单,避免增加开发人员的使用成本。
  • ⑤ 趋势递增: 最好趋势递增,这样方便进行数据排序、过滤,当然这个要求还需要根据具体的业务场景作出安排。
  • ⑥ 信息安全: 如果 ID 是连续递增的,恶意用户就可以很容易的推测出订单号的规则,从而猜出下一个订单号,如果是竞争对手,就可以直接知道我们一天的订单量。所以在某些场景下,需要 ID 无规则来保证安全性。

常用分布式 ID 生成方案

下面列出的这几种方案都是生成 ID 的常用方法:

  • (1)、使用 UUID 生成 ID;
  • (2)、使用数据库单机自增生成 ID;
  • (3)、使用数据库集群模式自增生成 ID;
  • (4)、使用数据库号段模式生成 ID;
  • (5)、使用 Redis 单节点实现生成 ID;
  • (6)、使用 Redis 集群模式实现生成 ID;
  • (7)、根据 Snowflake 算法生成 ID;
  • (8)、使用 Zookeeper 生成 ID;
  • (9)、使用 MongoDB 创建 ObjectID 生成 ID;

每种方案的生成 ID 的性能、可用性、有序性等都不相同,接下来将对这些方案进行简单介绍,只有了解其原理后才能结合实际业务需求,找到适合的解决方案。

方案一:使用 UUID 生成 ID

UUID 什么是

UUID 是通用唯一识别码 Universally Unique Identifier 的缩写,开放软件基金会(OSF)对其规范进行定义,规定该组成元素中可以包含网卡 MAC 地址、时间戳、名字空间、随机或伪随机数、时序等元素,利用这些元素组合进而生成 UUID。

UUID 的结构组成

UUID 一般是由一组 32 位数的 16 进制数字所构成,常包含时间戳和 MAC 地址信息这些元素,以连字号 "-" 分隔成五组进行显示, 标准的 UUID 格式为:

1xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

即"8-4-4-4-12"共 32 个英数字母和 4 个连字号"-"组成,下面的字符串就是一个 UUID 的样例:

1550e8400-e29b-41d4-a716-446655440000

Java 中使用 UUID 工具生成 ID

在 Java 中对 UUID 的生成也有默认实现的方法,如下代码:

 1public class UUIDTest {
 2
 3    public static void main(String[] args) {
 4        // 生成 UUID
 5        String uuid = UUID.randomUUID().toString();
 6        // 输出 UUID 串
 7        System.out.println(uuid);
 8    }
 9
10}

UUID 作为分布式 ID 的优缺点

优点:

  • 高性能
  • 实现简单;
  • 不需要第数据库等第三方组件依赖;

缺点:

  • 并不是趋势递增,不方便排序;
  • 生成的 ID 只能用字符串类型存储,占用空间大;
  • 生成的串没有规律,出问题时不易根据 ID 进行排查;

方案二:使用数据库单机自增生成 ID

单机数据库是如何生成 ID

数据库自增 ID 是依赖数据库中提供的自动增量功能实现,这种生成 ID 的方案比较容易实现与使用。在这种方案中,为了存储生成的 ID 值,往往我们会单独创建立一张专用于存储生成 ID 的表,然后往表中插入数据替换旧数据,过程中 ID 会递增,我们只需要查询该递增的 ID 值,然后再与时间戳、随机值等元素进行组合处理,生成分布式 ID。

数据库实现 ID 递增生成的过程

如果使用的数据库是 Mysql,则可以按下面步骤进行 ID 自增。

创建用于存储 ID 的表,其结构如下:

  • id:自增生成的 ID 值;
  • stub:用于记录 ID 是归属于业务的;
1CREATE TABLE `myid` (
2    id bigint(20) unsigned NOT NULL auto_increment,
3    stub char(1) NOT NULL default '',
4    PRIMARY KEY (id)
5)

每当我们的应用需要 ID 的时,就插入一条数据使其自增 ID,然后替换旧数据,最后读取新生成的 ID 值:

1begin;
2REPLACE INTO `myid`(stub) VALUES ('a');
3SELECT LAST_INSERT_ID();
4commit;

等取到 ID 值后我们在让其与"时间戳"、"随机值"、"业务码"等组合,生成与业务挂钩的分布式 ID 串,一般时候我们生成的串都不会超过 64 位,以方便用 long 类型存储该串;

数据库生成分布式 ID 方案的优缺点

优点:

  • 实现简单;
  • 有序递增;

缺点:

  • 性能较差,只能并发量小的业务需求;
  • 存在单点问题,如果数据库不可用将导致依赖它的服务不能正常执行逻辑。

方案三:使用数据库集群模式自增生成 ID

数据库集群模式实现分布式 ID

前面讲述了单机数据库方式通过自增方式生成 ID,这种方式由于单机部署,不论是性能还是可用性都无法得到保障。故而往往都不会直接采用该方案,而是对其进行改动,将其改为使用多主的集群模式部署,利用多个数据库来进行自增生成 ID。

使用多台数据库会导致每个数据库的 ID 都是从 1 开始递增,且递增步长为 1,在这种情况下一定会生成重复的 ID 值。解决这种 ID 重复生成的问题也很简单,只需要对每个数据库都提前配置好其初始值(auto_increment_increment),以数据库个数充当自增长步长(auto_increment_offset),这样每个库中增长的 ID 就不会重复了。

例如,在存在多个 Mysql 数据库,下面根据主节点格式分配"初始值"和"增长步长"的表如下:

参数/库个数 2个主数据库 3个主数据库
初始化值 db1=1db2=2 db1=1db2=2db3=3
增长步长 2 3
  • 初始值: 1~n(数据库个数)
  • 增长步长: n(数据库个数)

双主数据库配置示例

比如,在使用双主的数据库中,两个数据库可以按下面进行数据库配置"初始值"和"递增步长",如下:

1## 1 Mysql 数据库中配置如下
2SET @@auto_increment_offset = 1;     -- 初始化值
3SET @@auto_increment_increment = 2;  -- 增长步长
4
5## 2 Mysql 数据库中配置如下
6SET @@auto_increment_offset = 2;     -- 初始化值
7SET @@auto_increment_increment = 2;  -- 增长步长

这样两个 MySQL 实例的自增 ID 分别就是:

1## 第1个数据库:
21,3,5,7,9
3
4## 第2个数据库:
52,4,6,8,10

使用数据库集群模式自增生成 ID 的优缺点

优点:

  • 高可用;
  • 趋势递增;

缺点:

  • 性能一般,只能并发量小的业务需求;
  • 水平扩展比较麻烦,需要手动调整集群数据库中的初始值与步长;

方案四:使用数据库号段模式自增生成 ID

数据库号段模式生成 ID 如何实现

号段模式一般也是基于数据库自增实现分布式 ID 的一种方式,是当下分布式 ID 生成方式中比较流行的一种,其使用可以简单理解为每次从数据库中获取生成的 ID 号段范围,将范围数据获取到应用本地后,在范围内循递增生成一批 ID,然后将这批数据存入缓存。

每次应用需要获取 ID 时,这时就候就可以从缓存中读取 ID 数据,当缓存中的 ID 消耗到一定数目时候,这时再去从数据库中读取一个号段范围,再执行生成一批 ID 操作存入缓存,这是一个重复循环的过程,这样重复操作每次都只是从数据库中获取待生成的 ID 号段范围,而不是一次次获取数据库中生成的递增 ID,这样减少对数据库的访问次数,大大提高了 ID 的生成效率。

数据库号段模式生成 ID 实现流程

在使用号码模式时,我们通常会先建立一张表用于记录上述的 ID 号段范围,一般表内容如下:

1CREATE TABLE `myid` (
2  id int(10) NOT NULL AUTO_INCREMENT,
3  max_id bigint(20) NOT NULL,
4  step int(20) NOT NULL,
5  biz_type int(20) NOT NULL,   
6  version int(20) NOT NULL,  
7  PRIMARY KEY (`id`)
8) 
  • max_id:当前最大可用 ID。
  • step:号段的步长。
  • bit_type:业务类型。
  • version:记录更新的版本号,主要作用是乐观锁,每次更新时都会更新该值,以保证并发时数据的正确性。

每次从数据库中获取号段 ID 的范围时,都会执行更新语句,其中计算新号段范围最大值 max_id 的公式是 max_id + step 组成,所以 SQL 中设置 max_id = max_id+step 来执行更新语句,更新数据库中这个范围最大值 max_id,然后再通过查询语句查询更新后 ID 最大值,再根据最大值 max_id 与步长 step 计算出待生成的 ID 的范围,其中操作的 SQL 如下:

1begin
2UPDATE `myid` SET `max_id` = max_id + step, `version` = version + 1 WHERE `version` = {执行更新的版本号} AND `biz_type` = {业务类型}
3SELECT `max_id`, `step`, `version` FROM `myid` WHERE `biz_type` = {业务类型}
4commit

其中数据库表中内容大同小异,如下是某个测试数据的样式:

id max_id step biz_type version
1 2000 1000 pay_ordertag 2
2 10000 2000 test_ordertag 5

数据库号段模式生成 ID 过程描述

例如,某个业务需要批量获取 ID,首先它往数据库 myid 中插入一条初始化值,设置 max_id=0 和步长 step=1000 及使用该 ID 的业务标识 biz_type=test 与版本 version=0,如下:

1INSERT INTO `myid`(`max_id`,`step`,`biz_type`,`version`) VALUES(0,1000,"test",0) 

然后可以观察到数据库中多了一条数据:

id max_id step biz_type version
1 0 1000 test 0

然后执行获取分布式 ID 的方法,方法中应执行下面语句进行号段更新,方便生成新的一批号段:

1begin
2UPDATE `myid` SET `max_id` = max_id + step, `version` = version + 1 WHERE `version` = 0 AND `biz_type` = test
3SELECT `max_id`, `step`, `version` FROM `myid` WHERE `biz_type` = test
4commit

这时候数据库中的值为:

id max_id step biz_type version
1 1000 1000 test 1

然后以 biz_type 作为筛选条件,从数据库 myid 中读取 max_idstep 的值:

1- max_id:0
2- step:1000

通过这两个值可以知道号段范围为 (0,1000],生成该批 ID 存入到缓存中,那么这时候缓存大小为:

  • 缓存大小:1000

每次都从缓存中取值,创建一个监听器用于监听缓存中 ID 消耗比例,设置阈值,判断如果取值超过的阈值后就进行数据库号段更新操作,跟上面第一次执行更新时候一样,也是执行下面的更新 SQL 语句:

1begin
2UPDATE `myid` SET `max_id` = max_id + step, `version` = version + 1 WHERE `version` = 0 AND `biz_type` = test
3SELECT `max_id`, `step`, `version` FROM `myid` WHERE `biz_type` = test
4commit

比如,设置阈值为 50%,当缓存中存在 1000 个 ID,监听器监听到业务应用已经消耗到 500 个后(超过阈值),创建一个新的线程去执行上面的更新 SQL 语句,让数据库中号段范围按照设置的 step 扩大,然后获取新的号段最大值,应用中再生成一批范围为 (1001,2000] 范围的 ID 存入缓存供应用使用,这时候缓存中数据大小为:

  • 缓存大小:2000(已经使用 500,可用 1500)

过程是个循环的过程,每到消耗到一定数据后就会生成新的一批。这里只是对其进行了简单介绍,很多时候为了保证数据库可用性都会采用集群模式,现在通过号码模式生成 ID 的开源框架有很多,比如:

数据库号段模式生成 ID 的优缺点

优点:

  • 趋势递增;
  • 使用缓存机制,容灾性高,即使数据库不可用还能撑一段时间。
  • 可以自定义每次扩展的大小,控制 ID 生成速度;
  • 可以设置生成 ID 的初始范围,方便业务从原有的 ID 方式上迁移过来。

缺点:

  • 数据库宕机会造成整个系统不可用。
  • ID 号码不够随机,可能够泄露发号数量的信息,不太安全。

所以,采用这种方案我们也经常使用数据库多主模式,保证数据库的高可用性。

方案五:使用 Redis 单节点实现分布式 ID

Redis 中实现分布式 ID 方法

Redis 中存在原子操作指令 INCR 或 INCRBY,执行后可用于创建初始化值或者在原有数字基础上增加指定数字,并返回执行 INCR 命令之后 key 的值,这样就可以很方便的创建有序递增的 ID。

  • INCR: 将 key 中储存的数字值增一,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。
  • INCRBY: 将 key 中储存的数字加上指定的增量值,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。

获取到有序递增的值后,我们会对该值进行"时间戳"+"随机值"+"Redis递增编号"处理,组合方式多种多样,这里说的只是其中一种方式。

Java 中操作 Redis 生成 ID 示例

在 Java 中可以使用 Jedis 组件操作 Redis,可以 Maven 中引入 Jedis 相关包:

1<dependency>
2    <groupId>redis.clients</groupId>
3    <artifactId>jedis</artifactId>
4    <version>3.3.0</version>
5</dependency>

然后使用 INCR 命令生成 ID:

 1public class RedisTest {
 2
 3    public static void main(String[] args) {
 4        // 创建 Jedis 客户端
 5        Jedis jedis = new Jedis("127.0.0.1", 6379);
 6        // 执行 INCR 并获取 ID 值
 7        long id = jedis.incr("myid");
 8        // 输出 ID 值(正常情况下会执行'时间戳'+'随机值'+'Redis ID'进行组合使用)
 9        System.out.println(id);
10        // 关闭连接
11        jedis.close();
12    }
13
14}

这里只是简单介绍如何获取 INCR 命令自增的 ID 值,拿到 ID 后如何与时间戳、随机值等拼接组合,这点还需要考虑。

Redis 单节点实现分布式 ID 的优缺点

优点:

  • 实现简单;
  • 有序递增,方便排序;

缺点:

  • 强依赖于 Redis,可能存在单点问题;
  • 如果 Redis 超时,可能会对业务造成影响;
  • 占用宽带,而且需要考虑网络延时等问题带来地性能冲击。

方案六:使用 Redis 集群实现分布式 ID

为什么使用 Redis 集群模式

使用 Redis 单机生成 ID 存在性能瓶颈,无法满足高并发的业务需求,且一旦 Redis 崩溃或者服务器宕机,那么将导致整个基于它的服务不可用,这是业务中难以忍受的,所以一般时候会用集群的方式来实现 Redis 的分布式 ID 方案。

Redis 集群模式下如何实现分布式 ID

使用集群的方式需要设置提前设置 初始值步长 来保证每个节点增加的 ID 不会冲突,正常做法每个节点都配置一个跟节点挂钩的 Lua 脚本,脚本内容中设置好对应节点的 初始值步长,其中初始值是按照节点个数从 1 开始递增分配,而步长则是等于集群中 Master 节点的个数。按照这种方式生成 ID 并获取后,后面的执行逻辑跟单节点 Redis 一样,都是对 ID 进行加工处理操作。

假如 Redis 集群中存在 3 台 Master 节点,那么就可以针对每个节点的 Lua 脚本中配置初始值和步长,如下:

节点名称 初始值 步长
Redis-Master-1 1 3
Redis-Master-2 2 3
Redis-Master-3 3 3

初始化每台 Redis 的值分别是 1、2、3,然后步长都是 3(步长=集群节点数),那么各个 Redis 生成的 ID 为:

  • A:1,4,7,10,...
  • B:2,5,8,11,...
  • C:3,6,9,12,...

然后应用中获取到该 ID 值后与时间戳、随机值等组合处理,生成一个唯一 ID。

Redis 集群模式下生成分布式 ID 的优缺点

Redis 集群方案和单机比,ID 的有序递增变为趋势递增,且集群模式保证了可用性。

优点:

  • 集群模式,高可用;
  • 趋势递增,方便分类、排序;

缺点:

  • 如果 Redis 超时,可能会对业务造成影响;
  • 存在网络开销,集群模式需要数据同步,对性能有影响;
  • 集群规模固定后,改动规则影响很大,所以扩展比较困难;

方案七:利用 Snowflake 算法实现 ID 生成

Snowflake 算法是什么

Snowflake 算法是 Twitter 开源的分布式 ID 生成算法,我们常称其为雪花算法。其生产的结果 ID 是一个 64bit 的 ID,能够很方便的使用 long 类型进行存储。其核心组成是使用 41bit 作为毫秒数,10bit 作为机器的 ID(5个bit是数据中心,5个bit的机器ID),12bit 作为毫秒内的序列号。

Snowflake 算法的组成

其结构组成:

  • 1 位的正负标识位: 由于 long 基本类型在 Java 中是带符号的,整数为 0 负数为 1,一般生成的 ID 都为正数,所以固定为0;
  • 41 位的时间戳: 该时间戳为毫秒级,时间戳不是存储当前时间的时间戳,而是存储时间的差值(当前时间-固定的开始时间),这里的的开始时间戳为我们的ID生成器开始使用的时间,通过计算(1L << 41) / (1000L * 60 * 60 * 24 * 365)得出69,说明该算法生成的 ID 足够我们使用69年。
  • 10 位的 WorkerID: 一般是5位数据中心标识与 5 位机器标识共同组成,以区分不同的集群节点,相当于能在1024个节点上生成 ID。
  • 12 位的序列号: 在同一机器同一毫秒内可生成不同的序列号,12位支持最多能生成4096个。

雪花算法效率很高,理论上其生成 ID 的 QPS 约为 409.6w/s,这种分配方式可以保证在任何一个机房的任何一台机器在任意毫秒内生成的 ID 都是不同的。

Snowflake 算法的扩展位

在实际使用过程中,我们往往都会根据具体的业务对雪花算法的组成进行改动,常改动的是10bit的 WorkerID 位置,该位置由5位数据中心标识与5位机器标识共同组成,那么这时候可以:

  • 如果部署的服务都在同一个数据中心,即不考虑数据中心概念,可以将5bit数据中心为替换成我们的业务编码。
  • 如果数据中心不是很多,这时候可以将5bit数据中心位拆成3bit+2bit,其中3bit为数据中心标识,2bit为业务编码,可以设置该值为随机值,放置别人猜测 ID 号。

还有很多拆分方法,这里省略,请大家根据业务需求进行拆分。

Snowflake 算法的不足点

根据上面介绍,已经对雪花算法有了大概的了解,不过雪花算法中部分由时间戳组成,所以其强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。

为了解决这个问题,网上给出了很多方案:

  • ① 关闭时钟同步: 将ID生成交给少量服务器,并关闭时钟同步;
  • ② 抛出异常: 直接抛出异常,交给上层业务处理。
  • ③ 短时间等待: 如果回拨时间较短,在耗时要求内,比如 5ms,那么可以让时钟等待一小段时间,时间到达后再次进行判断,如果已经超过回拨前的时间则正常执行逻辑,否则接着抛出异常。
  • ④ 使用扩展位预防时钟回拨: 如果回拨时间很长,那么无法等待,可以调整算法占用的64位,将 1~2位作为回拨位,一旦时钟回拨将回拨位+1,可得到不一样的ID,2位回拨位允许标记三次时钟回拨,基本够使用。如果超出了,再选择抛出异常。

其中比较推荐的就是使用上面介绍的雪花算法扩展位,如利用 WorkerID 作为扩展位,可以让这 10bit 预留出 2bit,让其作为回滚的标识,当发生时钟回拨时候使其值 +1,由于是 2bit 预留位,所以支持最多三次回拨,一般来说够用,毕竟时钟回拨几率比较小,当然如果还发生了,且超过三次后只能抛出进行处理了。

Snowflake 的 Java 实现示例

这里提供两种方式在 Java 中使用 Snowflake 生成分布式 ID,第一种是使用现成封装好的工具 Hutool,其对 Snowflake 进行了封装,可以直接使用。另一种是自己写代码实现 Snowflake,这种方式可以灵活配置其中的位数分配。

方式一:使用 Hutool 工具封装的 Snowflake 工具

通过 Maven 引入 Hutool 工具包:

1<dependency>
2    <groupId>cn.hutool</groupId>
3    <artifactId>hutool-all</artifactId>
4    <version>5.4.2</version>
5</dependency>

使用 Hutool 中提供的 Snowflake 工具:

1public class SnowflakeHutool {
2
3    public static void main(String[] args) {
4        // 实例化生成 ID 工具对象
5        Snowflake snowflake = IdUtil.getSnowflake(1, 3);
6        long id = snowflake.nextId();
7    }
8
9}

方式二:自己写代码实现 Snowflake 生成 ID 工具

  1import java.util.ArrayList;
  2import java.util.HashSet;
  3import java.util.List;
  4
  5/**
  6 * 手动实现 Snowflake 生成 ID 逻辑
  7 *
  8 * @author mydlq
  9 */
 10public class Snowflake {
 11
 12    /** 机器id(5位)*/
 13    private final long machineId;
 14    /** 数据中心id(5位)*/
 15    private final long datacenterId;
 16    /** 序列号(12位) */
 17    private long sequence = 0L;
 18
 19    /** 初始时间戳 */
 20    private final long INIT_TIMESTAMP = 1288834974657L;
 21    /** 机器id位数 */
 22    private final long MAX_MACHINE_ID_BITS = 5L;
 23    /** 数据中心id位数 */
 24    private final long DATACENTER_ID_BITS = 5L;
 25
 26    /** 机器id最大值 */
 27    private final long MAX_MACHINE_Id = -1L ^ (-1L << MAX_MACHINE_ID_BITS);
 28    /** 数据中心id最大值 */
 29    private final long MAX_DATACENTER_ID = -1L ^ (-1L << DATACENTER_ID_BITS);
 30    /** 序列号id最大值 */
 31    private final long SEQUENCE_BITS = 12L;
 32    /** 序列号最大值 */
 33    private final long sequenceMask = -1L ^ (-1L << SEQUENCE_BITS);
 34
 35    /** workerid需要左移的位数(12位) */
 36    private final long WORKER_ID_SHIFT = SEQUENCE_BITS;
 37    /** 数据id需要左移位数(12序列号)+(5机器id)共17位 */
 38    private final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + MAX_MACHINE_ID_BITS;
 39    /** 时间戳需要左移位数(12序列号)+(5机器id)+(5数据中心id)共22位 */
 40    private final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + MAX_MACHINE_ID_BITS + DATACENTER_ID_BITS;
 41
 42    /** 上次时间戳,初始值为负数 */
 43    private long lastTimestamp = -1L;
 44
 45    /**
 46     * 构造方法,进行初始化检测
 47     *
 48     * @param machineId    机器ID
 49     * @param datacenterId 数据ID
 50     */
 51    public Snowflake(long machineId, long datacenterId) {
 52        // 检查数(机器ID)是否大于5或者小于0
 53        if (machineId > MAX_MACHINE_Id || machineId < 0) {
 54            throw new IllegalArgumentException(String.format("机器id不能大于 %d 或者小于 0", MAX_MACHINE_Id));
 55        }
 56        // 检查数(据中心ID)是否大于5或者小于0
 57        if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
 58            throw new IllegalArgumentException(String.format("数据中心id不能大于 %d 或者小于 0", MAX_DATACENTER_ID));
 59        }
 60        // 配置参数
 61        this.machineId = machineId;
 62        this.datacenterId = datacenterId;
 63    }
 64
 65    /**
 66     * 获取下一个生成的分布式 ID
 67     *
 68     * @return 分布式 ID
 69     */
 70    public synchronized long nextId() {
 71        // 获取当前时间戳
 72        long currentTimestamp = timeGen();
 73        //获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常
 74        if (currentTimestamp < lastTimestamp) {
 75            // 等待 10ms,如果时间回拨时间短,能在 10ms 内恢复,则正常生产 ID,否则抛出异常
 76            long offset = lastTimestamp - currentTimestamp;
 77            if (offset <= 10) {
 78                try {
 79                    wait(offset << 1);
 80                    if (currentTimestamp < lastTimestamp) {
 81                        throw new RuntimeException("系统时间被回调,无法生成ID");
 82                    }
 83                } catch (InterruptedException e) {
 84                    Thread.currentThread().interrupt();
 85                    throw new RuntimeException("系统时间被回调,无法生成ID,且等待中断");
 86                }
 87            }
 88        }
 89        // 判断当前时间戳是否等于上次生成ID的时间戳(同1ms内),是则进行序列号递增+1,如果递增到设置的最大值(默认4096)则等待
 90        if (lastTimestamp == currentTimestamp) {
 91            sequence = (sequence + 1) & sequenceMask;
 92            if (sequence == 0) {
 93                currentTimestamp = tilNextMillis(lastTimestamp);
 94            }
 95        }
 96        // 如果当前时间戳大于上次生成ID的时间戳,说明已经进入下一毫秒,则设置序列化ID为0
 97        else {
 98            sequence = 0;
 99        }
100        // 设置最后时间戳为当前时间戳
101        lastTimestamp = currentTimestamp;
102        // 生成 ID 并返回结果:
103        // (currStamp - INIT_TIMESTAMP) << TIMESTAMP_LEFT_SHIFT     时间戳部分
104        // datacenterId << DATACENTER_ID_SHIFT                      数据中心部分
105        // machineId << WORKER_ID_SHIFT                             机器标识部分
106        // sequence                                                 序列号部分
107        return ((currentTimestamp - INIT_TIMESTAMP) << TIMESTAMP_LEFT_SHIFT) |
108                (datacenterId << DATACENTER_ID_SHIFT) |
109                (machineId << WORKER_ID_SHIFT) |
110                sequence;
111    }
112
113    /**
114     * 当某一毫秒时间内产生的ID数超过最大值则进入等待,
115     * 循环判断当前时间戳是否已经变更到下一毫秒,
116     * 是则返回最新的时间戳
117     *
118     * @param lastTimestamp 待比较的时间戳
119     * @return 当前时间戳
120     */
121    private long tilNextMillis(long lastTimestamp) {
122        long timestamp = timeGen();
123        while (timestamp <= lastTimestamp) {
124            timestamp = timeGen();
125        }
126        return timestamp;
127    }
128
129    /**
130     * 获取系统当前时间
131     *
132     * @return 系统当前时间(毫秒)
133     */
134    private long timeGen() {
135        return System.currentTimeMillis();
136    }
137
138    /**
139     * 测试 main 方法
140     */
141    public static void main(String[] args) {
142        // 实例化生成 ID 工具对象
143        Snowflake worker = new Snowflake(1, 3);
144        // 创建用于存储 id 的集合
145        List<Long> idList = new ArrayList<>();
146        // 标记开始时间
147        long start = System.currentTimeMillis();
148        // 设置 1000ms 内循环生成 ID
149        while (System.currentTimeMillis() - start <= 1000) {
150            // 生成 ID 加入集合
151            idList.add(worker.nextId());
152        }
153        // 输出1s内生成ID的数量
154        System.out.println("生成 ID 总数量:" + idList.size());
155    }
156
157}

Snowflake 生成分布式 ID 的优缺点

优点:

  • 高性能;
  • 趋势递增;
  • 可以灵活调整结构;
  • 不需要第数据库等第三方组件依赖;

缺点:

  • 强依赖时钟,可能发生时钟回拨导致生成的 ID 重复。

方案八:使用 Zookeeper 生成 ID

Zookeeper 中如何生成 ID 值

在 Zookeeper 中主要通过节点数据版本号来生成序列号,可以生成 32 位和 64 位的数据版本号,客户端可以使用这个版本号来作为唯一的序列号。在 Zookeeper 中本身就是支持集群模式,所以能保证高可用性,且生成的 ID 为趋势递增且有序,不过在实际使用中很少用 Zookeeper 来充当 ID 生成器,因为 Zookeeper 中存在强一致性,在高并发场景下其性能可能很难满足需求。

不过由于使用 Zookeeper 节点的版本号来充当 ID 号是比较繁琐,需要创建节点获取生成的 ID,然后去掉节点命令前缀,只截取数字部分,最后还要异步执行删除节点(启动新的线程执行删除节点操作,防止占用生成ID线程执行的实际)。过程比较耗时且繁琐,所以,在操作 Zookeeper 时经经常不会采用该方案,常使用 Curator 客户端提供的基于乐观锁的计数器来自增实现 ID 生成,这个过程和数据库自增生成 ID 类似。

Java 操作 Zookeeper 生成 ID 的实现

在 Java 中可以使用 Curator 包操作 Zookeeper,引入的 Maven 包如下:

 1<dependency>
 2    <groupId>org.apache.curator</groupId>
 3    <artifactId>curator-recipes</artifactId>
 4    <version>5.0.0</version>
 5</dependency>
 6<!-- 创建 Zookeeper 客户端依赖,一定要和 Zookeeper Server 版本保持一致 -->
 7<dependency>
 8    <groupId>org.apache.zookeeper</groupId>
 9    <artifactId>zookeeper</artifactId>
10    <version>3.6.0</version>
11</dependency>

然后使用 curator 工具提供的 increment 方法,使计数器递增,获取递增后的值:

 1public class ZookeeperIdExample {
 2
 3    /** 
 4     * 原子递增器对象 
 5     */
 6    private DistributedAtomicLong distributedAtomicLong;
 7
 8    /**
 9     * 初始化操作
10     */
11    @PostConstruct
12    public void init() {
13        // 初始化参数(Zookeeper 地址、Session 超时时间、节点路径、重试策略)
14        String zkServer = "127.0.0.1:2181";
15        int sessionTimeoutMs = 10000;
16        String counterNode = "/distribute_id";
17        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 10);
18        // 创建 CuratorFramework 对象并启动客户端
19        CuratorFramework cf = CuratorFrameworkFactory.builder()
20                .connectString(zkServer)
21                .sessionTimeoutMs(sessionTimeoutMs)
22                .retryPolicy(retryPolicy)
23                .build();
24        cf.start();
25        // 初始化原子递增器对象
26        distributedAtomicLong = new DistributedAtomicLong(cf, counterNode, retryPolicy);
27    }
28
29    /**
30     * 获取 ID 号
31     */
32    public Long generateId() {
33        Long generateId = null;
34        try {
35            // 计数器 +1
36            AtomicValue<Long> sequence = distributedAtomicLong.increment();
37            // 判断获取序列号是否是成功的
38            if (sequence.succeeded()) {
39                generateId = sequence.postValue();
40            } else {
41                throw new RuntimeException("生成 ID 异常");
42            }
43        } catch (Exception e) {
44            e.printStackTrace();
45        }
46        // 返回 ID 号
47        return generateId;
48    }
49
50    public static void main(String[] args) {
51        // 方法类初始化
52        ZookeeperIdExample zookeeperIdExample = new ZookeeperIdExample();
53        zookeeperIdExample.init();
54        // 设置存储生成的 ID 的 List 集合
55        List<Long> idList = new ArrayList<>();
56        // 设置开始时间,该时间作为起始时间
57        long start = System.currentTimeMillis();
58        // 统计 10s 内生成的 ID 数量
59        while (System.currentTimeMillis() - start < 10000){
60            // 生成 ID
61            Long id = zookeeperIdExample.generateId();
62            System.out.println("生成的 ID = " + id);
63            // 加入集合
64            idList.add(id);
65        }
66        System.out.println("大小:" + idList.size());
67    }
68
69}

使用 Zookeeper 生成 ID 的优缺点

优点:

  • 高可用;
  • 趋势递增;

缺点:

  • 性能差;
  • 定期删除之前生成的节点,比较繁琐;

方案九:使用 MongoDB 创建 ObjectID 生成 ID

MongoDB 中如何生成 ID 值

在 MongoDB 中每插入一条数据且没有指定 ID 就会生成一个 _id 键作为唯一标识,该键默认是 ObjectID 串,常常可以类似于像数据库插入数据一样往 MongoDB 中插入数据,获取其默认生成的 ObjectID 值来充当分布式 ID。

MongoDB 的 ObjectId 的组成

在 MongoDB 中默认生成的 ObjectId(十六进制)是由一个 12bit 组成的BSON,ObjectID 是一个 12bit 的 BSON 类型数据,组成类似于雪花算法,如下图就是一个 ObjectID 的组成图:

结构组成如下:

  • 4字节时间戳,以 Unix 纪元以来的秒数为单位(精确到秒)。
  • 5字节随机数。
  • 3字节递增计数器,初始化为随机值,它能确保相同进程同一秒产生的 ObjectId 也是不一样的。同一秒钟最多允许每个进程拥有2563(16 777 216)个不同的 ObjectId。

如下,就是一个十六进制组成的 ObjectID 示例:

15f55e4dddd2ab03204e13167

其中 16 进制转 10 进制如下:

组成 十六进制 十进制
时间戳 5f55e4d 1599464669
随机数 dd2ab03204 949903962628
递增计算器随机值 e13167 14758247

Java 中操作 MongoDB 生成 ID 的实现

在 Java 中可以使用 MongoDB 官方提供的 MongoDB Java 驱动操作 MongoDB,可以如下引入 Maven 包:

1<dependency>
2    <groupId>org.mongodb</groupId>
3    <artifactId>mongo-java-driver</artifactId>
4    <version>3.12.7</version>
5</dependency>

然后使用插入一条数据生成 ID:

 1public class MongoExample {
 2
 3    public static void main(String[] args) {
 4        //连接 MongoDB 服务器,端口号为 27017
 5        MongoClient mongoClient = new MongoClient("127.0.0.1", 27017);
 6        // 获取数据库(如果就创建不存在就创建)
 7        MongoDatabase dbTest = mongoClient.getDatabase("test");
 8        // 插入一条文档数据(如果就创建不存在就创建)
 9        Document doc = new Document();
10        dbTest.getCollection("myid").insertOne(doc);
11        // 获取 ID 值
12        ObjectId id = (ObjectId) doc.get("_id");
13        // 输出 ID 值
14        System.out.println(id);
15    }
16
17}

使用 MongoDB 的生成分布式 ID 优缺点

优点:

  • 实现简单;
  • 集群模式易于扩展,没有单点问题;

缺点:

  • 性能一般,只能并发量小的业务需求;

流行的分布式 ID 开源框架

现在网上有很多分布式 ID 开源框架,这里比较常用的有:

这里个开源框架中,大都是按照上面介绍的这几种分布式 ID 生成方案原理开发的,如:

  • 滴滴 Tinyid: 数据库号段模式
  • 百度 Uid-Generator: 数据库 + Snowflake
  • 美团 leaf: 数据库号段模式、Zookeeper + Snowflake

这几款性能都不错,他们的 github 和相关博客中都有详细的介绍,这里我就不过多叙述,感兴趣的博友自行去查阅相关资料。根据本人分析,这几张流行的开源分布式 ID 的实现都做了如下操作:

  • (1)、减少网络延迟,没有使用 Zookeeper、Redis 等作为分布式锁的核心组件;
  • (2)、可以灵活配置生成的 ID,可以在其中添加跟业务挂钩的业务号,以满足不同业务需求;
  • (3)、大部分考虑的是高可用方案,组成统一分布式 ID 分发组件,且组成集群模式,保证可用性;
  • (4)、将生成的 ID 存入缓存,这样相当于提前往缓存中存入了一批数据,能防止并发突增导致 ID 需求大,也能防止数据库突然不可用。
  • (5)、都会设置一个监控器和异步更新缓存中分布式 ID 的多个线程,监控器会监控缓存中的使用比例,达到一定比例后会通知更新缓存的线程执行更新分布式 ID 任务,这样会再往缓存中放入一批可以数据。

总结:各种方案的优缺点对比

对部分方案进行了简单测试,由于没有精细的配置组件环境和参数所以这里的数据不一定准确,只供参考:

方案名称 性能 ID生成速度(单位:s)
使用数据库号段模式生成 ID 非常高 100000000+
使用 Snowflake 生成 ID 很高 4000000+
使用 UUID 生成 ID 710000+
使用 MongoDB 创建 ObjectID 生成 ID 一般 1500+
使用 Redis 的 INCR 或 INCRBY 命令生成 ID 一般 2000+
使用 Zookeeper 的节点 ID 600+
使用数据库自增生成 ID 300+

根据上面比较,还是比较推荐使用 号段模式Snowflake 两种方案用于生成分布式 ID,具体还是得根据业务实际来选择不同方案。

---END---


  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。