Files
cs-note/hhs/MySQL/05-表设计/22-主键策略对比.md
2026-05-24 11:42:38 +08:00

14 KiB
Raw Permalink Blame History

tags, create time
tags create time
MySQL
主键
Auto Increment
UUID
Snowflake
2026-05-16 00:00

主键策略对比

概述

主键是聚簇索引的核心——决定了数据在磁盘上的物理排列方式。不同的主键策略直接影响写入性能、索引碎片化程度以及分布式扩展能力。

[!QUESTION] 一个有趣的问题 假设你的日活用户是 100 万,每年增长约 3600 万。你会用 INT(最大 42 亿)还是 BIGINT(最大 1800 亿)?

直觉上 BIGINT 更保险。但每个字节在主键上的代价都在放大——因为 InnoDB 的所有二级索引都包含主键列。一条 BIGINT 比 INT 多 4 字节,每张二级索引表每条记录就多 4 字节的开销。如果你的系统有 5 张外键关联这张表的二级索引,那每行就白白多了 20 字节。选大一级不犯错是有代价的。

策略全景图

flowchart TD
    Start["选择主键策略"] --> Type{"自增还是分散?"}

    Type -->|自增| AUTO["Auto Increment"]
    Type -->|分散| RAND["分布式生成"]

    AUTO --> A1["INT / BIGINT AUTO_INCREMENT"]

    RAND --> R1{"有序还是随机?"}
    R1 -->|随机| RIA["UUID / GUID"]
    R1 -->|大致有序| RID["Snowflake / ULID"]

    RIA --> U1["索引严重碎片化"]
    RIA --> U2["写放大 5~10x"]

    RID --> V1["近似递增 · 紧凑"]
    RID --> V2["分布式友好"]

    style AUTO fill:#00D866,color:#fff
    style RID fill:#00B6BC,color:#fff
    style RIA fill:#EE5A24,color:#fff

AUTO_INCREMENT 自增主键

最简单也最常用的方案。

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE,
    email VARCHAR(255)
);

-- InnoDB 自动管理自增值
-- 每张 InnoDB 表有一个隐藏的 auto_increment_counter
-- 默认从 1 开始,按步长递增

步长与偏移

-- 全局设置
SET GLOBAL auto_increment_increment = 2;   -- 每次 +2
SET GLOBAL auto_increment_offset = 1;       -- 从 1 开始

-- 这样服务器 A 得到 1,3,5,...;服务器 B offset=2 得到 2,4,6,...
-- 可以用于简单的双主复制去重

-- 查看当前自增值
SHOW TABLE STATUS LIKE 'users'\G
-- Auto_increment: 12345

-- 手动重置
ALTER TABLE users AUTO_INCREMENT = 10000;

优点与挑战

优点 挑战
顺序写入,无碎片 集中式增长,单机上限受 BIGINT 限制
索引紧凑,空间利用率高 泄露总数(公开 API 暴露数量趋势)
查询极快(数字比较) 跨库合并 ID 时需人工规划

[!WARNING] 并发下的自增值跳跃 在高并发下,AUTO_INCREMENT 可能跳过一些数值。比如并发 INSERT 时,InnoDB 可能会分配 1, 3, 5 而不是 1, 2, 3。这是因为 InnoDB 为每个 INSERT 分配一批值以避免锁竞争。如果业务要求连续编号(如流水号),需要用其他方式实现。

性能调优与监控

[!TIP] INT 还是 BIGINT? 这是一个常见的选型陷阱。BIGINT 占 8 字节,INT 只占 4 字节(上限约 21 亿)。如果你的表不超过 21 亿行,用 INT 可以让索引更紧凑——二级索引、JOIN 条件都会更省空间。什么时候该升级到 BIGINT?当你的分库方案预估总量超过 21 亿时。

-- 查看表的自增列类型占用
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'users';

