凌晨两点半,我正在工位上盯着屏幕上一行行滚动日志,眼皮打架但脑子清醒得可怕。数据库唯一键冲突的报警已经断断续续持续了两个小时,从最初的“偶发”变成了“频繁”。测试环境、预发环境,凡是对外发布的接口,几乎无一幸免。更要命的是,所有冲突的字段,都指向同一个字段:ID。我扒开日志里的具体值,看到了一批数字相近、但逻辑上完全不应该重复的ID。那一刻,我脊背发凉——雪花算法生成的ID,居然真的重复了。这个事故让我花了一整夜排查,也让我彻底想明白了一件事:ID生成这种基础组件,真的不要轻易造轮子,除非你已经把里面每一个坑都踩了一遍。
这篇复盘,我会把整个事故的前因后果、雪花算法的位段原理、自研实现常见的翻车点、以及我当时一步步定位和修复的过程,完整记录下来。如果你也在用自己封装的雪花算法,或者在纠结“网上这么多代码,我抄一个改一改能用吗”,这篇文章值得你花十分钟看完。
1. 从一次深夜事故说起:雪花算法ID为什么会重复
1.1 事故现场:第一波报警和错误初判
那天晚上的第一波报警来自于一个内部管理系统的写入接口。调用方反馈,批量导入数据时,明明每次都是新数据,但数据库反复抛出类似这样的异常:
### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException: ### Duplicate entry '7143282793184997376' for key 'orders.id' ### The error occurred while handling a batch insert of 200 records. ### Cause: java.sql.SQLIntegrityConstraintViolationException: ### Duplicate entry '7143282793184997376' for key 'orders.id'冲突的ID是7143282793184997376,和它冲突的另一条记录ID是7143282793184997377。注意,两个ID只差1,但它们应该属于两条完全不同的订单。我当时第一反应是:这会不会是数据库主从不同步?或者哪段代码用了固定的ID?再或者缓存穿透把同一个请求打到了接口上?
于是我先查了一圈:确认没有显式传ID的代码、确认数据库没有触发器改写主键、确认缓存层没有命中同一个Key。什么都查了,就是没怀疑到ID生成器身上——因为这是我之前从开源项目里“抄”过来、自认为已经理解透的雪花算法实现。
1.2 矛盾点:单实例测试却又复现
更诡异的是,我在本地单机起服务,用并发工具疯狂压这个接口,居然也能复现。这就奇怪了:单机单进程、没有多实例部署,为什么会重复?
后来我冷静下来,把重复的ID放在一起看,发现一个规律:冲突的ID基本都集中在某个很小的区间内,而且是毫秒级连续生成的。比如上面这两个ID,时间戳和序列号看起来都很接近,明显是同一毫秒内产生的。这时候我把目光重新投向了那段“雪花算法”代码,开始逐行看实现,结果发现里面有一个非常经典的错误:序列号的循环使用条件写错了——在同一毫秒内序列号达到最大值后,不是阻塞等待下一秒,而是直接归零重新生成。
这一下全对上了:高并发时同一毫秒内超过4096个请求,序列号绕了一圈回来,和这一毫秒早先生成的ID完全撞车。好家伙,这不是并发竞争,这是算法实现本身的边界没处理好。
2. 先吃透64位:雪花算法结构拆解与设计意图
在我给出完整的修复方案前,我需要先带你把雪花算法这位“当事人”研究明白。很多人抄了代码但没理解位段设计,所以踩坑都踩得稀里糊涂。
2.1 64位布局:每一段都是有用意的
标准雪花算法(Twitter Snowflake风格)生成的ID是一个64位Long型整数,通常这样分配:
| 位段 | 长度 | 含义 |
|---|---|---|
| 符号位 | 1 bit | 固定为0,保证ID为正数 |
| 时间戳 | 41 bit | 毫秒级时间戳(相对自定义纪元的偏移) |
| 数据中心ID | 5 bit | datacenterId,最多32个 |
| 机器ID | 5 bit | workerId,最多32个 |
| 序列号 | 12 bit | 同一毫秒内自增序列,最多4096个 |
这种分配方案的设计意图很直接:时间戳保证ID大致有序,数据中心和机器ID保证分布式环境下不同节点不冲突,序列号保证单节点单毫秒内有足够的吞吐。三者合在一起,64位正好容纳,且大致有序、紧凑、高性能。
2.2 关键位段的作用与容量边界
我们逐段细看:
41位时间戳:以自定义纪元(epoch)为起点计算毫秒偏移量。41位二进制能表示的最大值是
2^41 - 1 = 2199023255551毫秒,大约是69年。如果从Twitter最初的1288834974657(2010-11-04)起算,可以支撑到2080年左右;但如果有人把epoch设置成一个很近的时间点甚至0,那么时间戳位能覆盖的年份范围就会大幅缩短,极端情况下可能在项目上线几年内就“爆掉”。我在事故当晚就排查过这个参数——好在我那版用的是正常epoch,不然还得再申请一次灾。10位机器信息(datacenterId + workerId):这10位决定了最多支持
32 * 32 = 1024个节点。注意这是上限,但部署时如果超过1024个实例,ID就有概率碰撞。而且,如果配置得不严谨,比如两个实例拿到了相同的datacenterId和workerId,那么就算当前只有两个节点,也会直接撞车。12位序列号:每毫秒最多生成4096个ID。如果在同一毫秒内要生成超过4096个,就必须等下一毫秒。
这三段中任何一段出问题,都会导致ID重复或趋势错乱。但现实中的自研实现里,几乎每一段都有对应的“坑位”,下面我逐个说。
3. 为什么会重复:自研雪花算法最常见的几个翻车点
3.1 时间回拨:你永远猜不到系统时间会怎么跳
雪花算法高度依赖系统时钟。如果机器的当前时间比上一次生成ID时的时间早,那么新生成的时间戳就会变小,这直接导致两件事:一是新ID可能小于旧ID,破坏有序性;二是时间戳一旦回到之前用过的值,再加上相同的机器ID和序列号,就会产生和过去完全相同的ID。
哪些情况会造成时间回拨?实际生产里我遇到过的就有:
- 运维手动校准系统时间,把时间从未来调回现在;
- NTP客户端自动校时,发现偏差过大进行跳跃式同步;
- 虚拟机或容器从快照恢复,系统时间回退到快照时刻;
- 物理机电池没电、BIOS时间异常,开机后时间倒退。
成熟的实现一般会有回拨处理策略:要么拒绝生成并抛出异常或阻塞等待,要么记录最后的生成时间并在回拨时用备用方案。但我见到的不少自研雪花,只是简单判断了一下当前时间戳是否小于上一次时间戳,然后随便continue或return null。打日志一看,错误根本没被正确处理,甚至把错误吞掉了,直接继续往下生成。
3.2 workerId/datacenterId分配混乱:多个实例共用一套机器标识
这是这次事故里最让我无语的坑。我的那版“改写过”的代码里,机器ID是这么算的:
workerId := int(ipSeg[3]) % 32 datacenterId := int(ipSeg[2]) % 32在单机测试时,IP是127.0.0.1,得到的workerId是1 % 32 = 1;服务部署到测试环境后,两台机器的IP后一段分别是0.10和0.10——没错,它们处于同一个NAT网段,解析到外层IP是一模一样的。于是,两台服务实例用了完全相同的(datacenterId, workerId)。只要这两台机器在同一毫秒内各自生成了相同序列号的ID,就必然重复。
类似的问题在容器化部署中更容易发生:K8s里多个Pod如果没有显式注入节点标识,只靠hostname或IP取模,很容易撞车。因为容器看到的IP可能是同一个NAT出口IP,hostname也可能是随机的但截断后相同。
正确做法是把workerId的分配交给一个可靠的注册中心(如ZooKeeper、Redis、数据库自增),或者在部署时通过环境变量强制配置。无论用哪种方式,目标是保证“全局唯一”而不是“大概唯一”。
3.3 序列号溢出与并发边界:一毫秒并不总能扛住4096个ID
序列号的作用就是解决同一毫秒内多个并发请求的ID唯一性。但它的容量是有限的,超过4096就必须阻塞至下一毫秒。很多自研实现的问题在于:
- 没有加锁或没有用原子操作:当多个线程同时进入方法时,读到了同一个当前序列号。
- 序列号达到4096后处理错误:有些实现直接序列号重置成0,而不是等到下一毫秒;更有甚者把序列号最大值判断写错,导致在溢出边界反复生成冲突ID。
我事故里的那版代码就是典型的第二种错误——错误处理时的逻辑让序列号直接归0,并且不等待下一毫秒。在批量导入场景下,200条数据的插入在同一毫秒内很容易超过4096的上限,于是序列号立刻“回头”,与这一毫秒稍早产生的ID撞车。
3.4 业务量预估错误:你以为的极限不是极限
有人说:“我们的接口峰值也就每秒几千,每毫秒怎么可能超过4096?”——那你低估了批量写入、重试、定时任务叠加的威力。比如批量导入,一个批就有几百上千条,内部循环到ID生成器的时候,不只是每秒N次请求,而是每次请求内循环N次。毫秒级局部流量完全可能冲破4096。
我当时属于第三种和第二种叠加:序列号溢出逻辑错误 + 批量导入高频调用,两个条件凑在一起,直接变成稳定复现的线上BUG。
我把自研实现里常见的错误和成熟方案的处理方式放在一起对比一下:
| 自研常见错误 | 后果 | 成熟方案通常怎么处理 |
|---|---|---|
| 时间回拨不处理或吞异常 | 生成过期时间戳的ID,可能冲突/乱序 | 屏蔽并缓存未来时间/等待/拒绝服务 |
| 用IP或hostname取模分配workerId | 多实例复用同一机器位,直接冲突 | 注册中心/配置中心统一分配,数据库自增预留 |
| 序列号溢出时归零继续 | 同一毫秒内ID冲撞 | 自旋等待至下一毫秒,必要时让位 |
| 没有互斥锁或原子类 | 并发下多个线程取出相同序列号 | CAS、synchronized、atomic类保证原子性 |
| epoch设置错误或符号位误用 | ID范围缩短、可能出现负数 | 严格校验符号位,规范epoch基准 |
| 不监测时钟偏差 | 长时间漂移后在调校时踩雷 | 定期上报当前时间/时钟偏差,异常预警 |
4. ID重复定位过程:从报错到逐位拆解实操记录
如果你也遇到了ID重复但不确定原因,我建议严格按照下面这套流程来排查,非常有效。这套方法帮我从“什么都像原因”到“锁定唯一元凶”。
4.1 抓取冲突样本:先把证据留下来
当数据库抛出唯一键冲突时,异常信息里通常会带上冲突的ID值。比如MySQL的Duplicate entry 'xxx' for key 'PRIMARY',那个xxx就是ID。但线上日志刷得极快,异常转瞬即逝,建议应用层额外做一件事:catch住唯一键冲突异常,把异常里的ID、时间、业务参数、堆栈原样记录到专门的日志文件或日志表。
我当时是写了一个简单的AOP切面,在所有DAO层插入方法上捕获DuplicateKeyException,把错误线程栈和参数打成WARN日志,这样能拿到足够多的冲突样本。
4.2 二进制位拆解“三步法”:把ID的每一段剥出来
当你拿到疑似重复的两个ID后,不要盯着大数字看,用程序把它们拆成二进制,再按64位布局切分。下面是我事故后写的一个小脚本(Python示例),你也可以直接用IDE的进制查看功能:
def parse_snowflake_id(snow_id, epoch_ms=1288834974657): # 假设标准布局:1位符号 + 41位时间戳 + 5位数据中心 + 5位机器 + 12位序列 binary = f"{snow_id:064b}" sign = binary[0] timestamp_bin = binary[1:42] datacenter_bin = binary[42:47] worker_bin = binary[47:52] sequence_bin = binary[52:64] timestamp_ms = int(timestamp_bin, 2) + epoch_ms import datetime dt = datetime.datetime.fromtimestamp(timestamp_ms / 1000) return { "binary": binary, "sign": sign, "timestamp_bin": timestamp_bin, "datacenter_id": int(datacenter_bin, 2), "worker_id": int(worker_bin, 2), "sequence": int(sequence_bin, 2), "datetime": dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], "timestamp_ms": timestamp_ms } # 使用示例 id_a = 7143282793184997376 id_b = 7143282793184997377 print(parse_snowflake_id(id_a)) print(parse_snowflake_id(id_b))输出结果类似这样:
{'binary': '...', 'sign': '0', 'datacenter_id': 1, 'worker_id': 1, 'sequence': 0, 'datetime': '2024-...'} {'binary': '...', 'sign': '0', 'datacenter_id': 1, 'worker_id': 1, 'sequence': 1, 'datetime': '2024-...'}看到没?两个ID的datacenter_id和worker_id都是(1, 1),时间戳完全相同,序列号分别是0和1。这立刻说明了两个事实:第一,这两个ID确实在同一毫秒内产生;第二,产生它们的“机器标识”是一样的。再加上当时服务是多实例部署,答案几乎呼之欲出——两个实例用了一样的(datacenterId, workerId),而且序列号从0开始,于是各自生成了同一毫秒编号为0、1的ID,互相撞了。
4.3 时间戳反查:把ID还原成具体时刻
如果你想知道“这个ID到底是在哪个精确时间生成的”,计算公式很简单:
timestamp_ms = (snow_id >> 22) + epoch_ms也就是把二进制时间戳位段转成十进制,再加上你实现里定义的epoch偏移量。这个推算在事故复盘时很有用:你可以把重复ID对应的时间,和应用日志、NTP校时记录、部署记录做交叉对比,判断到底是时间回拨、机器位冲突,还是序列号溢出。
当时我用这条公式反推出所有冲突ID都集中在某个批量导入任务的执行时间内,进一步印证了“批量高频调用 + 错误边界逻辑”的结论。
5. 我不是不让你造轮子:但请按工程标准造
5.1 成熟方案与自研的边界在哪
很多人一看到“请勿轻易造轮子”就理解成“永远不要自己实现”。其实不是。我私以为:造轮子最大的问题不是写不出可运行的代码,而是写不出扛得住生产环境的代码。
成熟方案经历过大流量考验,踩过无数边界,比如:
- 标准雪花算法(Twitter Snowflake):最原始的参考实现,但需要自己处理时钟回拨和workerId分配;
- 美团Leaf:腾讯、美团等大厂实践沉淀,支持号段模式与雪花模式,提供HTTP服务对外发号,并且有完善的回拨处理(如用ZooKeeper持久化、缓存上次时间等);
- 百度UidGenerator:基于Snowflake的变体,使用
workId占用更多位,并借助数据库为每个Worker分配唯一ID; - 滴滴Tinyid:更偏号段模式,适合对性能要求较高的发号场景;
- sonyflake:用时间区间代替毫秒时间戳,在流量较小的场景更省位。
它们在关键边界上的处理明显比“临时抄一段代码”要严谨得多。下表是它们大致的差异:
| 方案 | 时钟回拨处理 | workerId分配 | 部署要求 | 适用场景 |
|---|---|---|---|---|
| 标准Snowflake | 由使用者额外处理 | 需要自建分配机制 | 单机/简单分布式 | 中小规模,需快速上手 |
| 美团Leaf | 内置处理,ZooKeeper保证 | 注册中心下发 | 依赖ZK | 大流量、高可用 |
| 百度UidGenerator | 内置容忍策略 | 数据库分配 | 依赖MySQL | 对时间回拨敏感的金融类场景 |
| 滴滴Tinyid | 不依赖时间 | 基于数据库号段 | 部署独立服务 | 高性能发号、订单号等 |
5.2 如果坚持自研,必须满足的底线
我知道总有人会继续选择自研(我自己现在也会在非核心模块里造轮子练手),所以我给出我事故后总结的“自研必备底线清单”:
- 必须有互斥或原子操作:同一进程内,序列号增长必须线程安全;
- 必须处理时间回拨:当检测到当前时间小于最后一次生成时间时,至少要等待到时钟追上,或直接拒绝生成、抛出明确异常,绝不能静默吞掉;
- 必须校验机器ID唯一性:不能依赖IP取模这种“看起来随机”的方式,建议通过Redis/数据库自增/配置中心启动时申请,并在进程内部缓存;
- 必须处理序列号溢出:达到4096后必须自旋等待下一秒;
- 必须有监控和告警:别等到DB唯一键冲突才感知。监控每毫秒序列号使用率、时钟回拨次数、生成速率上限,超过阈值立即告警;
- 必须留DuplicateKey兜底:在使用雪花ID当主键的表上继续保留唯一索引,即便算法有bug,也不会在数据库层面产生脏数据。
上面几点看起来简单,但每一个都对应着一类真实的线上事故。如果你自研的实现不具备这些,那就不是“简化版”,而是“炸弹简化版”。
下面是一个带基本防护的简化示例(Go语言思路):
type Snowflake struct { mu sync.Mutex lastTs int64 dcId int64 workerId int64 sequence int64 } func (s *Snowflake) NextID() (int64, error) { s.mu.Lock() defer s.mu.Unlock() ts := time.Now().UnixMilli() if ts < s.lastTs { // 时钟回拨:等待追平,而不是直接生成 diff := s.lastTs - ts if diff > 5 { // 回拨超过5ms,直接报错 return 0, fmt.Errorf("clock moved backwards: %d ms", diff) } time.Sleep(time.Duration(diff) * time.Millisecond) ts = time.Now().UnixMilli() } if ts == s.lastTs { s.sequence = (s.sequence + 1) & 4095 if s.sequence == 0 { // 序列号溢出,等到下一毫秒 for ts <= s.lastTs { ts = time.Now().UnixMilli() } } } else { s.sequence = 0 } s.lastTs = ts id := (ts << 22) | (s.dcId << 17) | (s.workerId << 12) | s.sequence return id, nil }5.3 我最终是怎么收场的
那次事故最终的修复方案,我没有选择修复自研代码了事——虽然修复起来不难,但我对这套实现已经彻底失去了信心。我直接把核心发号组件替换成了基于美团Leaf思路的成熟实现,并且把workerId的分配改成了启动时从Redis申请一个全局唯一值。同时,在数据库表上保留了唯一索引作为最后一道防线,并把“重复ID率”“时钟回拨次数”“序列号使用率”三个监控指标接入了告警。
结果非常明显:替换之后,再没有出现过ID冲突。而且因为监控到位,后来有一次某台容器时钟漂移,还没等影响业务,告警就先打到了我手机上。
6. 最后分享两个实操心得
这个事故让我彻底改变了对待基础组件的态度。我现在看到任何“XX算法实现代码”的开源文章,第一反应都是:先看它怎么处理边界,再看它怎么测试。你能在文章里把正常流程跑通,不代表你处理得了回拨、溢出、多实例分配。
心得一:当你想自研一个基础组件时,先列一个“我可能踩的坑”清单。如果你只能列出两三个坑,说明你还没准备好,千万别动手。真正靠谱的做法是把边界写进测试用例里,比如在代码里注入一个假的时钟,手动回拨一秒,看看组件是拒绝服务还是默默生成重复ID。我当时如果提前做了这个测试,后面那通宵就不用熬了。
心得二:ID生成器的监控比ID生成器本身更重要。一次ID重复,如果没被唯一约束挡住,可能直到数据变成脏数据、报表对不上、客户投诉才被发现。给ID生成器增加“生成量突刺”“时钟偏差”“重复率”三块监控,这种投入的性价比极高。
雪花算法本身是好东西,我只是想说:把好东西用坏,往往是认知不到位。希望这篇复盘能帮你避开我踩过的那个坑,也让你在下次看到一张雪花算法代码图时,多留一个心眼。