This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/02-模型定义/UUID主键策略.md
T
2026-05-05 21:21:58 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
UUID
主键
分布式
性能优化
InnoDB
2026-05-05 00:00

UUID 主键策略

概述

自增 ID 简单高效,但在分布式系统和对外 API 中暴露出明显短板——ID 可被猜测、数据规模暴露、多节点冲突难解。UUID 提供了去中心化的主键生成方案,代价则是对 InnoDB 写入性能的冲击。本文从选型到落地,完整讲解在 GORM 中使用 UUID 的方方面面。

自增 ID 的隐性代价

// 这是最直观的写法——但你很快会发现它的问题
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 写入性能
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 用法

方式一:手动赋值(最直接)

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 钩子(推荐)

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 不应覆盖它。这个检查确保了「只在未设置时自动生成」的语义。

方式三:自定义类型封装(适合团队规范)

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 字节。

// 方案 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 的聚簇索引机制

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 的结构与 UUID v7 类似(时间戳 + 随机数),但使用 Crockford Base32 编码,生成的字符串不含混淆字符(如 I、L、O),对用户更友好:

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 生成基础设施的场景:

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 时,关联表的外键也必须使用相同类型:

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) 存储来减少索引体积。

决策流程

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% 的性能差异。

关联笔记