分布式 ID 生成:雪花算法、号段模式与时钟回拨

分布式 ID 生成:从 Snowflake 位运算到号段双 Buffer,以及那个绕不开的时钟回拨

关键词标签 :分布式ID Snowflake 号段模式 时钟回拨 Leaf

一个反直觉的结论先放在这里:Snowflake 的难点从来不是那 64 位怎么切,而是当 NTP 把机器时间往回拨了 3 秒时,你的服务该怎么办。 位运算部分看十分钟文档就能写对,但时钟回拨的处理策略,直接决定了这套 ID 方案在生产环境里是"能用"还是"能扛"。本系列前面聊过线程池、AQS、CompletableFuture 这些并发底座,分布式 ID 恰好是把这些底座和分布式协调揉在一起的一个典型场景,值得单独拆一次。


一、Snowflake 的 64 位到底怎么切

1.1 标准结构

Twitter 原始 Snowflake 方案把 64 位 long 切成四段:

段 位数 含义
符号位 1 bit 恒为 0,保证 ID 为正数
时间戳 41 bit 相对某个自定义纪元的毫秒差
机器 ID 10 bit 最多 1024 个节点
序列号 12 bit 同一毫秒内的自增序号

41 位毫秒时间戳的理论上限是 2^41 - 1 毫秒,换算过来约 69.7 年。所以"自定义纪元"(epoch)的选择很关键------它决定了你这套 ID 能用多久。常见做法是把纪元设成系统上线那年的某个时间点,而不是 1970。

1.2 位运算实现

复制代码
public class SnowflakeIdGenerator {
    // 自定义纪元:2024-01-01 00:00:00 UTC
    private static final long EPOCH = 1704067200000L;

    private static final long WORKER_ID_BITS = 5L;
    private static final long DATACENTER_ID_BITS = 5L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);         // 31
    private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); // 31

    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;                   // 12
    private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 17
    private static final long TIMESTAMP_SHIFT =
            SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;                 // 22
    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);           // 4095

    private final long workerId;
    private final long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException("workerId out of range");
        }
        if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
            throw new IllegalArgumentException("datacenterId out of range");
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();

        if (timestamp < lastTimestamp) {
            throw new IllegalStateException(
                "Clock moved backwards. Refusing to generate id for "
                + (lastTimestamp - timestamp) + " ms");
        }

        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                // 当前毫秒的 4096 个序列号用尽,自旋到下一毫秒
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;

        return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
                | (datacenterId << DATACENTER_ID_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    private long tilNextMillis(long lastTimestamp) {
        long ts = System.currentTimeMillis();
        while (ts <= lastTimestamp) {
            ts = System.currentTimeMillis();
        }
        return ts;
    }
}

几个容易忽略的细节:

  • ~(-1L << n) 是取 n 位全 1 掩码的惯用写法,比 (1L << n) - 1 更安全(后者在 n=64 时溢出)。
  • 拼接时用 | 而不是 +,因为各段位不重叠,| 语义更清晰且不会进位串扰。
  • nextId() 加了 synchronized。在高并发下这把锁会成为热点,后面会讲怎么用号段模式绕开它。

1.3 序列号用尽的自旋

注意 sequence == 0 那个分支:当同一毫秒内第 4096 次请求进来时,序列号回绕到 0,此时必须等到下一毫秒。这意味着单节点 Snowflake 的理论 QPS 上限约为 4096 × 1000 = 409.6 万/秒(示意数值,非真实测量)。绝大多数业务远达不到这个量级,所以 12 位序列号通常够用。


二、时钟回拨:Snowflake 的真正命门

2.1 为什么回拨一定会发生

System.currentTimeMillis() 读的是操作系统时钟,而操作系统时钟会被 NTP 校正。当本机时间比 NTP 服务器快时,NTP 会把时间往回拨。这在容器化环境里尤其常见------虚拟机迁移、宿主机负载抖动、NTP 同步策略激进,都可能触发。

