ElIauk

分布式 ID 与时钟回拨

雪花结构再拆一遍 标准雪花 ID 是一个 64 位长整型:1 位符号位恒为 0、41 位毫秒时间戳、10 位工作节点、12 位毫秒内序列号。41 位从自定义纪元开始算,能覆盖约 69 年;12 位序列号意味着单节点每毫秒最多 4096 个 ID,也就是约 409 万 QPS。 这套结构最大的优点是趋

评论
分布式 ID 与时钟回拨

雪花结构再拆一遍

标准雪花 ID 是一个 64 位长整型:1 位符号位恒为 0、41 位毫秒时间戳、10 位工作节点、12 位毫秒内序列号。41 位从自定义纪元开始算,能覆盖约 69 年;12 位序列号意味着单节点每毫秒最多 4096 个 ID,也就是约 409 万 QPS。

这套结构最大的优点是趋势递增,对 B+ 树索引友好,插入不会随机打散页;缺点是强依赖时钟。时间戳占了一半以上的位宽,时钟一旦往回走,生成器就可能吐出重复 ID,或者吐出“过去”的 ID,让索引插入位置回退。

时钟回拨从哪来

很多人以为时钟只会往前走,实际上回拨在生产环境里非常常见:

  • NTP 校时。ntpd 默认是渐进 slewing,但启动时的大偏差校正、或者 chrony 的 makestep 会直接把时间拽回去;
  • 虚拟机迁移、快照恢复、容器从镜像恢复。宿主机时间比容器内的时间新,恢复瞬间就是一次回拨;
  • 运维手工改时间,或者时区配置错误后又被纠正;
  • 闰秒处理不当,部分内核在闰秒插入时会出现短暂的时间跳变。

回拨幅度可能是几毫秒,也可能是几分钟。生成器必须能区分这两种情况,因为处理策略完全不同。

回拨的三档处理

小回拨(几毫秒内):自旋等待时钟追平,代价可以接受,业务无感。这也是绝大多数开源实现的默认策略。

中等回拨(几十毫秒到一秒):等待会让请求在这段时间内全部挂住,得不偿失。更好的做法是快速失败,让上层重试或换一个发号节点。

大回拨(超过一秒):必须报警并拒绝发号。此时继续“借用未来时间”会让时间戳提前消耗,等到真实时间追上来之前,所有 ID 的时序都是错的,而且日志时间会与 ID 内嵌时间长期对不上,排查时非常误导。

还有一条更彻底的思路:不要让时间成为唯一约束。号段模式、数据库序列、UUIDv7 都不依赖本机时钟,代价分别是中心依赖、不连续、以及索引局部性不如雪花。选哪个取决于业务对“ID 有序”的强需求程度。

一份可用的发号实现

public final class SnowflakeIdGenerator {

    private static final long EPOCH = 1735689600000L;
    private static final long WORKER_ID_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;
    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
    private static final long MAX_BACKWARD_WAIT_MS = 5L;

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

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > MAX_WORKER_ID) {
            throw new IllegalArgumentException("workerId 超出范围: " + workerId);
        }
        this.workerId = workerId;
    }

    public synchronized long nextId() {
        long now = System.currentTimeMillis();
        if (now < lastTimestamp) {
            long offset = lastTimestamp - now;
            if (offset > MAX_BACKWARD_WAIT_MS) {
                throw new IllegalStateException(
                        "时钟回拨 " + offset + "ms,拒绝发号, workerId=" + workerId);
            }
            now = waitUntil(lastTimestamp);
        }
        if (now == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                // 同一毫秒内序列号用尽,等到下一毫秒
                now = waitUntil(lastTimestamp + 1);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = now;
        return ((now - EPOCH) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence;
    }

    private long waitUntil(long targetMillis) {
        long now = System.currentTimeMillis();
        while (now < targetMillis) {
            Thread.onSpinWait();
            now = System.currentTimeMillis();
        }
        return now;
    }
}

这个实现只覆盖了 5ms 以内的回拨。更大回拨直接抛异常,由上层决定是重试、降级到备用节点、还是返回“系统繁忙”。把判断权交给调用方,比在生成器里偷偷借时间要安全得多。

workerId 的分配是另一个坑:它必须在整个集群内唯一。容器环境里用 Pod IP 后 10 位推导,看起来聪明,实际会在 Pod 重建、IP 复用时产生重复,最终表现为“不同订单拿到同一个 ID”。正确做法是用 ZooKeeper、etcd 或数据库做租约,启动时申请、运行中续约、释放后归还。

号段模式与雪花怎么选

维度 雪花 号段模式
依赖 本机时钟 数据库或中心服务
吞吐 单机数百万/秒 受号段长度与预取策略限制,通常几十万/秒
ID 形态 趋势递增,长度固定 19 位 递增但可能跳号
容灾 无中心,但怕回拨 中心不可用时靠本地预取号段续命
适用 订单、消息、日志等海量写入 数据量中等、想彻底摆脱时钟依赖

很多系统用组合方案:主体用雪花,同时把 workerId 从配置中心下发;一旦检测到长时间回拨,自动切到号段模式兜底。代价是两套实现都要维护,收益是时钟故障不再等于业务停摆。

常见坑

纪元设得太早。 41 位从 1970 年算起只能撑到 2039 年,很多老代码直接用了 0L,等到溢出时会出现负数 ID。

前端拿到 19 位 ID 精度丢失。 JavaScript 的安全整数上限是 2^53,超过就变成近似值。接口层要统一用 ToStringSerializer 把 Long 序列化成字符串,这是最常被投诉的“ID 最后几位总是 0”。

用 ID 排序代替业务时间。 不同节点的时钟有几毫秒偏差,跨节点按 ID 排序不等于按真实发生顺序排序,做对账、审计时会产生误判。

workerId 静态写死。 多副本部署时如果复制了同一份配置,重复 ID 是必然的,而且只在数据量上来后才暴露。

只测正常路径。 时钟回拨几乎无法在开发环境复现,至少要写一个可以注入时间的单测,把三档回拨的分支都覆盖到。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者