--- 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 做协调