那天晚上九点四十七分,我盯着监控大屏上的告警,心里一阵发毛——订单号出现了重复,而且不止两条。分库分表上线三个月,全局唯一ID这个环节,在最不该出问题的时候出了问题。排查到最后,问题指向服务器系统时间回拨。其实那台机器只是被NTP校准了一下,时间往后退了几十毫秒,雪花算法的生成器就“罢工”了,而应用层在捕获异常后降级成了时间戳加随机字符串的方案,结果在多实例下直接撞车。
这件事之后,我把雪花算法从“会用”到“弄懂”到“能自己动手改”整个走了一遍。如果你也在做分库分表,或者正在纠结全局唯一ID到底怎么选,这篇东西值得你看完。我尽量把原理讲透,把代码写成可以直接抄作业的样子,也把那些文档里不会写的坑一并说出来。
1. 分库分表后,主键设计的“易碎品”清单:自增ID、UUID和随机拼接
1.1 一条自增ID在8库16表面前的分裂现场
没分库分表之前,MySQL自增ID几乎是所有项目的默认选择。它简单、有序、对索引友好,插入的时候完全不用动脑子。但当你把一张表拆成8个库、每库16张表之后,问题立刻就摆到了桌面上:每张子表都有自己的AUTO_INCREMENT计数器,各自从1开始。你在订单表1里插入一条ID=1,在订单表2里也插入一条ID=1,等这些数据汇总到大数据平台或者做跨库查询时,两条ID=1的订单就“撞”了。
有人会想到“设置不同步长”,比如库1的表从1开始每次加8,库2的表从2开始每次加8。这在扩容之前是能用的,可一旦你得从8库扩到16库,步长和起始偏移全部要重新算,历史数据怎么迁移?新老规则怎么衔接?光是想想就头大。
说到底,自增ID的最大问题是:它的“全局唯一”建立在单表单库的边界之内。分库分表把这个边界拆碎了,自增ID的根基也就没了。这不是配置问题,而是模型问题。
1.2 UUID和Redis自增各自的硬伤
自增ID出局之后,很多人第一反应是用UUID。UUID v4是随机生成的,32位十六进制加4个连字符,总共36个字符,人类没法直接读懂,但起码全球唯一。可UUID的代价也很明确:
- 存储空间大。36个字符,就算去掉连字符也有32字节,是BIGINT的4倍。
- 完全无序。InnoDB的聚簇索引是按主键顺序维护B+树的,UUID这种随机值会让索引页频繁分裂,插入性能随着数据量增长直线下滑。
- 没有时间语义。出事排查的时候,你看着一串UUID根本不知道它是什么时候生成的。
再说Redis自增。用Redis的INCR命令拿一个全局递增数字,看起来好像平衡了唯一性和有序性。但仔细想一下:
- 每次生成ID都要走一次网络往返,延迟从本地纳秒级跳到了毫秒级,甚至更高。
- Redis本身要保证高可用,否则Redis挂了,整个写入链路都断了。
- 最怕的是Redis持久化配置不当,主从切换之后计数器回退,直接产生重复ID。
我在生产环境见过不少团队用Redis生成ID,最后都因为可用性或数据一致性要求被迫换掉。Redis适合做缓存、做分布式锁,但把它当成ID发号器,本质上是给核心链路引入了一个外部依赖。
1.3 全局唯一ID必须满足的五条铁律
把自增ID、UUID、Redis自增都排除之后,你会发现,一个合格的全局唯一ID方案,需要同时满足下面几条:
- 全局唯一。这个不用解释,重复就是事故。
- 趋势递增。至少要有序或者近似有序,否则数据库索引扛不住。
- 高可用。不能在生成ID这个环节引入单点,也不能因为外部依赖抖动导致业务不可用。
- 低延迟。生成过程最好只在本机完成,不依赖网络请求。
- 占用空间小。最好是BIGINT(8字节)级别,方便存B+树,也方便上层传输。
把这五条摆在桌面上,你会发现市面上的方案里,雪花算法几乎是唯一一个能全部满足的。它只需要在本地做几次位运算和一次系统时间读取,不需要网络,不需要外部存储,生成的64位长整型天然就是MySQL的BIGINT。正因为如此,分库分表场景下的全局唯一ID,最终几乎都会落到雪花算法头上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 64位雪花ID拆开看:时间位、机器位、序列号位是怎么“分赃”的
2.1 一bit符号位 + 41bit时间戳 + 10bit机器ID + 12bit序列号
雪花算法的核心是一段64位的二进制数字。从高位到低位,它被分成了四段:
| 位段 | 比特数 | 作用 |
|---|---|---|
| 符号位 | 1 bit | 恒为0,保证ID是正数 |
| 时间戳 | 41 bit | 记录当前时间与起始时间twepoch的毫秒差值 |
| 机器ID | 10 bit | 区分不同节点,可以是数据中心+工作机器 |
| 序列号 | 12 bit | 同一毫秒内的自增序列,避免冲突 |
41位时间戳能表示的最大值是2^41 - 1,也就是2199023255551毫秒,换算成年大概是69.7年。这个时间不是从1970年开始算的,而是从一个你自定义的“起始时间twepoch”开始算。很多实现把twepoch设为2010年或者2020年,这样整个算法能用到大几十年的之后,完全够业务用了。
10位机器ID最多支持1024个节点。如果你觉得不够,可以把bit位重新划分,比如拿出更多位给机器ID,但代价是压缩时间戳或序列号的可用范围。12位序列号意味着每个节点在每一毫秒内可以生成最多4096个ID。一个节点每秒的理论上限就是4096乘以1000,也就是409.6万个。
2.2 时间戳为什么必须坐在“最高位”
这是很多人没有细想过的问题。从位运算结果来看,时间戳放在最高位,意味着ID的数值大小主要由时间戳决定。时间越大,ID越大。这样才能保证生成的ID是“趋势递增”的。
如果反过来,把序列号放在最高位,比如一堆机器在同一毫秒内各自生成ID,那它们的ID最大值很可能会被机器号或序列号主导,同一条数据流里的ID顺序就会变得很乱。对MySQL的聚簇索引来说,乱序插入意味着要频繁移动索引页,性能会明显劣化。
另外,时间戳在高位还有一个好处:你可以直接通过右移位运算,从任意一个ID里反解出它的生成时间。定位线上问题的时候,把ID右移22位,加上twepoch,就能算出这条数据是什么时候生成的,不用查数据库就大概知道时间窗口。这个能力是UUID给不了的。
2.3 理论吞吐量:从位运算到每秒百万级
雪花算法另一个被低估的地方是性能。它生成一个ID,只做了几次位运算、一次比较和一次System.currentTimeMillis()调用,整个过程不碰网络。很多年前我刚开始写代码的时候,总觉得要生成全局唯一ID肯定得请求某个中心服务,后来把位运算捋清楚才发现,原来我可以在本机用纳秒级的时间算出ID来。
理论吞吐量可以简单算一下:假设你有1024个节点,每个节点每毫秒生成4096个ID,那整个集群的理论峰值是1024乘以4096乘以1000,大概每秒42亿个。当然,这是纯数字空间的理论值,真实场景中单节点能不能打到每毫秒4096个,取决于你的并发竞争和锁开销,但即使只跑出理论值的十分之一,也足够碾压绝大多数业务了。
在我自己压测中,单线程调用一个带synchronized的雪花生成器,每秒大概能到80万到100万次。这还没做任何高级优化。所以,绝大多数业务系统真正需要担心的不是ID生成器的吞吐,而是它会不会因为时钟回拨而抛异常。
3. 时钟回拨不是黑天鹅,它只是“系统时间”在偷偷开小差
3.1 System.currentTimeMillis()读的是墙钟时间,不是时间大师
要理解时钟回拨,先得明白一件反直觉的事:我们每天看电脑右下角的“系统时间”,看起来又稳定又权威,但它实际上非常脆弱。System.currentTimeMillis()返回的是当前系统的墙钟时间,这个时间来自操作系统维护的内部时钟,而这个时钟可以被人为修改,可以被NTP服务校准,也可能因为虚拟机迁移而跳动。
NTP(Network Time Protocol)是很多服务器上的常驻服务。它会定期跟时间服务器对时,发现本地时间快了或者慢了,就会调整系统时间。如果本地时间比标准时间快了几十毫秒,NTP会把系统时间往回拨。在网络抖动、宿主机负载高、虚拟机迁移这些场景下,这种回拨是很常见的。
关键问题在于,NTP的调整往往不经过应用层。你的Java进程还在老老实实地跑着,但每次调用System.currentTimeMillis()拿到的数值,可能比上一次拿到的要小。
3.2 一次真实的回拨事故复盘
回拨为什么会直接搞坏雪花算法?我们来看核心逻辑。生成器内部会保存一个lastTimestamp,记录上一次生成ID时的时间戳。每次生成新ID时,先拿到当前时间戳,然后跟lastTimestamp比较:
- 如果当前时间戳等于
lastTimestamp,说明还在同一毫秒内,序列号自增。 - 如果当前时间戳大于
lastTimestamp,说明进入了新的一毫秒,序列号清零。 - 如果当前时间戳小于
lastTimestamp,说明系统时间回拨了,此时如果再按这个时间戳生成ID,就会得到跟过去某个时刻完全相同的“时间戳+序列号”组合。
我的那次事故就是这样:某台应用服务器被NTP回拨了大概2.6秒,雪花生成器检测到时间戳小于上次的值,抛了RuntimeException。业务层看到ID生成失败,立刻走了一个降级逻辑:用时间戳加随机数拼了一个“备用ID”。这个备用ID完全没有全局唯一性,两个服务实例在几乎同一毫秒内生成了相同的时间戳、相同的随机数,于是订单号就在数据库主键上炸了。
这个事故给我的教训不是“雪花算法不好”,而是“该处理回拨的地方,我没有处理”。
3.3 即使不重复,时间倒序也会坑你一把
还有一个容易被忽略的副作用。假设你在回拨期间选择了等待,或者用某种方式绕过了重复,时间戳仍然会短暂地“倒退”。这会导致什么问题?新生成的ID反而比之前生成的ID小。
如果你做过分页查询,用的是ORDER BY id DESC,在回拨窗口内新插入的数据就会跑到分页列表的后面去,用户会看到页面数据好像“闪了一下”,明明是新增的数据,却排不到最前面。如果下游还有依赖ID大小做增量同步的,比如从binlog或日志里按ID同步数据,一旦出现时间倒序,同步任务就会漏数据。
所以,时钟回拨处理得当,不只是为了避免主键冲突,还是为了保住“ID随时间单调递增”这个隐含契约。
4. 应对时钟回拨的五种策略:从一刀切拒绝到逻辑时钟兜底
4.1 直接抛异常:唯一性满分,可用性零分
这是Twitter最初版实现的做法。检测到时间戳小于lastTimestamp,直接抛异常,让上层决定怎么处理。从唯一性角度看,这是最安全的,它保证永远不会生成重复ID。但从可用性角度看,这很差劲。NTP校时抖动一次,可能就导致几十毫秒甚至几秒的ID不可用,对在线交易类业务来说是不可接受的。
问题在于,很多团队根本没想到时钟会回拨,他们把抛异常当成了“永远不应该发生”的防御代码,却没想在分布式环境下,系统时间的回拨是“一定会发生”的。所以,除非你的系统能容忍偶发的ID生成中断,并且有严格的降级方案,否则我不建议直接用这个策略。
4.2 短回拨等待+长回拨拒绝:生产环境最稳的默认方案
我自己在线上用的就是这个组合。思路很朴素:把回拨分成两种,极短时间内的回拨(比如10毫秒以内)大概率是NTP抖动,直接让线程睡一会,睡到系统时间超过lastTimestamp再继续生成;如果回拨量超过阈值,说明时间可能被手动改过,或者NTP做过大幅度校准,这时候再抛出异常并告警。
这个方案的好处很明显:
- 对毫秒级抖动免疫,业务无感知。
- 对秒级以上回拨仍然保持警惕,不会生成错误ID。
- 实现成本低,只多了几行判断和一次等待。
代价是:如果回拨量刚刚超过阈值,你仍然需要承担短暂的不可用。但实测下来,绝大多数NTP校时都在几十毫秒以内,这个方案能覆盖掉99%的场景。
4.3 借位给逻辑时钟:物理时间倒流,逻辑序号续命
如果你连“短时间等待”都不能接受,那就得考虑更硬核的方案:把ID里的部分位段拿出来,作为“逻辑时钟计数”。
这个方案的核心思路是,不再严格依赖物理时间戳作为ID排序的唯一依据。当物理时间发生回拨时,借用机器ID位或者序列号位的一部分,让计数器继续递增。比如,回拨期间的ID不再使用真实时间戳,而是沿用lastTimestamp,但把序列号的起始区间抬高,保证不跟回拨前已经生成过的序列号重叠。
这个方案的优点是处理回拨时完全不需要阻塞,生成器可以继续工作。缺点是ID的单调性会受到影响,因为你可能在一个“旧时间戳”下生成了很多ID,等物理时间追上后,时间戳又跳到新的值,这些ID的排序关系会变得很不直观。实现复杂度也高,需要自己控制“逻辑计数区间”的边界。
4.4 重分配workerId的诱惑与陷阱
还有一种思路:发现时钟回拨后,立刻申请一个新的workerId,用新的机器ID继续生成ID,因为机器ID变了,即使时间戳倒退,最终生成的ID也不太可能重复。
这个方案理论上可行,但陷阱非常多。最核心的问题是:workerId不能立即复用。如果旧workerId已经生成过ID,而你又把它重新分配给了另一台机器,一旦时间戳重叠,就存在重复的可能。所以你需要一个全局协调者来管理workerId的分配和回收,而且要记录每个workerId“最后使用时间”,确认安全后才能复用。
这和“把简单问题复杂化”往往只隔一线。除非你的公司已经有配置中心、有完善的分布式协调机制,否则为了处理一个本来就可以用“等待”解决的时钟回拨,去引入一套workerId动态分配体系,性价比很低。
4.5 真时间服务与HLC:把问题抛给更复杂的系统
如果你把视角再抬高一层,会发现时钟回拨的本质是“分布式系统中不存在全局一致的物理时间”。业界对这个问题的终极解法,是放弃“精确时间”这个假设,转而使用逻辑时钟或混合逻辑时钟。
Google的TrueTime返回的不是一个精确时间点,而是一个时间区间。这个方案需要原子钟和GPS支持,普通人想都不用想。HLC(Hybrid Logical Clock)则是把物理时间和逻辑计数器结合,能够在网络分区、消息乱序的情况下维持因果关系,代价是实现复杂度极高。
我的建议是:除非你在做一个全球级分布式数据库,否则不要跳进这个坑。99%的业务,用“短回拨等待+长回拨拒绝”就够了。
最后用一张表把这五种策略放在一起对比:
| 策略 | 处理回拨时是否阻塞 | 唯一性保障 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 直接抛异常 | 是 | 极高 | 低 | 可接受短暂不可用的离线系统 |
| 短等待+长拒绝 | 短时间阻塞 | 高 | 低 | 绝大多数在线业务 |
| 逻辑时钟借位 | 否 | 较高 | 高 | 对阻塞零容忍的高并发系统 |
| 重分配workerId | 否 | 取决于管理逻辑 | 高 | 有配置中心的大型团队 |
| TrueTime/HLC | 否 | 高 | 极高 | 全球级分布式系统 |
5. 一份扛住生产的雪花算法实现:代码、注解与踩坑
5.1 完整实现:带10毫秒回拨兜底的SnowflakeIdGenerator
直接贴一份我改过多次的Java实现。它不花哨,但把时钟回拨、序列号溢出、线程安全这几个关键点都处理了。
java复制import java.util.concurrent.locks.LockSupport;
public class SnowflakeIdGenerator {
/** 起始时间戳:请改成你业务上线的时间,单位毫秒 */
private static final long TW_EPOCH = 1725148800000L;
/** 机器ID占用10位,最多1024个节点 */
private static final long WORKER_ID_BITS = 10L;
/** 序列号占用12位,每毫秒最多4096个 */
private static final long SEQUENCE_BITS = 12L;
/** 最大机器ID:1023 */
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
/** 序列号掩码:4095 */
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
/** 机器ID左移位数 */
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
/** 时间戳左移位数 */
private static final long TIMESTAMP_SHIFT = WORKER_ID_BITS + SEQUENCE_BITS;
/** 允许的最大回拨间隔,单位毫秒 */
private static final long MAX_BACKWARD_MS = 10L;
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 must between 0 and " + MAX_WORKER_ID);
}
this.workerId = workerId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 处理时钟回拨
if (timestamp < lastTimestamp) {
long backwardMs = lastTimestamp - timestamp;
if (backwardMs <= MAX_BACKWARD_MS) {
// 小范围回拨:睡到追上上次生成的时间,加1毫秒保证严格大于
LockSupport.parkNanos((backwardMs + 1) *
