Snowflake 雪花算法实战:41位时间戳里的三个坑(时钟回拨、workerId、位运算)

老炮踩坑录 · D08 · 技术深挖系列

· 基于「企业融合平台」真实源码

· 逐行拆解手写 Snowflake ID 生成器

· 关键词:3大坑·位运算·时钟回拨·序列溢出·workerId撞车·CPU假死

引子:面试必问雪花算法,这个项目里居然有人手写了

2026 年,Java 21 LTS 成了生产标配,Java 26 都发布了。分布式 ID 这个老话题,依然是面试高频考点------"分库分表后全局 ID 怎么生成?"十有八九会引出雪花算法,然后面试官追一句:"时钟回拨怎么处理?"

很多人背过 Twitter Snowflake 的 64 位结构,面试时能默写 1 + 41 + 5 + 5 + 12 = 64,但真让你打开源码,逐行看位运算是怎么拼装的、时钟回拨是怎么检测的、序列溢出是怎么处理的------大部分人都停在 "看懂结构图" 这一步,没真正读过一行实现。

巧了,我翻的这个 2022 年的老项目里,有人手写了完整的雪花算法,从位结构设计到 Spring 集成,一个都不落(la)。

今天这篇,我就拿着项目里真实的 SnowflakeIdGenerator.java,逐行拆给你看:

  • 64 位 long 是怎么一段一段拼出来的
  • synchronized 为什么必须加
  • 时钟回拨 3 毫秒就能让线上报 500错误

先看全貌:雪花算法长什么样子

打开 SnowflakeIdGenerator.java,类注释直接画了一张结构图:

java 复制代码
/**
 * Twitter_Snowflake
 * SnowFlake的结构如下(每部分用-分开):
 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
 * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1
 * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值
 * 41位的时间截,可以使用69年
 * 10位的数据机器位,可以部署在1024个节点
 * 12位序列,毫秒内的计数,支持每个节点每毫秒产生4096个ID序号
 * 加起来刚好64位,为一个Long型。
 */
public class SnowflakeIdGenerator {
    // ...
}

注释写得规规矩矩,一看就是从 Twitter 原版翻译过来的。但光看注释不够,我们得一段一段拆开看。

64 位 long 的位结构设计:每一比特都不浪费

五段划分

图示如下:

位数 作用 容量
符号位 1 保证 ID 为正(Java long 带符号) 固定 0
时间戳 41 毫秒级时间差值 约 69 年
数据中心 ID 5 区分机房 0~31
机器 ID 5 区分同机房节点 0~31
序列号 12 同毫秒内递增 0~4095

起始时间戳:为什么是 2015 年

java 复制代码
private final long twepoch = 1420041600000L; // 2015-01-01 00:00:00 UTC

注意 ,41 位存的不是 System.currentTimeMillis(),而是 当前时间 - twepoch差值

为什么?

因为 41 位最多表示 2^41 - 1 = 2,199,023,255,551 毫秒,换算成年:

text 复制代码
2,199,023,255,551 / 1000 / 60 / 60 / 24 / 365 ≈ 69.7 年

如果从 1970 年算起,到 2039 年就溢出了。但从 2015 年算起,能撑到 2084 年

这就是为什么 twepoch 很重要------它决定了 ID 生成器的"寿命"。你完全可以把它设成项目上线的那天,最大化利用这 41 位。

位移量:低位段位数之和 = 高位段移位量

java 复制代码
private final long sequenceBits = 12L;
private final long workerIdShift = sequenceBits;                          // = 12
private final long datacenterIdShift = sequenceBits + workerIdBits;       // = 17
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; // = 22

发现规律了吗?每一段的左移量,等于它右边所有段的位数之和。

text 复制代码
序列号:不移位(最右边)
机器ID:左移 12 位(右边有 12 位序列号)
数据中心:左移 17 位(右边有 12 + 5 = 17 位)
时间戳:左移 22 位(右边有 12 + 5 + 5 = 22 位)

这样设计的好处是:每段在 64 位 long 中各占各的位置,互不干扰,最后用按位或(|)拼装即可。就像五条铁轨,每列火车走自己的道,永远不会撞车。


掩码的妙用:用位运算代替 if 判断

最大值计算

java 复制代码
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);       // = 31
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); // = 31
private final long sequenceMask = -1L ^ (-1L << sequenceBits);       // = 4095

第一次看这段代码的人大概率会懵:-1L ^ (-1L << 5) 是什么鬼?

别急,拆成二进制看就清楚了:

text 复制代码
-1L 的二进制 = 11111111 11111111 ... 11111111(64 个 1)
-1L << 5    = 11111111 11111111 ... 11100000(低 5 位变 0)
异或(^)    = 00000000 00000000 ... 00011111(只有低 5 位是 1)
           = 31

