在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识:
此时一个能够生成全局唯一ID的系统是非常必要的。
UUID.randomUUID()
,UUID(Universally Unique Identifier)的标准形式包含 32 个 16 进制数字,以连字号分为 5 段,形式为 8-4-4-4-12
的 36 个字符,示例:550e8400-e29b-41d4-a716-446655440000
性能非常高:本地生成(java.util 包下),没有网络消耗。
问题:如果只考虑唯一性,没问题,但是UUID入数据库的性能非常差,因为它是无序的。
无序,无法预测他的生成顺序,不能生成递增有序的数字
首先分布式id一般都会作为主键,但是按照MySQL官方推荐主键尽量越短越好,UUID每一个都很长,所以不是很推荐。
主键,ID作为主键时,在特定的环境下会存在一些问题
比如做DB主键的场景下,UUID就非常不适用,MySQL官方有明确的建议主键尽量越短越好,36个字符长度的UUID不符合要求。
索引,B+树索引的分裂
既然分布式ID是主键,然后主键是包含索引的,而MySQL的索引是通过B+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的B+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键的B+树进行很大的修改,这一点很不好,插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。
UUID只能保证全局唯一性,不满足后面的趋势递增、单调递增,所以不推荐。
单机✅
在分布式里面,数据库的自增ID机制的主要原理是:数据库自增ID和MySQL数据库的 replace into 实现的。
这里的 replace into 跟 insert 功能类似,不同点在于:replace into 首先尝试插入数据列表中,如果发现表中已经有此行数据(根据主键或唯一索引判断)则先删除,再插入,否则直接插入新数据。
REPLACE INTO 的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。
CREATE TABLE t_test(
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
stub CHAR(1) NOT NULL DEFAULT '',
UNIQUE KEY stub (stub)
)
SELECT * FROM t_test;
REPLACE INTO t_test (stub) VALUES('b');
创建好表,先执行第一次 REPLACE INTO
,影响的行数为一行:
进行查询:
再执行第二次 REPLACE INTO
,此时它影响的行数为两行:
进行查询:
原来的数据被删除了,新插入了主键为2
的数据。
此时该方案满足了:唯一性、递增性、单调性。
在分布式情况下,且并发量不多,可以使用这种方案来解决,获得一个全局的唯一ID。
集群分布式❌
那数据库自增ID机制适合做分布式ID吗?答案是不太适合。
系统水平扩展比较困难,比如定义好步长和机器台数之后,如果要添加机器该怎么做?
假设现在有一台机器发号是:1,2,3,4,5(步长是1),这个时候需要扩容机器一台。可以这样做:把第二台机器的初始值设置得比第一台超过很多,貌似还好,但是假设线上如果有100台机器,这个时候扩容要怎么做,简直是噩梦。所以系统水平扩展方案复杂难以实现。
数据库压力还是很大,每次获取ID都得读写一次数据库,非常影响性能,不符合分布式ID里面的延迟低和高QPS的规则(在高并发下,如果都去数据库里面获取ID,那是非常影响性能的)。
在分库分表场景下使用数据库自增 ID 作为主键可能会遇到以下问题:
单机✅
因为Redis是单线程,天然保证原子性,可以使用原子操作 INCR
和 INCRBY
来实现。
SET count 10
INCR count // 返回 11
INCRBY count 5 // 返回 16
集群分布式❌
注意:在Redis集群情况下,同样和MySQL一样需要设置不同的增长步长,同时key一定要设置有效期。
可以使用Redis集群来获取更高的吞吐量。
假设一个集群中有5台Redis,可以初始化每台Redis的值分别是 1,2,3,4,5, 然后设置步长都是5。
各个Redis生成的ID为:
A:1 6 11 16 21
B:2 7 12 17 22
C:3 8 13 18 23
D:4 9 14 19 24
E:5 10 15 20 25
但是存在的问题是,就是Redis集群的维护和配置比较麻烦,因为要设置避免单点故障,哨兵机制等。
如果我们为了一个ID,却需要引入整个Redis集群,有种杀鸡焉用牛刀的感觉,不推荐。
上面的三个方案:UUID、数据库自增主键、基于Redis生成全局ID策略,都可以落地实现,但各有优缺点。
接下来我们来了解一下雪花算法。
Twitter的分布式自增ID算法——Snowflake。
最初Twitter把存储系统从MySQL迁移到Cassandra(由Facebook开发的一套开源分布式NoSQL数据库系统),因为Cassandra没有顺序ID生成机制,所有开发了这样一套全局唯一ID生成服务。
Twitter的分布式雪花算法SnowFlake,经测试SnowFlake每秒可以产生26万个自增可排序的ID。
分布式系统中,有一些需要全局唯一ID的场景,生成ID的基本要求:
雪花算法的几个核心组成部分:
号段解析:
1bit - 符号位
不用,因为二进制中最高位是符号位,1表示负数,0表示正数。生成的ID一般都是用正数,所以最高位固定为0。
41bit - 时间戳位,用来记录时间戳,毫秒级:
41位可以表示 2^{41}-1
个数字
如果只用来表示正整数(计算机中正数包含0),可以表示的范围是:0
至2^{41}-1
,减1是因为可以表示的数值范围是从0开始计算的,而不是从1。
也就是说41位可以表示 2^{41}-1
个毫秒的值,转换成单位年则是 (2^{41}-1)/(1000*60*60*24*365) = 69
年
10Bit - 工作机器ID
可以部署在 2^{10} = 1024
个节点,包括5位 datacenterId
(数据中心,机房) 和 5位 workerID
(机器码)
5位可以表示的最大正整数是 2^{5}-1 = 31
个数字,即可以用 [0, 1, 2, 3, …, 31] 这32个数字来表示不同的数据中心 和 机器码
12位bit - 序列号位
可以用来表示的正整数是 2^{12}-1 = 4095
,即可以用 [0, 1, 2, …, 4094] 这4095个数字来表示同一个机器同一个时间戳(毫秒)内产生的4095个ID序号。
SnowFlake可以保证:
不过Twitter的雪花算法是由 Scala 语言编写的,但已经有前辈用 Java 实现了:GitHub地址
package com.example.demo.util;
/**
* @author: yuanyuan.jing
* @date: 2018/11/30 11:09
* @description: 雪花算法生成全局唯一id
*/
public class SnowflakeIdWorker {
/**
* Twitter_Snowflake
* SnowFlake的结构如下(每部分用-分开):
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
* 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0
* 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
* 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69
* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId
* 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号
* 加起来刚好64位,为一个Long型。
* SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万个ID左右。
*/
// ==============================Fields==============================
/** 开始时间截 (2015-01-01) */
private final long twepoch = 1420041600000L;
/** 机器id所占的位数 */
private final long workerIdBits = 5L;
/** 数据标识id所占的位数 */
private final long datacenterIdBits = 5L;
/** 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数) */
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/** 支持的最大数据标识id,结果是31 */
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
/** 序列在id中占的位数 */
private final long sequenceBits = 12L;
/** 机器ID向左移12位 */
private final long workerIdShift = sequenceBits;
/** 数据标识id向左移17位(12+5) */
private final long datacenterIdShift = sequenceBits + workerIdBits;
/** 时间截向左移22位(5+5+12) */
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/** 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
/** 工作机器ID(0~31) */
private long workerId;
/** 数据中心ID(0~31) */
private long datacenterId;
/** 毫秒内序列(0~4095) */
private long sequence = 0L;
/** 上次生成ID的时间截 */
private long lastTimestamp = -1L;
// ==============================Constructors==============================
/**
* 构造函数
* @param workerId 工作ID (0~31)
* @param datacenterId 数据中心ID (0~31)
*/
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
// ==============================Methods==============================
/**
* 获得下一个ID (该方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();
// 如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
// 如果是同一时间生成的,则进行毫秒内序列
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
// 毫秒内序列溢出
if (sequence == 0) {
// 阻塞到下一个毫秒,获得新的时间戳
timestamp = tilNextMillis(lastTimestamp);
}
}
// 时间戳改变,毫秒内序列重置
else {
sequence = 0L;
}
// 上次生成ID的时间截
lastTimestamp = timestamp;
// 移位并通过或运算拼到一起组成64位的ID
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
/**
* 阻塞到下一个毫秒,直到获得新的时间戳
* @param lastTimestamp 上次生成ID的时间截
* @return 当前时间戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒为单位的当前时间
* @return 当前时间(毫秒)
*/
protected long timeGen() {
return System.currentTimeMillis();
}
// ==============================Test==============================
public static void main(String[] args) {
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
for (int i = 0; i < 100; i++) {
long id = idWorker.nextId();
//System.out.println(Long.toBinaryString(id));
System.out.println(id);
}
}
}
但是我们实际工作中是不会用这个代码的,仅仅作为学习使用,我们在工程中会使用 Hutool 工具包。
Hutool 工具包:https://github.com/looly/hutool
SpringBoot整合雪花算法
pom.xml
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-captchaartifactId>
<version>4.6.8version>
dependency>
新建工具类
package com.atguigu.boot.util;
package cn.itedus.lottery.domain.support.ids;
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.net.NetUtil;
import cn.hutool.core.util.IdUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Slf4j
@Component
public class IdGeneratorSnowflake {
private long workerId = 0;
private long datacenterId = 1;
private Snowflake snowflake = IdUtil.createSnowflake(workerId, datacenterId);
@PostConstruct
public void init() {
try {
workerId = NetUtil.ipv4ToLong(NetUtil.getLocalhostStr());
log.info("当前机器的workerId:{}", workerId);
} catch(Exception e) {
e.printStackTrace();
log.warn("当前机器的workerId获取失败", e);
workerId = NetUtil.getLocalhostStr().hashCode();
}
}
public synchronized long snowflakeId() {
return snowflake.nextId();
}
public synchronized long snowflakeId(long workerId, long datacenterId) {
Snowflake snowflake = IdUtil.createSnowflake(workerId,datacenterId);
return snowflake.nextId();
}
// 测试
public static void main(String[] args) {
System.out.println(new IdGeneratorSnowflake().snowflakeId());
}
}
在业务类中使用
@Service
public class OrderService {
@Autowired
private IdGeneratorSnowflake idGeneratorSnowflake;
public String getIDBySnowFlake() {
// 利用线程池 同时创建多个id
ExecutorService threadPool = Executors.newFixedThreadPool(5);
for (int i = 1; i <= 20; i++) {
threadPool.submit(() -> {
log.info("{}", idGeneratorSnowflake.snowflakeId());
});
}
threadPool.shutdown();
return "hello snowflake";
}
}
生成的ID都是唯一的,只不过在多线程场景下我们打印的顺序不同。
优点
缺点
如果想彻底解决时钟问题,那么这两家大厂已经开源了成熟的解决方案,并且优化了雪花算法:
参考b站:尚硅谷