UUID v1、v4、v7 对比与数据库主键设计

2026-04-13发布 8分钟阅读

摘要 (TL;DR)

直话直说:把 UUID v4 当作高写入表的主键就是给自己挖坑。最近我看一个 PostgreSQL 16 工作负载,events.id 默认 gen_random_uuid()(v4),每次 INSERT 都落在 B-tree 的随机叶子页上,让 shared_buffers 持续被刷凉,pgstattuple 报的索引碎片爬到了高个位数。把这一列默认换成 v7 生成器——同样的 16 字节 uuid 类型、同样的索引——平均 INSERT 延迟降到原来的约 1/3,碎片指标也稳了下来。UUID 版本选择是数据库设计决定,不是密码学决定。UUID 是 128 比特,按惯例写成 32 个十六进制位 + 连字符的 8-4-4-4-12,4 比特固定为版本,2–3 比特固定为 variant,其余各版本不同。v1 把时间和节点标识(历史上是 MAC)打入这 128 比特,大致能按时间排序,但泄漏主机。v4 把 122 比特填随机数:强不可预测,但无序,插入落在索引随机位置。v7 在 RFC 9562(2024)里正式化,高位放 Unix 毫秒时间戳,尾部放随机,结合了 v4 的安全性和 v1 的索引局部性。对那种”不可猜测随机”真的重要的公开 API,v4 仍是默认。其他几乎所有场景里,v7 都是更好的主键。v1 是遗产——数据库会接受,但不该作为新设计的默认

背景与概念

UUID 是 128 比特,按惯例打印为 32 个十六进制位、用连字符分组:xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxxM 位放版本(1、4、7 等),N 高位放 variant,其余因版本而异。

版本 1 是为跨机、跨时间唯一性设计的。60 比特时间戳计数 1582-10-15 以来的 100 纳秒间隔,clock-sequence 字段处理时钟回拨,48 比特节点 ID 原本是网络 MAC 地址。隐私后果很直接:你笔记本上发的 v1 UUID 编码了你笔记本的 MAC,一行 uuid -d 就能反推。现代库有时把节点 ID 随机化以避免泄漏,但许多仍按原规则。

版本 4 是 122 比特随机加 6 比特版本/variant 固定。它假定有强 RNG(浏览器 crypto.randomUUID() 或 PostgreSQL 的 gen_random_uuid())。在任何现实规模下碰撞几乎不可能——生日界给出大约生成十亿个 v4 后才有约万亿分之一的碰撞概率。缺点是连续两个 v4 互不相关,所以插入索引时触碰随机页,缓存局部性和写入放大(Postgres 的 WAL 加 full-page write)都不友好。

版本 7 是 2024 年发布的 RFC 9562 的一部分,该 RFC 废弃了 RFC 4122 并加入 v6、v7、v8。v7 在高位放48 比特 Unix 毫秒时间戳,接版本标签、小尺寸 rand_a、variant 标签、62 比特 rand_b 尾部。实际效果是同一毫秒内生成的 v7 在排序上相邻,跨毫秒按时间排序。仅随机尾部就有足够熵在同毫秒内确保唯一。PostgreSQL 18 自带原生 uuidv7();更早版本可以用 pg_uuidv7 扩展或在应用层(Node uuid 9.x、Python uuid6)生成。

variant 比特之所以重要,是因为它把 RFC 9562/4122 家族与遗留的 Microsoft、Apollo UUID 区分开来。本文假设是 RFC variant(N 组首位十六进制是 89ab 之一)。

存储格式是另一个问题。PostgreSQL 16 的原生 uuid 类型把值存为 16 字节;MySQL 通常用 BINARY(16)CHAR(36)——后者把存储翻倍且让比较按字符进行。版本选择和存储格式相互影响:v7 按二进制排序便宜又正确;按十六进制字符串排序也正确但慢;v4 不论怎么存排序都没意义。

对比与数据

属性v1v4v7
生成输入时间戳 + clock sequence + 节点 ID122 随机比特48 比特 Unix ms + 随机尾部
隐私泄漏节点 ID(常是 MAC)无主机或时间信息泄漏毫秒级生成时间,无主机
时间可排可以(但字节序 ≠ 时间序,需重排)不可可以——字典序与时间序一致
索引局部性中等差(B-tree 全局随机插入)好(近单调)
典型用途遗留系统、部分 COM/Windows ID公开 API ID、会话 token、salt事件日志、高写入、按时间分页
低(多数比特是时间/节点)高(约 122 比特)尾部高(约 74 比特),毫秒内低碰撞

