分布式 ID 生成方案:从数据库自增到雪花算法
目录
自增 ID 的局限
在很多系统刚开始建设的时候,ID 并不是一个需要特别关注的问题。创建一张表,设置一个自增主键,数据库负责保证每条数据都有唯一编号,这种方式简单、稳定,而且几乎没有额外成本。
但随着业务规模扩大,系统从单体逐渐演变成多实例部署,甚至开始进行分库分表后,原本简单的自增 ID 就遇到了问题:
多个服务节点如何保证生成的 ID 仍然全局唯一?
单库场景下,所有数据写入都经过同一个数据库,自增序列天然是连续且唯一的。但到了分布式环境,每个数据库节点都有自己的自增序列,它们并不知道其他节点已经生成了哪些 ID。
例如:
数据库A(用户表分片1) 数据库B(用户表分片2)
id=1 张三 id=1 李四
id=2 王五 id=2 赵六
对于数据库 A 来说,ID=1 是合法的;对于数据库 B 来说,ID=1 同样也是合法的。但放到整个系统来看,两个 ID 已经发生冲突。
有人可能会想到提前给不同节点划分 ID 范围,比如让 A 使用奇数,B 使用偶数,或者给每个数据库设置不同的起始值和增长步长。但这种方式随着节点数量变化会越来越难维护。新增机器、数据迁移、业务扩容,都可能导致原有规则失效。
因此,在分布式系统中,ID 的生成不能再依赖某一个数据库实例的自增能力,而需要设计一套独立的 ID 生成机制,让不同节点在没有互相协调的情况下,也能生成全局唯一的编号。
UUID
UUID 最大的优点是简单。应用节点之间不需要协调,本地直接生成就能用:
java
String id = UUID.randomUUID().toString();
// 550e8400-e29b-41d4-a716-446655440000
零依赖、无网络开销、理论上不会重复。但简单也意味着牺牲了一些数据库场景下比较重要的特性。
做数据库主键性能差。 InnoDB 的主键索引本质是一棵 B+ 树,数据通常按照主键顺序插入。ID 不断递增时,新数据基本追加到索引尾部;而随机 UUID 会让数据插入位置更加分散,增加 B+ 树页分裂和索引维护成本。加上 UUID 是 36 个字符的字符串,比 8 字节的 Long 整数大好几倍,索引占用空间也更大。
可读性差。 出了问题排查时,user_id=1001 比 user_id=550e8400-e29b-41d4-a716-446655440000 好认得多。
UUID 适合做请求追踪 ID、日志关联 ID 这类不需要排序、不做主键的场景。拿来做数据库主键,代价不小。
数据库号段
一种自然的思路是引入一个专门的 ID 服务,由它统一分配编号。但如果每次生成 ID 都访问数据库,本质上只是把压力从业务表转移到了发号表。
号段模式解决的就是这个问题:一次取一批,用完了再取下一批。

数据库只在号段用完时才被访问一次,平时 ID 生成全在内存里完成。
实际实现中,通常用两个号段交替:当前号段快用完时,提前加载下一个号段,避免用完时的数据库查询阻塞请求。美团的 Leaf 就是这个思路,用双 Buffer 保证在任何时刻都有可用的号段。
号段模式的 ID 有序递增,对数据库索引友好,也不依赖时钟。缺点是强依赖数据库,号段表挂了就发不了号(双 Buffer 和预加载可以缓解,但不能完全消除)。
Redis INCR
Redis 的 INCR 命令是原子操作,天然适合做分布式 ID 生成:
java
public long generateId(String key) {
return redisTemplate.opsForValue().increment(key);
}
Redis 单线程模型保证了原子性,不需要加锁,高并发下也不会重复。实现比号段模式简单得多,不需要维护号段加载逻辑。
但它有自己的问题。号段模式一次数据库交互拿一批 ID,之后全在内存里生成。Redis INCR 每次生成 ID 都要一次网络往返,QPS 很高时网络开销会成为瓶颈。另外 Redis 宕机了 ID 就发不出来了,虽然可以做主从切换,但切换过程中有短暂的不可用窗口。还有 Redis 的持久化策略 ------ 如果配置不当,故障恢复后计数器可能回退,导致 ID 重复,需要额外保证持久化策略的正确性。
Redis INCR 适合对性能要求不是极端高、已有 Redis 基础设施的场景。
雪花算法
Snowflake 是 Twitter 开源的分布式 ID 生成方案。它不依赖任何外部存储,本地直接生成,性能极高。
核心思想是把一个 64 位的 Long 整数拆成几段,每段有不同的含义:

