【分布式组件雪花ID】

分布式组件雪花ID

组成

雪花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位机器ID0000000001(二进制)。

正常情况(时间向前走):
  • 当前时间戳 T1 = 1000
  • 预留回拨位 = 00000 (正常情况下全为0)
  • 序列号从0开始递增:0000000, 0000001, 0000010...
  • 生成的ID就是: (时间戳T1) + (机器ID) + 00000 + 序列号
发生时钟回拨(时间从1000跳回999):

系统检测到当前时间戳 999 小于上次的 1000

传统做法 :抛异常或等待。
预留回拨位做法

  1. 不等待 :直接使用上次的时间戳 1000 来生成ID。
  2. 修改标志位 :把预留的5位回拨位,从 00000 改成 00001
  3. 序列号归零 :从 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,强依赖于第三方组件的可用性,复杂度较高。
相关推荐
知识即是力量ol1 天前
深入理解 Snowflake 雪花算法:原理、本质、趋势递增问题与分布式顺序困境全解析
java·分布式·算法·雪花算法·snowflake·全局唯一id·分布式id生成器
没有bug.的程序员15 天前
分布式 ID 生成:Snowflake 算法物理内核、时间回拨黑科技与业务适配深度指南
分布式·算法·spring·分布式id·snowflake·物理内核·时间回拨
Zongsoft2 个月前
自适应可变速率ID生成器的设计与实践(视频)
redis·uuid·分布式id·snowflake·sequence
StarRocks_labs2 个月前
Fresha 的实时分析进化:从 Postgres 和 Snowflake 走向 StarRocks
数据库·starrocks·postgres·snowflake·fresha
wáng bēn3 个月前
Pig4Cloud微服务分布式ID生成:Snowflake算法深度集成指南
微服务·雪花算法·twitter·snowflake·pig4cloud
chat2tomorrow10 个月前
如何构建类似云数据仓库 Snowflake 的本地数据仓库?
大数据·数据仓库·低代码·数据治理·snowflake·sql2api
草明2 年前
对外提供开放式数据查询使用什么数据存储?
clickhouse·elasticsearch·solr·database·snowflake·bigquery
tmax52HZ2 年前
Leaf分布式ID
uuid·雪花算法·分布式id·snowflake·leaf
归去来 兮2 年前
分布式ID生成策略-雪花算法Snowflake
分布式·算法·uuid·分布式id·snowflake