所以 -1L ^ (-1L << n) 的效果就是:生成一个低 n 位全为 1 的掩码

  • 5 位掩码 = 0b11111 = 31(workerId / datacenterId 的最大值)
  • 12 位掩码 = 0b111111111111 = 4095(序列号的最大值)

序列号溢出检测

这是整段代码里最精妙的一行:

java 复制代码
sequence = (sequence + 1) & sequenceMask;

sequence = 4095 时:

text 复制代码
(4095 + 1) & 4095
= 4096 & 4095
= 0b1000000000000 & 0b111111111111
= 0

加 1 后与掩码做按位与,超过 4095 自动归零。 不需要 if (sequence > 4095) sequence = 0,一条位运算搞定。

这就是位运算的魅力:把 "判断 + 赋值" 两步操作压缩成了一步,这在高频调用的场景下,性能差异是实打实的高。

核心算法 nextId():逐行拆解

终于到了分析最核心的逻辑方法了。

核心源码

我把完整源码贴出来,并逐行加上注释:

java 复制代码
public synchronized long nextId() {
    // ① 获取当前时间戳(毫秒)
    long timestamp = timeGen();

    // ② 时钟回退检测:当前时间 < 上次生成 ID 的时间 → 抛异常
    if (timestamp < lastTimestamp) {
        throw new RuntimeException(
            String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
                lastTimestamp - timestamp));
    }

    // ③ 同一毫秒内的序列处理
    if (lastTimestamp == timestamp) {
        // 同一毫秒 → 序列号 +1,掩码保证不超过 4095
        sequence = (sequence + 1) & sequenceMask;
        // 序列溢出(回到 0)→ 自旋等到下一毫秒
        if (sequence == 0) {
            timestamp = tilNextMillis(lastTimestamp);
        }
    }
    // ④ 新的毫秒 → 序列重置为 0
    else {
        sequence = 0L;
    }

    // ⑤ 记录本次时间戳
    lastTimestamp = timestamp;

    // ⑥ 位运算拼装 64 位 ID
    return ((timestamp - twepoch) << timestampLeftShift)   // 时间戳差值左移 22 位
            | (datacenterId << datacenterIdShift)          // 数据中心 ID 左移 17 位
            | (workerId << workerIdShift)                  // 机器 ID 左移 12 位
            | sequence;                                    // 序列号不移位
}

六个步骤

nextId()方法的六大步骤流程图:

举个计算例子

假设 timestamp - twepoch = 1000, datacenterId = 20, workerId = 10, sequence = 5

text 复制代码
时间戳部分:1000 << 22 = 4,194,304,000
数据中心:  20 << 17   =     2,621,440
机器 ID:   10 << 12   =        40,960
序列号:    5          =             5
────────────────────────────────────────
按位或拼装:              4,196,966,405

每段井水不犯河水,按位或 = 拼装。因为每段的二进制位互不重叠,所以 | 运算等价于加法,但语义更清晰------我是在"组装",不是在"相加"。

自旋等待:tilNextMillis()

java 复制代码
protected long tilNextMillis(long lastTimestamp) {
    long timestamp = timeGen();
    while (timestamp <= lastTimestamp) {
        timestamp = timeGen(); // CPU 空转,浪费时间片
    }
    return timestamp;
}

序列号用完了怎么办?死循环等,等到下一毫秒再来。

这个 while 循环叫 busy-wait(忙等待) ------CPU 在空转,什么都不干,就反复问"到下一毫秒了没?"。

在正常情况下,一毫秒很快就过去了,循环个几十到几百次就拿到新时间戳。但在高并发场景下,如果每毫秒都在溢出,这个忙等待会吃掉 CPU 时间片,是个性能隐患。

如何解决呢? 可以参考美团 Leaf 在生产环境验证过的方案。你有其它更好的方案吗?欢迎在评论区讨论。

Spring 集成:四层配置链

光有算法不够,还得接入 Spring。这个项目的做法是标准的三层架构。

完整调用链路和架构图:

第一层:yml 配置

yaml 复制代码
# application.yml
pubframe:
  id:
    workId: 10      # 当前机器的工作 ID
    centerId: 20    # 当前数据中心 ID

第二层:配置属性类

java 复制代码
@ConfigurationProperties(prefix = "pubframe.id")
public class IdGenProperties {
    private long workId = 0;    // 工作机器ID (0~31)
    private long centerId = 0;  // 数据中心ID (0~31)
    // getter/setter/toString...
}

@ConfigurationProperties 把 yml 里的值自动绑到 Java 对象上。注意默认值都是 0------如果 yml 没配,两台机器都会拿到 workId=0, centerId=0

第三层:自动注册 Bean

