1. 从一个“看起来很简单”的需求说起
分布式系统里生成一个不重复的 ID,这件事听起来像是入门级需求,但真正在线上跑过高并发业务的人都清楚,它是个不折不扣的深水区。我最早接触这个问题是在一个订单系统里,当时用的是数据库自增主键,单库单表跑得好好的,日订单量也就几十万。后来业务扩张,订单表要分库分表,问题立刻暴露出来:两个库各自从 1 开始自增,订单号直接撞车,用户看到两个不同的订单拥有同一个编号,客服系统里查出来的记录张冠李戴。那次事故之后,我开始系统性地研究全局唯一 ID 的各种方案,踩过的坑、算过的账、半夜被叫起来排查的重复 ID 问题,加起来能写一本小册子。
这篇文章就是把这些年关于全局唯一 ID 的实战经验整理出来。核心关键词是全局唯一ID、分布式ID生成、高并发ID方案、雪花算法、号段模式。我会从方案选型的底层逻辑讲起,把每种方案的适用场景、性能边界、坑点都拆开揉碎,再给出可以直接参考的实操配置和代码骨架。不管你是刚接触分布式系统的新手,还是正在为 ID 生成方案头疼的资深开发,应该都能从中找到能直接用的东西。
先说清楚这个问题的本质:全局唯一 ID 要解决的不是“怎么生成一个不重复的数字”,而是“在分布式环境下,多个节点同时生成 ID 时,如何保证不重复、趋势递增、性能足够高、且不依赖单点”。这四个约束条件互相拉扯,没有一种方案能同时完美满足所有要求,所以选型的本质是在这些约束之间做取舍。
2. 全局唯一ID的核心需求与方案选型逻辑
2.1 一个合格的全局唯一ID到底要满足什么
很多人一上来就问“用雪花算法还是号段模式”,但我觉得应该先退一步,把需求理清楚。根据我实际踩坑的经验,一个合格的全局唯一 ID 至少要满足以下几个维度的要求,而且不同业务对这些维度的优先级完全不同。
唯一性是底线。这个不用多说,ID 重复意味着数据错乱,在订单、支付、账务这类场景里是灾难性的。但唯一性的实现方式有区别:是严格全局唯一,还是业务维度唯一?比如订单号在订单系统内唯一就够了,不需要和用户 ID 共享一个全局空间。
趋势递增。这个需求经常被低估。为什么 ID 要趋势递增?因为大多数数据库的索引结构是 B+ 树,如果 ID 是完全随机的,每次插入都会导致页分裂和随机 IO,写入性能会断崖式下跌。我实测过,在 MySQL InnoDB 里,用 UUID 做主键的写入吞吐量大概只有自增 ID 的三分之一到五分之一,数据量越大差距越明显。所以趋势递增不是为了好看,是实打实的性能需求。
高性能与低延迟。高并发场景下,ID 生成往往是关键路径上的操作。如果生成一个 ID 要花 10 毫秒,那单机 QPS 上限就被锁死在 100 左右,再多的机器也救不了。一般来说,本地生成的方案能做到微秒级,依赖远程调用的方案要做到毫秒级以内。
高可用。ID 生成服务挂了,整个业务就写不进去了。所以方案里不能有单点,或者单点故障后要有快速降级和恢复机制。
信息安全。有些业务场景下,ID 是暴露给用户的,比如订单号。如果订单号是连续的,竞争对手下两单就能算出你一天的订单量。所以这类场景需要 ID 不可预测,或者至少不能轻易推算。
可读性与业务含义。有些团队喜欢在 ID 里嵌入时间、业务类型、分片信息,方便排查问题。但这会增加复杂度,需要权衡。
下面这张表是我总结的不同业务场景对 ID 需求的优先级排序,选型时可以先对号入座:
| 业务场景 | 唯一性 | 趋势递增 | 性能要求 | 信息安全 | 典型方案 |
|---|---|---|---|---|---|
| 订单号 | 严格 | 中等 | 极高 | 高 | 雪花算法+混淆 |
| 用户ID | 严格 | 低 | 高 | 中 | 号段模式 |
| 日志追踪ID | 严格 | 低 | 极高 | 低 | UUID/雪花 |
| 账务流水号 | 严格 | 高 | 高 | 高 | 数据库自增+步长 |
| 消息ID | 严格 | 低 | 极高 | 低 | 雪花算法 |
2.2 为什么数据库自增主键在分布式下会失效
在单库单表时代,AUTO_INCREMENT是最省心的方案,性能好、趋势递增、实现简单。但一旦分库分表,它的局限性就暴露无遗。
最直接的问题是跨库冲突。两个库各自维护自己的自增序列,生成的 ID 必然重复。有人会说,那我把步长设成库的数量不就行了?比如两个库,一个从 1 开始步长 2,一个从 2 开始步长 2。这个方案确实能解决冲突,但它有几个致命缺陷。
第一,扩容困难。假设你一开始规划了 2 个库,步长设为 2。后来业务增长要扩到 4 个库,步长要改成 4,但已经生成的 ID 序列和新的步长对不上,需要停机迁移数据。我见过一个团队因为这个问题,扩容时整整停服了 6 个小时。
第二,性能瓶颈。所有 ID 生成都要写数据库,数据库的写入能力是有上限的。单库 MySQL 在优化得当的情况下,写入 QPS 大概在几千到一万左右,再高就要靠分库。但 ID 生成本身又需要全局协调,这就形成了一个矛盾。
第三,依赖数据库可用性。数据库挂了,ID 就生成不了,业务直接停摆。虽然可以用主从切换,但切换期间仍然有不可用窗口。
所以数据库自增方案只适合小规模、对可用性要求不极端的场景。一旦上了规模,就必须换方案。
2.3 主流方案的全景对比
在深入每种方案之前,先用一张表把主流方案的核心指标拉通对比,这样你心里有个全局图景:
| 方案 | 唯一性保证 | 趋势递增 | 性能(单机QPS) | 可用性 | 信息安全 | 实现复杂度 |
|---|---|---|---|---|---|---|
| 数据库自增 | 单库唯一 | 强 | 数千 | 低 | 差 | 低 |
| 数据库步长 | 全局唯一 | 强 | 数千 | 低 | 差 | 低 |
| UUID | 全局唯一 | 无 | 极高 | 高 | 好 | 极低 |
| 雪花算法 | 全局唯一 | 强 | 百万级 | 高 | 中 | 中 |
| 号段模式 | 全局唯一 | 强 | 十万级 | 中 | 中 | 中 |
| Redis自增 | 全局唯一 | 强 | 十万级 | 中 | 中 | 低 |
| 美团Leaf | 全局唯一 | 强 | 百万级 | 高 | 中 | 高 |
这张表里的性能数据是我在 4 核 8G 的机器上实测的粗略值,实际会因配置和实现细节有差异。可以看到,没有一种方案在所有维度上都占优。雪花算法在性能和趋势递增上表现最好,但依赖时钟;号段模式性能不错且不依赖时钟,但需要数据库配合;UUID 性能最高但完全没有趋势递增。
选型的核心逻辑是:先确定你的业务对哪个维度最敏感,然后在这个维度上找最优解,同时确保其他维度不踩红线。比如订单系统对信息安全敏感,那就要在雪花算法基础上做混淆;日志系统对性能敏感但对信息安全不敏感,UUID 就够用。
3. 雪花算法:高性能场景的首选,但时钟是命门
3.1 雪花算法的结构拆解与参数计算
雪花算法的核心思想是把一个 64 位的长整型拆成几段,每段代表不同的含义,拼在一起就保证了全局唯一。标准的雪花算法结构是这样的:
- 1 位符号位,固定为 0,保证 ID 为正数
- 41 位时间戳,单位是毫秒,从某个起始时间开始算
- 10 位工作机器 ID,可以拆成 5 位数据中心 ID + 5 位机器 ID
- 12 位序列号,同一毫秒内的自增计数
这个结构不是随便定的,每一段的位数都是算出来的。41 位时间戳能表示 2 的 41 次方毫秒,换算成年是约 69.7 年。也就是说,从你设定的起始时间开始,这个算法能用将近 70 年。如果你的系统生命周期没这么长,可以适当减少时间戳位数,把省下来的位数给机器 ID 或序列号。
10 位机器 ID 意味着最多支持 1024 个节点。如果你的集群规模超过这个数,要么扩展机器 ID 位数,要么用其他方案。12 位序列号意味着同一毫秒内每个节点最多生成 4096 个 ID,也就是单节点理论 QPS 上限是 4096 * 1000 = 409.6 万。这个数字看起来很夸张,但实际受限于系统调用和网络开销,单机跑到百万级已经很不错了。
我实际用过的配置里,比较稳妥的是把机器 ID 扩展到 12 位(支持 4096 个节点),序列号保持 12 位,时间戳保持 41 位,这样总位数还是 64 位,但节点容量翻了两番。代价是时间戳的起始点要往后挪,可用年限会缩短,但对于大多数业务来说,30 年足够了。
3.2 时钟回拨:雪花算法最大的坑
雪花算法最让人头疼的问题就是时钟回拨。什么是时钟回拨?简单说就是服务器的时间突然往回跳了。原因可能是 NTP 同步、闰秒调整、虚拟机迁移,甚至人为修改系统时间。
时钟回拨为什么致命?因为雪花算法依赖时间戳递增来保证 ID 递增。如果时间回拨了,新生成的时间戳可能比之前的小,那生成的 ID 就可能和之前的重复。我遇到过最严重的一次,某台机器的时钟回拨了 3 秒,导致那 3 秒内生成的 ID 全部和之前重复,数据库主键冲突,写入全部失败。
处理时钟回拨有几种常见策略,我按推荐程度排序:
策略一:小回拨等待,大回拨报警。如果回拨时间在可接受范围内(比如 5 毫秒以内),就自旋等待,直到时间追上上次生成的时间戳。如果超过阈值,直接抛异常并报警,让运维介入。这是最稳妥的做法,因为大回拨往往意味着系统出了问题,强行生成 ID 可能掩盖更大的故障。
策略二:备用机器 ID。每台机器预留几个备用机器 ID,检测到时钟回拨时,切换到备用 ID 继续生成。这样 ID 不会重复,但会破坏趋势递增。适合对递增要求不严格的场景。
策略三:使用逻辑时钟。不依赖系统时间,而是维护一个单调递增的逻辑时钟。每次生成 ID 时逻辑时钟加一,而不是读系统时间。这样彻底避免了时钟回拨,但逻辑时钟的持久化和恢复是个问题,重启后要保证逻辑时钟不回退。
我个人的经验是,策略一配合完善的监控是最实用的。在代码里加一个时钟回拨检测,回拨超过 5 毫秒就记录详细日志并触发告警,同时拒绝生成 ID。这样虽然会短暂影响可用性,但避免了数据错乱,两害相权取其轻。
3.3 雪花算法的代码骨架与配置要点
下面是一个经过生产验证的雪花算法实现骨架,用 Java 写的,关键地方我都加了注释:
public class SnowflakeIdGenerator { // 起始时间戳,2024-01-01 00:00:00 private static final long START_TIMESTAMP = 1704038400000L; // 各部分位数 private static final long SEQUENCE_BITS = 12L; private static final long WORKER_ID_BITS = 10L; // 最大值 private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 位移量 private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; private final long workerId; private long sequence = 0L; private long lastTimestamp = -1L; // 时钟回拨容忍阈值,单位毫秒 private static final long MAX_BACKWARD_MS = 5L; public SnowflakeIdGenerator(long workerId) { if (workerId < 0 || workerId > MAX_WORKER_ID) { throw new IllegalArgumentException("workerId out of range"); } this.workerId = workerId; } public synchronized long nextId() { long currentTimestamp = System.currentTimeMillis(); // 时钟回拨检测 if (currentTimestamp < lastTimestamp) { long offset = lastTimestamp - currentTimestamp; if (offset <= MAX_BACKWARD_MS) { // 小回拨,等待 try { Thread.sleep(offset + 1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } currentTimestamp = System.currentTimeMillis(); if (currentTimestamp < lastTimestamp) { throw new RuntimeException("Clock moved backwards, refusing to generate id"); } } else { throw new RuntimeException("Clock moved backwards too much: " + offset + "ms"); } } if (currentTimestamp == lastTimestamp) { sequence = (sequence + 1) & MAX_SEQUENCE; if (sequence == 0) { // 当前毫秒序列号用尽,等待下一毫秒 currentTimestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = currentTimestamp; return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; } private long waitNextMillis(long lastTimestamp) { long timestamp = System.currentTimeMillis(); while (timestamp <= lastTimestamp) { timestamp = System.currentTimeMillis(); } return timestamp; } }这段代码有几个关键点需要注意。nextId方法加了synchronized,因为序列号是共享状态,多线程并发时必须加锁。如果对性能有极致要求,可以用ThreadLocal或者 CAS 来优化,但大多数场景下synchronized的开销可以接受。
waitNextMillis里的自旋等待是必要的,因为同一毫秒内序列号用尽后,必须等到下一毫秒才能继续生成。这个自旋时间通常很短,不会成为瓶颈。
起始时间戳的选择有讲究。不要用 1970 年,因为那样时间戳部分会很快用完。也不要用当前时间,因为一旦设定就不能改,改了会导致 ID 重复。一般选一个最近的、有纪念意义的日期,比如系统上线日期。
3.4 雪花算法的部署与运维经验
雪花算法部署时最大的问题是机器 ID 的分配。1024 个节点,怎么保证每个节点的 ID 不重复?我见过几种做法,各有优劣。
手动配置是最简单的,在配置文件里写死。但集群规模大了之后,手动分配容易出错,而且扩容时要改配置重启。
基于 IP 自动计算是常见做法,取 IP 地址的后几位做机器 ID。但问题是容器环境下 IP 会变,而且不同网段的 IP 后几位可能冲突。
基于注册中心分配是最可靠的,服务启动时向注册中心申请一个机器 ID,注册中心保证不重复。但引入了额外依赖,注册中心挂了服务就起不来。
我实际用过的方案是:基于 ZooKeeper 或 etcd 的临时顺序节点分配机器 ID。服务启动时在指定路径下创建一个临时顺序节点,节点序号就是机器 ID。服务下线后节点自动删除,ID 可以被复用。这个方案的好处是自动化、无冲突、支持动态扩缩容。缺点是依赖外部组件,但 ZooKeeper 和 etcd 本身就是高可用组件,风险可控。
还有一个容易被忽略的点:时钟同步。雪花算法依赖系统时间,如果集群内机器时间不一致,生成的 ID 可能乱序。所以必须配置 NTP 同步,并且监控各机器的时间偏差。我一般会把时间偏差告警阈值设在 10 毫秒,超过就排查。
4. 号段模式:不依赖时钟的稳健选择
4.1 号段模式的核心原理与优势
号段模式(Segment)的思路和雪花算法完全不同。它不依赖时间戳,而是从数据库批量获取一段 ID 区间,然后在内存里分配。比如一次从数据库取 1000 个 ID,内存里慢慢发,发完了再取下一段。
这个方案的核心优势是不依赖时钟,所以没有时钟回拨问题。同时性能很高,因为大部分 ID 生成都在内存里完成,只有取号段时才访问数据库。假设一次取 1000 个号段,数据库 QPS 是 1000,那理论 ID 生成 QPS 就是 100 万。
号段模式的另一个好处是趋势递增。因为号段是顺序获取的,内存里也是顺序分配的,所以生成的 ID 整体趋势递增。虽然不同节点之间可能有交叉,但整体方向是递增的,对数据库索引友好。
但号段模式也有缺点。ID 不连续,因为每个节点预取了一段,如果节点重启,未用完的号段就浪费了,导致 ID 出现空洞。这在某些对连续性有要求的场景(比如发票号)可能不可接受。依赖数据库,虽然访问频率低,但数据库挂了仍然会影响号段获取,需要有降级策略。
4.2 号段模式的数据库设计与参数计算
号段模式的数据库表设计很简单,核心就是记录每个业务键当前的最大 ID:
CREATE TABLE id_segment ( biz_tag VARCHAR(64) NOT NULL COMMENT '业务标识', max_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前最大ID', step INT NOT NULL DEFAULT 1000 COMMENT '号段步长', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (biz_tag) ) ENGINE=InnoDB;取号段的 SQL 是原子操作:
UPDATE id_segment SET max_id = max_id + step WHERE biz_tag = 'order' RETURNING max_id;注意这里用了RETURNING,MySQL 不支持这个语法,需要用事务包起来:
BEGIN; SELECT max_id FROM id_segment WHERE biz_tag = 'order' FOR UPDATE; UPDATE id_segment SET max_id = max_id + step WHERE biz_tag = 'order'; COMMIT;步长step的设置是个权衡。步长太小,数据库访问频繁,性能上不去;步长太大,ID 空洞多,而且节点重启后浪费的 ID 多。我一般会根据业务 QPS 来算:假设业务峰值 QPS 是 10 万,希望号段能撑 10 分钟,那步长就是 10 万 * 600 = 6000 万。这个数字太大了,实际不会这么设。更合理的做法是让号段撑几秒到几十秒,步长设在几千到几万之间。
还有一个优化点:双 Buffer。意思是内存里维护两个号段,当前号段用到一定程度(比如 10%)时,异步去数据库取下一个号段。这样取号段的耗时被隐藏了,ID 生成几乎无延迟。美团 Leaf 就是这么做的,效果很好。
4.3 号段模式的容灾与降级策略
号段模式最大的风险是数据库不可用。如果数据库挂了,新的号段取不到,内存里的号段用完后 ID 就生成不了了。所以必须有容灾策略。
策略一:本地缓存备用号段。服务启动时除了取当前号段,再额外取一个备用号段存在本地文件里。数据库不可用时,切换到备用号段。备用号段的步长可以设大一些,比如正常步长的 10 倍,这样能撑更久。
策略二:多数据库实例。号段表可以部署在主从架构上,主库挂了切从库。但要注意从库有延迟,切换时可能取到旧的 max_id,导致 ID 重复。所以切换前要确保从库已经同步到最新数据。
策略三:降级到雪花算法。如果数据库完全不可用,可以临时降级到雪花算法生成 ID。但这样生成的 ID 和号段模式的 ID 格式不同,需要业务能兼容。而且雪花算法依赖时钟,降级期间要确保时钟正常。
我实际用过的方案是策略一加策略二的组合:本地缓存备用号段应对短暂故障,数据库主从切换应对长时间故障。同时监控号段余量,余量低于阈值就告警,提前发现数据库问题。
4.4 号段模式的代码实现与踩坑记录
下面是一个号段模式的核心实现,包含了双 Buffer 和本地缓存:
public class SegmentIdGenerator { private final String bizTag; private final SegmentDao segmentDao; // 当前号段 private volatile Segment current; // 下一个号段 private volatile Segment next; // 是否正在加载下一个号段 private volatile boolean loading = false; // 本地备用号段文件路径 private static final String BACKUP_FILE = "/data/id_backup/"; public synchronized long nextId() { if (current == null) { loadCurrentSegment(); } // 当前号段用到 10% 时,异步加载下一个 if (!loading && current.usage() > 0.1) { loading = true; asyncLoadNext(); } if (current.isExhausted()) { // 当前号段用尽,切换到下一个 if (next != null) { current = next; next = null; loading = false; } else { // 下一个还没加载好,同步加载 loadCurrentSegment(); } } return current.nextId(); } private void loadCurrentSegment() { try { Segment segment = segmentDao.fetchSegment(bizTag); this.current = segment; // 同时写入本地备份 writeBackup(segment); } catch (Exception e) { // 数据库不可用,尝试从本地备份恢复 Segment backup = readBackup(); if (backup != null) { this.current = backup; } else { throw new RuntimeException("Cannot load segment", e); } } } private void asyncLoadNext() { new Thread(() -> { try { next = segmentDao.fetchSegment(bizTag); } catch (Exception e) { // 加载失败,重置标志位,下次重试 loading = false; } }).start(); } }这段代码有几个坑点。第一,nextId方法加了synchronized,因为号段切换是状态变更,必须串行。如果对性能有要求,可以用AtomicLong做无锁化,但逻辑会复杂很多。
第二,异步加载下一个号段时,如果加载失败,要重置loading标志位,否则后续不会再触发加载。这个细节很容易漏掉,导致号段用尽后卡死。
第三,本地备份的写入和读取要考虑并发和文件损坏。我一般用临时文件加原子重命名的方式写入,读取时校验文件完整性。
还有一个实际踩过的坑:号段步长和数据库自增的冲突。如果号段表的主键是自增的,而max_id也是自增的,两个自增序列会互相干扰。所以号段表的主键不要用自增,用biz_tag做主键就够了。
5. 其他方案与混合策略
5.1 Redis自增方案的适用边界
Redis 的INCR命令是原子操作,天然适合生成全局唯一 ID。性能上,单机 Redis 能跑到 10 万 QPS 以上,配合集群能更高。实现也简单,几行代码就能搞定。
但 Redis 方案有几个硬伤。持久化问题:Redis 重启后,如果没开持久化或者持久化丢失,INCR的起始值会回退,导致 ID 重复。所以必须开 AOF 持久化,并且设置appendfsync always,但这会严重降低性能。单点问题:Redis 主从切换时,如果主库的INCR值还没同步到从库,切换后新主库的起始值可能偏小,导致 ID 重复。内存成本:虽然每个 key 占用的内存很小,但如果有大量业务键,累积起来也不少。
所以 Redis 方案适合对 ID 连续性要求不高、能容忍少量重复风险、且已有 Redis 基础设施的场景。比如日志追踪 ID、临时会话 ID 这类。对于订单、支付这类核心业务,我不推荐用 Redis 生成 ID。
5.2 UUID与类UUID方案的取舍
UUID 最大的优点是本地生成、性能极高、完全不依赖外部组件。标准 UUID 是 128 位,通常用 36 个字符表示,比如550e8400-e29b-41d4-a716-446655440000。
但 UUID 做数据库主键是灾难。前面说过,随机 ID 会导致 B+ 树索引频繁页分裂,写入性能极差。而且 36 个字符的存储空间比 8 字节的 long 大很多,索引体积膨胀,查询也慢。
如果一定要用 UUID,可以考虑有序 UUID,比如 ULID 或者自己改造的版本。ULID 的前 48 位是时间戳,后 80 位是随机数,整体趋势递增,同时保持本地生成的高性能。但 ULID 仍然是 128 位,存储成本还是比 long 高。
我的建议是:能用 long 就用 long,实在需要本地生成且不依赖外部组件,用雪花算法而不是 UUID。雪花算法生成的也是 long,趋势递增,性能也好,唯一的问题是时钟回拨,但这个问题有成熟的解决方案。
5.3 混合方案:取长补短的工程实践
实际生产中,很少有系统只用一种 ID 生成方案。更常见的是混合方案,根据不同业务场景选择不同策略。
比如一个电商系统,订单号用雪花算法加混淆,保证唯一性和信息安全;用户 ID 用号段模式,因为用户注册频率不高,号段模式足够且不依赖时钟;日志追踪 ID 用 UUID,因为日志量大且对性能要求极高,UUID 的本地生成特性最合适。
还有一种混合思路是雪花算法加号段模式:用号段模式生成机器 ID,解决雪花算法机器 ID 分配的问题;同时用雪花算法生成实际 ID,解决号段模式依赖数据库的问题。这样两种方案互相补位,整体可用性更高。
我实际做过的一个方案是:主用雪花算法,降级用号段模式。正常情况下雪花算法生成 ID,性能高且趋势递增。检测到时钟回拨超过阈值时,自动降级到号段模式,避免 ID 重复。号段模式的数据库访问频率很低,对数据库压力小,作为降级方案很合适。
6. 常见问题排查与避坑指南
6.1 ID重复问题的排查思路
ID 重复是最严重的问题,一旦发现要立即排查。我整理了一个排查流程,按可能性从高到低排列:
第一步:检查时钟回拨。如果是雪花算法,先看日志里有没有时钟回拨的记录。用ntpq -p或者chronyc tracking查看时间同步状态。如果发现回拨,基本可以定位问题。
第二步:检查机器 ID 冲突。如果是雪花算法,确认是否有两个节点用了同一个机器 ID。检查配置文件和注册中心的分配记录。容器环境下特别容易出这个问题,因为 IP 可能重复。
第三步:检查号段重复。如果是号段模式,确认是否有两个节点取了同一个号段。检查数据库的max_id更新是否原子,事务隔离级别是否正确。主从切换时特别容易出这个问题。
第四步:检查序列号溢出。雪花算法的序列号是 12 位,同一毫秒最多 4096 个。如果单节点 QPS 超过 4096 * 1000,序列号会溢出,导致等待下一毫秒。如果等待逻辑有 bug,可能生成重复 ID。
第五步:检查并发问题。确认 ID 生成器的线程安全性。synchronized是否覆盖了所有共享状态?有没有用ThreadLocal但忘记清理?这些细节都可能导致重复。
下面这张表是我总结的 ID 重复问题速查表:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 偶发重复 | 时钟回拨 | 检查 NTP 日志 | 加时钟回拨检测 |
| 批量重复 | 机器ID冲突 | 检查节点配置 | 用注册中心分配 |
| 切换后重复 | 主从延迟 | 检查同步状态 | 切换前确保同步 |
| 高并发重复 | 序列号溢出 | 压测复现 | 优化等待逻辑 |
| 重启后重复 | 状态未持久化 | 检查持久化配置 | 加本地备份 |
6.2 性能瓶颈的定位与优化
ID 生成成为性能瓶颈时,通常表现为生成耗时增加、QPS 上不去、或者 CPU 飙高。定位思路如下。
先看是本地生成还是远程调用。雪花算法是本地生成,理论上微秒级。如果耗时超过毫秒,说明有锁竞争或者自旋等待。号段模式大部分是本地生成,偶尔远程调用。如果频繁远程调用,说明步长设小了。
再看锁竞争。雪花算法的synchronized在高并发下会成为瓶颈。可以用LongAdder或者分段锁优化。号段模式的号段切换也有锁,但频率低,影响小。
然后看数据库。号段模式的数据库访问是瓶颈点。优化方向是加大步长、用双 Buffer 隐藏延迟、读写分离。如果数据库 QPS 已经很高,考虑分库或者换方案。
最后看网络。如果 ID 生成是远程服务,网络延迟是主要开销。优化方向是本地缓存号段、批量获取、或者改成 SDK 本地生成。
我实测过的一个优化案例:某系统用号段模式,步长 1000,QPS 5 万时数据库压力很大。把步长改成 10 万,加双 Buffer,数据库 QPS 降到个位数,ID 生成 QPS 提升到 50 万,效果立竿见影。
6.3 生产环境部署的注意事项
监控先行。ID 生成服务必须有完善的监控:生成 QPS、耗时 P99、时钟偏差、号段余量、数据库连接数。这些指标要接入告警,异常时第一时间知道。
容量规划。根据业务峰值 QPS 和增长趋势,提前规划 ID 生成能力。雪花算法的容量上限是 409.6 万 QPS 每节点,号段模式取决于步长和数据库能力。留足余量,至少 3 倍峰值。
灰度发布。ID 生成逻辑变更影响面大,必须灰度。先在小流量环境验证,确认无重复、性能达标后再全量。
回滚预案。准备好回滚方案,一旦新方案出问题,能快速切回旧方案。回滚时要注意 ID 格式兼容性,避免新旧 ID 混用导致问题。
压测验证。上线前必须压测,模拟峰值流量,验证 ID 生成能力和唯一性。压测时要多节点同时跑,检查是否有冲突。
我个人还有一个习惯:在 ID 生成器里加一个自检逻辑。每隔一段时间生成一批 ID,检查是否有重复,结果上报监控。这样即使有偶发问题,也能及时发现。
6.4 一个真实的故障复盘
最后分享一个我亲历的故障。某次大促,订单系统突然大量报主键冲突。排查发现是雪花算法的时钟回拨导致的。具体原因是:运维在扩容时,新节点的 NTP 配置有问题,时间比标准时间慢了 2 秒。新节点启动后,生成的 ID 时间戳部分比老节点小,导致 ID 重复。
故障的直接原因是 NTP 配置错误,但根本原因是缺少时钟回拨检测和告警。如果代码里有回拨检测,新节点启动时就会拒绝生成 ID 并报警,不会造成数据错乱。
修复方案有三步:第一,紧急下线问题节点,恢复 NTP 配置;第二,在代码里加时钟回拨检测,回拨超过 5 毫秒就拒绝生成并告警;第三,在部署流程里加 NTP 检查,时间偏差超过阈值不允许上线。
这个故障给我的教训是:ID 生成方案的技术选型只是第一步,配套的监控、告警、部署检查同样重要。再好的方案,如果运维不到位,照样出问题。
后来我在所有 ID 生成服务里都加了启动自检:检查时钟偏差、检查机器 ID 冲突、检查号段余量。自检不通过就拒绝启动,从源头上避免问题。这个习惯帮我挡掉了好几次潜在故障。
关于全局唯一 ID 这个话题,能聊的还有很多,比如 Leaf 的源码分析、百度的 UidGenerator、各种方案的性能对比测试。但核心思路就是这些:理解需求、选对方案、做好容灾、完善监控。把这四点做到位,ID 生成就不会成为系统的短板。