很早前曾接触过分布式日志系统,服务节点分布在全国各地。刚开始用自增 ID 做主键,合并数据时撞得一塌糊涂。切换到 UUID 之后解决了唯一性问题,但新问题来了:日志详情页加载越来越慢。
后来发现 MySQL 索引碎片严重,就是因为 UUID v4 完全随机,B+ 树索引频繁分裂。那时候我才开始认真研究 UUID 的不同版本。
UUID 的标准化格式
UUID 的全称是 Universally Unique Identifier,128 位长度,标准格式是 8-4-4-4-12 的 36 个字符:
xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx
其中 V 代表版本号。第 9 个分组(第三组的第一个字节)的高 4 位存版本号。第 10 个分组(第四组的第一个字节)的高 2 位存变体标识。
所以一个 UUID 的版本信息是内嵌在字符串中的。看到 550e8400-e29b-41d4-a716-446655440000 中的 4,就知道这是个 v4 版本。
仔细看一下位级别的布局。128 位里,版本号只占 4 位,变体标识占 2 位。剩下的 122 位是你的有效空间。v1 用这 122 位中的大部分来存时间戳和 MAC 地址,v4 全部随机,v7 则用时间戳 + 随机数。版本不同,这 122 位的分配方式完全不同。
后来遇到一个坑:有人问我为什么他生成的 UUID 字符串第 15 个字符总是 4。我说你看一下自己用的生成库——大概率调的是 uuid.v4()。版本位固定为 0100(二进制),转换成十六进制就是 4。这不是巧合,是规范规定的。
UUID v4 最通用的选择
v4 的原理最简单:生成 122 位随机数,加上 6 位固定的版本和变体标记,拼成 128 位。
JavaScript 中生成 v4 的标准方式:
crypto.randomUUID();
// 输出: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
这是现代浏览器和 Node.js 原生支持的 API。底层调用操作系统的加密随机数生成器(/dev/urandom 或类似),质量有保证。不要用 Math.random() 来生成 UUID——它只有 52 位精度,不够 128 位。用 Math.random() 生成的所谓 UUID,碰撞概率比正规实现高几个数量级。
v4 的优点很明确:简单、安全、去中心化。每个节点自己生成就行,不需要协调。缺点是完全不排序。作为数据库主键时,每次插入都在随机位置写数据。InnoDB 的聚簇索引基于 B+ 树,随机插入意味着频繁的页分裂。
在日志系统中就是用了 v4,MySQL InnoDB 的索引碎片率长期在 30% 以上。一个 SELECT 查询要扫描的页数比理论值多了 30%。当时没意识到是 ID 生成策略的问题,先加了定期 OPTIMIZE TABLE 来清理碎片。这操作本质上只是缓解——OPTIMIZE 之后碎片率降下来了,跑一个月又回去了。
说句实话,如果表的数据量在百万以下,v4 的碎片问题影响不大。日志系统每天几百万条写入,才暴露了这个瓶颈。
UUID v1 的时间序与隐私问题
v1 在当前时间戳前面截取 100 纳秒级的值,加上生成节点的 MAC 地址。时间戳保证了单调递增,MAC 地址保证了节点唯一性。
看到这里你可能会想:那 v1 既能排序又保证唯一,为什么不直接用?
问题出在 MAC 地址上。MAC 地址是固化在网卡里的,全球唯一且暴露了厂商信息。如果系统生成的 UUID 被外部拿到,从 UUID 里可以反推出生成设备的网络硬件信息。安全审计里这算信息泄露。
一个对外暴露的 API 返回了包含 v1 UUID 的资源 ID,第三方合作方通过 MAC 地址前缀查到服务器网卡型号和厂商。虽然不是直接的安全漏洞,但被客户问"你们为什么在 URL 里暴露硬件信息"的时候,解释起来很被动。
v1 还有一个工程上的限制:同一 100 纳秒内最多生成 8192 个 UUID。超过这个数,时间戳位会回绕。日常业务场景碰不到这个上限,但批量数据导入的高并发场景下确实会有问题。
另外,v1 的时间有序性只在单节点内保证。节点 A 和节点 B 在不同时区或不同时钟频率下,生成的 UUID 跨节点无法保证全局有序。
UUID v7 的现代设计
v7 是 2021 年提出的新标准(RFC 9562),比 v4 和 v1 晚了十几年。核心思路就一句话:取 v1 的有序性,弃 v1 的隐私泄露风险,保留 v4 的随机性。
位布局:
- 前 48 位:Unix 时间戳(毫秒级)
- 后 74 位:随机数 + 版本/变体位
这样数据库插入时新数据基本按时间顺序写入,B+ 树的主键索引几乎不产生碎片。同时不暴露 MAC 地址,随机部分提供了足够的唯一性。
// 用 uuid 库生成 v7
import { v7 as uuidv7 } from "uuid";
uuidv7(); // "018f9170-5a7a-7a8c-bc32-c1b2d3e4f5a6"
注意看生成的字符串:第三组开头是 7,代表版本号 7。前几组字符包含时间戳编码,所以字符串整体的字典序和生成时间正相关。
之前我帮同事排查一个性能问题:他的表主键是 UUID v4,附带 6 个二级索引。每次 INSERT 要写 7 个 B+ 树索引(1 个聚簇 + 6 个二级),全部随机写入。磁盘 IO 的写入放大系数接近 7 倍。改成 v7 后,聚簇索引变成顺序追加写入,只有 6 个二级索引是随机的,写入放大系数降到了 3 倍出头。这个改动不需要改表结构,只换了应用层的 ID 生成函数。
Node.js 23+ 已经内置支持 crypto.randomUUID() 生成 v7。不过生产环境升级 Node 版本有周期,用 uuid 包更稳妥。
有一点要注意:v7 的有序性精确到毫秒级。同一毫秒内的多个 UUID 之间的顺序由随机数决定,不是严格递增。对于绝大多数场景这完全够用了。如果要求严格递增(比如某些金融系统的流水号),需要用专门的序列号方案。
UUID 之外的方案
UUID 不是唯一的分布式标识方案,在某些场景下甚至不是最优的。
Auto-increment ID:单体应用的保底方案。简单、有序、索引友好。但在分布式环境下无法保证全局唯一,多主写入或者分库分表之后就不好用了。
Snowflake ID:Twitter 开源的方案,64 位整数。由 1 位符号 + 41 位时间戳 + 10 位机器 ID + 12 位序列号组成。比 UUID 短一半(占用 8 字节 vs 16 字节),在数据库里存储效率更高。缺点是依赖机器 ID 的全局配置——每个节点必须有唯一的 worker ID,部署扩容时要手动维护机器 ID 分配。
NanoID:21 字符,使用 URL-safe 字符集(A-Za-z0-9_-)。比 UUID 短,但碰撞概率不是"可忽略"级别。每天生成百万级别的场景,几年后有碰撞风险。适合短链接、临时标识这类对唯一性要求不极端的场景。
ULID:26 字符的可排序 ID,和 UUID v7 思路类似(时间戳 + 随机数),但出现得更早。如果不强制要求 UUID 标准格式,ULID 也是一个可选方案。
实际项目中的选型建议
踩过一些坑之后,可以这样大致选择:
- 数据库内部主键:量小用自增 ID,量大用 v7 或 Snowflake。单表月增量超过百万,就别用 v4 了
- 对外暴露的资源 ID:用 v7。既不暴露内部信息,又不会像自增 ID 那样让攻击者猜到资源总量
- 分布式追踪:v7 或 W3C Trace Context。关键是支持跨系统传递和排序
- 对象存储 key:v7 加目录前缀分片。避免单个前缀目录下文件数量过多,文件系统在目录条目数过大时性能下降
UUID 各版本的结构差异可以从这张图直观看到:
有一次和同事争论新项目用 v4 还是 v7,他觉得 v4 够用而且实现简单。我说服他用了 v7。一年后检查数据库,主键索引碎片率不到 5%。如果系统规模不大(几十万条的水平),v4 也不是不能用——索引碎片优化属于收益递减的提前优化。关键是判断你的系统会涨到什么量级。
最后补一句:网上很多文章说 UUID 有"128 位所以碰撞概率极低",这话没错但不完整。碰撞概率和随机数的质量直接相关。用加密安全随机源(crypto.randomUUID)生成的 v4/v7,碰撞概率确实验证过可以忽略。但如果用低质量的随机源,结果会完全不同。以前有个项目用了某第三方库的 UUID 生成,后来发现那个库底层调的是 Math.random(),复现了问题之后立刻换了实现。
需要在线生成和验证 UUID 的话,可以用一些在线的 UUID 生成器,支持 v4 和 v7 版本,生成结果可直接看到版本标识位。