跳到主要内容

高性能分布式 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 bit64 bit64 bit
连续性无序趋势递增跨段跳跃连续
批量支持N/AN/A需多次调用一次调用 N 个
重启浪费号段剩余作废零浪费
多实例安全需配置CASCAS
依赖时钟数据库数据库

10. 注意事项

  1. 连续性破坏:若外部删除了 table_id_segment 中的记录,拼接算法会自动降级,此时会产生 ID 空洞但不会重复
  2. 步长设置:默认步长应大于业务峰值 QPS × 号段消耗时间,避免频繁去数据库取号
  3. 表名安全initTableSegment() 中的 MAX(id) 查询使用 "tableName" 引号包裹以支持特殊字符,但 tableName 参数应由应用层控制,不应来自用户输入