-- 监控自增计数器使用率(%)
SELECT
    table_name,
    auto_increment,
    CASE data_type
        WHEN 'tinyint'  THEN 255
        WHEN 'smallint' THEN 65535
        WHEN 'mediumint' THEN 16777215
        WHEN 'int'      THEN 4294967295
        WHEN 'bigint'   THEN 18446744073709551615
    END AS max_val,
    ROUND(auto_increment /
        CASE data_type
            WHEN 'tinyint'  THEN 255
            WHEN 'smallint' THEN 65535
            WHEN 'mediumint' THEN 16777215
            WHEN 'int'      THEN 4294967295
            WHEN 'bigint'   THEN 18446744073709551615
        END * 100, 2) AS usage_pct
FROM INFORMATION_SCHEMA.TABLES t
JOIN INFORMATION_SCHEMA.COLUMNS c USING (table_schema, table_name)
WHERE c.extra LIKE '%auto_increment%'
ORDER BY usage_pct DESC;

优化建议:

场景 做法
预分配 ID 范围 用独立 id_generator 表,批量领取一段 ID,减少 DB 压力
防止 ID 泄露 API 返回时做偏移或加盐(例如 id + 100000,再存回库时用 id - 100000)
跨库合并去重 按机器 ID 规划偏移量(类似 MySQL 双主模式),或用 Snowflake 替代

UUID / GUID

-- MySQL 内置函数生成 UUID
SELECT UUID();
-- '550e8400-e29b-41d4-a716-446655440000' (36 字符含横杠)

SELECT UUID_SHORT();
-- 无横杠的 64-bit 整数,基于 server_id + 计数器
-- ⚠️ 重启后会重置计数器,可能导致重复

UUID 对聚簇索引的伤害

flowchart LR
    subgraph BPlus["InnoDB 聚簇索引 = B+ Tree"]
        Root["根节点"] --> BranchA["内部节点 A"]
        Root --> BranchB["内部节点 B"]
        Root --> BranchC["内部节点 C"]
        BranchA --> LeafA1["叶子页 P5"]
        BranchA --> LeafA2["叶子页 P2"]
        BranchB --> LeafB1["叶子页 P8"]
        BranchC --> LeafC1["叶子页 P1"]
        LeafA1 -.->|"双向链表"| LeafA2
        LeafA2 -.->|"双向链表"| LeafB1
        LeafB1 -.->|"双向链表"| LeafC1
        LeafC1 -.->|"双向链表"| LeafA1
    end

    UUID1["UUID: a3f1..."] -->|"插入"| LeafC1
    UUID2["UUID: b2c4..."] -->|"插入"| LeafA2
    UUID3["UUID: c7d8..."] -->|"插入"| LeafB1
    UUID4["UUID: d1e2..."] -->|"插入"| LeafA1
    UUID5["UUID: e9f3..."] -->|"插入"| LeafC1

    style BPlus fill:#F8F8F8,color:#333
    style UUID1 fill:#EE5A24,color:#fff
    style UUID2 fill:#EE5A24,color:#fff
    style UUID3 fill:#EE5A24,color:#fff
    style UUID4 fill:#EE5A24,color:#fff
    style UUID5 fill:#EE5A24,color:#fff

UUID 的随机性导致每次插入都可能落在 B+ Tree 的不同叶子页,与自增主键形成鲜明对比:

  • 页分裂频率飙升:16KB 页面快速填满 → 分裂
  • 索引碎片化:数据在磁盘上分散存放
  • 缓存命中率下降:热点区域变冷
  • 存储空间膨胀:36 字符 × 4 byte = ~144 bytes/条索引额外开销

解决思路:UUID 转二进制

-- ❌ 差:VARCHAR(36) 存字符串 UUID
CREATE TABLE bad_uuid_users (
    id VARCHAR(36) PRIMARY KEY,
    name VARCHAR(100)
);

