雪花结构再拆一遍
标准雪花 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 是必然的,而且只在数据量上来后才暴露。
只测正常路径。 时钟回拨几乎无法在开发环境复现,至少要写一个可以注入时间的单测,把三档回拨的分支都覆盖到。
评论