UUID v4 和 v7 区别:数据库主键该选哪个,v7 会泄露什么
很多人是在数据库变慢之后才开始搜”UUID 做主键 性能”的。表里几万行的时候一切正常,数据量上去、索引放不进内存之后, 插入延迟开始抖动。多数情况下,原因出在 v4 的随机性上,而 v7 就是为了解决它而出现的。
为什么随机主键会拖慢 B+ 树索引
v4 有 122 位随机数,新生成的 key 可以落在索引里的任何位置。每次插入都要定位到一个随机的叶子页:页在内存里还好, 不在就要读盘;那个页已经满了,就要分裂。自增主键的行为完全相反,每次都往索引最右边追加,热点页始终只有那几个, 页也能写满之后才开下一个。
v7 的开头是毫秒时间戳,后生成的 id 一定排在先生成的后面,所以插入也集中在索引右侧,和自增列类似,但 id 仍然可以 在任意客户端生成,不必向数据库申请。反过来说,如果表很小,或者这个 UUID 根本不参与范围查询,两者差别可以忽略, 用 v4 完全没问题。
v7 里面到底有什么
RFC 9562(2024 年 5 月发布,取代 RFC 4122)规定的布局如下,总共 128 位:
| 位数 | 字段 | 内容 |
|---|---|---|
| 48 | unix_ts_ms | 自 Unix 纪元起的毫秒数,大端 |
| 4 | ver | 0111,也就是数字 7 |
| 12 | rand_a | 随机数(或计数器) |
| 2 | var | 10 |
| 62 | rand_b | 随机数 |
所以 v7 只有 74 位随机数,v4 是 122 位。写成文本时,版本号是第三组的第一个字符,variant 是第四组的第一个字符:
xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx,其中 y 只会是 8、9、a、b。由于时间戳就是开头的 12 个十六进制
字符,可以直接读回来:
const ms = parseInt(id.replace(/-/g, '').slice(0, 12), 16);
new Date(ms).toISOString(); // 这个 id 的生成时间
48 位毫秒能撑到公元 10889 年,不用担心溢出。
同一毫秒内的顺序
同一毫秒生成的两个 id 时间戳相同,接下来的 74 位决定先后。如果这 74 位是各自重新取的随机数,后生成的那个完全可能
排在前面。RFC 9562 给了几种办法:把 rand_a 的一部分当计数器,或者时钟没前进时对随机字段加 1。页面下方链接的
UUID 生成工具用的是第二种,同一毫秒内的 id 在随机字段上递增,所以一次生成 1000 个,结果严格升序;系统时钟如果
倒退,它的时间戳也不会跟着倒退。
不管生成器怎么做,还有两个限制。其一,跨机器的顺序只和各机器的时钟一样准,v7 能给出的是”大致按时间”,不是事件的 全序。其二,用字符串列存储时,所有值必须是同一种写法:大小写混用,或者有的带连字符有的不带,普通字符串比较就会乱。
v7 会泄露什么
前 48 位是任何人都能解出来的创建时间,上面那段代码已经演示了。作为数据库主键这通常无伤大雅,但有几个场景要留意:
- 对外的链接如
/orders/<uuid>,访问者能看出这笔订单何时创建,把一批 id 的时间戳摊开还能估出你的订单量。 - 当作不可猜测的令牌,比如重置密码链接,应当使用专门的随机令牌(至少 128 位随机数),而不是任何版本的 UUID。 CSPRNG 生成的 v4 很难猜,但 UUID 这个格式本身并不对此作出承诺。
- 更老的 v1 更糟:它包含 60 位时间戳、时钟序列号和节点 id,节点 id 以前常常是机器的 MAC 地址;而且它的时间戳是低位在前 存放的,v1 的文本顺序并不等于时间顺序。
存储方式比版本更重要
UUID 本体是 16 字节,却常常被存成 36 个字符的字符串,也就是 36 字节再加开销。有原生类型就用原生类型:PostgreSQL 有
uuid 类型,MySQL 常见做法是 BINARY(16)。每个二级索引都会复制一份主键,所以主键越胖,代价被重复支付的次数越多。
v7 存成文本排序依然正确,只是更占空间;用二进制列则按字节比较,对 v7 来说正好就是时间顺序。
怎么选
| 场景 | 用哪个 |
|---|---|
| 会增长到很大的表的主键 | v7 |
| 不能暴露生成时间的标识符 | v4 |
| 测试数据、一次性 id、小表 | 都行,v4 几乎是各处的默认值 |
| 占位符或”无值”标记 | NIL,即 00000000-0000-0000-0000-000000000000 |
| 不可猜测的密钥 | 都不要,用专门的随机令牌 |
RFC 9562 里还有两个版本,很少是正确选择:v6 把 v1 的时间戳重新排列使其可排序,只对已经在用 v1 的系统有意义;v8 留给自定义布局。同一份 RFC 还新增了全 1 的 Max UUID,和全 0 的 NIL 相对应。
会不会重复
v4 有 122 位随机数,按生日悖论估算,要生成大约 2^61(约 2.3×10^18)个 id,出现重复的概率才会到一半。现实中的表
远远到不了这个量级,所以”UUID 会撞”这种担心一般不用花时间,真正出问题的往往是随机源:用 Math.random() 手写的
“UUID 函数”,或者在容器镜像里克隆出同一份随机数种子的老式实现,这类重复是实实在在发生过的。用 crypto.randomUUID()
或系统自带的 UUID 库就可以避开。
v7 的随机部分只有 74 位,但它的冲突域被时间戳切成了每毫秒一份:只有同一毫秒内生成的 id 才可能互相冲突,而单个 进程内用上面说的递增办法已经能保证不重复。多台机器在同一毫秒各生成一个,撞上同样 74 位随机数的概率依然小到可以忽略。
拿到一个 UUID,怎么看它是哪个版本
直接看字符串的第 15 个字符(下标 14),也就是第三组的第一个字符:是 4 就是 v4,是 7 就是 v7,是 1 就是 v1。
再看第 20 个字符(下标 19),也就是第四组的第一个字符,它应该是 8、9、a、b 之一,否则这个值不符合
RFC 的 variant 规定,可能是别的系统自己造的格式,或者被改动过。排查”数据库里为什么混进了奇怪的 id”时,这两个字符
是最快的检查点。
另外,不要因为长得像 UUID 就信任它:用户提交的 id 要先校验格式,再去查库。格式合法只说明它是 128 位、写法对, 不说明它真的存在,也不说明调用者有权访问。
怎么生成
浏览器里 crypto.randomUUID() 返回 v4,要求安全上下文(https 或 localhost)。浏览器没有内置的 v7 生成函数,这正是
下面这个工具补上的部分:它提供 v4、v7 和 NIL,一次 1 到 1000 个,可切换大写、连字符以及 urn:uuid: 形式。随机数来自
crypto.getRandomValues,生成全部在页面内完成,不会把 id 发到任何地方。