-- ✅ 好:BINARY(16) 存原始 UUID 字节
CREATE TABLE good_uuid_users (
    id BINARY(16) PRIMARY KEY,
    name VARCHAR(100)
);

INSERT INTO good_uuid_users VALUES (UUID_TO_BIN(UUID()), 'Alice');
-- UUID_TO_BIN() 把 UUID 打乱重组,让 B+ Tree 的分布更均匀

[!TIP] MySQL 8.0 的 uuid_to_bin / bin_to_uuid 系列函数

-- uuid_to_bin(uuid, swap_flag)
-- swap_flag = 0: 直接转换(仍随机)
-- swap_flag = 1: 重组字节序(近似递增)← 推荐

INSERT INTO t (id) VALUES (UUID_TO_BIN(UUID(), TRUE));
SELECT BIN_TO_UUID(id, TRUE) FROM t;  -- 还原为可读 UUID

Snowflake 雪花算法

Twitter 开源的分布式 ID 生成方案。

64-bit 结构:
│ 1bit │    41 bits     │     10 bits      │     12 bits     │
│ sign │  timestamp(ms) │   worker_id        │   sequence      │
│  0   │                │  (机器标识)         │  (序列号)        │

范围:41ms → 约 69 年
worker_id:最多 1024 台机器
sequence:每台机器每毫秒最多 4096 个 ID

Go 实现要点

package snowflake

import (
    "sync"
    "time"
)

// Worker 生成分布式 ID
// 位布局: [41bit timestamp][10bit workerID][12bit sequence]
type Worker struct {
    mu       sync.Mutex
    lastTime int64
    sequence uint16 // 每毫秒从 0 开始,溢出时回退等待
    workerID int64
    epoch    int64 // 起始时间戳(Epoch),避免负数
}

func NewWorker(workerID int64) (*Worker, error) {
    return &Worker{
        workerID: workerID,
        epoch:    time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli(),
    }, nil
}

func (w *Worker) NextID() int64 {
    w.mu.Lock()
    defer w.mu.Unlock()

    now := time.Now().UnixMilli()
    if now < w.epoch {
        panic("epoch not reached")
    }
    if now == w.lastTime {
        w.sequence++
    } else {
        w.sequence = 0
    }
    w.lastTime = now

    // 位拼接: (elapsed << 22) | (workerID << 12) | sequence
    return (now-w.epoch)<<22 | w.workerID<<12 | int64(w.sequence)
}

Snowflake 的优点

特点 说明
有序递增 时间戳在前,天然递增,对聚簇索引友好
分布式 无中心节点,每台机器独立生成
高密度 64-bit INT64,占 8 字节,比 UUID 省一半
可解析 从 ID 中可以还原出时间戳

Snowflake 的挑战

[!NOTE] 时钟回拨问题 如果服务器时间回退(NTP 同步、虚拟机卡顿),同一个时间戳可能生成两次相同 ID。解决方案:

  • 阻塞等待:时间回拨时暂停生成直到时间追上
  • 抛出异常:由上层重试
  • 预留比特位:预留少量 bit 给错误码/回拨标志

ULID

Universal Unique Lexicographically Sortable Identifier —— 比 UUID 更适合数据库的方案。

26 字符 Base32 编码:
│ 4 byte (32 bit) │ 8 byte (64 bit) │ 10 byte (80 bit random) │
│   milliseconds  │   entropy       │    randomness            │
│  Epoch ms since | 随机熵源         │                            │
│  1970-01-01     │                 │                            │

Go 实现

import (
    "crypto/rand"
    "time"

    "github.com/oklog/ulid/v2"
)

// 生成 ULID
func NewULID() string {
    t := uint64(time.Now().UnixMilli())
    entropy := ulid.MonotonicEntropy(rand.Reader, 5)
    id, _ := ulid.New(t, entropy)
    return id.String() // 如: "01JKQX5H3PMTB9R1KZAS7BMFZC"
}

