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

391 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, 主键, Auto Increment, UUID, Snowflake]
create time: 2026-05-16 00:00
---
# 主键策略对比
## 概述
主键是聚簇索引的核心——决定了数据在磁盘上的物理排列方式。不同的主键策略直接影响写入性能、索引碎片化程度以及分布式扩展能力。
> [!QUESTION] 一个有趣的问题
> 假设你的日活用户是 100 万,每年增长约 3600 万。你会用 INT(最大 42 亿)还是 BIGINT(最大 1800 亿)?
>
> 直觉上 BIGINT 更保险。但每个字节在主键上的代价都在放大——因为 InnoDB 的**所有二级索引都包含主键列**。一条 BIGINT 比 INT 多 4 字节,每张二级索引表每条记录就多 4 字节的开销。如果你的系统有 5 张外键关联这张表的二级索引,那每行就白白多了 20 字节。**选大一级不犯错是有代价的。**
## 策略全景图
```mermaid
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 自增主键
最简单也最常用的方案。
```sql
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE,
email VARCHAR(255)
);
-- InnoDB 自动管理自增值
-- 每张 InnoDB 表有一个隐藏的 auto_increment_counter
-- 默认从 1 开始,按步长递增
```
### 步长与偏移
```sql
-- 全局设置
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 亿时。
```sql
-- 查看表的自增列类型占用
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
```sql
-- MySQL 内置函数生成 UUID
SELECT UUID();
-- '550e8400-e29b-41d4-a716-446655440000' (36 字符含横杠)
SELECT UUID_SHORT();
-- 无横杠的 64-bit 整数,基于 server_id + 计数器
-- ⚠️ 重启后会重置计数器,可能导致重复
```
### UUID 对聚簇索引的伤害
```mermaid
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 转二进制
```sql
-- ❌ 差: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 系列函数
> ```sql
> -- 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 实现要点
```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 实现
```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 字符串)
> ```
## 关联笔记
- [[hhs/GORM/02-模型定义]] — GORM 中不同主键类型的 struct tag 设置
- [[hhs/GORM/08-事务管理]] — 分布式事务中的 ID 一致性
- [[hhs/Redis/08-SortedSet精解]] — Snowflake WorkerID 可用 Redis 做协调