项目亮点之分布式唯一主键
分布式唯一主键
- 全局唯一 :ID 的全局唯一性肯定是首先要满足的!
- 高性能 : 分布式 ID 的生成速度要快,对本地资源消耗要小。
- 高可用 :生成分布式 ID 的服务要保证可用性无限接近于 100%。
- 方便易用 :拿来即用,使用方便,快速接入!
- 安全 :ID 中不包含敏感信息。
- 有序递增 :如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。
- 有具体的业务含义 :生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。
- 独立部署 :也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。
数据库自增ID
最常见的方式。利用数据库,全数据库唯一(数据库一般只有一个主库,可以保证唯一)。
1.创建一个数据库表。
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 ('stub');
SELECT LAST_INSERT_ID();
COMMIT;
插入数据这里,我们没有使用 insert into
而是使用 replace into
来插入数据,具体步骤是这样的:
- 尝试把数据插入到表中。
- 如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。
优点
- 实现起来比较简单
- ID 有序递增
- 存储消耗空间小
缺点
- 支持的并发量不大
- 存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)
- ID 没有具体业务含义
- 安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )
- 每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)
数据库集群
做双主模式集群,也就是两个Mysql实例都能单独的生产自增ID。
设置起始值
和自增步长
MySQL_1 配置:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步长
MySQL_2 配置:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步长
水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。
增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。
优点
- 解决DB单点问题
缺点
- 不利于后续扩容。
- 实际上单个数据库自身压力还是大,依旧无法满足高并发场景。
数据库号段模式
1.创建一个数据库表。
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;
current_max_id
字段和step
字段主要用于获取批量 ID,获取的批量 id 为: current_max_id ~ current_max_id+step
。
version
字段主要用于解决并发问题(乐观锁),biz_type
主要用于表示业务类型。
2.先插入一行数据。
INSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`)
VALUES
(1, 0, 100, 0, 101);
3.通过 SELECT 获取指定业务下的批量唯一 ID
SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101
结果:
id current_max_id step version biz_type
1 0 100 0 101
4.不够用的话,更新之后重新 SELECT 即可。
UPDATE sequence_id_generator SET current_max_id = 0+100, 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 current_max_id step version biz_type
1 100 100 1 101
相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。
另外,为了避免单点问题,你可以从使用主从模式来提高可用性。
优点
- ID 有序递增
- 存储消耗空间小
缺点
- 存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)
- ID 没有具体业务含义
- 安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )
Redis
Redis
也同样可以实现,原理就是利用redis
的 incr
命令实现ID的原子性自增。
大致思路如下:
时间+用redis的incr自增命令(每日从1开始),代码如下:
public class RedisCounterRepository {
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
private RedisTemplate<String, Object> redisTemplate;
@Autowired
public RedisCounterRepository(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// 根据获取的自增数据,添加日期标识构造分布式全局唯一标识,changeNumPrefix是自己定义的随机前缀
private String getNumFromRedis(String changeNumPrefix) {
String dateStr = LocalDate.now().format(dateTimeFormatter);
Long value = incrementNum(changeNumPrefix + dateStr);
//不足4位补0,redis从1开始生成的,每天再次请0
return dateStr + StringUtils.leftPad(String.valueOf(value), 4, '0');
}
// 从redis中获取自增数据(redis保证自增是原子操作)
private long incrementNum(String key) {
RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
if (null == factory) {
log.error("Unable to connect to redis.");
throw new UserException(AppStatus.INTERNAL_SERVER_ERROR);
}
RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, factory);
long increment = redisAtomicLong.incrementAndGet();
if (1 == increment) {
// 如果数据是初次设置,需要设置超时时间
redisAtomicLong.expire(1, TimeUnit.DAYS);
}
return increment;
}
}
优点
- 性能不错、每秒10万并发量
- 生成的 ID 是有序递增的
缺点
- 存在Redis单点问题(可以使用Redis集群解决,不过增加了复杂度)
- ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )
- 每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)
MongoDB
MongoDB ObjectId 一共需要 12 个字节存储:
- 0~3:时间戳
- 3~6: 代表机器 ID
- 7~8:机器进程 ID
- 9~11 :自增值
优点
- 性能不错
- 生成的 ID 是有序递增的
缺点
- 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)
- 有安全性问题(ID 生成有规律性)
UUID
UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。
JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。
String uuid = UUID.randomUUID().toString().replaceAll("-","");
我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。
5 种不同的 Version(版本)值分别对应的含义(参考维基百科对于 UUID 的介绍):
- 版本 1 : UUID 是根据时间和节点 ID(通常是 MAC 地址)生成;
- 版本 2 : UUID 是根据标识符(通常是组或用户 ID)、时间和节点 ID 生成;
- 版本 3、版本 5 : 版本 5 - 确定性 UUID 通过散列(hashing)名字空间(namespace)标识符和名称生成;
- 版本 4 : UUID 使用随机性或伪随机性生成。
JDK 中通过 UUID
的 randomUUID()
方法生成的 UUID 的版本默认为 4。
优点
- 生成速度比较快、简单易用
缺点
- 存储消耗空间大(32 个字符串,128 位)
- 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)
- 无序(非自增)
- 没有具体业务含义
- 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)
Snowflake
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。生成的是一个64位的二进制正整数,然后转换成10进制的数。64位二进制数由如下部分组成:
其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。
优点:
- 生成速度比较快
- 生成的 ID 有序递增
- 比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID)
缺点
- 需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)。
UidGenerator
UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。
不过,UidGenerator 对 Snowflake(雪花算法)进行了改进,生成的唯一 ID 组成如下。
可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。
UidGenerator 官方文档中的介绍如下:
Leaf
Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话: “There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!
Leaf 提供了 号段模式 和 Snowflake(雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper 。
Leaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。
Leaf 对原有的号段模式进行改进,比如它这里增加了双号段避免获取 DB 在获取号段的时候阻塞请求获取 ID 的线程。简单来说,就是我一个号段还没用完之前,我自己就主动提前去获取下一个号段(图片来自于美团官方文章:《Leaf——美团点评分布式 ID 生成系统》)。
根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。
Tinyid
Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。
数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?
为了搞清楚这个问题,我们先来看看基于数据库号段模式的简单架构方案。(图片来自于 Tinyid 的官方 wiki:《Tinyid 原理介绍》)
在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。
这种方案有什么问题呢?在我看来(Tinyid 官方 wiki 也有介绍到),主要由下面这 2 个问题:
- 获取新号段的情况下,程序获取唯一 ID 的速度比较慢。
- 需要保证 DB 高可用,这个是比较麻烦且耗费资源的。
除此之外,HTTP 调用也存在网络开销。
Tinyid 的原理比较简单,其架构如下图所示:
相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:
- 双号段缓存 :为了避免在获取新号段的情况下,程序获取唯一 ID 的速度比较慢。 Tinyid 中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。
- 增加多 db 支持 :支持多个 DB,并且,每个 DB 都能生成唯一 ID,提高了可用性。
- 增加 tinyid-client :纯本地操作,无 HTTP 请求消耗,性能和可用性都有很大提升。
Java自定义
一般就是时间+机器码+jvm内部的自增数列组成,这样效率很高,也不依赖于第三方中间件(如redis),建议必须掌握。
我们的生成逻辑如下,采用的是机器码+毫秒数+自增序列
public class IdWorker {
//当前机器对应的编码,在初始化的时候加载一次,所以要设置为static,表示属于整个类
private static String machineId;
static {
InetAddress ia = null;
String machineName = "";
try {
ia = InetAddress.getLocalHost();
//机器有多个IP的,获取首个
machineName += ia.getHostAddress();
//一个机器可能部署多个程序,但路径肯定不一样,获取当前项目路径的地址
machineName += System.getProperty("user.dir");
//然后在数据库中存储一张表,字段是自增id和机器名,然后先查询,如果没有的话insert,就可以用自增id作为机器唯一标识了
//此处先做个范例,设个默认值
machineId = "2";
} catch (Exception e) {
e.printStackTrace();
}
}
//生成自增id的相关属性
private static AtomicInteger currentSequence = new AtomicInteger(100);
public static String nextId() {
StringBuffer sb = new StringBuffer(machineId);
long currentTimes = System.currentTimeMillis();
//原子增加并且返回值
int result = currentSequence.getAndIncrement();
//new AtomicInteger(100)表示从100开始自增,是为了笔墨1和21还有101这种位数不一样需要补零的情况
//为了防止AtomicInteger一直增长导致位数变化,此处设置当数字大于某个值后将值设置为原点,也就是每毫秒不生成800个订单ID就没问题
//上一步到这一步可能有并发问题,也就是同时有多个程序已经自增了,所以会出现901,902和903等情况
if (result > 900){
currentSequence.set(100);;
}
sb.append(machineId).append(currentTimes).append("-").append(result);
return sb.toString();
}
public static void main(String[] args) {
}
}
优点
- 实现简单
- ID 有序递增
- 存储消耗空间小
缺点
- 每个节点每毫秒生成数量不能超过800个,需要总量预判
- 需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)。