高性能分布式 ID 生成:智能号段拼接算法
1. 设计目标
在分布式系统中,ID 生成需要在 性能、连续性、唯一性 和 灵活性 之间取得平衡。本算法针对以下场景设计:
- ID 连续性:生成的 ID 在业务上尽可能连续,便于人眼识别和排序
- 零浪费:应用重启或号段切换时不丢弃未使用的 ID
- 批量支持:一次调用获取 N 个连续 ID,适配 Excel 导入等批处理场景
- 高性能:号段预载入内存,分发时零数据库开销
- 无中心化中间件:不依赖 Redis 等外部组件,仅依赖关系数据库
2. 架构设计
┌─────────────────────────────────────────────────────────┐
│ 应用实例 Instance A │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ SegmentIdGenerator │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ SegmentBuffer │ │ SegmentBuffer │ ... │ │
│ │ │ table: order │ │ table: user │ │ │
│ │ │ [1001,2000] │ │ [5001,6000] │ │ │
│ │ │ current=1501 │ │ current=5200 │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ │ │
│ └─────────┼─────────────────┼─────────────────────────┘ │
└────────────┼─────────────────┼───────────────────────────┘
│ │
┌────────┴─────────────────┴────────────────┐
│ table_id_segment │
│ ┌───────────┬──────────┬──────┐ │
│ │ table_name │ max_id │ step │ │
│ ├───────────┼──────────┼──────┤ │
│ │ order │ 3000 │ 1000 │ │
│ │ user │ 7000 │ 1000 │ │
│ └───────────┴──────────┴──────┘ │
└────────────────────────────────────────────┘
核心组件
SegmentIdGenerator:入口类,管理多个业务表的号段缓冲区SegmentBuffer:每个业务表对应一个缓冲区,维护[current, max]号段范围table_id_segment:数据库表,持久化每个业务表的当前max_id
3. 核心算法:零浪费拼接
3.1 问题背景
传统号段模式每次从数据库取一段 ID(如 [1, 1000]),在内存中发放。当号段用尽时,丢弃剩余 ID(如有),再从数据库取下一段。如果应用在号段未用完时重启,剩余 ID 全部作废。
3.2 拼接算法
本算法的核心创新:当本地号段剩余不足时,不丢弃剩余 ID,而是将旧号段剩余与新号段首部连续拼接,交付给调用方。
请求: 需要 50 个连续 ID
本地剩余: [981, 1000] 共 20 个 → 不足
拼接结果:
[981, 1000] ← 旧号段剩余 20 个(保留!)
[1001, 1030] ← 新号段首部 30 个
────────────
[981, 1030] ← 共 50 个连续 ID,零浪费 ✅
拼接成立的前提是 DB max_id 连续增长:
旧号段: [oldBase + 1, oldBase + step] = [1, 1000]
DB UPDATE: max_id = 1000 + loadStep → max_id = 2000
新号段: [1000 + 1, 1000 + loadStep] = [1001, 2000]
↑
紧挨着旧号段,无间隙
3.3 实现
synchronized long nextIds(long count) {
long cur = current.get();
long remaining = max - cur + 1;
// 本地充足:直接切分,零数据库开销
if (remaining >= count) {
current.addAndGet(count);
return cur + count - 1;
}
// 本地不足:加载新号段
int loadStep = (int) Math.max(this.step, count);
long oldMaxFromDb = fetchFromDb(loadStep);
long newMax = oldMaxFromDb + loadStep;
long result;
if (oldMaxFromDb == max) {
// DB 连续性成立 → 零浪费拼接
result = cur + count - 1;
} else {
// 连续性被破坏(外部重置等)→ 放弃旧残余,从新号段重新开始
result = oldMaxFromDb + count;
}
this.current = new AtomicLong(result + 1);
this.max = newMax;
return result;
}
连续性被破坏的场景(如 table_id_segment 记录被外部删除后重建):
oldMaxFromDb ≠ max,拼接不可行- 自动降级:放弃旧号段残余,从新号段重新分配
- 此时会有 ID 空洞,但不会产生重复
4. 乐观锁 CAS
4.1 问题
多实例部署时,两个实例可能同时从数据库抢号段。必须保证每个实例拿到的是互不重叠的号段。
4.2 方案:条件更新(CAS)
使用带有 WHERE 条件的 UPDATE 作为乐观锁:
UPDATE table_id_segment
SET max_id = max_id + ?
WHERE table_name = ? AND max_id = ?
max_id = ?条件是读取时的旧值- 只有 max_id 未被其他实例修改,更新才成功(rows=1)
- 更新失败(rows=0)则重试
4.3 fetchFromDb 完整流程
┌──────────────────────────────────────────────────────────┐
│ fetchFromDb(step) │
├──────────────────────────────────────────────────────────┤
│ ① 本地有缓存 (this.max > 0)? │
│ ├── 是 → UPDATE ... WHERE max_id = #{this.max} │
│ │ ├── rows=1 → return this.max ← 一刀入魂 🎯 │
│ │ └── rows=0 → ↓ │
│ └── 否 → ↓(初始化,跳过无意义的 UPDATE) │
│ │
│ ② SELECT max_id FROM table_id_segment │
│ ├── null → INSERT 初始记录 → return 0 │
│ └── 有值 → CAS 重试: │
│ UPDATE ... WHERE max_id = #{当前值} │
│ ├── rows=1 → return 当前值 │
│ └── rows=0 → 重试循环 │
│ │
│ ③ 异常 "table not found" │
│ └── initTableSegment(): │
│ ├── CREATE TABLE IF NOT EXISTS │
│ ├── SELECT MAX(id) FROM "业务表" ← 自愈锚点 │
│ └── INSERT 初始记录 │
└──────────────────────────────────────────────────────────┘
4.4 路径开销
| 场景 | DB 调用 | 说明 |
|---|---|---|
| 正常取号(缓存命中且无冲突) | 1 次 | UPDATE 即返回 |
| 首次初始化 | 1~2 次 | SELECT + 可能 INSERT |
| 多实例冲突(罕见) | 2~3 次 | UPDATE 失败 → SELECT → CAS 重试 |
| 表丢失 | 3~4 次 | 建表 + 查 MAX(id) + 插入 + 重试 |
5. 自动建表与自愈
5.1 自动建表
首次使用时若 table_id_segment 表不存在,自动创建:
CREATE TABLE IF NOT EXISTS table_id_segment (
table_name VARCHAR(64) PRIMARY KEY,
max_id BIGINT NOT NULL DEFAULT 0,
step INT NOT NULL DEFAULT 1000,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
5.2 自愈:从业务表 MAX(id) 接续
当 table_id_segment 表被重建或丢失时,自动从业务表的当前 MAX(id) 获取接续点:
Long realMax = sql.getFistColumn(
"SELECT MAX(id) FROM \"" + tableName + "\"", Long.class);
这确保即使 table_id_segment 被重置,新 ID 也不会与已有数据冲突。
6. 动态步长
批处理场景下,调用方可以一次请求 N 个连续 ID:
// 获取 2000 个连续 ID,返回终止值
long maxId = DBHolder.sequence("order", 2000);
// 使用 [maxId - 2000 + 1, maxId] 范围内的 ID
当 count > step 时,自动以 count 作为步长从数据库加载号段,保证一次数据库交互满足需求:
int loadStep = (int) Math.max(this.step, count);
7. 多数据源支持
SegmentIdGenerator 使用 tableName 作为缓冲区的 Key,不区分数据源。
当数据源切换时,DBHolder.getSqlExecutor() 自动路由到当前数据源的数据库连接,table_id_segment 表自然位于该数据库中。
调用链:
DBHolder.sequence("order", 1)
→ SegmentIdGenerator.nextId("order", 1)
→ SegmentBuffer.nextIds(1)
→ DBHolder.getSqlExecutor() ← 当前数据源的执行器
→ UPDATE table_id_segment ← 数据源对应库中的表
不同数据源的同名业务表各自独立维护号段,互不干扰。
8. 线程安全
内存分发
SegmentBuffer.current 使用 AtomicLong,在 synchronized 保护下进行 getAndAdd:
synchronized long nextIds(long count) {
long cur = current.get();
...
current.addAndGet(count);
...
}
数据库抢段
多实例间的并发通过数据库行级锁保证(MySQL InnoDB / SQLite):
- CAS UPDATE 是原子操作
- 只有
WHERE max_id = ?条件匹配时才更新成功 - 不匹配时自动重试
9. 对比
| 维度 | UUID | 雪花算法 | 传统号段 | 智能号段拼接 |
|---|---|---|---|---|
| 长度 | 36 字符 | 64 bit | 64 bit | 64 bit |
| 连续性 | 无序 | 趋势递增 | 跨段跳跃 | 连续 |
| 批量支持 | N/A | N/A | 需多次调用 | 一次调用 N 个 |
| 重启浪费 | 无 | 无 | 号段剩余作废 | 零浪费 |
| 多实例 | 安全 | 需配置 | CAS | CAS |
| 依赖 | 无 | 时钟 | 数据库 | 数据库 |
10. 注意事项
- 连续性破坏:若外部删除了
table_id_segment中的记录,拼接算法会自动降级,此时会产生 ID 空洞但不会重复 - 步长设置:默认步长应大于业务峰值 QPS × 号段消耗时间,避免频繁去数据库取号
- 表名安全:
initTableSegment()中的MAX(id)查询使用"tableName"引号包裹以支持特殊字符,但tableName参数应由应用层控制,不应来自用户输入