什么是 ID?
在日常的开发中,我们需要对系统中的各种数据使用 ID 作为唯一表示,比如系统的用户 ID 只能对应一个人,订单 ID 只能对应一个订单。我们现实生活中也有各种 ID,比如,身份证号就是一个 ID 对应一个人。简单来说,ID 就是数据的唯一标识。
什么是分布式 ID?
那么什么是分布式 ID?这里举个简单的例子。
比如单机 MySQL 数据库,前期因为业务量不大,只是使用单个数据库存数据,后期发现业务量一下子就增长,单机 MySQL 已经不能满足于现在的数据量,单机 MySQL 已经没办法支撑了,这时候就需要进行分库分表。
在分库分表之后会有一个问题, 数据发布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了,如下图主键 ID 重复:
那么我们该怎样生成全局唯一主键呢?这个时候就需要生成分布式 ID 解决这个问题。
有哪些办法可以解决 ID 重复的解决方案呢。
分布式 ID 解决方案
这篇文章主要做一些总结,并不会分析的特别详细,只要了解分布式情况下该选择哪种方案就好,
1、数据库主键自增 ID
第一种方案仍然还是基于数据库的自增 ID,需要单独使用一个数据库实例,在这个实例中新建一个单独的表:
创建数据库
CREATE TABLE `sequence_id` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`stub` char(10) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。
2、通过 replace into 来插入数据
BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('zhangsan');
SELECT LAST_INSERT_ID();
COMMIT;
插入数据这里,我们没有使用 insert into 而是使用 replace into 来插入数据,replace into 会先看是否存在 stub 指定值一样的数据,如果存在则先 delete 再 insert,如果不存在则直接 insert。
这种生成分布式 ID 的机制,需要一个单独的 Mysql 实例,虽然可行,但是基于性能与可靠性来考虑的话都不够,业务系统每次需要一个 ID 时,都需要请求数据库获取,性能低,并且如果此数据库实例下线了,那么将影响所有的业务系统。
号段模式
号段模式一般也是基于数据库自增实现分布式 ID 的一种方式,是当下分布式 ID 生成方式中比较流行的一种,其使用可以简单理解为每次从数据库中获取生成的 ID 号段范围,将范围数据获取到应用本地后,在范围内循递增生成一批 ID,然后将这批数据存入缓存。
每次应用需要获取 ID 时,这时就候就可以从缓存中读取 ID 数据,当缓存中的 ID 消耗到一定数目时候,这时再去从数据库中读取一个号段范围,再执行生成一批 ID 操作存入缓存,这是一个重复循环的过程,这样重复操作每次都只是从数据库中获取待生成的 ID 号段范围,而不是一次次获取数据库中生成的递增 ID,这样减少对数据库的访问次数,大大提高了 ID 的生成效率。相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。
在使用号段模式时,我们通常会先建立一张表用于记录上述的 ID 号段范围,如下:
CREATE TABLE `sequence_id_generator` (
`id` int(10) NOT NULL,
`current_max_id` bigint(20) NOT NULL COMMENT '当前最大id',
`step` int(10) NOT NULL COMMENT '号段的长度',
`version` int(20) NOT NULL COMMENT '版本号记录更新的版本号,主要作用是乐观锁,每次更新时都会更新该值,以保证并发时数据的正确性',
`biz_type` int(20) NOT NULL COMMENT '业务类型',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
每次从数据库中获取号段 ID 的范围时,都会执行更新语句,其中计算新号段范围最大值 max_id 的公式是 current_max_id+ step 组成,所以 SQL 中设置 current_max_id= current_max_id + step 来执行更新语句,更新数据库中这个范围最大值 current_max_id,然后再通过查询语句查询更新后 ID 最大值,再根据最大值 current_max_id与步长 step 计算出待生成的 ID 的范围,SQL 如下:
update `sequence_id_generator` set`current_max_id` = current_max_id+ step, `version` = version + 1 where `version` = #{执行更新的版本号} and `biz_type` = #{业务类型}
select`current_max_id`, `step`, `version` from`sequence_id_generator` where`biz_type` = #{业务类型}
下面实现数据库号段模式生成 ID 过程描述:
例如,某个业务需要批量获取 ID,首先它往数据库 sequence_id_generator 中插入一条初始化值,设置 current_max_id = 0 和步长 step=100 及使用该 ID 的业务标识 biz_type=test 与版本 version=0,如下:
INSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`)
VALUES (1, 0, 100, 0, 101);
这时数据库中多了一条数据:
id | current_max_id | step | version | biz_type |
---|---|---|---|---|
1 | 0 | 100 | 0 | 101 |
然后以 biz_type 作为筛选条件,从数据库 sequence_id_generator 中读取 current_max_id 与 step 的值:
- max_id:0- step:100
通过这两个值可以知道号段范围为 (0,100),生成该批量 ID 存入到缓存中,那么这时候缓存大小为100。
每次都从缓存中取值,创建一个监听器用于监听缓存中 ID 消耗比例,设置阈值,判断如果取值超过的阈值后就进行数据库号段更新操作,比如,设置阈值为 50%,当缓存中存在 100 个 ID,监听器监听到业务应用已经消耗到 50 个,已经超过阈值,创建一个新的线程去执行更新 SQL 语句,让数据库中号段范围按照设置的 step 扩大,然后获取新的号段最大值,应用中再生成一批范围为 (101,200) 范围的 ID 存入缓存供应用使用。
执行下面的更新 SQL 语句:
update `sequence_id_generator` SET `current_max_id` = current_max_id+ step, `version` = version + 1 where `version` = 0 and `biz_type` = 101
SELECT `current_max_id`, `step`, `version` FROM `sequence_id_generator` WHERE `biz_type` = 101
整个过程是一个循环的过程,每到消耗到一定数据后就会生成新的一批。相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。
数据库号段模式生成 ID 的缺点:
1、存在数据库单点问题,可以使用数据库集群解决,不过增加了复杂度
数据库宕机会造成整个系统不可用。ID 号码不够随机,可能够泄露发号数量的信息,不太安全
Redis 实现分布式 ID
Redis 中存在原子操作指令 INCR 或 INCRBY,执行后可用于创建初始化值或者在原有数字基础上增加指定数字,并返回执行 INCR 命令之后 key 的值,这样就可以很方便的创建有序递增的 ID。
1、INCR: 将 key 中储存的数字 +1,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。
2、INCRBY: 将 key 中储存的数字加上指定的增量值,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。
Redis 生成 ID 示例:
127.0.0.1:6379> set sequence_id_biz_type 1
OK
127.0.0.1:6379> incr sequence_id_biz_type
(integer) 2
127.0.0.1:6379> get sequence_id_biz_type
"2"
使用 Redis 单机生成 ID 存在性能瓶颈,无法满足高并发的业务需求,且一旦 Redis 崩溃或者服务器宕机,那么将导致整个基于它的服务不可用,这是业务中难以忍受的。
为了提高可用性和并发,我们可以使用 Redis Cluser。Redis Cluser 是 Redis 官方提供的 Redis 集群解决方案。
除了 Redis Cluser 之外,你也可以使用开源的 Redis 集群方案Codis (大规模集群比如上百个节点的时候比较推荐)。
除了高可用和并发之外,我们知道 Redis 基于内存,我们需要持久化数据,避免重启机器或者机器故障后数据丢失。Redis 支持两种不同的持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)。并且,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。
关于 Redis 持久化就不多说了,这个不是重点。
Redis 生成分布式 ID 的缺点:
1、增加了程序的复杂度,和硬件资源
2、如果 Rdeis 宕机,则服务不可用
3、可能达到Rdeis的性能瓶颈,则需要部署多台,使用步长方式
4、持久化问题,若使用RDB持久化策略则可能在最后一次持久化之前发生宕机则恢复后可能发生
5、ID重复问题,若使用AOF持久化策略则在恢复Redis时需要较长时间
UUID 实现分布式 ID
UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写,UUID 有着全球唯一的特性。那么 UUID 可以做分布式 ID 吗? 答案是可以的,但是这种的话并不推荐。
JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。
//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa
UUID.randomUUID()
但是 UUID 并不适用于实际的业务需求。像用作订单号 UUID 这样的字符串没有丝毫的意义,看不出和订单相关的有用信息;而对于数据库来说用作业务主键 ID,它不仅是太长还是字符串,存储性能差查询也很耗时,所以不推荐用作分布式 ID。
UUID 生成分布式 ID 的缺点:
1、无序的字符串,不具备趋势自增特性
2、不易于存储,UUID 太长,16 字节 128 位,因为其长度原因占用多余的空间
3、存储以及查询对 MySQL 的性能消耗较大,MySQL 官方明确建议主键要尽量越短越好,作为数据库主键 UUID 的无序性会导致数据位置频繁变动,严重影响性能
Snowflake (雪花算法)实现分布式 ID
Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成。
这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:
1、第 0 位:由于 long 基本类型在 Java 中是带符号的,整数为 0 负数为 1,一般生成的 ID 都为正数,所以固定为 0,没有用,不用管
2、第 1~41 位 :需要注意的是,41 位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)得到的值,这里的开始时间截,一般是指我们的 ID 生成器开始使用的时间截,由我们的程序来指定。41 位的毫秒时间截,可以使用69年(即T =(1L << 41)/(1000 * 60 * 60 * 24 * 365)= 69)。
3、第 42~52 位 :10 位的数据机器位:包括 5 位数据中心标识 ID(datacenterId)、5位机器标识Id(workerId),最多可以部署 1024 个节点(即1 << 10 = 1024)。超过这个数量,生成的 ID 就有可能会冲突
4、第 53~64 位 :毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生 4096 个 ID 序号(即1 << 12 = 4096)
如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团的 Leaf、百度的 UidGenerator,并且这些开源实现对原有的 Snowflake 算法进行了优化。
Snowflake 算法的缺点 :
由于雪花算法是强依赖于时间的,在分布式环境下,如果发生时钟回拨,很可能会引起 ID 重复、ID 乱序、服务会处于不可用状态等问题
UidGenerator (百度)实现分布式 ID
UidGenerator 是百度开源的一款基于 Snowflake (雪花算法)的唯一 ID 生成器,与原始的 snowflake 算法不同在于,UidGenerator 支持自定义时间戳、 工作机器 ID 和序列号等各部分的位数,而且UidGenerator 中采用用户自定义 workId 的生成策略。
生成的唯一 ID 组成如下:
可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。
UidGenerator 官方文档中的介绍如下:
自 18 年后,UidGenerator 就基本没有再维护了,这里不过多介绍。想要进一步了解的小伙伴,可以看看 UidGenerator 的官方介绍。
Leaf (美团)实现分布式 ID
Leaf 是美团开源的一个分布式 ID 解决方案,Leaf 同时支持号段模式和 Snowflake (雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper 。
Leaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。
Leaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章:《Leaf——美团点评分布式 ID 生成系统》[11])
Tinyid (滴滴)实现分布式 ID
Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。 Tinyid 原理实现的与 Leaf 如出一辙,每个服务获取一个号段(1000,2000]、(2000,3000]、(3000,4000]
在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。
那么这种方案有什么问题呢?Tinyid 官方 wiki 也有介绍到,主要由下面这 2 个问题:
1、获取新号段的情况下,程序获取唯一 ID 的速度比较慢
2、需要保证 DB 高可用,这个是比较麻烦且耗费资源的
除此之外,HTTP 调用也存在网络开销。
相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:
1、双号段缓存 :为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段
2、增加多 db 支持 :支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性
3、增加 tinyid-client :纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升
总结
本文只是简单介绍一下每种分布式 ID 生成器,主要是给小伙伴一个详细学习的方向,每种生成方式都有它自己的优缺点,具体如何使用还要看具体的业务需求。