一、 分布式ID基础

1.背景

1.为什么要引用分布式主键ID?

比如单机 MySQL 数据库,前期因为业务量不大,只是使用单个数据库存数据,后期发现业务量一下子就增长,单机 MySQL 已经不能满足于现在的数据量,单机 MySQL 已经没办法支撑了,这时候就需要进行分库分表。

在分库分表之后会有一个问题, 数据发布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了,那样就无法作为业务的唯一标识了。如下图主键 ID 重复:

2.引用分布式主键ID能解决什么问题?

分表之后,不同表生成全局唯一的Id是非常棘手的问题。因为同一个逻辑表内的不同,实际表之间的自增键是无法互相感知的, 这样会造成重复Id的生成。

比如如果涉及到查询多张表进行排序等,分布式主键ID性能将更高。

二、 分布式主键ID生成的几种策略

下面是常用的策略

1.UUID生成分布式主键

UUID是通用唯一识别码 (Universally Unique Identifier),在其他语言中也叫GUID,可以生成一个长度32位的全局唯一识别码。

String uuid = UUID.randomUUID().toString()

结果示例:

046b6c7f-0b8a-43b9-b35d-6489e6daee91

1.UUID的缺点

由于InnoDB采用的B+Tree索引特性,UUID生成的主键插入性能较差

为什么无序的UUID会导致入库性能变差呢?

这就涉及到 B+树索引的分裂:


众所周知,关系型数据库的索引大都是B+树的结构,拿ID字段来举例,索引树的每一个节点都存储着若干个ID。

如果我们的ID按递增的顺序来插入,比如陆续插入8,9,10,新的ID都只会插入到最后一个节点当中。当最后一个节点满了,会裂变出新的节点。这样的插入是性能比较高的插入,因为这样节点的分裂次数最少,而且充分利用了每一个节点的空间。


但是,如果我们的插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。

2.数据库自增主键

假设名为table的表有如下结构:

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 字段创建了唯一索引,保证其唯一性。

每一次生成ID的时候,访问数据库,执行下面的语句:

BEGIN;REPLACE INTO sequence_id (stub) VALUES ('zhangsan');SELECT LAST_INSERT_ID();COMMIT;

REPLACE INTO 的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。

这样一来,每次都可以得到一个递增的ID。

为了提高性能,在分布式系统中可以用DB proxy请求不同的分库,每个分库设置不同的初始值,步长和分库数量相等:

这样一来,DB1生成的ID是1,4,7,10,13…,DB2生成的ID是2,5,8,11,14…

1.缺点

3.号段模式

号段模式一般也是基于数据库自增实现分布式 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);

这时数据库中多了一条数据:


然后以 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 存入缓存供应用使用。

整个过程是一个循环的过程,每到消耗到一定数据后就会生成新的一批。相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。

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

存在数据库单点问题,可以使用数据库集群解决,不过增加了复杂度数据库宕机会造成整个系统不可用。ID 号码不够随机,可能够泄露发号数量的信息,不太安全

4.snowflake雪花算法

JDBC接口来实现对于生成Id的访问,而将底层具体的Id生成实现分离出来。如Sharding-JDBC使用分布式ID。

5.Redis 实现分布式 ID

Redis由于是单线程模型,所以对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 1OK127.0.0.1:6379> incr sequence_id_biz_type(integer) 2127.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 持久化就不多说了,这个不是重点。

1.Redis 生成分布式 ID 的缺点:

1、增加了程序的复杂度,和硬件资源

2、如果 Rdeis 宕机,则服务不可用

3、可能达到Rdeis的性能瓶颈,则需要部署多台,使用步长方式

4、持久化问题,若使用RDB持久化策略则可能在最后一次持久化之前发生宕机则恢复后可能发生

5、ID重复问题,若使用AOF持久化策略则在恢复Redis时需要较长时间

2.针对redis 不可用或者key失效的解决方案

当key如果失效了,则从DB数据库中取最大值,然后再放进到数据库中。

当redis不可用,则从DB中取最大值,然后基于服务内存运算,但这样会引进新的缓存击穿和分布式并发问题而导致数据不唯一

参考资料

1.漫画:什么是SnowFlake算法?https://blog.csdn.net/bjweimengshu/article/details/80162731
2.为什么需要分布式 ID,在项目中该怎么做?https://blog.csdn.net/wuhuayangs/article/details/125203180