回拨的后果很直接:如果时间戳变小,新生成的 ID 可能比之前生成的 ID 还小,破坏单调递增;更糟的是,如果回拨跨过了纪元,可能生成重复 ID。

2.2 三种处理策略

策略一:直接抛异常(上面代码的做法)。 简单,但服务可用性受损------回拨期间整个 ID 生成不可用。

策略二:小回拨等待,大回拨报警。 设定一个阈值(比如 5ms),回拨小于阈值就自旋等待时间追上;超过阈值则抛异常或降级。这是很多生产实现的选择。

策略三:备用位/扩展位。 预留几位作为"回拨计数",回拨发生时递增该计数,用空间换时间。但会缩短 ID 可用年限。

MongoDB 官方文档在讨论 ObjectId 时也提到过类似的时间戳单调性问题,值得对照阅读。Snowflake 的原始讨论可以参考 Twitter 工程博客的历史资料。

2.3 一个务实的回拨处理

复制代码
private static final long MAX_BACKWARD_MS = 5L;
private long lastTimestamp = -1L;

public synchronized long nextId() {
    long timestamp = System.currentTimeMillis();

    if (timestamp < lastTimestamp) {
        long offset = lastTimestamp - timestamp;
        if (offset <= MAX_BACKWARD_MS) {
            // 小幅回拨:自旋等待,直到时间追上 lastTimestamp
            timestamp = tilNextMillis(lastTimestamp);
        } else {
            // 大幅回拨:交给上层决定降级还是告警
            throw new IllegalStateException(
                "Clock moved backwards by " + offset + " ms, exceeds threshold");
        }
    }
    // ... 后续逻辑同上
}

这里有个取舍:自旋等待会阻塞调用线程。如果回拨频繁且幅度接近阈值,会拖慢整体吞吐。所以阈值不宜设大,且要配合监控------回拨次数本身就是重要的运维指标。


三、号段模式:把数据库从热点里解放出来

3.1 核心思想

Snowflake 依赖机器时钟,号段模式(Segment)则依赖数据库。核心思路是:一次从数据库取一批 ID(一个号段),在内存里分发,用完再取下一批。

数据库表可以简单到只有一行:

复制代码
CREATE TABLE id_segment (
    biz_tag     VARCHAR(128) NOT NULL,
    max_id      BIGINT       NOT NULL DEFAULT 0,
    step        INT          NOT NULL,
    update_time TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
                             ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (biz_tag)
);

每次取号段执行:

复制代码
UPDATE id_segment
SET max_id = max_id + step, update_time = NOW()
WHERE biz_tag = #{bizTag};
SELECT max_id, step FROM id_segment WHERE biz_tag = #{bizTag};

拿到 [max_id - step + 1, max_id] 这个区间,缓存在内存里逐个分发。数据库的写压力从"每个 ID 一次"降到"每个号段一次",量级直接下降 step 倍。

3.2 双 Buffer 解决取号段时的毛刺

单 Buffer 有个问题:号段用完那一刻才去数据库取,取的过程(网络 + DB)会阻塞。美团 Leaf 的经典解法是双 Buffer:内存里维护两个号段,当前号段消耗到某个阈值(比如 10%)时,异步去加载下一个号段。

复制代码
public class SegmentBuffer {
    private final Segment[] segments = new Segment[2];
    private volatile int currentPos = 0;
    private volatile boolean nextReady = false;
    private final AtomicBoolean loading = new AtomicBoolean(false);

    public long nextId() {
        while (true) {
            Segment current = segments[currentPos];
            long value = current.nextValue();
            if (value != -1) {
                // 当前号段消耗超过阈值,触发异步预加载
                if (!nextReady
                        && current.usageRatio() > 0.1
                        && loading.compareAndSet(false, true)) {
                    asyncLoadNext();
                }
                return value;
            }
            // 当前号段耗尽,切换
            if (nextReady) {
                switchPos();
            } else {
                // 下一个还没准备好,只能同步等待
                waitForNext();
            }
        }
    }
    // ...
}

