为什么会需要分布式ID?
在设计业务系统的时候,都会需要一个唯一标识,而这个标识往往是数据库的主键。
单库中的主键
分布式ID在单个库中往往作为主键,是一种聚簇索引(通过聚簇索引查询会比较快)。聚簇索引存储方式:按照每张表的主键构造一棵B+树,叶子节点页存放整张表的行数据,节点页只包含索引列(一般通过主键聚集数据)。

注意:聚簇索引,是将数据和索引都保存在b树中,所以通过聚簇索引查询快。
分布式ID关键点
没有一个全局时钟,难以保证绝对的时序,要想保证绝对的时序,还是只能使用单点服务,用本地时钟保证“绝对时序”。
UUID生成数据库主键思路
通过程序UUID生成:
UUID.randomUUID().toString().replace("-", "")
- 好处:
扩展性好,不会重复。
直接本地生成。 - 坏处:
没有顺序性。
uuid比较长,32位,作为主键索引效率低。
雪花算法
雪花算法的数据结构:

- 符号位:占1bit,默认符号位为0。
- 时间戳:占41bit,精准到毫秒。
- 机器编码:占10bit,高位 5 bit 是数据中心 ID(集群ID),低位 5 bit 是机器 ID,最多可以容纳 1024 个节点。
- 序列号:占用12bit,当从同一毫秒开始,可以一直累加,最多可以累加到4095,一共可以产生2的12次幂 4096 个ID。
算法编写思路:
- 获取当前时间的毫秒时间戳。
- 如果发现当前时间戳和上一次时间相同,说明是同毫秒请求,那么将序列号加1(如果序列号不够用了,就延时到下一个毫秒时间戳)。
- 如果发现当前时间戳大于上一次时间,说明是新的毫秒时间请求,那么将序列号置为0。
- 更新时间戳。
- 通过移位,将时间戳、数据中心、机器的值进行移位,放到指定的范围内,生成分布式ID。
代码实现:
public class SnowFlakeGen {
// ============================= 时间戳、数据中心、机器、序列号的位数 ====================================
/** 开始时间截 (2022-08-10) */
private final long twepoch = 1660129904936L;
/** 数据中心id所占的位数 */
private final long datacenterIdBits = 5L;
/** 机器id所占的位数 */
private final long workerIdBits = 5L;
/** 序列在id中占的位数 */
private final long sequenceBits = 12L;
// ============================= 数据中心、机器最大值 ==========================================
/** 支持的最大机器id值,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数) */
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/** 支持的最大数据中心id值,结果是31 */
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
// =================== 机器id向左移位、数据中心id向左移位、时间截向左移位 ======================================
/** 机器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;
// ================================ 序列号掩码 0b11111111111 ============================================
/** 生成序列的掩码,这里为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;
/**
* 构造函数
* @param workerId 机器ID (0~31)
* @param datacenterId 数据中心ID (0~31)
*/
public SnowFlakeGen(long workerId, long datacenterId) {
// 输入的机器id不能超过最大值
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
// 输入的数据中心id不能超过最大值
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;
}
/**
* 获得下一个ID (该方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long genNextId() {
// 获取当前的时间戳
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) {
// sequenceMask :(0b111111111111=0xfff=4095)
sequence = (sequence + 1) & sequenceMask;
/**
* 同一毫秒+数据中心+机器:序列号最大只有4096
* 如果超出了,那么就将请求,延时到下一个毫秒
*/
if (sequence == 0) {
//阻塞到下一个毫秒,获得新的时间戳
timestamp = tilNextMillis(lastTimestamp);
}
}
// 时间戳改变,毫秒内序列号重置,从0L开始
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();
}
/** 测试 */
public static void main(String[] args) {
SnowFlakeGen idWorker = new SnowFlakeGen(0, 0);
for (int i = 0; i < 100; i++) {
long id = idWorker.genNextId();
System.out.println(id);
}
}
}
测试结果:
40034468102144
40034472296448
40034472296449
40034472296450
40034472296451
40034472296452
....
雪花算法的问题:
- 时间回拨,会出现重复ID。
- 水平切分分表时,如果采用对当前生成的分布式ID取模的方式,生成的分布式ID的序列号都是从0开始的,这样会造成数据不平均,那么我们对于起始序列号可以采用随机方式,起始序列号范围:0000 ~ 1001(就是0到9)。
基于业务改进雪花算法
公司业务需求:
- 之前做的健康医疗系统中的健康档案模块,需要录入一个居民的健康档案。
- 业务中设计居民的健康档案的分布式ID。
- 居民本身有所属区域:省-市-县或区-乡镇-村。

粉色块:存为16长度bigint(注意:主要还是由于对应java的long类型,返回前端可能会丢失精度,返回前端转为字符串)。
蓝色块:存为16长度bigint,其中业务模块占4长度,后面行政区划,采用12长度。
- 问题1,为什么要把业务相关的放在前面:
主要还是便于进行模糊查询,统计指定省市下有多少人,有哪些人。 - 问题2,地区编码如何设置:
直接使用国家的行政编码就可以实现了。
一些成熟方案
美团的Leaf:基于雪花算法的改进
百度的uid-generator
滴滴的Tinyid
参考
https://www.w3cschool.cn/architectroad/architectroad-distributed-id.html
网友评论