UUID 当数据库主键已经有十多年争议——支持者说”分布式系统必须用”,反对者说”性能爆炸不可用”。2022 年 RFC 9562 引入 UUID v7,把”时序友好”这块短板补上后,争论的天平真的变了。这篇按数据库引擎、QPS、隐私需求拆解 v4/v7/v1/Snowflake/ULID 的取舍,给出每个场景的最佳选型。
UUID 各版本速查
| 版本 | 大小 | 时序 | 含 MAC | RFC | 主要用途 |
|---|---|---|---|---|---|
| v1 | 128 位 | 有但被打乱 | 是 | 4122 | 历史遗留 |
| v3 | 128 位 | 无 | 否 | 4122 | namespace + name 哈希(MD5) |
| v4 | 128 位 | 无(纯随机) | 否 | 4122 | 通用唯一 ID |
| v5 | 128 位 | 无 | 否 | 4122 | namespace + name 哈希(SHA-1) |
| v6 | 128 位 | 有 | 是 | 9562 | v1 的时序修复版 |
| v7 | 128 位 | 有(毫秒前缀) | 否 | 9562 | 数据库主键首选 |
| v8 | 128 位 | 自定义 | 自定义 | 9562 | 实验性自定义 |
九成场景只需要 v4 和 v7——v3/v5 用于稳定哈希、v1/v6 已被 v7 取代。
v4 vs v7 的核心差异
v4:完全随机
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
└─122 位随机 + 4 位版本(4)+ 2 位变体──┘
122 位随机内容,重复概率忽略不计(每秒生成 10 亿个 UUID 跑 85 年才有 50% 概率重复)。
v7:时间戳 + 随机
xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx
├─48 位毫秒 Unix 时间戳─┤
└─74 位随机 + 4 位版本(7)+ 2 位变体─┘
前 48 位是毫秒级 Unix 时间戳——同一毫秒内生成的 UUID 共享前 12 个十六进制字符,字典序 ≈ 时间序。后 74 位随机保证唯一。
为什么 v4 当主键慢
以 MySQL InnoDB 为例:
- 主键是聚簇索引——表数据按主键顺序物理存储
- 数据存在 16KB 数据页里
- 插入一行就要找到对应主键位置的数据页,把行塞进去
v4 完全随机:
- 每次插入都可能落在 B+Tree 不同位置,触发节点分裂
- 数据页填充因子从 ≈95% 跌到 ≈50%,磁盘占用翻倍
- Buffer Pool(内存缓存)热点分散,命中率下降
- IO 模式从顺序变随机——机械盘灾难,SSD 影响小
v7 主键:
- 新插入的 UUID 总是大于已有的,落在 B+Tree 最右侧
- 类似自增主键,只有最右一页参与分裂
- 填充因子接近 95%
- 写入热点集中在右侧——和自增主键的 IO 模式一致
实测亿级表插入性能对比:
| 主键 | 插入速度 | 备注 |
|---|---|---|
| 自增 BIGINT | 100K rows/s | 基线 |
| UUID v7 | 80-100K rows/s | 接近自增 |
| UUID v4 | 10-30K rows/s | 慢 3-10 倍 |
但 v7 不是万能:两个隐私代价
暴露生成时间
v7 前 48 位是毫秒时间戳,任何拿到 UUID 的人都能反推生成时刻:
const uuid = '01949c40-3a32-7000-...'
const ms = parseInt(uuid.slice(0, 8) + uuid.slice(9, 13), 16);
new Date(ms); // 反推时间
业务影响:
- 订单 ID 暴露每天订单量(竞争对手能采样统计)
- 用户注册 ID 暴露注册时间(推断业务增长)
- 支付流水号暴露交易时序
同时段可枚举
同 1ms 内生成的多个 UUID 共享前 12 字符。如果实现没足够熵:
- 攻击者拿到一个 UUID
- 同毫秒生成的”邻居 UUID”枚举范围有限
- 用于猜测一次性 token、邀请链接
v4 vs v7 选型决策表
| 场景 | 选择 | 理由 |
|---|---|---|
| 数据库内部主键 | v7 | 性能 vs 隐私无对比 |
| 内部日志关联 ID | v7 | 同上 |
| 公开 URL 资源 ID | v4 | 防枚举 + 防业务量泄露 |
| 一次性 token | v4 | 不能被反推时间 |
| API key | v4 | 同上 |
| Session ID | v4 | 同上 |
| 邀请链接 | v4 | 防枚举 |
| 内部消息队列 ID | v7 | 时序对消费有用 |
| 用户 ID | v4(公开)/ v7(不公开) | 看是否暴露给外部 |
经验法则:ID 会出现在公开 URL 或返回给前端 → v4;纯内部、不暴露 → v7。
数据库存储格式
MySQL:BINARY(16) + 函数转换
CREATE TABLE orders (
id BINARY(16) PRIMARY KEY,
...
);
-- 写入
INSERT INTO orders (id, ...) VALUES (UUID_TO_BIN('...', 1), ...);
-- 读取
SELECT BIN_TO_UUID(id, 1), ... FROM orders;
UUID_TO_BIN 第二个参数 1 表示交换前 3 段——这是给 v1 设计的(让时序前缀来到字节首位让索引更友好)。v7 不需要交换,但兼容性写法仍可用。
PostgreSQL:原生 uuid 类型
CREATE TABLE orders (
id uuid PRIMARY KEY,
...
);
INSERT INTO orders (id, ...) VALUES ('...'::uuid, ...);
PostgreSQL 内部存 16 字节,不用纠结。
千万别用 CHAR(36)
| 列类型 | 大小 | 索引大小(亿行) | 备注 |
|---|---|---|---|
BINARY(16) | 16 字节 | ≈1.6 GB | 推荐 |
CHAR(36) | 36 字节 | ≈3.6 GB | 浪费 |
VARCHAR(36) | 37 字节 | ≈3.7 GB | 最浪费 |
差距 2.25 倍,亿级表是几十 GB 的索引差异,直接影响 Buffer Pool 命中率。
v1 / v6 为什么被淘汰
v1 的两个致命伤:
- 节点 ID = MAC 地址——拿到 UUID 反推出生成机器的网卡 MAC,泄露物理位置;2003 年微软因此修了 Office 文档作者识别漏洞
- 时间字段被反转——OSF DCE 规范让 60 位时间戳”低位优先”排列,字典序不等于时间序,当索引前缀没用
v6:把时间字段顺序排好,但仍带 MAC——隐私问题没解决。
v7:直接用毫秒 Unix 时间戳作为前缀(顺序排列)+ 后续位纯随机,不含 MAC 地址——v1/v6 的两个问题一次解决。
新项目:直接 v7,不用 v1/v6。
和 ULID / Snowflake 的对比
ULID(Crockford Base32 编码 26 字符)
01HQ2ZX5K3M7Q9R1S2T3V4W5X6Y
└─10 字符时间戳─┤└─16 字符随机─┘
- 优势:人眼可读、无
-、26 字符紧凑、URL safe、字典序=时间序 - 劣势:不是 RFC 标准、部分库支持弱、Base32 解码略慢
- 适合:业务 ID 公开(订单号、消息 ID)
Snowflake(64 位整数)
[0][41 位时间戳][10 位机器 ID][12 位序号]
- 优势:只有 8 字节、纯数字、自带分片信息、Twitter/Discord 验证
- 劣势:需中心化配置机器 ID、69 年寿命、机器 ID 冲突会重复 ID
- 适合:分布式架构、QPS 极高、需要分片信息
决策树
1. 单库单表?
├─ 是 → 自增 BIGINT
└─ 否 → 继续
2. 公开 ID 还是内部?
├─ 公开内部不用区分 → ULID
├─ 内部专用 → 继续
3. QPS 极高 + 需要分片信息?
├─ 是 → Snowflake
└─ 否 → UUID v7
4. 需要不可预测性?
└─ 是 → UUID v4(接受性能代价)
工程实务清单
新项目主键选型:
- 数据量预估 < 1000 万行 + QPS < 100 → 用什么都行,v4 也没问题
- 数据量 千万到亿级 → v7 + BINARY(16)
- 十亿级 + 高 QPS → Snowflake / 分库分表 + 业务键
- 公开 ID(订单/消息号) → ULID 或自定义编号
- 一次性 token / API key → v4
老项目从 v4 切换:
- 先量化痛点——QPS 上限、Buffer Pool 命中率、写延迟 P99
- 真正扛不住才切;切就直接上 Snowflake,比 v7 工程上更可控
- 切换不要做”双主键”——成本高、bug 多,不如一次性重建
UUID 不是越复杂越好——够用就行,不留性能债。