雪花算法
开篇:你的分布式 ID 真的靠谱吗?
先做个自测------你现在用的分布式 ID 方案是什么?
- A. 数据库自增 :
AUTO_INCREMENT,简单省事 - B. UUID :
UUID.randomUUID(),一行代码搞定 - C. 雪花算法:自己写的或用框架内置的
- D. Redis 自增 :
INCR order:id,凑合能用
如果选了 A 或 B,先别急------这两种方案在单机上确实没问题,但一到分布式环境就会暴露硬伤:
- 数据库自增在分库分表后,两个库可能生成相同的 ID
- UUID 作为主键会让 B+ 树频繁裂页,写入性能直接腰斩
- Redis 自增需要额外维护一套 Redis 集群,且持久化方案复杂
这篇文章不会只给你贴一段雪花算法的代码就完事。从 64 位的二进制拆解到时钟回拨处理,从 Worker ID 分配策略到 MyBatis-Plus 集成的坑,全部拆开来讲------看完你应该能自己写一个生产可用的雪花生成器。
第一篇:为什么要用雪花算法?
1.1 分布式 ID 的四个硬性要求
一个好的分布式 ID 方案需要同时满足:
| 要求 | 含义 | 反面例子 |
|---|---|---|
| 全局唯一 | 整个集群不会生成重复 ID | 数据库自增在分库后可能冲突 |
| 趋势递增 | ID 按时间大致递增,利于 B+ 树索引 | UUID 乱序导致页分裂 |
| 高性能 | 生成速度足够快,不成为瓶颈 | 每次调 Redis / ZK 有网络开销 |
| 无外部依赖 | 不依赖外部服务(Redis、Zookeeper) | 外部服务挂了,整个系统 ID 生成全部停摆 |
1.2 各方案优劣对比
分布式 ID 方案选择
数据库自增
AUTO_INCREMENT
UUID
UUID.randomUUID()
Redis INCR
Redis 自增
雪花算法
Snowflake (推荐)
全局唯一?
分库后不唯一
趋势递增? Yes
高性能?
受DB限制
无外部依赖?
强依赖DB
全局唯一? Yes
(极小概率冲突)
趋势递增? No
完全乱序
高性能? Yes
本地生成
无外部依赖? Yes
全局唯一? Yes
趋势递增? Yes
高性能?
有网络开销
无外部依赖?
强依赖Redis
全局唯一? Yes
趋势递增? Yes
(时间戳)
高性能? Yes
本地生成
无外部依赖?
(需分配WorkerID)
各方案的致命缺陷:
- 数据库自增:分库分表后两个库各自从 1 递增 → ID 冲突
- UUID:完全无序 → 作为 InnoDB 主键导致 B+ 树频繁页分裂 → 写入性能下降 50%+
- Redis INCR:引入外部依赖 → Redis 一旦不可用,整个系统无法生成任何 ID
- 雪花算法:基本无外部依赖(仅需 Worker ID 分配),本地内存生成,趋势递增
1.3 一句话定义
雪花算法(Snowflake) 是 Twitter 开源的一种分布式 ID 生成算法,它把 64 位的 Long 型整数拆分成多个段,分别填充时间戳、机器标识和序列号,利用本机时钟和计数器在无外部依赖的情况下生成趋势递增的全局唯一 ID。
第二篇:64 位二进制拆解
2.1 经典位分配结构
64 位 Long = 1位(未使用) + 41位(时间戳) + 5位(数据中心) + 5位(工作节点) + 12位(序列号)
┌─┬──────────────────────────┬──────────┬──────────┬──────────────────┐
│0│ 41 bit 时间戳 │5 bit DC │5 bit WID │ 12 bit 序列号 │
└─┴──────────────────────────┴──────────┴──────────┴──────────────────┘
↑ ↑ ↑ ↑ ↑
│ └─ 从 EPOCH 开始的毫秒数 │ │ └─ 单毫秒内序列号 (0~4095)
│ (可用约 69 年) │ └─ 工作节点ID (0~31)
└─ 符号位(恒为0,表示正数) └─ 数据中心ID (0~31)
2.2 每一段的含义与容量
| 段名 | 位数 | 取值范围 | 可承载上限 | 用途 |
|---|---|---|---|---|
| 符号位 | 1 bit | 0(固定) | --- | 保证 ID 始终为正数 |
| 时间戳 | 41 bit | 0 ~ 2^41-1 | 约 69 年(从 EPOCH 算起) | 毫秒级时间戳,保证趋势递增 |
| 数据中心ID | 5 bit | 0 ~ 31 | 32 个数据中心 | 标识机房/地域 |
| 工作节点ID | 5 bit | 0 ~ 31 | 每 DC 内 32 个节点 | 标识单台机器/进程 |
| 序列号 | 12 bit | 0 ~ 4095 | 每毫秒 4096 个 ID | 同毫秒内递增序列 |
2.3 关键推算
时间戳段能用多久?
2^41 = 2,199,023,255,552 毫秒 ≈ 69.7 年
以 EPOCH = 2024-01-01 为起点:
最大可用到 2024 + 69.7 ≈ 2093 年
单节点每秒能生成多少个 ID?
每毫秒 4096 个 × 1000 毫秒 = 409.6 万/秒
对于大多数应用场景,这个数字足够用。
如果需要更高性能,可以把序列号段扩到 14~16 bit(缩短时间戳段)。
总共能支持多少台机器?
32 个数据中心 × 32 个节点 = 1024 台机器
如果需要更多,可以从时间戳段拆 1~2 bit 给机器ID段。
2.4 ID 生成过程的二进制演示
假设当前毫秒相对于 EPOCH 的差值 = 1715840600123,数据中心 ID = 1,工作节点 ID = 1,序列号 = 0:
Step 1: 时间戳差值左移 22 位(12+5+5)
0b000...001011100101001001101001110001011
<< 22 = 0b...0101110010100100110100111000101100000000000000000000000
Step 2: 数据中心ID (1) 左移 17 位(12+5)
0b00001 << 17 = 0b...00000000000000000000001000000000000000000
Step 3: 工作节点ID (1) 左移 12 位
0b00001 << 12 = 0b...000000000000000000000000001000000000000
Step 4: 序列号 (0) 不移位
0b000000000000
Step 5: 四个结果做 OR 运算 → 最终 64 位 ID
= 7198499257854468096 (十进制)
第三篇:完整代码实现------从常量到生成逻辑
3.1 常量定义与初始化
java
@Component
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_LEFT_SHIFT =
SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; // 22
/** 序列号掩码:4095 (0b111111111111) */
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
private final long datacenterId;
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator() {
this(1, 1);
}
public SnowflakeIdGenerator(long datacenterId, long workerId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException(
"workerId out of range: " + workerId);
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException(
"datacenterId out of range: " + datacenterId);
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}
}
逐行深度解析
第一组:起始时间戳 EPOCH
java
private static final long EPOCH = 1704067200000L;
这个值是 2024-01-01 00:00:00.000 UTC 对应的毫秒级时间戳。
为什么选这一天?因为它是最近的"整年"起点,方便计算可用年限:
Long.MAX_VALUE 的最大值(带符号)= 2^63 - 1 = 9,223,372,036,854,775,807
但雪花算法只用 41 位存时间戳,最大值是 2^41 - 1 = 2,199,023,255,552 毫秒
≈ 69.7 年
所以从 2024-01-01 算起:
2024 + 69.7 ≈ 2093 年(理论上限)
如果你在 2025 年才开始用,EPOCH 设成 2025-01-01 也完全没问题,
只是会"浪费"一年的可用期(从 2093 变成 2094,差别不大)
第二组:各段位数定义
java
private static final long WORKER_ID_BITS = 5L; // 工作节点:5 位
private static final long DATACENTER_ID_BITS = 5L; // 数据中心:5 位
private static final long SEQUENCE_BITS = 12L; // 序列号:12 位
这三个值决定了 64 位 ID 中各段的"宽度"。它们不是随便定的:
| 段名 | 位数 | 可承载数量 | 设计理由 |
|---|---|---|---|
| WORKER_ID_BITS = 5 | 2^5 = 32 个节点 | 单个数据中心内 32 台机器足够大多数场景 | |
| DATACENTER_ID_BITS = 5 | 2^5 = 32 个数据中心 | 覆盖全国主要机房/地域 | |
| SEQUENCE_BITS = 12 | 2^12 = 4096 个/毫秒 | 单节点 QPS 上限约 409 万,够用 |
如果需要更多节点或更高 QPS,可以调整这些值------但必须保证总和不超过 41+5+5+12 = 63(留 1 位给符号位)。
第三组:最大值计算(位运算技巧)
java
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 结果: 31
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); // 结果: 31
这是整个类中最精巧的位运算,值得拆开讲清楚:
以 MAX_WORKER_ID 为例,逐步推演:
步骤 1: -1L 在 Java 中的二进制表示
-1L = 0b1111111111111111111111111111111111111111111111111111111111111111
(64 个 1)
步骤 2: 左移 5 位
-1L << 5 = 0b1111111111111111111111111111111111111111111111111111111111100000
(前 59 位是 1,后 5 位是 0)
步骤 3: 取反(按位 NOT,~ 运算符)
~(-1L << 5) = 0b0000000000000000000000000000000000000000000000000000000000011111
(前 59 位是 0,后 5 位是 1)
步骤 4: 转为十进制
= 0b11111 = 16 + 8 + 4 + 2 + 1 = 31
结论: MAX_WORKER_ID = 31,即 Worker ID 的合法范围是 [0, 31]
为什么不用直接写 31?
java
// 如果写死:
private static final long MAX_WORKER_ID = 31; // 忘了改成 63 → BUG!
// 如果用位运算:
private static final long WORKER_ID_BITS = 6L; // 只改这一行
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 自动变成 63 ✅
这就是**"配置驱动"优于"硬编码"**的体现------修改一个常量就能自动适配所有依赖它的计算。
第四组:位偏移量(决定每段在 64 位中的位置)
java
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_LEFT_SHIFT =
SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; // 22
偏移量的作用是告诉算法:"这一段数据应该放在 64 位整数中的哪个位置"。
用一张图来理解:
64 位 Long 的二进制布局 (从高位到低位):
位: 63 ... 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
└──────┘ └─┘└──────────┘└──────────┘└────────────────────────────┘
时间戳 DC WorkerID 序列号(SEQUENCE)
(41 bit) (5) (5) (12 bit)
时间戳左移多少位? → 左移 22 位 (12+5+5),把低 22 位空出来给其他段
DC 左移多少位? → 左移 17 位 (12+5),把低 17 位空出来给 WorkerID + 序列号
WorkerID 左移多少位? → 左移 12 位,把低 12 位空出来给序列号
序列号不移位 → 直接放在最低 12 位
第五组:序列号掩码
java
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); // = 4095
掩码的作用是限制序列号的取值范围,防止溢出到其他字段:
java
// 场景:同一毫秒内第 4097 次调用 nextId()
sequence = (sequence + 1) & SEQUENCE_MASK;
// 如果 sequence 当前是 4095:
// sequence + 1 = 4096
// 4096 & 4095 (即 0b1000000000000 & 0b0111111111111)
// = 0b0000000000000 = 0 ← 自动归零!
// 这就是"掩码"的妙处:超过 4095 的值被截断回 0
// 触发后续的 waitNextMillis() 自旋等待下一毫秒
第六组:实例变量(运行时状态)
java
private final long datacenterId; // 数据中心 ID(不可变,构造时确定)
private final long workerId; // 工作节点 ID(不可变)
private long lastTimestamp = -1L; // 上一次生成 ID 时的时间戳(可变)
private long sequence = 0L; // 当前毫秒内的序列号(可变)
四个变量的生命周期和线程安全策略:
| 变量 | 类型 | 可变性 | 线程安全保护 |
|---|---|---|---|
datacenterId |
final |
不可变 | 无需保护(构造后不变) |
workerId |
final |
不可变 | 无需保护(构造后不变) |
lastTimestamp |
普通 long |
每次 nextId() 都可能变 |
由 synchronized 保护 |
sequence |
普通 long |
同一毫秒内递增,新毫秒归零 | 由 synchronized 保护 |
注意 final 和 synchronized 的配合使用:不变的用 final 声明,变化的用锁保护。这是一种清晰的并发设计模式。
第七组:构造器与参数校验
java
public SnowflakeIdGenerator() {
this(1, 1); // 默认:数据中心=1,工作节点=1
}
public SnowflakeIdGenerator(long datacenterId, long workerId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException(
"workerId out of range: " + workerId);
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException(
"datacenterId out of range: " + datacenterId);
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}
默认构造器写死 (1, 1) 是一种开发便利性妥协 ------单机开发或测试时不需要额外配置。但在生产环境中,务必通过有参构造器传入不同的 Worker ID,否则多实例部署会产生 ID 冲突。
参数校验的 IllegalArgumentException 是**快速失败(Fail-Fast)**原则的体现:在对象创建时就拒绝非法参数,而不是等到 nextId() 执行时才发现问题,那时排查成本会高得多。
3.2 核心生成方法+
java
public synchronized long nextId() {
long timestamp = currentTime();
// ==================== 时钟回拨处理 ====================
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
// 小幅度回拨(NTP 校时抖动):休眠等待
try {
Thread.sleep(offset);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Thread interrupted while waiting for clock", e);
}
timestamp = currentTime();
if (timestamp < lastTimestamp) {
throw new IllegalStateException(
"Clock still behind after waiting. last="
+ lastTimestamp + ", now=" + timestamp);
}
} else {
// 大幅度回拨:直接拒绝
throw new IllegalStateException(
"Clock moved backwards too much. offset=" + offset + "ms");
}
}
// ==================== 序列号逻辑 ====================
if (lastTimestamp == timestamp) {
// 同一毫秒内:序列号 +1,用掩码保证不超 4095
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
// 4096 个名额用完,自旋等待下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
// 新的一毫秒:序列号归零
sequence = 0L;
}
lastTimestamp = timestamp;
// ==================== 组装 64 位 ID ====================
return ((timestamp - EPOCH) << TIMESTAMP_LEFT_SHIFT) // 时间戳左移 22 位
| (datacenterId << DATACENTER_ID_SHIFT) // DC ID 左移 17 位
| (workerId << WORKER_ID_SHIFT) // Worker ID 左移 12 位
| sequence; // 序列号在最低 12 位
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTime();
while (timestamp <= lastTimestamp) {
timestamp = currentTime(); // 忙等(busy-wait)
}
return timestamp;
}
private long currentTime() {
return System.currentTimeMillis();
}
3.3 为什么用 synchronized 而不是 CAS?
这是一个经常被问到的问题。两种方案各有侧重:
| 方案 | 优势 | 劣势 |
|---|---|---|
| synchronized | 代码简单,悲观锁保证绝对线程安全 | 线程竞争激烈时会排队 |
| CAS(AtomicLong) | 无锁,高并发下理论性能更高 | 代码复杂,需要处理 CAS 失败重试和时钟回拨交织的问题 |
在这个场景里,选择 synchronized 有几个理由:
- 每毫秒最多 4096 次调用:单个 JVM 每秒 ID 生成上限约 400 万,这个 QPS 下 synchronized 的锁竞争并不激烈
- 代码可读性 :整个生成逻辑中涉及
lastTimestamp、timestamp、sequence三个共享变量,synchronized 比多个 Atomic 变量组合更容易维护 - 时钟回拨处理需要原子性:回拨检查 + 时间更新 + 序列更新这三步必须在同一个临界区内完成
如果你的场景单节点需要超过 400 万/秒的 ID 生成量,可以考虑:
- 把序列号段从 12 bit 扩到 14 bit(单毫秒 16384 个),减少锁竞争频率
- 使用分段计数器(LongAdder),牺牲绝对单调性提升吞吐
第四篇:时钟回拨
4.1 什么情况会导致时钟回拨?
时钟回拨原因
NTP 时间同步
虚拟机时间漂移
运维手动修改时间
闰秒调整
NTP 客户端定期对时
偏差通常在 1~50ms
Docker/KVM 宿主机
负载高时可能漂移
人为操作
可能回拨任意时长
极少见,UTC 闰秒
文字版说明:
- NTP(Network Time Protocol)校时是最常见的原因------服务器每隔几分钟会自动同步时间,偏差通常在几毫秒到几十毫秒
- 虚拟化环境(Docker、KVM)中,宿主机 CPU 负载过高可能导致时钟漂移
- 人为操作虽然少见但危害最大------运维可能直接
date -s手动修改服务器时间
4.2 分层处理策略
java
if (timestamp < lastTimestamp) { // 发生了时钟回拨
long offset = lastTimestamp - timestamp; // 回拨了多少毫秒
if (offset <= 5) {
// 第一层:小幅度回拨(NTP 抖动)→ 等待
Thread.sleep(offset); // 睡到时钟追回来
timestamp = currentTime(); // 重新获取时间
if (timestamp < lastTimestamp) {
// 等待后仍未恢复 → 抛异常拒绝
throw new IllegalStateException("...");
}
} else {
// 第二层:大幅度回拨 → 直接拒绝
throw new IllegalStateException(
"Clock moved backwards too much. offset=" + offset + "ms");
}
}
为什么 5ms 是分界线?
这不是拍脑袋定的。NTP 校时的典型偏差范围是 1~50ms,在这个范围内,等待是最简单有效的处理方式。超过 5ms 的等待会阻塞调用方太久(尤其对于处理用户请求的同步线程),所以选择直接拒绝。
4.3 其他可选方案对比
| 方案 | 原理 | 优势 | 劣势 |
|---|---|---|---|
| 抛异常 | 直接拒绝生成,让上层重试 | 实现最简单 | 可用性降低 |
| 睡眠等待 | 等时钟追回来再生成 | 对短回拨有效 | 长回拨会阻塞线程 |
| 备用时钟 | 额外维护一个原子递增的"逻辑时钟" | 永久解决回拨问题 | 与真实时间脱钩,ID 不再精确反映生成时间 |
| 借用未来 | 回拨时继续用 lastTimestamp,增加序列号耗尽后的等待 | 不阻塞 | 会导致一段时间内的时间戳不真实 |
| 号段模式 | 不依赖时钟,从数据库取号段 | 无时钟回拨问题 | 依赖数据库 |
实际生产建议 :对于绝大多数应用,"小幅度等待 + 大幅度抛异常"的组合已经足够。如果你们的系统要求 99.99% 的可用性,可以考虑增加"备用时钟"策略(用 AtomicLong 维护一个逻辑时钟,当系统时钟回拨时,改用逻辑时钟作为时间戳)。
第五篇:Worker ID 分配------被遗忘的难点
5.1 硬编码的风险
java
// 这是目前代码中的默认构造器
public SnowflakeIdGenerator() {
this(1, 1); // 写死了 workerId=1, datacenterId=1
}
如果你的服务只部署一个实例,这没问题。但一旦扩容:
服务 A: workerId=1, datacenterId=1 → 生成的 ID 与
服务 B: workerId=1, datacenterId=1 → 完全一致!
→ 两条不同的业务数据,拿到了相同的 ID
→ 插入数据库时,后插入的那条会覆盖先插入的那条(或报主键冲突)
这是一个"单机测试正常,上了生产就炸"的典型问题。
5.2 业界常用的 Worker ID 分配方案
Worker ID 分配方案
环境变量
数据库自增
Redis INCR
ZK 临时节点
K8s StatefulSet
启动参数: -Dworker.id=1
优点: 简单
缺点: 手动维护,扩容易忘
启动时INSERT获取ID
优点: 无额外组件
缺点: 强依赖DB
INCR worker:id
优点: 快速
缺点: 依赖Redis
创建临时顺序节点
优点: 自动回收
缺点: 引入ZK复杂度
取Pod序号作为WorkerID
优点: K8s原生
缺点: 仅限K8s环境
文字版说明:
- 环境变量是最少依赖的方案,配合 Docker Compose 或 K8s ConfigMap 使用,缺点是扩容时需要手动更新配置
- 数据库自增 利用唯一键约束保证不重复,启动时
INSERT一条记录取回 ID,缺点是服务启动多了一个数据库依赖 - Redis INCR 与数据库方案类似但更快,同样引入了外部依赖
- ZK 临时顺序节点能自动回收(服务挂掉后节点自动删除),但如果本来就还没引入 ZK,为此单独部署一套得不偿失
- K8s StatefulSet 的 Pod 序号天然递增,取
hostname的最后一段即可
5.3 数据库自增方案的示例实现
java
/**
* 基于数据库自增的 Worker ID 分配
* 启动时执行一次,将 IP + 端口注册到数据库,获取自增ID作为 Worker ID
*/
@Component
public class DbWorkerIdAssigner {
// 假设有一张 worker_node 表:
// CREATE TABLE worker_node (
// id BIGINT AUTO_INCREMENT PRIMARY KEY,
// host VARCHAR(64) NOT NULL,
// port INT NOT NULL,
// create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
// UNIQUE KEY uk_host_port (host, port)
// );
public long assignWorkerId(String host, int port) {
try {
// INSERT ... ON DUPLICATE KEY UPDATE(幂等插入)
// 如果 host+port 已存在,返回已有的 ID;否则插入新行返回新 ID
return jdbcTemplate.queryForObject(
"INSERT INTO worker_node (host, port) VALUES (?, ?) " +
"ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)",
Long.class, host, port
);
} catch (Exception e) {
throw new IllegalStateException("Failed to assign worker ID", e);
}
}
}
关键点在于 ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id):利用数据库的唯一键约束保证同一台机器的 host+port 只注册一次,重启时拿到的是同一个 Worker ID。
第六篇:与其他框架的集成与对比
6.1 MyBatis-Plus 内置雪花 ID
MyBatis-Plus 提供了开箱即用的雪花算法支持:
java
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
IdType.ASSIGN_ID 会调用 DefaultIdentifierGenerator,其底层实现也是标准的雪花算法(序列号 12 位 + 工作节点 10 位)。
与自定义生成器配合使用时的注意点:
java
// 这段代码同时用了两种方式
@Service
public class KnowPostsServiceImpl {
private final SnowflakeIdGenerator idGen;
public long createDraft(long creatorId) {
long id = idGen.nextId(); // ① 自定义生成器生成 ID
KnowPosts post = KnowPosts.builder()
.id(id) // ② 手动 set
.build();
this.save(post); // ③ MP 检测到 id 已非 null,跳过 ASSIGN_ID
return id;
}
}
调用链路中,MyBatis-Plus 的 IdType.ASSIGN_ID 实际上没有发挥作用------因为 id 在调用 save() 之前已经被手动赋过值了。这不是 bug,但意味着两种方式同时存在,逻辑上存在冗余。
6.2 Hutool 的 Snowflake 工具
java
// Hutool 封装(如果项目中引入了 Hutool)
long id = IdUtil.getSnowflake(1, 1).nextId();
Hutool 的实现与标准雪花算法一致,优势在于 API 简洁,劣势在于它的时钟回拨处理比较粗糙(直接抛异常,不做等待)。
6.3 三者的对比
| 维度 | 自研 SnowflakeIdGenerator | MyBatis-Plus ASSIGN_ID | Hutool Snowflake |
|---|---|---|---|
| 时钟回拨处理 | 分两层:<=5ms等待,>5ms抛异常 | 抛异常 | 抛异常 |
| Worker ID 管理 | 手动管理 | 通过 MAC 地址后两位计算 | 手动传入 |
| 序列号耗尽 | 自旋等待下一毫秒 | 自旋等待 | 自旋等待 |
| 灵活性 | 高(可自定义位分配) | 低(固定格式) | 低(固定格式) |
| 引入成本 | 需要维护代码 | 零成本(已有 MP) | 零成本(已有 Hutool) |
第七篇:常见使用方式与典型坑位
7.1 在业务代码中的正确调用姿势
java
@Service
public class PostService {
private final SnowflakeIdGenerator idGen;
// 方式一:同步生成(适用于单条记录创建)
@Transactional
public long createPost(long userId) {
long id = idGen.nextId(); // 在事务开始前就生成好 ID
Post post = Post.builder()
.id(id)
.userId(userId)
.build();
postMapper.insert(post);
return id;
}
// 方式二:批量生成(避免循环调用 nextId 影响性能)
public List<Long> batchGenerate(int count) {
List<Long> ids = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
ids.add(idGen.nextId());
}
return ids;
}
}
关键原则 :在事务开始之前生成 ID 。虽然 synchronized 只是 JVM 级别的锁,不受数据库事务影响,但把 ID 生成放在事务外可以减少锁的持有时间。
7.2 七个典型坑位
坑 1:雪花 ID 传到前端后精度丢失
后端生成的 ID: 1745678901234567890
前端 JavaScript: Number(1745678901234567890)
= 1745678901234568000 ← 最后三位变成了 0!
原因:JavaScript 的 Number 类型是 IEEE 754 双精度浮点数,
只能安全表示 -9007199254740991 ~ 9007199254740991 之间的整数
(即 Number.MAX_SAFE_INTEGER)
而雪花算法生成的 64 位 ID 远大于这个范围
解决:后端序列化时转为 String
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
坑 2:默认构造器硬编码 Worker ID
java
// 如果部署了 3 个节点,默认都是 (1, 1)
// → 三台机器生成相同的 ID
// → 生产事故!
// 应该根据环境变量或配置动态设置
public SnowflakeIdGenerator() {
this(
Long.parseLong(System.getProperty("datacenter.id", "1")),
Long.parseLong(System.getProperty("worker.id", "1"))
);
}
坑 3:大流量下序列号耗尽自旋浪费 CPU
java
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTime();
while (timestamp <= lastTimestamp) {
timestamp = currentTime(); // 忙等,CPU 空转!
}
return timestamp;
}
在极端场景下(每毫秒超过 4096 个请求),这个 while 循环会一直空转,把 CPU 打到 100%。改进方案:
java
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTime();
while (timestamp <= lastTimestamp) {
// 让出 CPU 时间片,避免空转耗尽 CPU
Thread.onSpinWait(); // JDK 9+ 可用
// 或者 Thread.yield()
timestamp = currentTime();
}
return timestamp;
}
坑 4:Docker 容器中使用雪花算法
Docker 容器的时钟可能与宿主机不同步,加上 NTP 无法在容器内正常工作(部分基础镜像),时钟回拨的风险比物理机高。
坑 5:ID 作为分库分表的 Sharding Key
雪花 ID 是趋势递增的,如果直接用 ID 取模分库,会导致写入热点------所有新数据都写入同一个库/表。解决方案是在 ID 中加入业务维度:
java
// 用用户 ID 哈希作为分片键,而不是雪花 ID
int shardKey = Math.abs(userId.hashCode()) % shardCount;
坑 6:System.currentTimeMillis() 的精度问题
在某些操作系统上,System.currentTimeMillis() 的精度是 10~15ms,而不是 1ms。这意味着同一毫秒内可能生成超过 4096 个 ID,导致序列号溢出。可以用 System.nanoTime() 辅助校准高精度时间。
坑 7:时钟回拨后未抛出异常,上层无感知
java
// 如果回拨处理中的 Thread.sleep() 通过了,但未做二次检查
// 下面的代码可能生成重复 ID
// 正确的做法是 sleep 后必须重新检查
if (timestamp < lastTimestamp) {
throw new IllegalStateException(...); // 必须抛异常
}
第八篇:面试高频追问------从八股到实战
Q1:雪花算法生成的 ID 真的是全局唯一吗?
回答方向:
在 Worker ID 分配正确的前提下,雪花的唯一性由三点保证:
- 时间戳不同:不同毫秒生成的时间戳段不同
- Worker ID 不同:不同机器生成的工作节点段不同
- 序列号不同:同一毫秒同一机器内序列号递增
唯一可能打破唯一性的是时钟回拨。所以严谨地说:在时钟正确且 Worker ID 唯一的前提下,雪花 ID 是全局唯一的。
Q2:相比美团的 Leaf 方案,雪花算法有什么不足?
回答方向:
| 维度 | 雪花算法 | Leaf(号段模式) | Leaf(Snowflake 模式) |
|---|---|---|---|
| ID 连续性 | 趋势递增但不连续 | 完全连续 | 趋势递增但不连续 |
| 时钟依赖 | 强依赖 | 不依赖 | 依赖(但通过 ZK 解决回拨) |
| 外部依赖 | 无(Worker ID 需分配) | 依赖数据库 | 依赖 Zookeeper |
| 性能 | 极高(纯内存) | 中等(定时取号段) | 高(内存 + ZK 协调) |
| Worker ID | 需自行管理 | 无需管理 | ZK 自动分配 |
一句话总结:雪花算法适合不依赖外部组件、追求极致性能 的场景;Leaf 号段模式适合需要严格递增、能接受数据库依赖的场景。
美团 Leaf 号段模式深度解析
既然提到了 Leaf,这里补充一下它的核心原理------很多面试官会追问"号段模式到底怎么工作的"。
一句话定义:
Leaf 号段模式的核心思想是:不依赖时钟,而是从数据库批量预取一段连续的 ID 到本地内存中,用完后再取下一段。
架构图:
数据库
Leaf Server 集群
业务服务 (多实例)
ID 耗尽(用完 1000 个)
ID 考尽
ID 耗尽
批量取号段
UPDATE SET max_id=max_id+step
WHERE biz_tag='order' AND version=5
批量取号段
返回新号段
4001\~5000
Client-1
内存: [1001~2000]
Client-2
内存: [2001~3000]
Client-3
内存: [3001~4000]
Leaf-1
号段缓存
Leaf-2
号段缓存
leaf_alloc 表
biz_tag = 'order'
max_id = 4000
step = 1000
version = 5
文字版说明:
- 业务服务启动时,向 Leaf Server 申请一个号段(如
1001~2000) - Leaf Server 从数据库的
leaf_alloc表中批量取一个步长(step=1000)的号段 - 业务服务将号段缓存在本地内存中,直接自增返回,不再访问数据库
- 当本地号段即将耗尽时(如用到 1990),异步向 Leaf Server 申请新号段
- 数据库使用乐观锁(
version字段 + CAS 更新)保证多个 Leaf Server 取到的号段不重叠
核心数据库表结构:
sql
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) NOT NULL COMMENT '业务标识',
max_id BIGINT NOT NULL DEFAULT 1 COMMENT '当前最大已分配ID',
step INT NOT NULL DEFAULT 1000 COMMENT '每次取号的步长',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (biz_tag)
);
-- 初始化示例
INSERT INTO leaf_alloc (biz_tag, max_id, step, version)
VALUES ('order', 1, 1000, 0);
-- 第一次取号后变为: max_id=1001, version=1
-- 第二次取号后变为: max_id=2001, version=2
取号的核心 SQL(双 buffer 设计):
java
// Leaf Server 内部取号逻辑(伪代码)
public Segment allocate(String bizTag) {
// 1. 先从当前正在使用的 segment 取 ID
if (!currentSegment.isExhausted()) {
return currentSegment.nextId();
}
// 2. 当前 segment 用完,切换到下一个 segment(双 buffer 无缝衔接)
if (nextSegment != null && !nextSegment.isExhausted()) {
currentSegment = nextSegment;
// 异步加载新的 segment 到 nextSegment
asyncLoadNextSegment(bizTag);
return currentSegment.nextId();
}
// 3. 两个 segment 都空了,同步等待数据库取号
Segment newSegment = fetchFromDB(bizTag);
currentSegment = newSegment;
asyncLoadNextSegment(bizTag); // 提前加载下一个
return currentSegment.nextId();
}
// 数据库取号(乐观锁 CAS)
private Segment fetchFromDB(String bizTag) {
int updated = jdbcTemplate.update(
"UPDATE leaf_alloc SET max_id = max_id + ?, version = version + 1 " +
"WHERE biz_tag = ? AND version = ?",
step, bizTag, currentVersion
);
if (updated == 0) {
throw new ConcurrentUpdateException("版本冲突,重试");
}
// 查询新的 max_id 和 version
Row row = jdbcTemplate.queryForObject(
"SELECT max_id, version FROM leaf_alloc WHERE biz_tag = ?",
bizTag);
long newMaxId = row.getLong("max_id");
int newVersion = row.getInt("version");
// 返回号段: [newMaxId - step + 1, newMaxId]
return new Segment(newMaxId - step + 1, newMaxId);
}
双 Buffer 设计的关键作用:
时间轴:
t0: 当前segment=[1~1000], 下一个segment=null
t1: 当前segment快用完(到990), 触发异步加载 → 下一个segment=[1001~2000]
t2: 当前segment耗尽(1000), 切换到下一个segment=[1001~2000]
同时异步触发加载 → 新的下一个segment=[2001~3000]
t3: 当前segment=[1001~2000]使用中...
收益:
业务服务永远不需要等待数据库查询(除非两个 segment 都恰好同时耗尽)
数据库压力极低(每个业务每分钟可能只查一次,而不是每次生成 ID 都查)
Leaf 号段模式的优劣势总结:
| 维度 | 评价 |
|---|---|
| ID 连续性 | 完美连续(这是它最大的卖点,适合订单号/流水号场景) |
| 性能 | 本地内存操作,QPS 可达百万级(仅号段切换时有一次 DB 访问) |
| 可用性 | Leaf Server 可以集群部署,某个节点挂了不影响其他节点 |
| 复杂度 | 需要部署和维护 Leaf Server 集群 + 数据库表 |
| ID 浪费 | 如果某台机器重启,未使用的号段就浪费了(但 step 通常设为 1000~10000,浪费可接受) |
什么时候选 Leaf 号段?
- 需要严格递增的 ID(如订单号、支付流水号、对账单号)
- 已经有 MySQL 集群且能接受额外的写入压力
- 团队有能力维护一套 Leaf Server 服务
什么时候选雪花算法?
- 只需要趋势递增即可(如帖子 ID、评论 ID、消息 ID)
- 追求极致简单和零外部依赖
- 单机 QPS 需求在百万级以上
Q3:如果序列号段的 12 位不够用了怎么办?
回答方向:
有几种调整方向:
- 缩减时间戳段:从 41 位减到 40 位,序列号从 12 位增到 13 位(从 69 年可用期减到 34 年,多数系统足够)
- 缩减机器标识段:如果你的机器数量小于 32 台,可以把 5+5 位压到 4+4 位,多出 2 位给序列号
- 改用号段模式:彻底不需要序列号,转而从数据库批量取号
- 业务层批处理 :一次性生成一批 ID 放入队列,减少
nextId()的调用频率
Q4:服务重启后,会不会生成和重启前一样的 ID?
回答方向:
不会。因为时间戳段在持续前进(从 EPOCH 算起的毫秒数),重启后即使序列号又从 0 开始,时间戳已经比重启前大了。唯一需要注意的是时钟回拨------如果重启后服务器时钟比关机前慢(例如 NTP 校时),就需要靠回拨处理逻辑兜底。
第九篇:选型建议------什么时候该用,什么时候不该用
9.1 各场景方案推荐
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单库单表、QPS < 1000 | 数据库自增 | 最简单,无额外复杂度 |
| 分库分表、QPS 中等 | 雪花算法 + 环境变量 Worker ID | 性能好,趋势递增有利索引 |
| 分库分表、QPS 极高 | 雪花算法 + 数据库分配 Worker ID | Worker ID 自动化管理 |
| 需要严格连续递增 | 号段模式(如美团 Leaf) | ID 绝对连续,方便对账 |
| K8s 微服务架构 | 雪花算法 + StatefulSet Pod 序号 | K8s 原生方案,零运维成本 |
| 已引入 Zookeeper | 雪花算法 + ZK 临时节点分配 Worker ID | 自动回收,自动分配 |
| 前端需要展示 ID | 雪花算法 + JSON 序列化转 String | 防止 JS 精度丢失 |
9.2 决策流程图
单库单表
QPS < 1000
分库分表
QPS > 1000
是
否
能
有 ZK
只有 DB
不能
是
否
需要分布式 ID
数据量多大?
数据库 AUTO_INCREMENT
结论: 别用雪花,杀鸡焉用牛刀
需要严格连续递增吗?
号段模式 (Leaf)
结论: 号段的连续性无法被雪花替代
能接受外部依赖吗?
已有 ZK 或数据库?
雪花 + ZK 分配 Worker ID
结论: 工业级方案
雪花 + 数据库自增分配 Worker ID
结论: 最务实的折中方案
部署在 K8s 上?
雪花 + StatefulSet 序号
结论: 云原生最优解
雪花 + 环境变量
结论: 最简方案,但需人工维护
文字版说明:
- 先判断数据量和分库分表需求:单库单表直接数据库自增最省事
- 再判断是否需要严格连续递增:如果需要(如资金流水号),雪花算法不适合
- 根据已有的基础设施选择 Worker ID 分配方式,避免为了雪花算法额外引入 Zookeeper
总结:雪花算法的设计哲学
回到开头的问题:为什么雪花算法能同时满足"全局唯一、趋势递增、高性能、无外部依赖"这四个看似矛盾的需求?
答案藏在它的设计里:
雪花算法 = 时间戳(天然递增 + 全局唯一的大前提)
+ 机器标识(解决分布式并发)
+ 序列号(解决单机并发)
+ 位运算(极致的计算效率)
它不是银弹,但它的设计哲学值得学习:
**用最小的成本解决最核心的问题。**不引入外部服务,不依赖数据库,只用本机的时钟和几个原子变量,就完成了分布式 ID 的生成。
当你下次遇到"需要生成分布式唯一 ID"的需求时,不仅要知道雪花算法怎么写,更要清楚:
- 为什么选了 41+5+5+12 这个位分配
- 时钟回拨的三层处理策略分别对应什么场景
- Worker ID 在你的部署环境下怎么自动化分配
- 当 QPS 超过单毫秒 4096 时有什么降级方案
这些才是面试官真正想听的,也是你在生产环境写代码时真正需要做的决策。