Files
cs-note/hhs/GORM/02-模型定义/UUID主键策略.md
T
2026-05-24 11:42:38 +08:00

321 lines
12 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: [GORM, Go, UUID, 主键, 分布式, 性能优化, InnoDB]
create time: 2026-05-05 00:00
---
# UUID 主键策略
## 概述
自增 ID 简单高效,但在分布式系统和对外 API 中暴露出明显短板——ID 可被猜测、数据规模暴露、多节点冲突难解。UUID 提供了去中心化的主键生成方案,代价则是对 InnoDB 写入性能的冲击。本文从选型到落地,完整讲解在 GORM 中使用 UUID 的方方面面。
## 自增 ID 的隐性代价
```go
// 这是最直观的写法——但你很快会发现它的问题
type User struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Name string
}
// RESTful API 中,用户的个人资料页 URL 可能是这样:
// GET /users/1 → 某个用户
// GET /users/2 → 另一个用户
// GET /users/100 → 哦,你们只有 100 个用户?
```
> [!warning] 自增 ID 的三大风险
> 1. **信息泄露**:通过 ID 可推断用户总量、增长速度、业务规模。
> 2. **枚举攻击**:遍历 `/users/1`、`/users/2`、`/users/3`……轻松抓取全量数据。
> 3. **分布式冲突**:多个数据库节点各自自增,合并时必然产生重复 ID。
> [!question] 思考
> 如果只是在 API 层面对外暴露一个哈希过的「展示 ID」、内部仍用自增主键,能否解决上述问题?
> **部分可以**——它能隐藏数据规模,但增加了额外的映射开销(哈希冲突处理、额外索引等)。如果业务本身就是分布式的,自增 ID 的主键冲突问题依然无解。因此 UUID 在分布式场景下是更根本的解法。
## UUID 版本选型
UUID 不是一个单一的算法,它有多个版本,选错版本等于白用。
| 版本 | 生成依据 | 有序性 | 适用场景 |
|------|----------|--------|----------|
| v1 | 时间戳 + MAC 地址 | 有序 | 需全局唯一 + 可排序,但有 MAC 隐私风险 |
| v4 | 随机数 | 完全无序 | 最通用,隐私性好 |
| v7 | 毫秒时间戳 + 随机数 | 时间有序 | **推荐**:兼顾唯一性与 InnoDB 写入性能 |
```go
import "github.com/google/uuid"
// UUID v4 — 最常用但写入性能差
id := uuid.New() // 例如: 550e8400-e29b-41d4-a716-446655440000
// UUID v7 — Go 1.24+ 才原生支持,现阶段用 google/uuid 的 Must 方法
id, _ := uuid.NewV7() // 例如: 018f3a80-8c5d-7a1b-b000-6b3a1c234567
// ^^^^^^^^ 时间戳部分使写入更有序
```
> [!tip] 优先使用 UUID v7
> UUID v7 的前 48 位是毫秒级时间戳(Unix Epoch),使得生成的 ID 天然按时间递增。在 InnoDB 聚簇索引下,这能大幅减少页分裂——后文会详细说明原因。
## GORM 中的三种 UUID 用法
### 方式一:手动赋值(最直接)
```go
import "github.com/google/uuid"
type Order struct {
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
Code string `gorm:"uniqueIndex;not null"`
}
// 每次 Create 前手动生成
order := Order{
ID: uuid.New(),
Code: "ORD-20240001",
}
db.Create(&order)
```
> [!warning] 手动赋值的问题
> 如果你忘记在某个 `Create` 调用前生成 UUID,GORM 不会报错——它会尝试插入零值 `00000000-0000-0000-0000-000000000000`,导致重复主键错误。下面的方式二可以根治此问题。
### 方式二:BeforeCreate 钩子(推荐)
```go
type Order struct {
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
Code string `gorm:"uniqueIndex;size:32;not null"`
CreatedAt time.Time
}
// BeforeCreate:GORM 在每次 INSERT 前自动调用,ID 零值时自动生成
func (o *Order) BeforeCreate(tx *gorm.DB) error {
if o.ID == uuid.Nil { // uuid.Nil 是零值判断的标准方式
o.ID = uuid.New()
}
return nil
}
// 调用方无需关心 ID 生成
db.Create(&Order{Code: "ORD-20240002"}) // ID 自动填充
```
> [!tip] 为什么还要检查 `uuid.Nil`?
> 如果调用方已经显式设置了 ID(例如从消息队列消费带有 ID 的数据),`BeforeCreate` 不应覆盖它。这个检查确保了「只在未设置时自动生成」的语义。
### 方式三:自定义类型封装(适合团队规范)
```go
type PK uuid.UUID // 自定义主键类型
func (pk *PK) BeforeCreate(tx *gorm.DB) error {
if *pk == PK(uuid.Nil) {
*pk = PK(uuid.New())
}
return nil
}
// Value:写入数据库时的转换
func (pk PK) Value() (driver.Value, error) {
return uuid.UUID(pk).String(), nil
}
// Scan:从数据库读取时的转换
func (pk *PK) Scan(value interface{}) error {
id, err := uuid.Parse(string(value.([]byte)))
if err != nil {
return err
}
*pk = PK(id)
return nil
}
// 使用示例
type Product struct {
ID PK `gorm:"type:char(36);primaryKey"`
Name string `gorm:"size:128;not null"`
}
```
> [!note] 封装自定义类型的收益
> - 所有使用 `PK` 的模型自动获得 UUID 生成能力,零样板代码。
> - 团队统一主键策略——Code Review 时一看 `PK` 就明白这是 UUID 主键模型。
> - 将来如果想从 v4 切换到 v7,只需改 `BeforeCreate` 一处。
## 存储优化:char(36) vs binary(16)
UUID 的标准字符串形式是 `550e8400-e29b-41d4-a716-446655440000`,占 36 字节。但实际的 UUID 数据只需 16 字节。
```go
// 方案 A:存为字符串(可读性好)
type OrderA struct {
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
// 每条记录主键占用 36 字节 + 索引开销
}
// 方案 B:存为 binary(16)(空间效率高,推荐)
type OrderB struct {
ID uuid.UUID `gorm:"type:binary(16);primaryKey"`
// 每条记录主键仅占 16 字节
}
```
| 方案 | 主键大小 | 1000 万行主键索引体积 | 可读性 |
|------|----------|----------------------|--------|
| `char(36)` | 36 字节 | ~360 MB(不含辅助索引) | 直接 SELECT 可读 |
| `binary(16)` | 16 字节 | ~160 MB | 需 `HEX()` 或 `UUID_TO_BIN()` 转换 |
> [!tip] MySQL 特殊技巧
> MySQL 提供了 `UUID_TO_BIN()` 和 `BIN_TO_UUID()` 函数,可在存储时自动做二进制转换。如果使用原生 SQL 或迁移脚本建表,可以配合触发器自动处理。但在 GORM 中,用 `type:binary(16)` + `Value/Scan` 接口手动控制更为灵活。
> [!question] 思考
> 如果你采用 `binary(16)` 存储,在数据库管理工具(如 DataGrip、DBeaver)中查看数据时主键列显示为乱码——你会如何处理?
> **两种思路**:① 查询时用 `SELECT BIN_TO_UUID(id), ... FROM orders`;② 保留 `char(36)`,毕竟现代 SSD 下 20 字节的差异对大多数业务影响微乎其微。**对于内部系统或数据量不大(<1000 万)的场景,char(36) 的可读性优势远大于空间节省的收益。**
## 性能深度解析:UUID 为什么"慢"
### InnoDB 的聚簇索引机制
```mermaid
graph LR
A["INSERT 自增 ID<br/>id=1001"] --> B["写入最后一页"]
C["INSERT UUID v4<br/>随机值"] --> D["定位目标页<br/>(可能已刷出)"] --> E["页已满 →<br/>页分裂"] --> F["分裂后的两页<br/>各写一半"]
```
InnoDB 使用聚簇索引——表数据按主键物理排序存储在 B+ 树中:
- **自增 ID**:每次插入都在 B+ 树的「最右侧」追加新页,触发的页分裂极少。
- **UUID v4**:随机值意味着新行可能插入到 B+ 树的**任意位置**。如果目标页已满,InnoDB 必须执行页分裂——将一页拆成两页,重新平衡树结构。这导致:
- 磁盘随机 I/O 增加
- 页利用率下降(分裂后的页各约 50% 填充率)
- 缓冲池命中率降低
> [!note] 性能下降的量级
> 业界测试表明,UUID v4 的写入吞吐量比自增 ID 低约 **25%-40%**,页分裂导致的数据碎片化还会拖慢范围查询。但请注意——这是在**持续高并发写入**场景下的差异。如果你的写入 QPS 在几百级别,日常几乎感受不到。
### UUID v7:兼顾唯一性与有序性
UUID v7 的前 48 位是毫秒级 Unix 时间戳,使得不同毫秒生成的 ID 天然递增:
```
UUID v4: e2d3f1a4-... (随机) → 随机插入,页分裂严重
UUID v7: 018f3a80-... (时间) → 大致有序,页分裂大幅减少
^^^^^^^^
毫秒时间戳,同一毫秒内顺序排列
```
> [!warning] 同一毫秒内的并发写入
> UUID v7 在同一毫秒内的多个 ID,随机部分决定了它们的相对顺序。因此同一毫秒内的批量写入仍可能触发少量页分裂——但这远比 v4 的全随机要温和。
## 替代方案对比
| 方案 | 长度 | 有序 | 全局唯一 | 可读性 | 性能(vs 自增) |
|------|------|------|----------|--------|-----------------|
| 自增 ID (`int64`) | 8 字节 | 有序 | ❌ 单点 | 高 | 基准 |
| UUID v4 | 16 字节 | 无序 | ✅ | 中(字符串) | -30% |
| UUID v7 | 16 字节 | 大致有序 | ✅ | 中(字符串) | -10% |
| ULID | 16 字节 / 26 字符 | 有序 | ✅ | 高(Crockford Base32) | -10% |
| Snowflake (`int64`) | 8 字节 | 有序 | ✅ | 低(需反解) | -5% |
### ULID 示例
[ULID](https://github.com/ulid/spec) 的结构与 UUID v7 类似(时间戳 + 随机数),但使用 Crockford Base32 编码,生成的字符串不含混淆字符(如 `I`、`L`、`O`),对用户更友好:
```go
import "github.com/oklog/ulid/v2"
type Article struct {
ID string `gorm:"type:char(26);primaryKey"` // ULID 固定 26 字符
// ...
}
func (a *Article) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = ulid.Make().String() // 例如: 01HX3V8M2P4K5Q6R7S8T9V0W1X
}
return nil
}
```
### Snowflake(雪花算法)
适合对 ID 长度敏感、且已有分布式 ID 生成基础设施的场景:
```go
import "github.com/bwmarrin/snowflake"
var snowflakeNode *snowflake.Node
func init() {
snowflakeNode, _ = snowflake.NewNode(1) // 节点编号 1
}
// 生成 int64 ID:ID 包含时间戳 + 节点 ID + 序列号
id := snowflakeNode.Generate().Int64() // 例如: 1541828496137482240
```
> [!tip] Snowflake 的取舍
> Snowflake 小巧(8 字节),性能优异,但需要部署节点编号服务(或依赖 Redis/etcd 协调 WorkerID)。而 UUID v7 是纯本地生成,零依赖。**团队规模小(<5 个节点)优先用 UUID v7,大规模微服务架构再考虑 Snowflake。**
## UUID 作为外键
当主键使用 UUID 时,关联表的外键也必须使用相同类型:
```go
type Order struct {
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
OrderCode string `gorm:"uniqueIndex;size:32;not null"`
}
type OrderItem struct {
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
OrderID uuid.UUID `gorm:"type:char(36);not null;index"` // 外键列
ProductID uuid.UUID `gorm:"type:char(36);not null"`
Quantity int `gorm:"not null"`
Order Order `gorm:"foreignKey:OrderID"` // GORM 关联定义
}
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
if oi.ID == uuid.Nil {
oi.ID = uuid.New()
}
return nil
}
// 预加载关联查询
var items []OrderItem
db.Preload("Order").Where("order_id = ?", orderID).Find(&items)
```
> [!warning] 外键 UUID 的索引考量
> 外键列通常需要创建索引(如上例中的 `index` tag)。如果 OrderItem 数据量很大(千万级),`order_id` 的 UUID 索引同样会面临 InnoDB 页分裂问题。此时可以考虑在该索引上使用 `binary(16)` 存储来减少索引体积。
## 决策流程
```mermaid
graph TD
A["选择主键策略"] --> B{"是否需要<br/>分布式部署?"}
B -->|"否,单数据库"| C{"API 是否<br/>对外暴露 ID?"}
C -->|"否,纯内部"| D["自增 ID (uint)<br/>简单高效"]
C -->|"是"| E["UUID v7<br/>binary(16)存储"]
B -->|"是"| F{"写 QPS 是否<br/>超过 5000?"}
F -->|"否"| E
F -->|"是"| G{"团队是否有<br/>分布式 ID 基础设施?"}
G -->|"有"| H["Snowflake (int64)<br/>8字节,高吞吐"]
G -->|"无"| I["UUID v7<br/>配合 binary(16)<br/>可接受性能折损"]
style D fill:#10B981,color:#fff
style E fill:#3B82F6,color:#fff
style H fill:#F59E0B,color:#fff
style I fill:#EF4444,color:#fff
```
> [!summary] 一句话建议
> **2026 年了,新项目默认用 UUID v7 + `binary(16)` 存储。** 除非你在做极致的写入性能优化(如日志采集、时序数据),否则它带来的分布式友好性和隐私保护远超那 10% 的性能差异。
## 关联笔记
- [[02-模型定义]] — 父文档,包含主键策略概述与其他字段定义
- [[03-CRUD 操作]] — UUID 模型的增删改查操作
- [[12-自定义字段类型]] — Value/Scan 接口的更多用法