老炮踩坑录 · 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;
}
}
@Bean 把 SnowflakeIdGenerator 注册为 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
workId 和 centerId 直接写死在配置文件里。
想象一下这个场景:运维要部署两台机器,拷了一份配置文件,改改数据库地址就上了。结果 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-Plus :
IdType.ASSIGN_ID默认就是雪花算法 - Spring 生态 :
spring-context-support里有现成实现
自己写适合学习和面试,生产环境还是用成熟的开源方案------它们帮你处理了你没想到的边界情况。
老炮点评
这篇文章我们从一个真实项目的手写雪花算法出发,我们看到了:
- 位结构设计的精妙:41 位时间戳 + 10 位机器标识 + 12 位序列号,64 位 long 刚好用完,每一比特都不浪费。
- 位运算的性能之美 :掩码检测溢出、移位拼装 ID,用
&代替if,用|代替+,一条指令搞定。 - 时钟回拨的残酷现实:算法假设时间是单调向前的,但物理世界的时钟会被 NTP 校准、被闰秒打乱、被运维手动改。3 毫秒的回拨就能让线报 500错误。
- 配置化 workerId 的隐患:靠人记着改的配置,迟早有人忘记改。
- synchronized 的天花板:简单有效,但高并发下是瓶颈。
最后送一句话:分布式系统里没有银弹,雪花算法也不例外。 它用对时间的依赖换来了简单和高效,但时间本身是不可靠的------这就是工程世界的永恒矛盾。在·
下一篇预告:《workerId 配错,两台机器生成了一样的 ID》
这篇文章里我说了"workerId 写在 yml 里迟早有人忘记改"------这不是假设,是真实发生过的生产事故。下一篇我把这个坑的前因后果完整讲一遍:怎么发现的、怎么排查的、怎么修的。
我是老炮,18 年 Java 老兵,仍在一线。关注「老炮踩坑录」,真实案例,帮你少踩坑。