分布式组件雪花ID
-
- 组成
- 时钟回拨解决方案汇总
-
- 方案一:等待后重试(阻塞等待)
- 方案二:预留回拨位(占用序列号位)
-
- 1. "预留回拨位"的核心思想
* 2. 位分配对比图
* 3. 具体工作场景模拟
* * 正常情况(时间向前走):
* 发生时钟回拨(时间从1000跳回999):
* 4. 这种方案的优缺点
* 5. 位运算代码示意(Java)
- 1. "预留回拨位"的核心思想
- 方案三:采用"未生成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。
1// 伪代码示例 2public synchronized long nextId() { 3 long currentTimestamp = System.currentTimeMillis(); 4 5 if (currentTimestamp < lastTimestamp) { 6 // 计算需要等待的时间(加上一点缓冲,比如5ms) 7 long offset = lastTimestamp - currentTimestamp; 8 if (offset <= 5) { 9 try { 10 // 等待差值时间 11 Thread.sleep(offset); 12 // 睡醒后重新获取时间 13 currentTimestamp = System.currentTimeMillis(); 14 15 // 如果时间依然小于上次记录,说明回拨时间较长,抛异常 16 if (currentTimestamp < lastTimestamp) { 17 throw new RuntimeException("时钟回拨严重,等待后仍无法恢复"); 18 } 19 } catch (InterruptedException e) { 20 throw new RuntimeException("等待时钟恢复被中断"); 21 } 22 } else { 23 throw new RuntimeException("时钟回拨时间太长,无法通过等待解决"); 24 } 25 } 26 27 // ... 后续序列号处理逻辑 28 lastTimestamp = currentTimestamp; 29} 30
适用场景:适用于回拨时间较短(<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位):
1┌─────────────────┬──────────────────────┬──────────────────┬─────────────────┐ 2│ 1位符号位(0) │ 41位时间戳 │ 10位机器ID │ 12位序列号 │ 3│ │ │ │ (0-4095) │ 4└─────────────────┴──────────────────────┴──────────────────┴─────────────────┘ 5
改造后(预留回拨位):
1┌─────────────────┬──────────────────────┬──────────────────┬────────┬─────────┐ 2│ 1位符号位(0) │ 41位时间戳 │ 10位机器ID │ 5位回拨 │ 7位序列号 │ 3│ │ │ │ 标志位 │ │ 4└─────────────────┴──────────────────────┴──────────────────┴────────┴─────────┘ 5
看到变化了吗?我们把原来的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)
为了让你更有体感,这里是一段极简的位运算示意代码:
1public class SnowflakeWithReservedBit { 2 // 假设机器ID是10位,这里就不展开了 3 private long machineId = 1L; 4 5 // 位分配 6 private final long timestampBits = 41L; 7 private final long machineIdBits = 10L; 8 private final long reservedBits = 5L; // 预留回拨位 9 private final long sequenceBits = 7L; // 真正的序列号 10 11 // 上次生成的时间戳 12 private long lastTimestamp = -1L; 13 // 预留位值 (正常情况下为0) 14 private long reserved = 0L; 15 // 序列号 16 private long sequence = 0L; 17 18 public synchronized long nextId() { 19 long currentTimestamp = System.currentTimeMillis(); 20 21 // 发生时钟回拨 22 if (currentTimestamp < lastTimestamp) { 23 // 核心:不改变时间戳,只递增预留位 24 reserved++; 25 // 如果预留位溢出(超过了5位的最大值31),说明回拨次数太多,真的扛不住了 26 if (reserved > (1 << reservedBits) - 1) { 27 throw new RuntimeException("回拨次数过多,预留位已用完"); 28 } 29 // 序列号重新从0开始 30 sequence = 0L; 31 } 32 // 同一毫秒内 33 else if (currentTimestamp == lastTimestamp) { 34 // 正常的序列号递增 35 sequence++; 36 // 如果7位序列号用完了(>127),等待下一毫秒 37 if (sequence > (1 << sequenceBits) - 1) { 38 // 自旋等待下一毫秒 39 while (System.currentTimeMillis() <= lastTimestamp) { 40 // busy wait 41 } 42 // 进入下一毫秒后,序列号和预留位都要重置 43 sequence = 0L; 44 reserved = 0L; // 预留位归零,因为时间前进了 45 currentTimestamp = System.currentTimeMillis(); 46 } 47 } 48 // 时间正常向前走 49 else { 50 sequence = 0L; 51 reserved = 0L; // 时间正常,预留位归零 52 } 53 54 lastTimestamp = currentTimestamp; 55 56 // 位运算组装(这里简化了移位操作) 57 return (currentTimestamp << (machineIdBits + reservedBits + sequenceBits)) 58 | (machineId << (reservedBits + sequenceBits)) 59 | (reserved << sequenceBits) 60 | sequence; 61 } 62} 63
看完这个例子,你对"预留回拨位"的机制是不是更清晰了?如果有具体的代码细节想讨论,可以再告诉我。
方案三:采用"未生成ID最大上限"自动漂移
这种方案是当检测到时钟回拨时,不抛异常,而是在上次生成ID的时间戳上继续生成,直到当前系统时间追上那个时间戳。
核心逻辑:
1// 如果当前时间小于上次时间 2if (currentTimestamp < lastTimestamp) { 3 // 直接使用上次的时间戳来生成ID(相当于允许时间静止) 4 currentTimestamp = lastTimestamp; 5 6 // 但需要处理序列号溢出问题 7 // 如果同一毫秒内序列号用完,则自旋到下一毫秒(逻辑时间前进) 8} 9
这种做法的好处是对调用方完全透明,没有等待,性能好。但缺点是生成的ID中的"时间戳"可能略慢于实际物理时间,不过在大多数业务场景下是可接受的。
方案四:外部存储兜底(依赖Redis/ZooKeeper)
如果你们的系统对ID的唯一性要求极高,且服务器时钟经常大幅跳动,可以考虑引入外部组件来辅助。
核心逻辑:
- 发生时钟回拨时,不再依赖本地时间戳。
- 向Redis请求一个自增的序号,或者查询ZooKeeper的zxid来作为时间戳的补充。
- 这种做法会把雪花ID变成一个准分布式ID,强依赖于第三方组件的可用性,复杂度较高。
《【分布式组件雪花ID】》 是转载文章,点击查看原文。

