UUID v4 vs v7:数据库主键到底该选哪个

· 约 5 分钟 🆔 UUID 生成

UUID 当数据库主键已经有十多年争议——支持者说”分布式系统必须用”,反对者说”性能爆炸不可用”。2022 年 RFC 9562 引入 UUID v7,把”时序友好”这块短板补上后,争论的天平真的变了。这篇按数据库引擎、QPS、隐私需求拆解 v4/v7/v1/Snowflake/ULID 的取舍,给出每个场景的最佳选型。

UUID 各版本速查

版本大小时序含 MACRFC主要用途
v1128 位有但被打乱4122历史遗留
v3128 位4122namespace + name 哈希(MD5
v4128 位无(纯随机)4122通用唯一 ID
v5128 位4122namespace + name 哈希(SHA-1
v6128 位9562v1 的时序修复版
v7128 位有(毫秒前缀)9562数据库主键首选
v8128 位自定义自定义9562实验性自定义

九成场景只需要 v4v7——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 模式一致

实测亿级表插入性能对比:

主键插入速度备注
自增 BIGINT100K rows/s基线
UUID v780-100K rows/s接近自增
UUID v410-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 隐私无对比
内部日志关联 IDv7同上
公开 URL 资源 IDv4防枚举 + 防业务量泄露
一次性 tokenv4不能被反推时间
API keyv4同上
Session IDv4同上
邀请链接v4防枚举
内部消息队列 IDv7时序对消费有用
用户 IDv4(公开)/ 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 的两个致命伤

  1. 节点 ID = MAC 地址——拿到 UUID 反推出生成机器的网卡 MAC,泄露物理位置;2003 年微软因此修了 Office 文档作者识别漏洞
  2. 时间字段被反转——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(接受性能代价)

工程实务清单

新项目主键选型:

  1. 数据量预估 < 1000 万行 + QPS < 100 → 用什么都行,v4 也没问题
  2. 数据量 千万到亿级 → v7 + BINARY(16)
  3. 十亿级 + 高 QPS → Snowflake / 分库分表 + 业务键
  4. 公开 ID(订单/消息号) → ULID 或自定义编号
  5. 一次性 token / API key → v4

老项目从 v4 切换:

  • 先量化痛点——QPS 上限、Buffer Pool 命中率、写延迟 P99
  • 真正扛不住才切;切就直接上 Snowflake,比 v7 工程上更可控
  • 切换不要做”双主键”——成本高、bug 多,不如一次性重建

UUID 不是越复杂越好——够用就行,不留性能债

❓ 常见问题

UUID 当主键真的有那么慢?我用 v4 一直没事啊?

数据量小时看不出来,超过千万行就会掉坑慢在哪里:(1) B+Tree 插入位置随机——MySQL InnoDB 主键是聚簇索引,v4 完全随机意味着每次插入都可能在已有 B+Tree 的不同位置触发节点分裂;(2) 页填充因子下降——本来一个 16KB 数据页能装 100 行,分裂后变成 50 行半空,磁盘占用翻倍;(3) Buffer Pool 命中率下降——随机插入让热点页不连续,缓存里同时要装很多页;(4) 磁盘 IO 模式从顺序变随机——SSD 影响小,机械盘影响巨大。实测数据(亿级 InnoDB 表):(1) 自增 BIGINT 主键插入 ≈100K rows/s;(2) UUID v4 主键插入 ≈10-30K rows/s(慢 3-10 倍);(3) UUID v7 主键插入 ≈80-100K rows/s(接近自增)。什么时候没事:(1) 表小于 1000 万行;(2) 写入 QPS < 100;(3) 全在内存(Buffer Pool 比表大);(4) 用 PostgreSQL(堆表,不是聚簇索引,差异小很多)。

v7 比 v4 好这么多,全部用 v7 不就行了?

v7 有两个真实代价:暴露生成时间 + 序号可预测问题 1 —— 暴露时间戳:(1) v7 前 48 位是毫秒级 Unix 时间戳,任何人拿到 UUID 都能反推出生成时刻;(2) 业务暴露:用户注册 token、订单 ID、支付流水号——竞争对手能粗略统计你每天的订单量;(3) 安全暴露:账户激活 token、密码重置 token——攻击者根据时间猜测可能的 token 范围。问题 2 —— 同时段连续生成:(1) 高并发场景同 1ms 内生成多个 UUID 时,部分实现用计数器递增子位,同节点连续生成的 UUID 序号可枚举;(2) 即便加了随机位,同 1ms 内的 UUID 之间相关性强——攻击者拿到一个能猜邻近的。何时该用 v4:(1) 公开 URL 里的资源 ID(防遍历);(2) 一次性 token、邀请链接;(3) API key、session ID;(4) 不希望被反推业务量的标识。何时该用 v7:(1) 数据库内部主键(不暴露给外部);(2) 内部日志关联 ID;(3) 内部消息队列 message ID。

数据库存 UUID,用 CHAR(36) 还是 BINARY(16)?

优先 BINARY(16),CHAR(36) 是性能浪费对比:(1) CHAR(36)——存 36 字符的字符串形式 550e8400-e29b-41d4-a716-446655440000,UTF-8 下占 36 字节;(2) BINARY(16)——直接存 16 字节二进制;(3) VARCHAR(36)——同 CHAR(36) 但带长度前缀,浪费 1 字节;(4) MySQL 8.0+ 的 UUID() 函数默认返回字符串。性能差异:(1) 存储空间 —— BINARY 比 CHAR 节省 55%(亿级表节省几十 GB);(2) 索引大小 —— 索引节点能装更多 entry,B+Tree 层数减少,IO 减少;(3) 比较速度 —— 二进制比较比字符串快;(4) 网络传输 —— 二进制更紧凑。MySQL 实务:(1) 列类型 BINARY(16);(2) 写入用 UUID_TO_BIN(uuid_str, 1)——第二参数 1 表示交换前 3 段(让 v1/v6/v7 时序前缀来到字节首位,索引更友好);(3) 读取用 BIN_TO_UUID(uuid_bin, 1);(4) 应用层(Java/Go/Node)UUID 库通常支持 byte array 直接对接。PostgreSQL 自带 uuid 类型,内部就是 16 字节,不用纠结——CREATE TABLE x (id uuid) 即可。

我已经用了 v4,现在表很大要不要切 v7?

视痛点决定,不要为切而切先量化痛点:(1) 测当前 INSERT 的 QPS 上限——sysbench oltp_insert 连续跑 5 分钟看 IOPS;(2) 看 Buffer Pool hit rate 是否健康(< 95% 就紧张);(3) 看磁盘 fragmentation——OPTIMIZE TABLE 后大小有没有显著缩;(4) 监控 P99 写入延迟有没有突刺。真正需要切的信号:(1) 写入 QPS > 5000 且开始扛不住;(2) 表 > 5000 万行且每周持续增长;(3) Buffer Pool 远小于表 + 索引大小;(4) 已经做了分库分表但写入仍是瓶颈。迁移方案:(1) 新表用 v7、老表保留 v4 —— 简单但分裂;(2) 双主键时段切换 —— 保留旧 v4 作为业务键,新增 v7 作为内部排序键,写入按 v7 排序;(3) 彻底重建 —— 新表用 v7,老数据 dump 后导入新表,应用层批量更新引用;(4) 分库分表后用 Snowflake —— 单 ID 不够用就上 Snowflake,全局有序且包含分片信息。多数情况建议:(1) 写入压力不大就保留 v4,集中在隐私优势;(2) 真的扛不住就直接上 Snowflake,比 v7 工程上更可控。

ULID、Snowflake、UUID v7 都能时序,怎么选?

按生成方、是否分布式、字符串需求选ULID(128 位、Crockford Base32 编码 26 字符):(1) 优势——人眼可读(无 -、26 字符紧凑)、URL safe、字典序 = 时间序;(2) 缺点——不是 RFC 标准,部分库支持差;(3) 适合——业务 ID 公开(订单号、消息 ID)。UUID v7(128 位、标准 UUID 格式 36 字符):(1) 优势——RFC 9562 标准、所有 UUID 库都升级支持、二进制 16 字节;(2) 缺点——36 字符长、视觉上和 v4 没区别;(3) 适合——内部主键、迁移自 UUID v4 场景。Snowflake(64 位整数):(1) 优势——只有 8 字节、纯数字、自带时间戳+机器 ID+序号、Twitter/Discord 等大公司验证;(2) 缺点——需要中心化配置(机器 ID 不能冲突)、64 位数年限有限(41 位时间戳约 69 年);(3) 适合——分布式架构、QPS 极高、需要分片信息。自增主键(BIGINT 8 字节):(1) 优势——最快、最小、自带时序;(2) 缺点——单库单表才能自增,无法跨库;(3) 适合——分库分表前的简单架构、内部表。决策:(1) 单库 → 自增主键;(2) 分布式但内部 → UUID v7;(3) 分布式 + 高 QPS → Snowflake;(4) 公开 ID + 紧凑 → ULID;(5) 需要不可预测 → UUID v4 但接受性能代价。

UUID v1 不是已经有时序信息了吗?为什么还要 v7?

v1 有两个致命伤:MAC 地址泄露 + 时序字段被反转v1 结构(128 位):(1) 60 位时间戳(自 1582 年的 100 纳秒);(2) 14 位时钟序列;(3) 6 位变体/版本标记;(4) 48 位 节点 ID(默认是 MAC 地址)问题:(1) MAC 地址泄露——任何拿到 v1 UUID 的人都能反推出生成机器的 MAC 地址,2003 年微软因此修了 Office 漏洞(用 v1 标识文档作者);(2) 时间字段是反转的——为了符合 OSF DCE 规范,60 位时间戳被分成"低 32 位 + 中 16 位 + 高 12 位"反向排列,字典序不等于时间序——当索引前缀完全没用;(3) 多机器时钟不同步会冲突。v7 怎么解决:(1) 前 48 位直接是毫秒级 Unix 时间戳(不反转),字典序 = 时间序;(2) 中间 12 位是版本+计数器+随机;(3) 后 64 位纯随机;(4) 不含 MAC 地址v6 是 v1 的"重新排序版"——把时间字段顺序排好,保留节点 ID。但 v6 也泄露 MAC,所以新项目直接跳到 v7

🆔 打开 UUID 生成 v4/v7/v1/v3/v5·批量 1–1000·大小写/连字符/花括号·解析校验·本地生成