[!TIP] ULID vs Snowflake:怎么选?

  • 需要人类可读的 ID(打印到日志、放在 URL 里)→ ULID,Base32 只含兼容字符,无大小写混淆
  • 追求极致紧凑 → Snowflake,8 字节纯数字,INT64 直接可用
  • 不想维护 worker_id → ULID 不需要节点标识,天然去中心化
  • 16 字节存储,与 Snowflake 同级别
  • 字符串形式可直接用于 URL、HTTP header
  • 自然按字典序排序(时间戳在前)
  • Go 标准库已有 oklog/ulid 包

性能实测数据

[!QUESTION] 思考:为什么同样是 16 字节,UUID Binary 和 ULID 的索引效率差距那么大? 答案是有序性。B+ Tree 最怕随机插入——它会导致大量的页分裂(page split)和碎片。即使存储长度相同,写入模式决定了最终性能。

以下数据基于单机 MySQL 8.0 (InnoDB),100 万条 INSERT benchmark:

指标 BIGINT AI UUID(BIN) Snowflake ULID
写入耗时 ~3s ~25s ~4s ~4s
索引大小 24 MB 48 MB 24 MB 48 MB
页面填充率 ~70% ~35% ~68% ~67%
SELECT 平均延迟 0.15 ms 0.25 ms 0.16 ms 0.16 ms
磁盘空间比 1x 2.1x 1x 1.4x

[!NOTE] 数据来源说明 以上为典型场景下的参考值。实际性能取决于数据分布、并发量、硬件配置。关键结论是一致的:有序 ID 在 InnoDB 上的写入效率约为随机 ID 的 5~8 倍。如果看到 UUID 写入了更快的场景,大概率是测试方法有误(例如只测了单次插入而忽略了页分裂累积效应)。

反模式警示

[!DANGER] 常见陷阱

1. 用 UUID 做外键关联 每条二级索引都要存一份外键引用。一张有 5 个外键的表,VARCHAR(36) 版本的 UUID 会让索引膨胀到原来的 5 倍以上。永远优先使用 BINARY(16)。

2. 把 Snowflake 序列号存在 MySQL 里 Snowflake ID 本身已经包含时间戳,再额外加一个 created_at 字段属于典型的重复存储。除非你有特殊的审计需求,否则用一个字段就够了。

3. 用自增 ID 做多分片部署 两个独立的 MySQL 实例各自从 1 开始自增,一旦需要合并就 ID 冲突。这是早期很多创业团队踩过的坑——等发现问题时业务量已经很大,迁移代价极高。从一开始就用分布式 ID。

4. 依赖 UUID_SHORT() 做去重 该函数依赖 server_id + 内存计数器,重启后计数器归零。如果你的服务偶尔重启(OOM kill、滚动发布),可能产生重复 ID。不要把它用在消息队列或事件溯源这种对幂等性敏感的场景。

对比总结

策略 长度 有序性 分布式 存储空间 索引效率 适用场景
BIGINT AUTO_INCREMENT 8 byte ✅ 严格递增 ❌ 需分片规划 最小 🏆 最优 单库 / 小分布式
UUID (Binary) 16 byte ❌ 随机 ✅ 中等 较差 跨库合并
UUID TO_BIN(swapped) 16 byte ⚠️ 近似递增 ✅ 中等 改善 MySQL 8.0+
Snowflake 8 byte ✅ 近似递增 ✅ 最小 🏆 最优 大规模分布式
ULID 16 byte ✅ 严格递增 ✅ 中等 良好 微服务 / 事件溯源

[!TIP] 选型决策树

单库? → AUTO_INCREMENT (简单可靠)
↓
分布式且 < 100 万 QPS? → Snowflake / ULID
↓
需要与外部系统对接 UUID? → UUID_TO_BIN(uuid, TRUE)
↓
需要人类可读? → ULID(Base32 字符串)

关联笔记