java 复制代码
@Slf4j
@Configuration
@EnableConfigurationProperties(IdGenProperties.class)
public class AutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(IdGenProperties.class)
    public SnowflakeIdGenerator snowflakeIdWorker(IdGenProperties properties) {
        SnowflakeIdGenerator generator = new SnowflakeIdGenerator(
            properties.getWorkId(),    // workId = 10
            properties.getCenterId()   // centerId = 20
        );
        log.info("bean [{}] properties [{}]", generator, properties);
        return generator;
    }
}

@BeanSnowflakeIdGenerator 注册为 Spring 容器里的 Bean,@ConditionalOnMissingBean 保证如果你没自定义,就用默认的。

看起来很美,对吧?配置、绑定、注册,三层各司其职,开箱即用。

看到这里,你可能觉得这个实现挺规范的。别急,坑在后面,我们一个一个的分析并解决。

三个坑,一个比一个狠

⚠️ 坑 1:时钟回拨直接抛异常,3 毫秒就 500

java 复制代码
if (timestamp < lastTimestamp) {
    throw new RuntimeException(
        String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
            lastTimestamp - timestamp));
}

时钟一回拨,直接 throw RuntimeException

你知道这意味着什么吗?

生产环境的 NTP 服务每隔一段时间会校准时间,回拨几毫秒是家常便饭。 只要回拨发生,任何调用 nextId() 的业务代码就会收到一个 RuntimeException,如果没有 catch,就是 500 错误。

正确的做法是什么? 分级处理:

java 复制代码
if (timestamp < lastTimestamp) {
    long offset = lastTimestamp - timestamp;
    if (offset <= 5) {
        // 5ms 以内的微小回拨:等它追上来
        Thread.sleep(offset << 1); // 等两倍时间,留余量
        timestamp = timeGen();
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock still moving backwards");
        }
    } else if (offset <= 100) {
        // 100ms 以内的中等回拨:用上次的时间戳继续生成,序列号递增
        timestamp = lastTimestamp;
    } else {
        // 超过 100ms 的大回拨:真的出问题了,抛异常
        throw new RuntimeException("Clock moved backwards too much: " + offset + "ms");
    }
}

总结:小回拨等一等,中回拨借用上次时间戳硬撑,大回拨才抛异常。这才是生产级的处理方式。

⚠️ 坑 2:workerId 写在 yml 里,两台机器配了一样的 ID

yaml 复制代码
pubframe:
  id:
    workId: 10    #运维拷配置的时候忘记改了
    centerId: 20

workIdcenterId 直接写死在配置文件里。

想象一下这个场景:运维要部署两台机器,拷了一份配置文件,改改数据库地址就上了。结果 workId 忘了改------两台机器都是 workId=10, centerId=20

后果是什么? 两台机器在同一毫秒内,用相同的序列号,生成完全一样的 ID

雪花算法的 "全局唯一" 保证是建立在 "每台机器的 workerId 不同" 这个前提上。workerId 撞了,唯一性就崩了。

正确的做法是什么? 让 workerId 自动分配,而不是靠人记着改配置:

方案一:从 ZooKeeper / Redis 自动获取

启动时注册临时节点,拿到全局唯一的 workerId

java 复制代码
@Bean
public SnowflakeIdGenerator snowflakeIdWorker(IdGenProperties properties,
                                               ZookeeperClient zkClient) {
    // 启动时从 ZK 占一个临时节点
    long workerId = zkClient.createEphemeralSequential("/snowflake/worker-");
    long datacenterId = properties.getCenterId();  // 机房级别还是配
    return new SnowflakeIdGenerator(workerId, datacenterId);
}

方案二:基于 IP 地址末两段计算

text 复制代码
int workerId = (ip[2] & 0x1F);  // 取 IP 第三段低 5 位
int centerId = (ip[3] & 0x1F);  // 取 IP 第四段低 5 位

总之,凡是靠人记着改的配置,迟早会有人忘记修改。

⚠️ 坑 3:synchronized 全局锁,高并发下排队

