分布式系统中生成唯一ID
使用redis生成id
在分布式系统中,还可能使用流水号来追踪某一请求,以满足多个节点的协作。在这种情况下,并不需要插入数据,采用数据库机制就有点不合适了。为此,我们可以考虑使用Redis来满足这个要求。从性能上来说,Redis的性能要比数据库好得多。从扩展性来说,使用多个Redis服务器就能实现扩展。因此,无论是性能,还是可扩展性,Redis都要比数据库好很多。并且Redis可以在不插入数据的时候生成ID,为分布式协作提供流水号。
1、Spring boot实例代码如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<!-- 加入Spring Boot的Redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
这样就能够引入Redis和Web相关的依赖包了。接着就需要在application.yml中配置对应的内容了。
# 配置启动端口
server:
port: 1013
# 配置Redis
spring:
redis:
# 服务器
host: 192.168.224.131
# 密码
password: 123456
# jedis配置
jedis:
#连接池配置
pool:
# 最大活动线程数
max-active: 20
# 最大空闲线程数
max-idle: 10
# 最小空闲线程数
min-idle: 5
# 最大等待1 s
max-wait: 1s
这样就配置好了启动的端口和关于Redis的配置,然后我们在Redis服务器上执行以下脚本。
hset user_table_key offset 1 -- ID开始值
hset user_table_key step 5 -- 步长
hset user_table_key current 1 -- 当前值
hset user_table_key start 0 -- 是否已经启用,0为不启用,1为启用
hgetall user_table_key -- 查看键(“user_table_key”)的所有信息
假设这里设置的是用户表ID的生成规则,这些规则使用Redis的哈希结构(Hash)进行保存。其中,开始值(offset)设置为1,步长(step)为5,这意味着下一个ID为1+5=6。启动标志(start)设置为0,表示没有开启,开启后设置为1。有了这些规则数据,我们就可以采用下列代码来获取对应的ID了。
package com.spring.cloud.chapter13.main;
/**** imports ****/
@SpringBootApplication
@RestController // REST风格控制器
@RequestMapping("/chapter13")
public class Chapter13Application {
public static void main(String[] args) {
SpringApplication.run(Chapter13Application.class, args);
}
private String lua = // ①
// 获取是否启用标志
" local start = redis.call('hget', KEYS[1], 'start') \n"
// 如果未启用
+ " if tonumber(start) == 0 then \n"
// 获取开始值
+ " local result = redis.call('hget', KEYS[1], 'offset') \n"
// 将当前值设置为开始值
+ " redis.call('hset', KEYS[1], 'current', result) \n"
// 将是否启用标志设置为已经启用(1)
+ " redis.call('hset', KEYS[1], 'start', '1') \n"
// 返回结束
+ " return result \n"
// 结束if语句
+ " end \n"
// 获取当前值
+ " local current = redis.call('hget', KEYS[1], 'current') \n"
// 获取步长
+ " local step = redis.call('hget', KEYS[1], 'step') \n"
// 结算新的当前值
+ " local result = current + step \n"
// 设置新的当前值
+ " redis.call('hset', KEYS[1], 'current', result) \n"
// 返回结果
+ " return result \n";
@Autowired
private StringRedisTemplate stringRedisTemplate = null;
/**
* 获取对应的ID
* @param keyType -- Redis的键
* @return ID
*/
@GetMapping("/id/{keyType}")
public String getKey(@PathVariable("keyType") String keyType) { // ②
// 结果返回为Long
DefaultRedisScript<Long> rs = new DefaultRedisScript<Long>();
rs.setScriptText(lua);
rs.setResultType(Long.class);
// 定义脚本中的key参数
List<String> keyList = new ArrayList<>();
keyList.add(keyType);
// 执行脚本,并传递参数
Object result = stringRedisTemplate.execute(rs, keyList);
return result.toString();
}
}
这段代码比较长,核心是代码①处的Lua脚本。在代码中,我做了详细的注释,请自行参考。代码②处的getKey方法是执行Lua脚本的。有了这段代码,启动模块chapter13,然后在浏览器中打开网址http://localhost:1013/chapter13/id/user_table_key,就能看到返回的一个ID。这里因为Redis执行Lua脚本的过程是具备原子性的,所以不会产生重号的问题。在ID规则中设置了步长为5,也就是可以启用5个Redis服务器节点来生成ID,这足以满足一般应用性能的需要了。如果有必要,可以把步长设置得更大一些,这样就可以使用更多的Redis服务器节点来提高性能了。
使用Redis的方式,比数据库的方式快速。但是因为Redis服务可能出现故障,所以一般会考虑使用哨兵和集群等方式来降低故障的发生,从而保证系统能够持续提供服务。但是这样会依赖Redis服务,且算法也比较复杂,这会增加开发者实现的复杂度,造成性能的下降。
时钟算法
在时间表达上,Java的java.util.Date类使用了长整型数字进行表示,该数字代表距离格林尼治时间1970年1月1日整点的毫秒数。因此很多开发者提出利用这点,采用时钟算法来获取唯一的ID。使用时钟算法的好处有这么几点。
- 相对简单,可以获取一个整数型,有利于数据库的性能。
- 可以知道业务发生的时间点,通过时间来追踪业务。
- 如果在原有时间信息的基础上加入数据存储机器编号,就能快速定位业务。
在介绍时钟算法前,我们需要对Java中的时间有一定的认知,为此,这里先介绍一些简单的知识。在Java的System类中有下列两个静态(static)方法。
// 返回当前时间,精确到毫秒
System.currentTimeMillis();
// 返回当前时间,精确到纳秒
System.nanoTime();
这便是获取当前时间长整型数字的方法,此外还需要大家记住的下面的单位换算规则:
1s=1000ms
1ms=1000μs=1000000ns
从上述的单位换算来看,毫秒(ms)已经是一个很小的单位,纳秒(ns)则是一个更加小的时间单位,这就意味着,使用它则需要更多位数的长整型数字表示。而事实上,一些数据库能够允许的整数位是有限的。拿MySQL来说,BIGINT(大整数)类型支持的数字的大小范围是64位二进制,有符号的范围是−263 ~263−1;而INT类型(整数)支持的数字范围是32位二进制,有符号的范围是−231~231−1。一个纳秒时间的数值在BIGINT类型的范围之内,在INT类型的范围之外。因此当前纳秒时间的长整型数字完全可以作为一个主键,于是就可以在Chapter13Application中添加下列代码,用来生成主键。
// 同步锁
private static final Class<Chapter13Application> LOCK
= Chapter13Application.class;
public static long timeKey() {
// 线程同步锁,防止多线程错误
synchronized (LOCK) { // ①
// 获取当前时间的纳秒值
long result = System.nanoTime();
// 死循环
while(true) {
long current = System.nanoTime();
// 超过1 ns后才返回,这样便可保证当前时间肯定和返回的不同,
// 从而达到排重的效果
if (current - result > 1) { // ②
// 返回结果
return result;
}
}
}
}
先看一下代码①处,这里启用了同步锁机制,保证在多线程中不会出现差错,并且在同步代码块中获取了当前时间的纳秒值。为防止调用产生同样的时间纳秒值,代码②处退出死循环,让程序循环到下一个纳秒才返回,这样就能够保证其返回ID的唯一性了。
从上述代码可以看到,使用时钟算法相对来说比较简单。实际测试时,使用上列代码可以每秒产生数百万个ID,显然在性能上是十分优越的,甚至只需要使用单机就能够满足分布式系统发号的需求。但是如果在多个分布式节点上使用这样简易的时钟算法,就有可能发出重复的号,所以这种简单的时钟算法并不能应用在多个分布式节点上。另外,有些企业希望ID能够放入更多的业务逻辑,以便在后续出现问题时定位具体出现问题的机器和数据库,于是就出现了一些变种的时钟发号算法,其中最出名、使用最广泛的当属SnowFlake算法。
变异时钟算法——SnowFlake算法(推荐)
SnowFlake(雪花)算法是Twitter提出的一种算法,我们之前在阐述时钟算法的时候谈到过,如果MySQL的主键采用BIGINT(大整数)类型,那么它的取值范围是−263到263−1,从计算机原理的角度来说,存储一个BIGINT类型就需要64位二进制位。基于这个事实,SnowFlake算法对这64位二进制位做了下图所示的约定。
关于SnowFlake算法对于64位二进制的约定,这里结合上图做更为详细的阐述。
- 第1位二进制值固定为0,没有业务含义,在计算机原理中,它是一个符号位,0代表正数,1代表负数,这里恒定为0。
- 第2~42位,共41位二进制,为时间戳位,用于存入精确到毫秒数的时间。
- 第43~52位,共10位二进制,为工作机器id位,其中工作机器又分为5位数据中心编号和5位受理机器编号,而5位二进制表达整数时取值区间为[0, 31]。
- 第53~64位,共12位二进制,代表1ms内可以产生的序列号,当它表示整数时,取值区间为[0, 4095]
第1位二进制值固定为0,没有业务含义,在计算机原理中,它是一个符号位,0代表正数,1代表负数,这里恒定为0。●第2~42位,共41位二进制,为时间戳位,用于存入精确到毫秒数的时间。●第43~52位,共10位二进制,为工作机器id位,其中工作机器又分为5位数据中心编号和5位受理机器编号,而5位二进制表达整数时取值区间为[0, 31]。●第53~64位,共12位二进制,代表1ms内可以产生的序列号,当它表示整数时,取值区间为[0, 4095]
package com.spring.cloud.chapter13.main;
public class SnowFlakeWorker {
// 开始时间(这里使用2019年4月1日整点)
private final static long START_TIME = 1554048000000L;
// 数据中心编号所占位数
private final static long DATA_CENTER_BITS = 10L;
// 最大数据中心编号
private final static long MAX_DATA_CENTER_ID = 1023;
// 序列编号占位位数
private final static long SEQUENCE_BIT = 12L;
// 数据中心编号向左移12位
private final static long DATA_CENTER_SHIFT = SEQUENCE_BIT ;
/** 时间戳向左移22位(10+12) */
private final static long TIMESTAMP_SHIFT
= DATA_CENTER_BITS + DATA_CENTER_SHIFT;
// 最大生成序列号,这里为4095
private final static long MAX_SEQUENCE = 4095;
// 数据中心ID(0~1023)
private long dataCenterId;
// 毫秒内序列(0~4095)
private long sequence = 0L;
// 上次生成ID的时间戳
private long lastTimestamp = -1L;
/**
* 因为当前微服务和分布式趋向于去中心化,所以不存在受理机器编号,
* 10位二进制全部用于数据中心
* @param dataCenterId -- 数据中心ID [0~1023]
*/
public SnowFlakeWorker(long dataCenterId) { // ①
// 验证数据中心编号的合法性
if (dataCenterId > MAX_DATA_CENTER_ID) {
String msg = "数据中心编号[" + dataCenterId
+"]超过最大允许值【" + MAX_DATA_CENTER_ID + "】";
throw new RuntimeException(msg);
}
if (dataCenterId < 0) {
String msg = "数据中心编号[" + dataCenterId + "]不允许小于0";
throw new RuntimeException(msg);
}
this.dataCenterId = dataCenterId;
}
/**
* 获得下一个ID (为了避免多线程环境产生的错误,这里方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
// 获取当前时间
long timestamp = System.currentTimeMillis();
// 如果是同一个毫秒时间戳的处理
if (timestamp == lastTimestamp) {
sequence += 1; // 序号+1
// 是否超过允许的最大序列
if (sequence > MAX_SEQUENCE) {
sequence = 0;
// 等待到下一毫秒
timestamp = tilNextMillis(timestamp); // ②
}
} else {
// 修改时间戳
lastTimestamp = timestamp;
// 序号重新开始
sequence = 0;
}
// 二进制的位运算,其中“<<”代表二进制左移,“|”代表或运算
long result = ((timestamp - START_TIME) << TIMESTAMP_SHIFT)
| (this.dataCenterId << DATA_CENTER_SHIFT)
| sequence; // ③
return result;
}
/**
* 阻塞到下一毫秒,直到获得新的时间戳
* @param lastTimestamp -- 上次生成ID的时间戳
* @return 当前时间戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp;
do {
timestamp = System.currentTimeMillis();
} while(timestamp > lastTimestamp);
return timestamp;
}
}
这个算法的难点在于二进制的位运算。代码①处的构造方法,主要是验证和绑定数据中心编号(dataCenterId)。核心是nextId方法,它通过获取当前时间毫秒数,判断上次生成的ID是否在同一个时间戳内,于是,计算序号就存在两种可能。第一种可能是在同一个时间戳内,这个时候通过序号加一的方法来处理。而代码②处的序号已经超过最大限制,这时候通过tilNextMillis方法阻塞到下一毫秒,就可以获得下一毫秒的时间戳,避免产生重复的ID。第二种可能是不在同一个时间戳内,这个时候让序号从0重新开始,且重新记录上次生成ID的时间戳即可。接下来看代码③处,这里的运算为二进制位运算,其中,通过左移运算符“<<”将对应的二进制数字移动到对应的位上,然后通过“|”将数字拼凑在一起,最终生成ID。通过循环测试nextId方法可以看到生成的ID,代码如下:
4151043847884800
4151043847884801
4151043847884802
4151043847884803
4151043847884804
4151043847884805
4151043847884806
4151043847884807
4151043847884808
4151043847884809
4151043847884810
4151043847884811
4151043847884812
4151043847884813
......
由以上ID可见,存在16位数字,按照给出的算法保证了ID的唯一性。SnowFlake算法是一种高效的算法,每秒可以产生数十万的ID,它包含了数据中心(旧算法在不去中心化的情况下还可以包含受理机器编号)、时间戳和序号3种业务逻辑,可以在一定的程度上帮助我们定位业务。由于性能好且带有一定的业务数据,因此受到了许多互联网企业的青睐,使用得也比较广泛。
自定义发号机制
在前面介绍SnowFlake算法的时候,我谈到了它的诸多缺点,例如,产生的序号和机器位可能浪费了太多的二进制。为了克服这些问题,我们会改造SnowFlake算法,编写自定义发号机制。
在改造前,需要先明确自己的系统的实际情况,这是第一步。这里做如下假设:
- 在改造前,需要先明确自己的系统的实际情况,这是第一步。这里做如下假设
- 系统不会超过每秒10万次的请求(这符合大部分企业的需求),如果超过可以使用限流算法进行处理,所以序号使用8位二进制即可,这样原来的12位就能够节省出4位了
由此来看,一共可以节省6位二进制,可以用于表示发号机器编号,这样就可以同时在多个节点上使用发号算法了。但是需要进行约定,为了更好地讲述约定,先看一下图
在上图中,约定如下。
- 第1位二进制固定为0
- 第2~42位,共41位二进制,存储时间戳
- 第43~48位,共6位二进制,存储发号机器号
- 第49~56位,共8位二进制,存储数据中心编号
- 第57~64位,共8位二进制,存储序号
有了这些约定,就可以实现算法了
package com.spring.cloud.chapter13.main;
public class CustomWorker {
// 开始时间(这里使用2019年4月1日整点)
private final static long START_TIME = 1554048000000L;
// 当前发号节点编号(最大值63)
private static long MACHINE_ID = 21L;
// 最大数据中心编号
private final static long MAX_DATA_CENTER_ID = 127L;
// 最大序列号
private final static long MAX_SEQUENCE = 255L;
// 数据中心位数
private final static long DATA_CENTER_BIT=8L;
// 机器中心位数
private final static long MACHINE_BIT= 6L;
// 序列编号占位位数
private final static long SEQUENCE_BIT = 8L;
// 数据中心移位(8位)
private final static long DATA_CENTER_SHIFT = SEQUENCE_BIT;
// 当前发号节点移位(8+8=16位)
private final static long MACHINE_SHIFT
= SEQUENCE_BIT + DATA_CENTER_BIT;
// 时间戳移位(8+8+6=22位)
private final static long TIMESTAMP_SHIFT
= SEQUENCE_BIT + DATA_CENTER_BIT + MACHINE_BIT;
// 数据中心编号
private long dataCenterId;
// 序号
private long sequence = 0;
// 上次时间戳
private long lastTimestamp;
public CustomWorker(long dataCenterId) {
// 验证数据中心编号的合法性
if (dataCenterId > MAX_DATA_CENTER_ID) {
String msg = "数据中心编号[" + dataCenterId
+ "]超过最大允许值【" + MAX_DATA_CENTER_ID + "】";
throw new RuntimeException(msg);
}
if (dataCenterId < 0) {
String msg = "数据中心编号[" + dataCenterId + "]不允许小于0";
throw new RuntimeException(msg);
}
this.dataCenterId = dataCenterId;
}
/**
* 获得下一个ID (该方法是线程安全的)
* @return 下一个ID
*/
public synchronized long nextId() {
// 获取当前时间
long timestamp = System.currentTimeMillis();
// 如果是同一个毫秒时间戳的处理
if (timestamp == lastTimestamp) {
sequence += 1; // 序号+1
// 是否超过允许的最大序列
if (sequence > MAX_SEQUENCE) {
sequence = 0;
// 等待到下一毫秒
timestamp = tilNextMillis(timestamp);
}
} else {
// 修改时间戳
lastTimestamp = timestamp;
// 序号重新开始
sequence = 0;
}
// 二进制的位运算,其中“<<”代表二进制左移,“|”代表或运算
long result = ((timestamp-START_TIME) << TIMESTAMP_SHIFT)
| (MACHINE_ID << MACHINE_SHIFT)
| (this.dataCenterId << DATA_CENTER_SHIFT)
| sequence;
return result;
}
/**
* 阻塞到下一毫秒,直到获得新的时间戳
* @param lastTimestamp -- 上次生成ID的时间戳
* @return 当前时间戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp;
do {
timestamp = System.currentTimeMillis();
} while(timestamp > lastTimestamp);
return timestamp;
}
}
这个算法和SnowFlake算法大同小异,只是二进制位表达的含义略微有所不同。代码中,我已经给出了注释,请读者自行参考。这个自定义的算法也加入了发号节点编号,能够支持区间[0, 63]的64个数字的编号,这样该算法就能够支持最多64个发号节点的服务了
通过自定义算法,我只是想告诉大家,发号机制没有固定不变的方法,只要合理即可。但是ID会受到数据存储的限制,例如,MySQL的BIGINT类型也只能支持−263到263−1的范围,这是需要注意的地方。如果需要MySQL支持更多的位数,可以使用DECIMAL类型,但DECIMAL是一种格式化的数字,如金额,其内部算法比较复杂,性能上不如BIGINT高,所以在大部分情况下,我都推荐使用BIGINT作为主键
总结
前面我们讨论了几种常见的发号机制,并且讨论了它们的利弊。在现实中,可以根据自己的需要进行选型。此外,还有使用ZooKeeper分布式锁或者MongoDB的ObjectId机制来生成分布式ID的办法,只是它们比较复杂,并且性能不高,所以这里就不介绍了。实际上,我们也不需要受限于这些所谓的常见办法,因为我们也可以自定义发号算法。
记录笔记并调整 << Spring Cloud微服务和分布式系统实践 >> 作者: 杨开振