分布式 ID 原理与使用(一份能直接落地的工程指南)
目标:在高并发、多实例、多机房 场景下,生成全局唯一、趋势递增、高性能 的 ID。
本文从原理 → 方案对比 → 代码实现 → 工程坑位,一步到位。
1. 为什么需要分布式 ID?
在单机时代:
- MySQL
AUTO_INCREMENT足够好用
但一旦进入分布式系统:
- 多库多表
- 多实例并发写
- 异步消息 / 跨系统调用
你会立刻遇到问题:
- ❌ ID 冲突
- ❌ 无法保证顺序
- ❌ 数据迁移/合库困难
- ❌ 数据库成为性能瓶颈
所以:ID 必须从"数据库附属品"升级为"基础设施"
2. 一个"好 ID"通常要满足什么?
不是所有系统都需要全部,但你要知道你在取舍什么。
常见诉求:
- 全局唯一
- 高性能 / 高并发
- 趋势递增(对数据库、索引、分页友好)
- 长度可控(最好 64bit long)
- 不依赖单点
- 可跨机房
⚠️ 注意:
绝对递增 vs 高可用 通常是冲突的,只能选一个"更重要"。
3. 主流分布式 ID 方案总览
| 方案 | 是否唯一 | 趋势递增 | 性能 | 依赖 | 典型问题 |
|---|---|---|---|---|---|
| UUID | ✅ | ❌ | 高 | 无 | 太长、无序 |
| 数据库号段 | ✅ | ✅ | 中 | DB | DB 压力 |
| Redis INCR | ✅ | ✅ | 高 | Redis | 回退风险 |
| Snowflake | ✅ | ✅ | 极高 | 本地 | 时钟回拨 |
| Leaf / Uid | ✅ | ✅ | 极高 | DB | 架构复杂 |
4. UUID:能用,但很多时候你不该用
java
UUID.randomUUID().toString();
问题:
- 长度大(36 字符)
- 无序,索引性能差
建议:
- ❌ 不做数据库主键
- ✅ 适合幂等键、业务唯一标识
5. 数据库号段(Segment)------最稳妥的方案
原理
- DB 只负责分配区间
- 服务内存自增
表结构
sql
CREATE TABLE id_segment (
biz_type VARCHAR(50) PRIMARY KEY,
max_id BIGINT NOT NULL,
step INT NOT NULL
);
简化实现
java
class SegmentIdGenerator {
private long current;
private long max;
synchronized long nextId() {
if (current > max) {
loadFromDb();
}
return current++;
}
}
优点:
- 稳定
- 趋势递增
缺点:
- 实现稍复杂
6. Redis INCR:简单但要清楚风险
shell
INCR order:id
优点:
- 实现简单
- 性能高
风险:
- Redis 宕机
- AOF 回放导致 ID 回退
适合:
- 中小系统
- 非强一致 ID
7. Snowflake(雪花算法)
64 位结构
时间戳 | 机器号 | 序列号
Java 示例
java
public synchronized long nextId() {
long ts = System.currentTimeMillis();
if (ts == lastTs) {
seq = (seq + 1) & 4095;
} else {
seq = 0;
}
lastTs = ts;
return (ts << 22) | seq;
}
⚠️ 一定要处理 时钟回拨
8. 选型建议
- 订单 / 支付:数据库号段 or Leaf
- 高并发日志:Snowflake
- 内部系统:Redis INCR
- 非 DB ID:UUID
9. 面试 30 秒总结
分布式 ID 的目标是全局唯一、高性能、趋势递增。UUID 简单但无序;数据库号段通过区间分配最稳定;Snowflake 性能高但要处理时钟回拨;Redis INCR 简单但存在回退风险。
10. 高频坑
- UUID 当主键
- Snowflake 无回拨保护
- Redis 单点
- 后期改 ID 方案
11.Segment实现分布式ID示例
1)表结构(MySQL)
sql
CREATE TABLE id_segment (
biz_type VARCHAR(50) NOT NULL PRIMARY KEY COMMENT '业务标识,比如 order/pay/refund',
max_id BIGINT NOT NULL COMMENT '当前已分配到的最大ID',
step INT NOT NULL COMMENT '每次分配的号段步长',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 初始化:order 每次取 1000 个
INSERT INTO id_segment(biz_type, max_id, step, version)
VALUES ('order', 0, 1000, 0);
解释:
max_id:DB 里记录"已经发出去的最大值"
每次取段:把 max_id 增加 step,返回区间 (oldMax+1 ~ newMax)
version:CAS 更新,防并发发重段
2)Mapper(MyBatis / MyBatis-Plus 都行)
SQL(核心是"先查后 CAS 更新")
sql
-- 1. 查当前段信息
SELECT biz_type, max_id, step, version
FROM id_segment
WHERE biz_type = #{bizType};
-- 2. CAS 更新(乐观锁)
UPDATE id_segment
SET max_id = max_id + step,
version = version + 1
WHERE biz_type = #{bizType}
AND version = #{version};
Mapper 接口示例
java
public interface IdSegmentMapper {
IdSegmentDO selectForUpdate(String bizType); // 实际不需要for update,靠 version CAS
int updateMaxIdCas(String bizType, long version);
}
不用 SELECT ... FOR UPDATE,因为那会把并发打回串行;Segment 的优势就是让 DB 轻松。
3)核心模型:SegmentBuffer(双 Buffer + 预加载)
目标:用号段 A 的时候,后台预取号段 B,A 快用完时无缝切到 B。
java
import java.util.concurrent.atomic.AtomicLong;
public class Segment {
volatile long start; // inclusive
volatile long end; // inclusive
final AtomicLong cur = new AtomicLong(0);
void reset(long start, long end) {
this.start = start;
this.end = end;
this.cur.set(start);
}
long next() {
long v = cur.getAndIncrement();
return v <= end ? v : -1;
}
long remain() {
return end - cur.get();
}
long size() {
return end - start + 1;
}
}
public class SegmentBuffer {
final String bizType;
final Segment[] segments = {new Segment(), new Segment()};
volatile int currentIndex = 0;
// 是否正在加载 next 段
volatile boolean loadingNext = false;
SegmentBuffer(String bizType) { this.bizType = bizType; }
}
4)发号逻辑(核心服务)
4.1 DB 拉号段(CAS 重试)
java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SegmentAllocator {
private final IdSegmentMapper mapper;
public SegmentAllocator(IdSegmentMapper mapper) {
this.mapper = mapper;
}
@Transactional
public IdRange allocate(String bizType) {
for (int i = 0; i < 10; i++) { // CAS 重试
IdSegmentDO row = mapper.selectByBizType(bizType);
if (row == null) throw new IllegalArgumentException("bizType not found: " + bizType);
int updated = mapper.updateMaxIdCas(bizType, row.getVersion());
if (updated == 1) {
long oldMax = row.getMaxId();
long newMax = oldMax + row.getStep();
return new IdRange(oldMax + 1, newMax);
}
}
throw new IllegalStateException("allocate segment failed after retries, bizType=" + bizType);
}
public record IdRange(long start, long end) {}
}
4.2 ID 生成器(内存自增 + 预加载 + 切段)
java
import java.util.concurrent.*;
import java.util.concurrent.ConcurrentHashMap;
public class SegmentIdGenerator {
private final SegmentAllocator allocator;
private final ConcurrentHashMap<String, SegmentBuffer> cache = new ConcurrentHashMap<>();
private final ExecutorService preloadPool =
Executors.newFixedThreadPool(Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
// 触发预加载阈值:当前段剩余 < 10%
private final double preloadFactor = 0.10;
public SegmentIdGenerator(SegmentAllocator allocator) {
this.allocator = allocator;
}
public long nextId(String bizType) {
SegmentBuffer buf = cache.computeIfAbsent(bizType, this::initBuffer);
while (true) {
Segment curSeg = buf.segments[buf.currentIndex];
long id = curSeg.next();
if (id != -1) {
maybePreloadNext(buf, curSeg);
return id;
}
// 当前段用完,切换到 next 段
switchSegment(buf);
}
}
private SegmentBuffer initBuffer(String bizType) {
SegmentBuffer buf = new SegmentBuffer(bizType);
// 同步加载两段,启动即热
fillSegment(buf.segments[0], bizType);
fillSegment(buf.segments[1], bizType);
buf.currentIndex = 0;
return buf;
}
private void fillSegment(Segment seg, String bizType) {
SegmentAllocator.IdRange r = allocator.allocate(bizType);
seg.reset(r.start(), r.end());
}
private void maybePreloadNext(SegmentBuffer buf, Segment curSeg) {
long remain = curSeg.remain();
if (remain <= 0) return;
if (remain < curSeg.size() * preloadFactor && !buf.loadingNext) {
synchronized (buf) {
if (buf.loadingNext) return;
buf.loadingNext = true;
int nextIdx = 1 - buf.currentIndex;
preloadPool.submit(() -> {
try {
fillSegment(buf.segments[nextIdx], buf.bizType);
} finally {
buf.loadingNext = false;
}
});
}
}
}
private void switchSegment(SegmentBuffer buf) {
synchronized (buf) {
int nextIdx = 1 - buf.currentIndex;
// 如果 next 段还没准备好,就同步加载(兜底)
Segment nextSeg = buf.segments[nextIdx];
long probe = nextSeg.cur.get();
if (probe == 0 || probe > nextSeg.end) { // 粗略判断是否可用
fillSegment(nextSeg, buf.bizType);
}
buf.currentIndex = nextIdx;
}
}
}
特点:
绝大多数请求:只是在内存里 AtomicLong++
DB 调用:每 step 次请求才触发一次(比如 step=1000)
预加载:快用完时后台拉下一段,避免"用完瞬间卡一下"
5)怎么在 Spring Boot 里注入使用
java
@Configuration
public class IdConfig {
@Bean
public SegmentIdGenerator segmentIdGenerator(SegmentAllocator allocator) {
return new SegmentIdGenerator(allocator);
}
}
业务里直接用:
java
long orderId = segmentIdGenerator.nextId("order");
6)工程级建议(你一定会用到)
step 怎么设?
经验:step = 峰值QPS * 1~5秒
比如订单峰值 2000 QPS → step 2000~10000 都行
step 太小:DB 压力上升
step 太大:重启可能浪费一段(但一般可接受)
多实例会不会冲突?
不会。因为每次分段是 DB CAS 更新 version,天然全局唯一。
重启会不会丢号?
会"浪费"一段,但不会重复。大多数系统都接受(ID 不需要连续)。
监控你该看什么?
分配号段次数/分钟(DB 压力指标)
预加载耗时
同步兜底加载次数(说明预加载不够快/step太小)