本文已经收录自 JavaGuide(60k+ Star【Java 学习 + 面试指南】一份涵盖大部分 Java 程序员所需要掌握的核心知识。)
本文授权转载自:https://juejin.im/post/5d6fc8…,作者:1 点 25。
ID 是数据的唯一标识,传统的做法是利用 UUID 和数据库的自增 ID,在互联网企业中,大部分公司使用的都是 Mysql,并且因为需要事务支持,所以通常会使用 Innodb 存储引擎,UUID 太长以及无序,所以并不适合在 Innodb 中来作为主键,自增 ID 比较合适,但是随着公司的业务发展,数据量将越来越大,需要对数据进行分表,而分表后,每个表中的数据都会按自己的节奏进行自增,很有可能出现 ID 冲突。这时就需要一个单独的机制来负责生成唯一 ID,生成出来的 ID 也可以叫做 分布式 ID,或 全局 ID。下面来分析各个生成分布式 ID 的机制。
这篇文章并不会分析的特别详细,主要是做一些总结,以后再出一些详细某个方案的文章。
数据库自增 ID
第一种方案仍然还是基于数据库的自增 ID,需要单独使用一个数据库实例,在这个实例中新建一个单独的表:
表结构如下:
CREATE DATABASE `SEQID`;
CREATE TABLE SEQID.SEQUENCE_ID (id bigint(20) unsigned NOT NULL auto_increment,
stub char(10) NOT NULL default '',
PRIMARY KEY (id),
UNIQUE KEY stub (stub)
) ENGINE=MyISAM;
可以使用下面的语句生成并获取到一个自增 ID
begin;
replace into SEQUENCE_ID (stub) VALUES ('anyword');
select last_insert_id();
commit;
stub 字段在这里并没有什么特殊的意义,只是为了方便的去插入数据,只有能插入数据才能产生自增 id。而对于插入我们用的是 replace,replace 会先看是否存在 stub 指定值一样的数据,如果存在则先 delete 再 insert,如果不存在则直接 insert。
这种生成分布式 ID 的机制,需要一个单独的 Mysql 实例,虽然可行,但是基于性能与可靠性来考虑的话都不够,业务系统每次需要一个 ID 时,都需要请求数据库获取,性能低,并且如果此数据库实例下线了,那么将影响所有的业务系统。
为了解决数据库可靠性问题,我们可以使用第二种分布式 ID 生成方案。
数据库多主模式
如果我们两个数据库组成一个 主从模式 集群,正常情况下可以解决数据库可靠性问题,但是如果主库挂掉后,数据没有及时同步到从库,这个时候会出现 ID 重复的现象。我们可以使用 双主模式 集群,也就是两个 Mysql 实例都能单独的生产自增 ID,这样能够提高效率,但是如果不经过其他改造的话,这两个 Mysql 实例很可能会生成同样的 ID。需要单独给每个 Mysql 实例配置不同的起始值和自增步长。
第一台 Mysql 实例配置:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步长
第二台 Mysql 实例配置:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步长
经过上面的配置后,这两个 Mysql 实例生成的 id 序列如下:mysql1, 起始值为 1, 步长为 2,ID 生成的序列为:1,3,5,7,9,… mysql2, 起始值为 2, 步长为 2,ID 生成的序列为:2,4,6,8,10,…
对于这种生成分布式 ID 的方案,需要单独新增一个生成分布式 ID 应用,比如 DistributIdService,该应用提供一个接口供业务应用获取 ID,业务应用需要一个 ID 时,通过 rpc 的方式请求 DistributIdService,DistributIdService 随机去上面的两个 Mysql 实例中去获取 ID。
实行这种方案后,就算其中某一台 Mysql 实例下线了,也不会影响 DistributIdService,DistributIdService 仍然可以利用另外一台 Mysql 来生成 ID。
但是这种方案的扩展性不太好,如果两台 Mysql 实例不够用,需要新增 Mysql 实例来提高性能时,这时就会比较麻烦。
现在如果要新增一个实例 mysql3,要怎么操作呢?第一,mysql1、mysql2 的步长肯定都要修改为 3,而且只能是人工去修改,这是需要时间的。第二,因为 mysql1 和 mysql2 是不停在自增的,对于 mysql3 的起始值我们可能要定得大一点,以给充分的时间去修改 mysql1,mysql2 的步长。第三,在修改步长的时候很可能会出现重复 ID,要解决这个问题,可能需要停机才行。
为了解决上面的问题,以及能够进一步提高 DistributIdService 的性能,如果使用第三种生成分布式 ID 机制。
号段模式
我们可以使用号段的方式来获取自增 ID,号段可以理解成批量获取,比如 DistributIdService 从数据库获取 ID 时,如果能批量获取多个 ID 并缓存在本地的话,那样将大大提供业务应用获取 ID 的效率。
比如 DistributIdService 每次从数据库获取 ID 时,就获取一个号段,比如(1,1000],这个范围表示了 1000 个 ID,业务应用在请求 DistributIdService 提供 ID 时,DistributIdService 只需要在本地从 1 开始自增并返回即可,而不需要每次都请求数据库,一直到本地自增到 1000 时,也就是当前号段已经被用完时,才去数据库重新获取下一号段。
所以,我们需要对数据库表进行改动,如下:
CREATE TABLE id_generator (id int(10) NOT NULL,
current_max_id bigint(20) NOT NULL COMMENT '当前最大 id',
increment_step int(10) NOT NULL COMMENT '号段的长度',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
这个数据库表用来记录自增步长以及当前自增 ID 的最大值(也就是当前已经被申请的号段的最后一个值),因为自增逻辑被移到 DistributIdService 中去了,所以数据库不需要这部分逻辑了。
这种方案不再强依赖数据库,就算数据库不可用,那么 DistributIdService 也能继续支撑一段时间。但是如果 DistributIdService 重启,会丢失一段 ID,导致 ID 空洞。
为了提高 DistributIdService 的高可用,需要做一个集群,业务在请求 DistributIdService 集群获取 ID 时,会随机的选择某一个 DistributIdService 节点进行获取,对每一个 DistributIdService 节点来说,数据库连接的是同一个数据库,那么可能会产生多个 DistributIdService 节点同时请求数据库获取号段,那么这个时候需要利用乐观锁来进行控制,比如在数据库表中增加一个 version 字段,在获取号段时使用如下 SQL:
update id_generator set current_max_id=#{newMaxId}, version=version+1 where version = #{version}
因为 newMaxId 是 DistributIdService 中根据 oldMaxId+ 步长算出来的,只要上面的 update 更新成功了就表示号段获取成功了。
为了提供数据库层的高可用,需要对数据库使用多主模式进行部署,对于每个数据库来说要保证生成的号段不重复,这就需要利用最开始的思路,再在刚刚的数据库表中增加起始值和步长,比如如果现在是两台 Mysql,那么 mysql1 将生成号段(1,1001],自增的时候序列为 1,3,4,5,7…. mysql1 将生成号段(2,1002],自增的时候序列为 2,4,6,8,10…
更详细的可以参考滴滴开源的 TinyId:github.com/didi/tinyid…
在 TinyId 中还增加了一步来提高效率,在上面的实现中,ID 自增的逻辑是在 DistributIdService 中实现的,而实际上可以把自增的逻辑转移到业务应用本地,这样对于业务应用来说只需要获取号段,每次自增时不再需要请求调用 DistributIdService 了。
雪花算法
上面的三种方法总的来说是基于自增思想的,而接下来就介绍比较著名的雪花算法 -snowflake。
我们可以换个角度来对分布式 ID 进行思考,只要能让负责生成分布式 ID 的每台机器在每毫秒内生成不一样的 ID 就行了。
snowflake 是 twitter 开源的分布式 ID 生成算法,是一种算法,所以它和上面的三种生成分布式 ID 机制不太一样,它不依赖数据库。
核心思想是:分布式 ID 固定是一个 long 型的数字,一个 long 型占 8 个字节,也就是 64 个 bit,原始 snowflake 算法中对于 bit 的分配如下图:
- 第一个 bit 位是标识部分,在 java 中由于 long 的最高位是符号位,正数是 0,负数是 1,一般生成的 ID 为正数,所以固定为 0。
- 时间戳部分占 41bit,这个是毫秒级的时间,一般实现上不会存储当前的时间戳,而是时间戳的差值(当前时间 - 固定的开始时间),这样可以使产生的 ID 从更小值开始;41 位的时间戳可以使用 69 年,(1L << 41) / (1000L 60 60 24 365) = 69 年
- 工作机器 id 占 10bit,这里比较灵活,比如,可以使用前 5 位作为数据中心机房标识,后 5 位作为单机房机器标识,可以部署 1024 个节点。
- 序列号部分占 12bit,支持同一毫秒内同一个节点可以生成 4096 个 ID
根据这个算法的逻辑,只需要将这个算法用 Java 语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式 ID,只需保证每个业务应用有自己的工作机器 id 即可,而不需要单独去搭建一个获取分布式 ID 的应用。
snowflake 算法实现起来并不难,提供一个 github 上用 java 实现的:github.com/beyondfengy…
在大厂里,其实并没有直接使用 snowflake,而是进行了改造,因为 snowflake 算法中最难实践的就是工作机器 id,原始的 snowflake 算法需要人工去为每台机器去指定一个机器 id,并配置在某个地方从而让 snowflake 从此处获取机器 id。
但是在大厂里,机器是很多的,人力成本太大且容易出错,所以大厂对 snowflake 进行了改造。
百度(uid-generator)
github 地址:uid-generator
uid-generator 使用的就是 snowflake,只是在生产机器 id,也叫做 workId 时有所不同。
uid-generator 中的 workId 是由 uid-generator 自动生成的,并且考虑到了应用部署在 docker 上的情况,在 uid-generator 中用户可以自己去定义 workId 的生成策略,默认提供的策略是:应用启动时由数据库分配。说的简单一点就是:应用在启动时会往数据库表 (uid-generator 需要新增一个 WORKER_NODE 表) 中去插入一条数据,数据插入成功后返回的该数据对应的自增唯一 id 就是该机器的 workId,而数据由 host,port 组成。
对于 uid-generator 中的 workId,占用了 22 个 bit 位,时间占用了 28 个 bit 位,序列化占用了 13 个 bit 位,需要注意的是,和原始的 snowflake 不太一样,时间的单位是秒,而不是毫秒,workId 也不一样,同一个应用每重启一次就会消费一个 workId。
具体可参考 github.com/baidu/uid-g…
美团(Leaf)
github 地址:Leaf
美团的 Leaf 也是一个分布式 ID 生成框架。它非常全面,即支持号段模式,也支持 snowflake 模式。号段模式这里就不介绍了,和上面的分析类似。
Leaf 中的 snowflake 模式和原始 snowflake 算法的不同点,也主要在 workId 的生成,Leaf 中 workId 是基于 ZooKeeper 的顺序 Id 来生成的,每个应用在使用 Leaf-snowflake 时,在启动时都会都在 Zookeeper 中生成一个顺序 Id,相当于一台机器对应一个顺序节点,也就是一个 workId。
总结
总得来说,上面两种都是自动生成 workId,以让系统更加稳定以及减少人工成功。
Redis
这里额外再介绍一下使用 Redis 来生成分布式 ID,其实和利用 Mysql 自增 ID 类似,可以利用 Redis 中的 incr 命令来实现原子性的自增与返回,比如:
127.0.0.1:6379> set seq_id 1 // 初始化自增 ID 为 1
OK
127.0.0.1:6379> incr seq_id // 增加 1,并返回
(integer) 2
127.0.0.1:6379> incr seq_id // 增加 1,并返回
(integer) 3
使用 redis 的效率是非常高的,但是要考虑持久化的问题。Redis 支持 RDB 和 AOF 两种持久化的方式。
RDB 持久化相当于定时打一个快照进行持久化,如果打完快照后,连续自增了几次,还没来得及做下一次快照持久化,这个时候 Redis 挂掉了,重启 Redis 后会出现 ID 重复。
AOF 持久化相当于对每条写命令进行持久化,如果 Redis 挂掉了,不会出现 ID 重复的现象,但是会由于 incr 命令过得,导致重启恢复数据时间过长。
开源项目推荐
作者的其他开源项目推荐:
- JavaGuide:【Java 学习 + 面试指南】一份涵盖大部分 Java 程序员所需要掌握的核心知识。
- springboot-guide : 适合新手入门以及有经验的开发人员查阅的 Spring Boot 教程(业余时间维护中,欢迎一起维护)。
- programmer-advancement : 我觉得技术人员应该有的一些好习惯!
- spring-security-jwt-guide : 从零入门!Spring Security With JWT(含权限验证)后端部分代码。
公众号