分布式 ID 方案(详细版)
目标:在多机房/多实例/高并发 场景下,生成全局唯一 、高性能 、(可选)大致有序 、(可选)可追踪的业务主键/流水号。
1. 你到底需要什么样的 ID?
先把需求"说人话":
- 唯一性:必须全局唯一(最基本)。
- 性能:QPS 目标?10k / 100k / 1M?
- 有序性:是否需要"按时间大致递增"(利于 MySQL B+Tree、范围查询、冷热分离)。
- 长度 :数据库用
BIGINT还是字符串? - 可读性/业务含义:要不要带日期、业务线、地区码?
- 多中心容灾:跨机房/跨地域是否必须可用?
- 可用性:ID 服务挂了能否继续下单?能否本地降级?
- 安全性:是否怕"爬虫看出订单量/时间"?(需要混淆/加密/映射)
- 成本:是否接受引入中间件/运维一套 ID 服务?
把这些问题确定后,你的选型会非常清晰。
2. 常见方案总览(优缺点一眼看懂)
| 方案 | 形态 | 性能 | 有序 | 依赖 | 优点 | 缺点/坑 | 适用 |
|---|---|---|---|---|---|---|---|
| 数据库自增 | DB | 低~中 | ✅ | 单库 | 简单 | 分库分表难、扩展差、写热点 | 单体/小规模 |
| 数据库号段(Segment/Leaf) | DB + 缓存 | 高 | ✅ | DB | 高性能、可控、成本低 | 号段表设计、时钟无关但要处理并发 | 电商/交易(强烈推荐) |
| Redis INCR | Redis | 高 | ✅ | Redis | 快、实现简单 | Redis 故障/切换、持久化/回放、集群槽迁移 | 业务流水号、短期 |
| Snowflake(雪花) | 本地算法 | 极高 | ✅(大致) | 无 | 不依赖中心、低延迟 | 时钟回拨、机器号分配 | 大多数场景(强烈推荐) |
| UUID(v4) | 本地算法 | 极高 | ❌ | 无 | 超简单、绝对唯一概率极高 | 太长、无序、索引膨胀 | 日志、追踪ID |
| UUID v1/v7/ULID | 本地算法 | 极高 | ✅(大致) | 无 | 比 v4 更有序 | 实现差异、字符串更大 | 需要可排序字符串 |
| Zookeeper/Etcd 递增节点 | 中心服务 | 低~中 | ✅ | ZK/Etcd | 强一致递增 | 性能一般、依赖重 | 小规模强一致序号 |
| MQ/时间序列服务 | 中心服务 | 中 | ✅ | MQ | 解耦 | 延迟、复杂 | 特殊业务 |
结论(经验主义但很实用):
- 你要
BIGINT且高并发 :优先 Snowflake 或 号段(Segment/Leaf)。 - 你要"绝对递增且强一致":通常只能中心化(DB/ZK),但代价很高。
- 你只是要"唯一":UUID v4 最省事,但别用在 MySQL 主键(除非你接受性能代价)。
3. 方案一:Snowflake(雪花)------最常用的本地算法
3.1 经典 64-bit 结构
典型结构(可调整):
- 1 bit:符号位(固定 0)
- 41 bit:时间戳(毫秒)
- 10 bit:机器标识(数据中心 + 机器)
- 12 bit:序列号(同毫秒内自增)
特点:
- 无需中心服务
- 性能极高
- 大致递增(按时间)
- 需要解决两个关键问题:
- 机器号怎么分配
- 时钟回拨怎么办
3.2 机器号分配策略(落地做法)
推荐顺序:
- K8s StatefulSet :用
pod ordinal作为 workerId(最稳、最省事) - 配置中心(Nacos/Apollo)按实例配置
- ZK/Etcd 临时节点抢占(自动分配,复杂一些)
- IP hash(不推荐:IP 变动、冲突风险)
只要能保证同一时间不会有两个实例拿到相同 workerId,就行。
3.3 时钟回拨处理策略
常见策略:
- 直接拒绝:检测到回拨就抛异常(简单但会影响业务)
- 等待追平:回拨不大时 sleep 等待(常用)
- 使用逻辑时钟:维护 lastTimestamp,回拨时继续用 lastTimestamp(会牺牲"真实时间")
- 切换 workerId:回拨时临时切换机器号(需要机器号池)
生产建议:
- 小回拨(如 < 5ms)等待追平;
- 大回拨报警 + 拒绝或降级(例如切到号段方案)。
4. 方案二:号段(Segment/Leaf)------交易/订单的"稳健之王"
4.1 核心思想
把"生成 ID"的压力从"每次请求都打 DB"变成:
- DB 里维护每个业务的
max_id(或next_id) - 应用一次从 DB 取一段(比如 10,000 个)
- 应用内存里自增发号,发完再取下一段
你会得到:
- 高性能:请求 ID 基本不访问 DB
- 强可控:递增、短、适合 BIGINT
- 时钟无关:不怕机器时钟问题
- 故障可控:DB 挂了还能用完手里这段
4.2 表结构(推荐)
sql
CREATE TABLE id_segment (
biz_tag VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '业务标识,如 order, pay, refund',
max_id BIGINT NOT NULL COMMENT '当前已分配到的最大ID',
step INT NOT NULL COMMENT '每次分配号段长度',
version BIGINT NOT NULL COMMENT '乐观锁版本',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
remark VARCHAR(128) NULL
);
取号段关键:乐观锁更新
sql
UPDATE id_segment
SET max_id = max_id + step,
version = version + 1
WHERE biz_tag = ?
AND version = ?;
读取当前 max_id、step、version,update 成功则获得号段:
- oldMaxId + 1 ~ oldMaxId + step
4.3 双 buffer(推荐)
Leaf 的经典做法:准备两段号段
- 当前段快用完时,后台线程预取下一段
- 切换时无阻塞
好处:更稳、更平滑,基本不会"卡一下"。
5. 方案三:Redis INCR ------简单粗暴但要想清楚一致性
5.1 最简用法
- key:
id:order - 命令:
INCR id:order
问题在于:
- Redis 宕机/切换时,是否会"回退"?
- Redis AOF/RDB 策略不当会导致"重复"或"跳号"
- 跨业务线需要不同 key
- 如果你要"日期前缀 + 当日递增",还要
id:order:20260109
建议:
- 如果是订单主键这种核心数据:慎用 Redis INCR 作为唯一来源(除非你把 Redis 做到非常强的可用与持久策略)。
- 但作为"展示用流水号/短期序列"很香。
6. 方案四:UUID / ULID / UUIDv7 ------字符串 ID 的选择
- UUID v4:随机,极难碰撞,但无序,MySQL 主键不友好
- ULID:可排序字符串(基于时间 + 随机)
- UUID v7:新标准方向(时间有序更强)
如果你:
- 数据库不是强依赖 B+Tree 主键性能(如文档库、日志)
- 或者你愿意用"业务自增主键 + 外部展示ID"
那么字符串 ID 就很舒服。
7. 生产选型建议(很实用)
7.1 订单/交易主键(强推荐)
- 优先:号段(Segment/Leaf)
- 稳定、递增、时钟无关
- 或者:Snowflake
- 依赖更少,性能更高,但要处理时钟回拨与机器号
7.2 分库分表场景
- 主键最好是 全局唯一且大致递增(避免热点可以加入业务 hash/分片键)
- 常用:
- Snowflake:ID 自带时间,分片通常用
id % N或按业务字段 - 号段:也是递增,且可控
- Snowflake:ID 自带时间,分片通常用
7.3 对外展示的订单号
别直接把内部主键暴露出去(容易被猜测订单量/时间)。
做法:
- 内部:BIGINT 主键(Snowflake / 号段)
- 外部:展示号 = base62(主键) 或 加盐混淆 或 映射表
8. Java 落地代码:Snowflake(可直接用)
说明:这是一个"单机安全 + 线程安全"的实现,包含基本的时钟回拨处理(等待追平)。
java
import java.util.concurrent.ThreadLocalRandom;
public class SnowflakeIdGenerator {
// 起始时间戳(可自定义,建议上线日)
private final long epoch = 1704067200000L; // 2024-01-01 00:00:00
private final long workerIdBits = 10L;
private final long sequenceBits = 12L;
private final long maxWorkerId = ~(-1L << workerIdBits);
private final long workerIdShift = sequenceBits;
private final long timestampShift = sequenceBits + workerIdBits;
private final long sequenceMask = ~(-1L << sequenceBits);
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > maxWorkerId) {
throw new IllegalArgumentException("workerId out of range: 0 ~ " + maxWorkerId);
}
this.workerId = workerId;
}
public synchronized long nextId() {
long ts = currentTimeMillis();
// 时钟回拨处理:等待追平(适合小回拨)
if (ts < lastTimestamp) {
long offset = lastTimestamp - ts;
if (offset <= 5) {
try {
Thread.sleep(offset);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
ts = currentTimeMillis();
if (ts < lastTimestamp) {
throw new IllegalStateException("Clock moved backwards. offset=" + offset);
}
} else {
throw new IllegalStateException("Clock moved backwards too much. offset=" + offset);
}
}
if (ts == lastTimestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
// 同毫秒内序列用尽,等到下一毫秒
ts = waitNextMillis(lastTimestamp);
}
} else {
// 新毫秒,序列可以从 0 或随机起步(减少同毫秒碰撞风险感知)
sequence = ThreadLocalRandom.current().nextLong(0, 2);
}
lastTimestamp = ts;
return ((ts - epoch) << timestampShift)
| (workerId << workerIdShift)
| sequence;
}
private long waitNextMillis(long lastTs) {
long ts = currentTimeMillis();
while (ts <= lastTs) {
ts = currentTimeMillis();
}
return ts;
}
private long currentTimeMillis() {
return System.currentTimeMillis();
}
}
8.1 Spring Boot 接入示例
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class IdConfig {
@Bean
public SnowflakeIdGenerator idGenerator() {
// 你需要保证 workerId 全局唯一:比如从配置中心、StatefulSet ordinal
long workerId = 1L;
return new SnowflakeIdGenerator(workerId);
}
}
9. Java 落地代码:号段(Segment)核心实现(可直接改造上线)
说明:下面给的是"单 buffer"版本(最容易落地),再附一个"预取思想"的升级建议。
9.1 Mapper(MyBatis)
java
public interface IdSegmentMapper {
IdSegmentDO selectForUpdate(String bizTag);
int updateMaxIdWithVersion(String bizTag, long oldVersion);
}
对应 SQL 示例(MyBatis XML / 注解都行):
sql
-- select
SELECT biz_tag, max_id, step, version
FROM id_segment
WHERE biz_tag = #{bizTag};
-- update(乐观锁)
UPDATE id_segment
SET max_id = max_id + step,
version = version + 1
WHERE biz_tag = #{bizTag}
AND version = #{oldVersion};
注意:这里的
select不一定要for update,因为我们依赖乐观锁。但如果你希望降低更新冲突次数,也可以 select for update(吞吐会下降)。
9.2 号段对象
java
public class Segment {
private final long start; // inclusive
private final long end; // inclusive
private long current;
public Segment(long start, long end) {
this.start = start;
this.end = end;
this.current = start;
}
public synchronized long next() {
if (current > end) {
return -1;
}
return current++;
}
public boolean isExhausted() {
return current > end;
}
}
9.3 服务实现(带重试)
java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class SegmentIdService {
private final IdSegmentMapper mapper;
private final Map<String, Segment> cache = new ConcurrentHashMap<>();
public SegmentIdService(IdSegmentMapper mapper) {
this.mapper = mapper;
}
public long nextId(String bizTag) {
Segment seg = cache.computeIfAbsent(bizTag, k -> loadSegment(k));
long id = seg.next();
if (id != -1) {
return id;
}
synchronized (this) {
// double-check
Segment current = cache.get(bizTag);
long retryId = current.next();
if (retryId != -1) {
return retryId;
}
Segment newSeg = loadSegment(bizTag);
cache.put(bizTag, newSeg);
return newSeg.next();
}
}
private Segment loadSegment(String bizTag) {
for (int i = 0; i < 10; i++) {
IdSegmentDO row = mapper.selectForUpdate(bizTag);
if (row == null) {
throw new IllegalStateException("bizTag not found: " + bizTag);
}
int updated = mapper.updateMaxIdWithVersion(bizTag, row.getVersion());
if (updated == 1) {
long oldMaxId = row.getMaxId();
long step = row.getStep();
long start = oldMaxId + 1;
long end = oldMaxId + step;
return new Segment(start, end);
}
// 乐观锁冲突,短暂退避重试
try { Thread.sleep(5L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
throw new IllegalStateException("loadSegment failed after retries, bizTag=" + bizTag);
}
}
9.4 升级到"双 buffer"的思路(建议)
- 当前 Segment 剩余量低于阈值(例如 10%)时,后台异步预取 nextSegment
- 当前用完立刻切换,避免请求线程去打 DB
实现要点:
- 每个 bizTag 维护:
- currentSegment
- nextSegment(可能为 null)
- loadingNext 标记(CAS/锁避免重复加载)
- 异步线程池负责预取
10. 常见坑(踩过一次就会记一辈子)
10.1 MySQL 用 UUID 当主键
会让索引页频繁分裂,写入随机 IO 增加,性能很容易崩。
(除非你用 UUIDv7/ULID 并且做了顺序化)
10.2 Snowflake 的时钟回拨
- 容器/虚拟机时间漂移
- NTP 调整
解决:等待追平 + 报警 + 降级(号段)
10.3 workerId 冲突
这是雪花"最致命"的坑:一冲突就是直接重复 ID。
解决:StatefulSet ordinal / 配置中心强约束 / 自动抢占(ZK/Etcd)
10.4 号段 step 设置过小
step 太小会导致频繁打 DB;太大导致"跳号"更明显(通常可接受)。
经验:
- 10k QPS:step 10k~100k
- 100k QPS:step 100k~1M
10.5 号段跨库/读写分离
号段的 update 必须走主库 ,而且要保证强一致读到最新 version/max_id。
如果你走读库读到旧 version,会导致更新失败重试增加甚至异常。
11. 推荐组合(强实战)
组合 A(最常见):
- 内部主键:Snowflake(BIGINT)
- 对外订单号:base62(snowflakeId) + 校验位/加盐
组合 B(交易稳健派):
- 内部主键:号段(BIGINT)
- 对外订单号:日期 + 业务码 + base36(递增号)
组合 C(全链路追踪):
- traceId:UUID/ULID
- 业务主键:Snowflake/号段
12. 快速落地清单(照着做就能上线)
Snowflake 上线清单
- 确定 epoch
- 设计 workerId 分配方式(最重要)
- 处理时钟回拨(等待追平 + 报警)
- 压测:同毫秒序列溢出时的表现
- 监控:回拨次数、生成失败次数、QPS
号段上线清单
- 建表 id_segment,初始化 biz_tag
- update 必须走主库
- step 合理配置 + 可动态调整
- 预取(双 buffer)可作为二期
- 监控:DB update 冲突次数、取段耗时、缓存命中率
13. 选型一句话建议
- 你是电商/支付/订单 :优先 号段(Segment/Leaf)(稳、递增、时钟无关)。
- 你想极简、极快、不想依赖中心服务 :用 Snowflake(但一定把 workerId 和回拨搞定)。
- 你只是要唯一,不在乎索引与长度 :用 UUID/ULID。