分布式组件雪花ID
-
- 组成
- 时钟回拨解决方案汇总
-
- 方案一:等待后重试(阻塞等待)
- 方案二:预留回拨位(占用序列号位)
-
- [1. "预留回拨位"的核心思想](#1. "预留回拨位"的核心思想)
- [2. 位分配对比图](#2. 位分配对比图)
- [3. 具体工作场景模拟](#3. 具体工作场景模拟)
- [4. 这种方案的优缺点](#4. 这种方案的优缺点)
- [5. 位运算代码示意(Java)](#5. 位运算代码示意(Java))
- 方案三:采用"未生成ID最大上限"自动漂移
- 方案四:外部存储兜底(依赖Redis/ZooKeeper)
组成
雪花ID(Snowflake ID)的生成规则,核心是将一个64位的整数(long型)按二进制位划分成几个部分,每个部分代表不同的含义,以此来保证在分布式系统中的唯一性和有序性。
下面是它的标准位分配规则:
-
1位符号位 :这是最高位,在二进制中固定为
0。这样做是为了确保最终生成的ID是一个正整数(如果为1,则结果为负数)。 -
41位时间戳 :记录生成ID时的毫秒级时间戳。通常不是一个绝对时间,而是从一个自定义的"纪元"(起始时间)开始计算的时间差值 。41位的时间戳足够使用大约 69年 而不重复。
-
10位机器标识 :用于区分分布式系统中的不同节点,最多可以支持 1024 台机器(或进程)同时生成ID而不冲突。这10位在实际应用中通常可以再拆分为两部分:
- 数据中心ID (5位):最多支持 32 个数据中心。
- 机器/进程ID (5位):每个数据中心最多支持 32 台机器。
-
12位序列号 :这是解决高并发冲突的关键。当同一毫秒内 在同一台机器上生成多个ID时,这个序列号会从0开始递增。12位的序列号允许每台机器每毫秒最多生成 4096 个不同的ID。如果同一毫秒内生成的ID数量超过4096个,生成器会等待到下一毫秒再继续生成。
你可以把整个ID想象成一个按位拼接起来的结构:
(0) | (41位时间戳差值) | (5位数据中心ID) | (5位机器ID) | (12位序列号)
额外需要注意的几点:
- 趋势递增:由于ID的高位部分包含时间戳,因此生成的ID在整体上是随着时间递增的,这对数据库索引很友好。
- 时钟回拨问题:该算法强依赖于服务器时间的正确性。如果系统时间发生了回拨(例如NTP同步导致),可能会导致生成重复的ID。标准的实现中,如果检测到当前时间戳小于上一次生成ID的时间戳,通常会抛出异常或阻塞等待。
希望这份拆解能帮你理清雪花ID的生成逻辑。如果你对其中提到的"时间回拨问题"的解决方案,或者不同编程语言的实现细节感兴趣,我们可以继续探讨。
时钟回拨解决方案汇总
方案一:等待后重试(阻塞等待)
既然回拨的时间通常很短(往往是NTP同步导致的几毫秒回拨),让线程等待一下是比较直接的做法。
核心逻辑 :如果当前时间戳小于上次生成ID的时间戳,就计算需要等待的时间,然后让线程sleep。
java
// 伪代码示例
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
if (currentTimestamp < lastTimestamp) {
// 计算需要等待的时间(加上一点缓冲,比如5ms)
long offset = lastTimestamp - currentTimestamp;
if (offset <= 5) {
try {
// 等待差值时间
Thread.sleep(offset);
// 睡醒后重新获取时间
currentTimestamp = System.currentTimeMillis();
// 如果时间依然小于上次记录,说明回拨时间较长,抛异常
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨严重,等待后仍无法恢复");
}
} catch (InterruptedException e) {
throw new RuntimeException("等待时钟恢复被中断");
}
} else {
throw new RuntimeException("时钟回拨时间太长,无法通过等待解决");
}
}
// ... 后续序列号处理逻辑
lastTimestamp = currentTimestamp;
}
适用场景:适用于回拨时间较短(<10ms)且对RT(响应时间)不敏感的业务。
方案二:预留回拨位(占用序列号位)
这是一种比较巧妙的设计,借鉴了百UidGenerator和美团Leaf的思路。既然回拨是不可避免的,干脆在ID生成逻辑里给它留一个位置。
核心逻辑 :
把12位序列号拆分成两部分,比如:
5位作为回拨标志位或预留位7位作为真正的序列号
当发生时钟回拨时,我们不去等待时间,而是允许在"过去的时间戳"上继续生成ID,但通过修改回拨标志位来确保唯一性。
注意 :这种方案会降低单机QPS(从原本的4096/ms降到128/ms左右),用并发性能换可用性。
1. "预留回拨位"的核心思想
"预留回拨位"的思路是:既然我无法阻止时间回拨,那我就在ID里留出几个比特位,专门用来标记"这是回拨期间生成的ID"。
这样一来,即使时间戳和机器码都一样,只要这个"回拨标志"不同,ID就是唯一的。
2. 位分配对比图
假设我们改造一下标准的位分配(标准是1+41+10+12):
标准雪花ID(64位):
text
┌─────────────────┬──────────────────────┬──────────────────┬─────────────────┐
│ 1位符号位(0) │ 41位时间戳 │ 10位机器ID │ 12位序列号 │
│ │ │ │ (0-4095) │
└─────────────────┴──────────────────────┴──────────────────┴─────────────────┘
改造后(预留回拨位):
text
┌─────────────────┬──────────────────────┬──────────────────┬────────┬─────────┐
│ 1位符号位(0) │ 41位时间戳 │ 10位机器ID │ 5位回拨 │ 7位序列号 │
│ │ │ │ 标志位 │ │
└─────────────────┴──────────────────────┴──────────────────┴────────┴─────────┘
看到变化了吗?我们把原来的12位序列号,拆成了两部分:
- 高位部分(5位):叫做"回拨标志位"或者"预留位"。
- 低位部分(7位):真正的序列号(0-127)。
3. 具体工作场景模拟
假设某台机器的10位机器ID 是 0000000001(二进制)。
正常情况(时间向前走):
- 当前时间戳 T1 =
1000 - 预留回拨位 =
00000(正常情况下全为0) - 序列号从0开始递增:
0000000,0000001,0000010... - 生成的ID就是:
(时间戳T1) + (机器ID) + 00000 + 序列号
发生时钟回拨(时间从1000跳回999):
系统检测到当前时间戳 999 小于上次的 1000。
传统做法 :抛异常或等待。
预留回拨位做法:
- 不等待 :直接使用上次的时间戳
1000来生成ID。 - 修改标志位 :把预留的5位回拨位,从
00000改成00001。 - 序列号归零 :从
0000000开始重新计数。
这样一来,虽然这一批ID和上一批ID的时间戳相同、机器码相同,但预留回拨位不同,所以它们在二进制层面是完全不同的数字,保证了唯一性。
如果回拨持续,或者同一毫秒内序列号用完了(7位只能支持128个/ms),还可以继续递增预留位:
- 第一批(正常):
00000+ 序列号 - 第二批(回拨1次):
00001+ 序列号 - 第三批(回拨2次):
00010+ 序列号 - ...
直到预留位用完(32种可能),才真正无法继续生成。
4. 这种方案的优缺点
优点:
- 零等待:发生回拨时,业务线程完全不用阻塞,性能不受影响。
- 可用性高:只要回拨次数不超过预留位的容量(比如5位最多支持32次),系统都能正常运行。
缺点:
- QPS下降:原本每毫秒能生成4096个ID,现在只能生成128个(7位序列号)。对于绝大多数单体应用,128个/ms已经够用(相当于12.8万 QPS),但如果你的接口流量特别大,这个方案就不太合适。
- ID趋势递增特性变弱:由于回拨期间时间戳是"停滞"的,ID的增长曲线会出现短暂的平台期,而不是严格的时间递增。
5. 位运算代码示意(Java)
为了让你更有体感,这里是一段极简的位运算示意代码:
java
public class SnowflakeWithReservedBit {
// 假设机器ID是10位,这里就不展开了
private long machineId = 1L;
// 位分配
private final long timestampBits = 41L;
private final long machineIdBits = 10L;
private final long reservedBits = 5L; // 预留回拨位
private final long sequenceBits = 7L; // 真正的序列号
// 上次生成的时间戳
private long lastTimestamp = -1L;
// 预留位值 (正常情况下为0)
private long reserved = 0L;
// 序列号
private long sequence = 0L;
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
// 发生时钟回拨
if (currentTimestamp < lastTimestamp) {
// 核心:不改变时间戳,只递增预留位
reserved++;
// 如果预留位溢出(超过了5位的最大值31),说明回拨次数太多,真的扛不住了
if (reserved > (1 << reservedBits) - 1) {
throw new RuntimeException("回拨次数过多,预留位已用完");
}
// 序列号重新从0开始
sequence = 0L;
}
// 同一毫秒内
else if (currentTimestamp == lastTimestamp) {
// 正常的序列号递增
sequence++;
// 如果7位序列号用完了(>127),等待下一毫秒
if (sequence > (1 << sequenceBits) - 1) {
// 自旋等待下一毫秒
while (System.currentTimeMillis() <= lastTimestamp) {
// busy wait
}
// 进入下一毫秒后,序列号和预留位都要重置
sequence = 0L;
reserved = 0L; // 预留位归零,因为时间前进了
currentTimestamp = System.currentTimeMillis();
}
}
// 时间正常向前走
else {
sequence = 0L;
reserved = 0L; // 时间正常,预留位归零
}
lastTimestamp = currentTimestamp;
// 位运算组装(这里简化了移位操作)
return (currentTimestamp << (machineIdBits + reservedBits + sequenceBits))
| (machineId << (reservedBits + sequenceBits))
| (reserved << sequenceBits)
| sequence;
}
}
看完这个例子,你对"预留回拨位"的机制是不是更清晰了?如果有具体的代码细节想讨论,可以再告诉我。
方案三:采用"未生成ID最大上限"自动漂移
这种方案是当检测到时钟回拨时,不抛异常,而是在上次生成ID的时间戳上继续生成,直到当前系统时间追上那个时间戳。
核心逻辑:
java
// 如果当前时间小于上次时间
if (currentTimestamp < lastTimestamp) {
// 直接使用上次的时间戳来生成ID(相当于允许时间静止)
currentTimestamp = lastTimestamp;
// 但需要处理序列号溢出问题
// 如果同一毫秒内序列号用完,则自旋到下一毫秒(逻辑时间前进)
}
这种做法的好处是对调用方完全透明,没有等待,性能好。但缺点是生成的ID中的"时间戳"可能略慢于实际物理时间,不过在大多数业务场景下是可接受的。
方案四:外部存储兜底(依赖Redis/ZooKeeper)
如果你们的系统对ID的唯一性要求极高,且服务器时钟经常大幅跳动,可以考虑引入外部组件来辅助。
核心逻辑:
- 发生时钟回拨时,不再依赖本地时间戳。
- 向Redis请求一个自增的序号,或者查询ZooKeeper的zxid来作为时间戳的补充。
- 这种做法会把雪花ID变成一个准分布式ID,强依赖于第三方组件的可用性,复杂度较高。