分表后全局 ID 如何生成?
结论:分表后自增主键只在单表内唯一,必须换成全局唯一 ID(Global Unique ID)。生产上主流是雪花算法(Snowflake)与号段模式(Segment):前者本地生成、趋势递增,依赖时钟;后者靠数据库批量发号,不依赖时钟但要中心化发号器。选型看是否要求趋势递增、能否容忍时钟回拨、以及日发号量级。
一、方案对比
| 方案 | 唯一性 | 趋势递增 | 性能 | 依赖 | 主要缺陷 |
|---|---|---|---|---|---|
| UUID | 强 | 否 | 极高(本地) | 无 | 36 字节,随机写引发页分裂 |
| 数据库自增步长 | 强 | 是 | 低 | 每个库表 | 扩容要改步长 |
| 号段模式 | 强 | 是 | 高(批量取号) | 数据库 | 重启浪费一段号 |
| 雪花算法 | 强 | 是 | 极高(本地) | 系统时钟 | 时钟回拨 |
Redis INCR | 强 | 是 | 高 | Redis | 多一次网络、持久化风险 |
| 美团 Leaf | 强 | 是 | 高 | 数据库或 ZooKeeper | 组件较重 |
二、雪花算法的位分配
1 bit 41 bit 毫秒时间戳 10 bit 机器号 12 bit 序列号
┌──────┬────────────────────────┬─────────────────┬────────────────┐
│ 0 │ 可用约 69 年 │ 最多 1024 个节点 │ 每毫秒 4096 个 │
└──────┴────────────────────────┴─────────────────┴────────────────┘
符号位 相对自定义纪元 数据中心 + 机器 同毫秒内自增
单节点理论峰值 4096 × 1000 ≈ 409 万/秒,实际受时钟精度与序列回绕限制。
三、实现与配置
public class SnowflakeIdGenerator {
private static final long WORKER_BITS = 10L;
private static final long SEQ_BITS = 12L;
private static final long EPOCH = 1_700_000_000_000L;
private final long workerId;
private long lastTs = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId >= (1L << WORKER_BITS)) {
throw new IllegalArgumentException("workerId 超出 0..1023");
}
this.workerId = workerId;
}
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now < lastTs) { // 时钟回拨
long back = lastTs - now;
if (back > 5) {
throw new IllegalStateException("时钟回拨 " + back + "ms,拒绝发号");
}
while ((now = System.currentTimeMillis()) < lastTs) {
// 短回拨:自旋等待时钟追平
}
}
if (now == lastTs) {
sequence = (sequence + 1) & ((1L << SEQ_BITS) - 1);
if (sequence == 0) {
now = waitNextMillis(lastTs);
}
} else {
sequence = 0L;
}
lastTs = now;
return ((now - EPOCH) << (WORKER_BITS + SEQ_BITS))
| (workerId << SEQ_BITS)
| sequence;
}
private long waitNextMillis(long last) {
long ts = System.currentTimeMillis();
while (ts <= last) {
ts = System.currentTimeMillis();
}
return ts;
}
}
号段模式(Segment)适合不能容忍时钟问题的业务:
CREATE TABLE t_segment (
biz_tag VARCHAR(64) NOT NULL,
max_id BIGINT NOT NULL,
step INT NOT NULL DEFAULT 1000,
updated_at DATETIME NOT NULL,
PRIMARY KEY (biz_tag)
) ENGINE=InnoDB;
-- 取号:原子地占住一段,应用缓存 [max_id - step, max_id)
UPDATE t_segment SET max_id = max_id + step, updated_at = NOW()
WHERE biz_tag = 'order';
SELECT max_id, step FROM t_segment WHERE biz_tag = 'order';
应用侧把号段缓存在内存,耗尽再取下一段,数据库压力降到千分之一。
四、常见追问
Q1:为什么不用 UUID 做主键? UUID v4 完全随机,而 InnoDB 聚簇索引按主键顺序组织,随机插入会频繁页分裂、降低缓冲池命中率;36 字节还让主键和每个二级索引一起膨胀。UUID v7 带时间前缀会好很多,但长度问题仍在。
Q2:时钟回拨怎么处理? 三种策略:回拨小于阈值时自旋等待;回拨较大时直接拒绝发号并告警;把历史最大时间戳持久化,重启后从最大值继续,避免倒退。
Q3:雪花 ID 能直接用于分片路由吗? 可以。把 workerId 或低位比特设计成分片基因,就能用 ID 直接定位分片,省掉一次映射查询。
Q4:号段模式重启后号会被浪费吗? 会。每次重启丢一段(通常 1000 个号),可以用「双 Buffer 预取 + 过期时间」缓解,但业务上一般接受这种空洞,因为 ID 只要求唯一不要求连续。
小结
- 自增主键在分表后失效,必须换成全局唯一 ID。
- 雪花靠本地生成、趋势递增,代价是时钟依赖;号段靠数据库批量发号,代价是中心化。
- 把「分片信息」设计进 ID,让主键查询不必扇出。