分表后全局 ID 如何生成?

#中间件#面试八股

分表后全局 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,让主键查询不必扇出。
笔记加载中…