关键在于 nextReady 标志和异步加载:只要预加载能在当前号段耗尽前完成,切换就是无感的。这本质上是把"取号段"这个慢操作从关键路径上挪走了。

3.3 号段模式的边界

  • ID 不连续:服务重启会丢弃内存里没用完的号段,造成空洞。对绝大多数业务无所谓,但如果业务要求 ID 严格连续(比如某些财务场景),号段模式不适用。
  • 强依赖数据库:数据库挂了,新号段取不到。双 Buffer 只能撑一段时间,撑多久取决于 step 和消耗速度。
  • step 的权衡:step 太小则 DB 压力大,step 太大则重启浪费多、ID 增长快。常见做法是按业务 QPS 动态调整。

四、两种方案怎么选,以及混合思路

维度 Snowflake 号段模式
依赖 机器时钟 数据库
趋势递增 是(同毫秒内) 是
时钟回拨影响 严重 无
DB 故障影响 无 有(双 Buffer 缓解)
ID 连续性 有空洞 有空洞
单点吞吐 受锁和序列号限制 受 step 和内存限制

实践中常见的是混合方案:用号段模式从数据库取"workerId 段",再在本地用 Snowflake 生成 ID。这样既避免了 workerId 手工分配的问题,又保留了 Snowflake 的高吞吐。百度 UidGenerator 走的是另一条路------用 RingBuffer 缓存已生成的 ID,把生成和消费解耦。


五、什么时候别用 / 别踩的坑

  1. 别在容器里硬编码 workerId。 容器重启后 IP 可能变,workerId 需要动态分配机制(ZooKeeper 顺序节点、Redis INCR、或号段模式),否则重复 workerId 会导致 ID 冲突。
  2. 别忽略 NTP 配置。 生产机器应统一 NTP 源,并考虑用 chrony 的 slew 模式(渐进调整)替代 step 模式(直接跳变),减少大回拨概率。
  3. 别把 Snowflake 的序列号位数想当然。 如果你的业务单节点 QPS 接近 409.6 万(示意数值),12 位序列号就会成为瓶颈,需要重新分配位宽。
  4. 号段模式别把 step 设太小。 step 太小等于把数据库又变回热点;但设太大又浪费。建议根据实际 QPS 和重启频率估算。
  5. 别用 ID 当业务语义。 分布式 ID 只保证唯一,不保证连续、不保证趋势严格递增(跨节点看)。任何依赖"ID 大小反映创建顺序"的逻辑都是脆弱的。
  6. 回拨监控必须做。 无论哪种策略,回拨次数和幅度都应该是告警指标,而不是等出问题才查日志。

系列预告

分布式 ID 解决的是"唯一性",但唯一性之外还有"顺序性"和"一致性"的问题。下一篇会聊分布式锁的几种实现路径,以及 Redlock 争议背后的分布式共识本质------同样是"看起来简单、深挖全是坑"的主题。感兴趣的话点个关注,我们下篇见。


参考链接

相关推荐
垆边人似月.1 小时前
华为机考题:字符串分隔
算法·华为
CQU_JIAKE1 小时前
9.4【A】
算法
CQU_JIAKE1 小时前
9.13[A]
算法
懿路向前1 小时前
【唤醒实战笔记】2026-09-29 | 智能体提示词迭代史(v1 → v8)
linux·笔记·算法
@陈小鱼2 小时前
基于CNN-Transformer的无袖带血压估计
人工智能·深度学习·神经网络·算法·cnn·transformer·血压
黄金龙PLUS2 小时前
5个1280比特大状态置换算法的设计与分析
算法·网络安全·密码学·哈希算法·同态加密
不会就选b2 小时前
算法日常・每日刷题--<动态规划>1
java·数据结构·算法
shehuiyuelaiyuehao2 小时前
算法54,链表 两数相加
数据结构·算法·链表
147API3 小时前
如何设计“回答、追问、停答”三类蒸馏训练集
人工智能·算法·机器学习