41 位时间戳提供了约 69 年的使用周期,对于绝大多数业务系统已经足够。12 位序列号意味着同一毫秒同一机器最多生成 4096 个 ID。5 位数据中心 + 5 位机器 ID,最多支持 1024 个节点。
生成逻辑:取当前时间戳,如果和上一次相同,序列号自增;如果不同,序列号归零。序列号溢出时,等下一毫秒再生成。
nextId():
timestamp = 当前时间戳
if timestamp < lastTimestamp:
时钟回拨,抛异常或等待恢复
if timestamp == lastTimestamp:
sequence++
if sequence > maxSequence:
等待下一毫秒,重新获取 timestamp
else:
sequence = 0
lastTimestamp = timestamp
return (timestamp - epoch) << 22
| datacenterId << 17
| workerId << 12
| sequence
位运算的拆解:<< 22 把时间戳移到最高位,<< 17 和 << 12 分别给数据中心和机器 ID 留出位置,最后 12 位留给序列号。OR 操作把各段拼成一个完整的 64 位整数。
雪花算法纯本地生成,没有网络开销,ID 整体递增,对数据库索引友好。缺点是对系统时钟有依赖。
时钟回拨
雪花算法最大的隐患是时钟回拨。
线上环境里,更常见的是机器时间同步异常。例如某台机器因为时间同步配置异常,时钟突然回拨几秒,雪花算法会立即拒绝生成 ID,最终表现为业务请求失败。
正常情况下服务器时间是单调递增的,但 NTP 同步、闰秒调整、虚拟机迁移这些场景都可能导致时间"往回跳"。
处理方式通常有两种:
小幅回拨,等待恢复。 检测到回拨幅度在几毫秒以内时,sleep 一小段时间再重试。大部分 NTP 同步造成的小幅回拨都能扛过去。
java
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
Thread.sleep(offset << 1); // 等待回拨恢复
timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨过大,拒绝生成ID");
}
} else {
throw new RuntimeException("时钟回拨过大,拒绝生成ID");
}
}
大幅回拨,直接报警。 回拨几秒以上通常意味着运维问题,这时候比兜底更有意义的是报警,让运维介入排查根因。
也有方案在 64 位中预留几位做"回拨计数器",每次检测到回拨就加一,这样即使时间回退也不会重复。代价是留给时间戳或序列号的位数变少了。
方案对比
| 方案 | 性能 | 趋势递增 | 适合作为主键 | 运维成本 | 适合场景 |
|---|---|---|---|---|---|
| 数据库自增 | 高 | 是 | 适合 | 低 | 单机部署,数据量不大 |
| UUID | 高 | 否 | 一般 | 低 | 追踪 ID、日志关联 |
| 数据库号段 | 高 | 是 | 适合 | 中 | 业务主键,已有数据库 |
| Redis INCR | 高 | 是 | 适合 | 中 | 已有 Redis 基础设施 |
| 雪花算法 | 极高 | 是 | 适合 | 低 | 大规模分布式系统 |
小结
分布式 ID 的设计是在业务规模和系统复杂度之间做取舍。对于早期系统,数据库自增通常已经足够;当业务发展到分库分表阶段,可以考虑号段模式或者 Redis INCR;如果需要更高并发、更大规模的分布式生成能力,再引入雪花算法。很多时候,问题并不是现有方案性能不够,而是在业务还没有复杂到那个程度时,就提前引入了复杂的分布式方案,最终增加了系统维护成本。