UUID 完全指南:v1 到 v7 的区别、碰撞概率与数据库实践
2026-08-06
打开任何一个现代系统的数据库,你大概率会看到像 0198a3e2-7b1c-7f3a-9e4d-2c5b8a1f6e90 这样的字符串躺在主键列里。它就是 UUID——通用唯一标识符。看起来像一串随手敲出来的乱码,实际上每一位都有严格的规范:版本号藏在固定的位置,前几位可能编码了时间,碰撞概率背后是一道经典的生日悖论数学题。这篇文章把 UUID 从 v1 到 v7 的来龙去脉、数学保证和最常见的误用一次讲清楚。
UUID 的结构:128 位,五个段
UUID 本质是一个 128 位(16 字节)的整数,标准写法是 8-4-4-4-12 的十六进制格式,一共 36 个字符(含 4 个连字符):
0198a3e2-7b1c-7f3a-9e4d-2c5b8a1f6e90
↑ ↑
版本号 变体位
两个关键位置值得记住:第三个段的第一位是版本号(上面是 7,表示 v7),第四个段的第一位是变体位(标准 RFC UUID 通常是 8、9、a 或 b)。所以拿到一个 UUID,先看第三段开头就能知道它是哪个版本——手头没有解析器时,可以把它粘贴到 UUID 解析工具 里,版本、时间戳和各位的含义都会直接标出来。剩下的位用来装什么,完全由版本决定。
从 v1 到 v7:每个版本在解决什么问题
v1(时间 + MAC 地址):最早的版本,用当前时间戳(1582 年以来的 100 纳秒数)、时钟序列和生成机器的 MAC 地址拼成。优点是有序且几乎不会重复;缺点同样明显——泄露生成机器的 MAC 地址和生成时间,算半个隐私问题,而且分布式环境下多台机器的时间戳可能回拨。一个典型的 v1 长这样:c2f4d8a0-6e3b-11ec-90d6-0242ac120003,注意第三段以 1 开头。
v3(MD5 名字散列):给定一个命名空间和一个名字,用 MD5 散列出固定结果。同样的输入永远得到同样的 UUID,适合”给已知实体生成稳定 ID”的场景。MD5 在密码学上早已不安全,v3 基本只在维护老代码时见到。
v4(纯随机):目前使用最广泛的版本。122 位全部随机(128 位减去 4 位版本号和 2 位变体位),比如 f47ac10b-58cc-4372-a567-0e02b2c3d479——第三段的 4 就是版本号。实现简单、不泄露任何信息,这是它流行的原因;代价是完全无序,对数据库索引不友好,后面细说。
v5(SHA-1 名字散列):v3 的升级版,把 MD5 换成 SHA-1,语义相同:相同命名空间加相同名字,永远得到相同 UUID。比如”把用户邮箱散列成稳定 ID”这种需求就该用 v5 而不是自己拼哈希。
v6(重排时间的 v1):把 v1 的时间戳字段重新排序,让高位在前,从而按字典序和时间序一致。可以理解为”v1 的索引友好版”,实际使用不多,主要为了兼容已有 v1 数据的系统平滑升级。
v7(时间有序 + 随机,2024 年正式标准化):前 48 位是 Unix 毫秒时间戳,后面 74 位随机。这让它同时继承了 v1 的单调递增和 v4 的隐私友好、无状态特性。0198a3e2-7b1c-7f3a-... 开头那几段其实就是毫秒数。需要亲手生成几个对比的话,用 UUID 生成器 切换版本跑一遍,v4 的”乱跳”和 v7 的”前缀一致”对比非常直观。今天新建的系统,主键选 v7 基本是社区共识。
碰撞概率:生日悖论的数学
“v4 是随机的,会不会撞?“答案是:会,但你活不到那一天。122 位随机数意味着理论空间是 2^122 ≈ 5.3 × 10^36。但碰撞不是等到”生成完整个空间”才发生——生日悖论告诉我们,碰撞来得比直觉早得多。23 个人里就有 50% 概率两人同生日,而一年有 365 天,因为碰撞的可能性随配对数平方增长。
套用生日悖论公式,生成约 2.71 × 10^18(27.1 亿亿)个 v4 UUID 才有 50% 的概率出现一次碰撞。换个更直观的尺度:每秒生成 10 亿个、连续跑 100 年,总共才约 3 × 10^18 个,碰撞概率约一半——而实际系统远远到不了这个生成速率。更常用的说法是:每生成 10 万亿个 UUID,碰撞一次的概率约为十亿分之一。这个风险低于硬盘同时坏掉、机房被陨石砸中这类事件,工程上可以放心忽略。真正会撞的情况是伪随机数生成器出了问题——用了 Math.random() 这种非加密级随机源,或者虚拟机快照被克隆导致随机种子重复。所以请记住:用各语言标准库(Python uuid.uuid4()、crypto.randomUUID()、Java UUID.randomUUID()),不要自己造随机轮子。
数据库索引:随机 vs 有序的实战差异
这是 UUID 选型中最有实际影响的一点。B-tree 索引(InnoDB 的聚簇索引就是)喜欢顺序插入:新值追加到最右边,页分裂少、写入快。自增整数和 v7 UUID 都属于这类”右追加”模式。
而 v4 是完全随机的:每条新记录随机落在索引树的任意位置,导致缓存命中率下降、页分裂频繁、索引碎片化,表一大写入性能明显劣化。这是 v4 做主键被诟病的核心原因。一个常用的折中方案是把 UUID 存成 BINARY(16) 而不是 CHAR(36)——存储从 36 字节降到 16 字节,索引体积直接减半多,对比和排序也更快。
结论很清晰:新系统用 v7 存二进制,老系统如果已经用 v4 且写入压力不大,没必要迁移。PostgreSQL 18 已经内置 uuidv7() 函数,各语言的主流 UUID 库也都跟进了 v7 支持。
最常见的误用:把 UUID 当安全令牌
必须单独强调这一条:UUID 是标识符,不是密钥。“122 位随机、别人猜不到”这句话对 ID 成立,对令牌不成立,原因有三:
- UUID 经常出现在日志、URL、referer 头、错误信息里,泄露面比你想象的大;
- 不是所有 UUID 都来自加密安全的随机源——你没法控制别人系统里那个 UUID 是怎么生成的;
- 令牌需要可撤销、可过期、可轮换,UUID 本身没有任何生命周期语义。
密码重置链接、API 密钥、会话凭证这些场景,应该用专门的随机令牌(如 256 位加密随机数的 base64url 编码)并配合过期与撤销机制,而不是随手生成一个 v4 就发出去。这条和”不要用 MD5/SHA-1 存密码”是同一级别的常识——后者如果还需要核对算法选择,哈希生成器 里能直观对比各算法的输出。
快速选型建议
最后给一个决策清单:
- 新建系统的主键:v7,存
BINARY(16)。 - 纯临时 ID、不写库的场景(前端临时节点、日志 trace id):v4 够用,生成也最简单。
- 需要从名字推导稳定 ID(比如对同一输入永远得到同一 ID):v5。
- 维护老系统遇到 v1/v3/v6:认识它们即可,不要在新代码里主动引入。
- 拿到一个陌生 UUID 想弄清来路:扔进 UUID 解析工具,版本和(v1/v7 的)时间一目了然。
- 安全相关的随机需求:不要用 UUID,用加密级随机令牌。
UUID 的成功在于它把”全局唯一”这件事变成了本地操作——不需要中心协调、不需要数据库自增锁,任何机器任何时刻都能生成。理解了版本差异、碰撞数学和索引特性这三件事,剩下的就是按场景抄作业。