粗略心智模型:v4 用索引行为换最大不可猜测性;v7 在多数应用中保留够用的不可猜测性,同时恢复数据库喜欢的”在末尾插入”模式;v1 是历史遗物,值得认得但不该选。

实战场景

**场景 1 — 追加为主的事件日志。**开头那个工作负载就是这种形状。每天进几百万行、通常按”最近 24 小时按时间排”查询的表,从 v7 直接受益。新行落在主键索引末尾,热页保持温热,时间范围扫描映射到连续的索引段。从 v4 迁到 v7 这种场景常常只改一行列默认值,查询代码不动,写延迟和索引碎片就都降。

场景 2 — 面向用户的公开 ID。/orders/{id} 这种分享链接必须不可猜测,否则其他用户的订单会被枚举。v4 是安全默认。如果你想要 v7 的好处也行,但记住 v7 会泄漏毫秒级创建时间——对订单可能没事,但在更敏感的语境下(比如分钟级结账量这种业务信号)就是泄漏。我推荐过的折中是双 ID 模式:内部主键用 v7、对外暴露另一个 v4 或短随机 slug。索引行为不丢,外部不泄漏时间。

场景 3 — 多区域或分片系统。v7 的时间戳前缀让两个区域在同毫秒发的 UUID 按时间自然交错,但同毫秒内跨区域没有顺序保证。如需更严格的跨区域顺序,ULID(48 比特时间戳 + 80 比特随机,用 Crockford Base32 编码)有几乎相同的特性且文本形态更紧凑(26 字符)。要更严格的保证,含显式机器 ID 的 Snowflake 风格 ID(Twitter 原版与 Discord 变体都是 64 比特)能做到,但代价是多承担一份机器 ID 协调分配的工作。

常见误解

**“UUID 在数据库里总是慢。“**比 4 字节 int 慢,但实际成本大头来自 B-tree 索引的随机插入碎片,v7 大体消除了这个问题。把 UUID 存为 16 字节而非 36 字符串能把索引体积减半、比较更快。许多”UUID 慢”的基准其实是”MySQL 里 v4 用 CHAR(36) 存就慢”。

**“只有 v4 是安全的。“**v7 的随机尾部仍是大熵池,对会话引用、API ID 这类应用攻击者枚举不现实。可猜测性问题在于时间戳前缀:v7 暴露行的创建时间。如果这种暴露能接受(通常都能),v7 对外部 ID 也是合理选择。

**“UUID 必须存为字符串。“**字符串形态 36 字符(32 十六进制 + 4 连字符),二进制 16 字节。二进制更紧凑,按字节排序就是正确顺序,对字节序非单调的 v1 和需要字节序与时间一致的 v7 都很重要。PostgreSQL 的 uuid 类型本身就是 16 字节存,不必额外考虑。

**“v1 的 MAC 泄漏没人看。“**v1 反推 MAC 是公开变换,uuid -d 和取证工具默认就提取。如果 UUID 出现在 URL、支持工单、对外共享的日志里,就是真实的信息暴露。

决策清单

  1. **会进入频繁插入的索引吗?**默认 v7。只有当创建时间不可猜测真的关键时才回退到 v4。
  2. **会暴露给最终用户或合作方吗?**两种都行,确认 v7 的时间戳泄漏在该场景下能接受即可。
  3. **用 Postgres?**存为 uuid(16 字节)。MySQL 上除非真的需要字符串兼容,否则用 BINARY(16)
  4. **需要跨多个生成器的顺序保证?**单靠 v7 不够;考虑同时间前缀的 ULID 或带显式机器 ID 的 Snowflake 风格。
  5. **代码库里还有 v1 吗?**写入文档说明 MAC 泄漏,schema 允许时安排迁移。
  6. **客户端生成 UUID 吗?**用调用密码学强 RNG 的库(现代浏览器的 v4 用 crypto.randomUUID(),v7 库通常封装同一 RNG)。

相关工具

Patrache Studio UUID 生成器 在本地生成 v4 与 v7,所以生成的值不会进第三方服务的日志。UUID 几乎总是装在 JSON 载荷里出行——JSON 格式化、校验、JSON Schema 在实战中的差异 讲了在服务间移动时如何用 schema 保证这些 ID 类型正确。需要紧凑文本表示(比如从 16 字节 UUID 派生 22 字符短 ID)时,Base64 与 URL 编码:作用、陷阱、正确用法 解释为什么该选 Base64URL 而不是标准 Base64。

参考资料