关于 Snowflake 算法奇偶性小探讨
如果说到分布式 ID,肯定逃不开 Snowflake 算法,其原理如下图
介绍如下
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。
用 Java 实现代码如下
public class SnowflakeIdWorker {
/** 开始时间截 (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);
private final long maxWorkerId = ~(-1L << workerIdBits);
/** 支持的最大数据标识id,结果是31 */
//private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
private final long maxDataCenterId = ~(-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);
private final long sequenceMask = ~(-1L << sequenceBits);
/** 工作机器ID(0~31) */
private long workerId;
/** 数据中心ID(0~31) */
private long dataCenterId;
/** 毫秒内序列(0~4095, 2^12) */
private long sequence = 0L;
/** 上次生成ID的时间截 */
private long lastTimestamp = -1L;
/**
* @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("data center Id can't be greater than %d or less than 0", maxDataCenterId));
}
this.workerId = workerId;
this.dataCenterId = dataCenterId;
}
/**
* 获得下一个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 当前时间戳
*/
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒为单位的当前时间
* @return 当前时间(毫秒)
*/
private long timeGen() {
return System.currentTimeMillis();
}
Snowflake 原理简单,但是也有一些问题,比如时间回拨,上面示例代码中针对时间回拨的处理方法简单粗暴,直接报错
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
然后我们重点看看生成 id 方法 nextId,逻辑也很简单
- (1)获取当前时间,使用的是
System.currentTimeMillis();
- (2)当前时间和上次获取时间作比较,如果小与上次获取时间,则说明服务器时间回拨,直接抛异常
- (3)如果时间没问题,先判断当前时间等不等于上次获取时间
- (3.1)如果等于,则获取毫秒内序列,如果序列溢出,阻塞直到下个毫秒
- (3.2)如果不等于,则将毫秒内序列置为 0
- (4)通过位运算返回 64 位 ID
这段逻辑也是网上关于 Snowflake 经典实现。
但是,我今天在看 Sharding Sphere(分库分表) 文档的时候,发现他们提到关于 Snowflake 还有个 ID 总是是偶数的问题。于是结合上面代码,发现果然还存在这个问题。话不多说,看 nextId 核心代码
//如果是同一时间生成的,则进行毫秒内序列
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
//毫秒内序列溢出
if (sequence == 0) {
//阻塞到下一个毫秒,获得新的时间戳
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 时间戳改变,毫秒内序列重置
//sequence = 0L;
}
如果毫秒内序列溢出,则重置毫秒内序列。问题就在这儿,如果并发量不高,别说毫秒,连秒内并发都少,所以序列基本上都是0,那生成的 ID 基本就都是偶数了。测试如下
解决这个问题的重点在于毫秒内序列溢出,sequence 该如何处理。Sharding Sphere 处理方式分为三个阶段,如下
- sequence = random.nextInt(64);
- sequence = random.nextInt(7);
上面两种方式总觉得不太优雅,所以最新的源码里是这么干的,定义一个 byte 类型的变量 sequenceOffset,每次重置序列时都会改变它的值(不是 0 就是 1)
private byte sequenceOffset;
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
//毫秒内序列溢出
if (sequence == 0) {
//阻塞到下一个毫秒,获得新的时间戳
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 时间戳改变,毫秒内序列重置
vibrateSequenceOffset();
sequence = sequenceOffset;
}
// 这个方法总是返回 0 或者 1
private void vibrateSequenceOffset() {
sequenceOffset = (byte) (~sequenceOffset & 1);
}
通过 vibrateSequenceOffset 方法使得 sequenceOffset 的值在 0 和 1 之间切换,生成的 ID 也会在奇偶性上切换。
不管是时间回拨还是奇偶性问题,依然掩盖不了 Snowflake 算法的魅力。
但是,我最近看到来自百度的分布式全局 ID 生成组件(UidGenerator)就完全没这两个问题,amazing!下面简单讲讲。
百度这个 UidGenerator 也是基于 Snowflake 算法实现的,但是它不是实时的去生成id,而是通过一个叫做 Ringbuffer 的环形数组先缓存一部分id(默认有六万多),获取都是从这个环形数组里获取。当数组的id数量小于某个阈值时(默认 50%),会启用异步线程的往数组填充id。
先看看奇偶问题怎么解决的呢,UidGenerator 在初始化的时候,会先填满 Ringbuffer,就是先获取一批id,然后依次放到数组中,那怎么获取这一批 id 呢。UidGenerator 的做法比较巧妙,它先获取秒(UidGenerator 是基于秒,不是毫秒)内序列的第一个id,然后依次增加,直到秒内序列最大值,代码如下
protected List<Long> nextIdsForOneSecond(long currentSecond) {
// 计算秒内序列最大值
int listSize = (int) bitsAllocator.getMaxSequence() + 1;
List<Long> uidList = new ArrayList<>(listSize);
// 获取序列第一个id
long firstSeqUid = bitsAllocator.allocate(currentSecond - epochSeconds, workerId, 0L);
// 循环递增,而不是循环调用生成id的方法
for (int offset = 0; offset < listSize; offset++) {
uidList.add(firstSeqUid + offset);
}
return uidList;
}
因为是递增,也就不存在奇偶问题。
其实到这,奇偶问题没了,时间回拨还是有可能的。UidGenerator 第一次的 lastSecond 还是要依赖系统时间,如果服务重启时,时间已经回拨了,那就有问题。然后 UidGenerator 另外一个设计点巧妙的避开了这个问题,那就是 Snowflake 算法中 workerId 的来源。传统做法可能是通过获取配置文件或者其他方式,但有一点就是单个服务器的 workerId 一般不会改变(即使重启)。UidGenerator 的做法是服务每次重启的时候都往数据库表中插入一条数据,然后将这条记录的 id 作为 workerId,因为表 id 使用自增,那每次重启都不一样。所以,即使服务重启时间回拨了,workId 不一样,生成的 id 也就不会一样。
百度 UidGenerator 这个因为采用借用未来时间缓存id 的方式,会有个小问题,就是生成的 ID 中的时间信息可能并不是这个 ID 生成的真正时间。但是我觉得,我们关注的是这 Id 本身(递增,全局唯一),至于它何时创建基本可以忽略。
关于百度的 UidGenerator 可以看我的 GitHub 上的实例代码。