java 复制代码
public synchronized long nextId() {

注意,这个 synchronized------它锁的是整个 SnowflakeIdGenerator 实例对象。

也就是说,不管有多少个线程同时调用 nextId(),都得排队,一个一个来。

在单机低并发场景下,这没什么问题。但如果你的服务每秒要生成几万个 ID,所有线程都在这一把锁上排队,这里就成了整个系统的吞吐量瓶颈。

有没有更好的方案?有------用 CAS(Compare-And-Swap)无锁实现:

思路:用 AtomicLong 代替 synchronized。把 lastTimestamp 和 sequence 打包成一个 long, 用 CAS 操作原子更新,失败就重试。

java 复制代码
public long nextId() {
    long current;
    long next;
    do {
        current = lastTimestamp.get();
        // ... 计算 next
    } while (!lastTimestamp.compareAndSet(current, next));
    return next;
}

但 CAS 方案实现复杂度高很多,需要压测验证是否值得做。

对于这个项目的并发量,synchronized 其实就够用------但是你要知道它的天花板在哪里。

雪花算法:面试怎么答

面试官问"说说雪花算法",你可以这样答:

第一层(结构):Snowflake 把 64 位 long 分成五段------1 位符号位、41 位时间戳差值、5 位数据中心、5 位机器 ID、12 位序列号。时间戳保证趋势递增,机器 ID 保证分布式不撞车,序列号保证同毫秒内不重复。

第二层(核心操作) :每段左移量等于右边所有段的位数之和,最后按位或拼装。序列号溢出用掩码 (seq+1) & mask 检测,归零后自旋等下一毫秒。

第三层(生产问题) :最大风险是时钟回拨。生产环境 NTP 校时可能导致几毫秒的回拨,直接抛异常会导致 500错误。正确做法是分级处理------小回拨等待、中回拨借用时间戳、大回拨降级到号段模式。另外 workerId 不能写在配置文件,要靠注册中心自动分配。

生产环境 Checklist

检查项 常见坑 正确做法
时钟回拨 直接抛异常 → 500 分级处理:等待 / 借用 / 降级
workerId 分配 yml 写死 → 撞 ID ZK/Redis 自动分配
起始时间戳 用 1970 年 → 2039 溢出 设为项目上线日期
线程安全 无锁 → ID 重复 synchronized 或 CAS
启动校验 不检查 → 大回拨流入线上 持久化 lastTimestamp,启动时比对
监控告警 无感知 → 出问题才知道 回拨次数/幅度接入 Prometheus

要不要自己写?

我的建议是:不要自己写。

  • 美团 Leaf:号段模式 + Snowflake 双模式,生产验证
  • ShardingSphere 内置:配合分库分表直接用
  • MyBatis-PlusIdType.ASSIGN_ID 默认就是雪花算法
  • Spring 生态spring-context-support 里有现成实现

自己写适合学习和面试,生产环境还是用成熟的开源方案------它们帮你处理了你没想到的边界情况。

老炮点评

这篇文章我们从一个真实项目的手写雪花算法出发,我们看到了:

  1. 位结构设计的精妙:41 位时间戳 + 10 位机器标识 + 12 位序列号,64 位 long 刚好用完,每一比特都不浪费。
  2. 位运算的性能之美 :掩码检测溢出、移位拼装 ID,用 & 代替 if,用 | 代替 +,一条指令搞定。
  3. 时钟回拨的残酷现实:算法假设时间是单调向前的,但物理世界的时钟会被 NTP 校准、被闰秒打乱、被运维手动改。3 毫秒的回拨就能让线报 500错误。
  4. 配置化 workerId 的隐患:靠人记着改的配置,迟早有人忘记改。
  5. synchronized 的天花板:简单有效,但高并发下是瓶颈。

最后送一句话:分布式系统里没有银弹,雪花算法也不例外。 它用对时间的依赖换来了简单和高效,但时间本身是不可靠的------这就是工程世界的永恒矛盾。在·

下一篇预告:《workerId 配错,两台机器生成了一样的 ID》

这篇文章里我说了"workerId 写在 yml 里迟早有人忘记改"------这不是假设,是真实发生过的生产事故。下一篇我把这个坑的前因后果完整讲一遍:怎么发现的、怎么排查的、怎么修的。

我是老炮,18 年 Java 老兵,仍在一线。关注「老炮踩坑录」,真实案例,帮你少踩坑。

相关推荐
花间相见1 小时前
【计算基础|网络04】—— HTTP接口实战(下):接口测试、鉴权与跨域排错
java·linux·人工智能·后端·python·计算机网络·postman
BryceBorder1 小时前
Agent Memory 不只是聊天记录:手搓三大记忆系统
后端·agent·面经·codex·claude code·agent memory
一条泥憨鱼2 小时前
苍穹外卖【day11| 用户统计,订单统计,销量排名统计功能实现】
java·后端·苍穹外卖
她的男孩2 小时前
缓存明明命中了却报 ClassCastException:拆完多级缓存控制面,我挖出 5 个静默失效的坑
java·后端·架构
孙启超2 小时前
【AI开发之Rust】第 13 课:async/await 与 tokio 异步运行时
开发语言·后端·rust
SimonKing2 小时前
SpringBoot 集成 SSE 实现服务端推送或可代替Websocket
java·后端·程序员
字节探索2 小时前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
Sam_Deep_Thinking2 小时前
关于java final关键字的可见性
java·后端·面试·程序员