选择标识符:UUID、短 ID,以及会泄露信息的那些

该用哪个版本的 UUID,为什么 v4 碰撞在实践中不值得担心,连续整数会暴露什么,短 ID 的字母表要如何取舍,以及随机比特该从哪里来。

内部使用的一切都用 UUID v4;当标识符要做一张大表的主键时用 v7;当有人得把它念出来时,用一个受限字母表生成的短码。对任何对外可见的东西,连续整数都是应当避开的选项。

值得关注的 UUID 版本

一个 UUID 是 128 位。其中四位承载版本、两位承载变体,所以没有哪个版本能把 128 位全部用掉。

版本里面装了什么什么时候用
v160 位时间戳、14 位时钟序列、48 位节点 ID(也就是 MAC 地址)只用于历史遗留数据
v3 / v5对命名空间和名称做 MD5 或 SHA-1 得到的散列需要确定性 ID 时
v4122 个随机位,此外别无他物默认选择,当不希望任何信息可被推断时
v748 位毫秒时间戳,随后是 74 个随机位插入顺序有意义的主键

v1 是那个反面教材。它嵌入了创建时间,而且在多数实现中还嵌入了生成它的那台机器的 MAC 地址,1999 年正是这类标识符帮助追查到了 Melissa 病毒的作者。v7 于 2024 年在 RFC 9562 中标准化,它是有意公开创建时间的:这既是它的特性,也是它的代价。

碰撞这个问题

一个 v4 UUID 有 122 个随机位,所以总共有 2^122 个,大约是 5.3 x 10^36。真正重要的是生日界限,而不是空间的大小:n 个值中任意两个相同的概率约为 n 的平方除以 2^123。生成一万亿个 UUID,出现重复的概率大约是十万亿分之一;要让概率达到抛硬币那样的一半,需要约 2.7 x 10^18 个值,按每秒十亿个的速度算大约要 85 年。

“实践中不必担心”终究不等于“不可能”,而差距从来不在算术上。重复来自生成器:固定的种子、连同熵池一起被克隆的虚拟机、把 PRNG 状态一并打包进去的容器镜像。唯一性属于随机源,而不属于格式,所以那一列上的唯一约束请保留着。

排序、索引局部性与信息泄露

随机标识符是散落的。在 B 树索引里,每次插入都落到一个随机的叶子页上,于是工作集变成了整个索引而不是它的右端,缓存命中率下降,页面不断分裂。随时间递增的键则总是追加在同一个地方,让固定的那几个页始终是热的。这正是 v7 和 ULID 存在的理由;ULID 是 48 位毫秒时间戳加 80 个随机位,写成 26 个可排序的 Crockford base32 字符。

代价是可预测性:按时间排序的 ID 公开了它所对应的记录是什么时候创建的,所以只要拿到一小把,就能看出注册速率和一天中的冷清时段。如果这一点要紧,就用 v4,并承担索引上的开销。

连续整数则走得更远。/invoices/1041 等于告诉别人你大约开出过一千张发票,而相隔一周的两个 ID 就给出了你的增长率。更糟的是,攻击者可以直接去请求 1040。如果服务器只因为这条记录存在、调用者又已登录就把它返回,而不检查它属于谁,那就是一次访问控制失效,通常被归类为不安全的直接对象引用。不可猜测的 ID 并不等于授权检查,但它确实能阻止别人把整张表遍历一遍。

Slug 也是标识符,只要构造得当就不会泄露任何关于业务量的信息:折叠重音符号、统一转为小写、把标点归并成连字符,Slug 生成器做的就是这些。发布之后就让它保持稳定;改动它会让外部链接失效。

短 ID、字母表与长度

随机码的每一个字符值 log2(字母表大小)个比特:base62 是 5.95 位,base32 是 5 位,十六进制是 4 位。长度和字母表共同决定了给定数量下的碰撞概率。

长度(base62)不同取值数量比特数在 10 亿个 ID 内的碰撞概率
82.2 x 10^1443.7必然发生
108.4 x 10^1759.5约 45%
123.2 x 10^2171.5约 6500 分之一
164.8 x 10^2895.3约 1000 亿分之一

这里假设每个字符都是独立随机的;像 inv_ 这样的前缀带来的比特数是零。

对任何需要人来读的东西,base62 都是错的字母表。0O1lI 都是等着发生的抄写错误,而区分大小写的码根本经不起一通电话。Crockford base32 去掉了 ILOU,输入时两种大小写都接受,值得为此多付出一个字符。

随机比特从哪里来

Math.random() 不是密码学随机源。V8 用 xorshift128+ 实现它,只要拿到一小段输出就能还原它的内部状态,此后每一个未来值都是可预测的。用来做演示用的洗牌或者占位数据没问题。用来做会话标识符、API 密钥、重置链接或令牌就是错的。

// Browsers, Node 19+, Deno and Bun
const id = crypto.randomUUID();

const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);

其他环境里:Node 用 crypto.randomBytes,Python 用 secrets.token_urlsafe,Go 用 crypto/rand,Java 用 SecureRandom,PHP 用 random_bytes。UUID 生成器产生的是密码学随机的 v4 值,密码生成器用的是同一套浏览器 API;随机数工具是用来抽样的,不是用来生成机密的。

自己动手时有一个陷阱:用 % 62 把一个随机字节映射到 62 个字符的字母表上,会偏向前八个字符;请改用拒绝采样。

占位文本与示例数据

Lorem ipsum 是被打乱的西塞罗,它之所以延续至今,正因为它读不懂:一段看着像语言却不是语言的文本,能让排版按形状和行长来评判,而不是按句子内容,Lorem Ipsum 生成器产出的就是这种东西。定稿之前请换成真实的文案,因为占位的拉丁文会掩盖真实标题将会造成的溢出。

编造的数据只有一条规则:绝不能被误认成真的。

  • 卡号要取自支付服务商公布的测试号段,或者干脆通不过 Luhn 校验。一个随机的 16 位数字如果通过了 Luhn 校验,那它就是某个人的。
  • 身份证件号码要用保留号段。以 000、666 或 900 到 999 开头的美国社会安全号码从不签发。
  • 使用 RFC 2606 保留的 example.com 及其同类,以及文档专用地址块 192.0.2.0/24。

测试数据流入生产环境的频率比人们预想的高:一个指错了数据库的种子脚本,一封发给全体订阅者的邮件模板里遗留的占位符。伪造的记录就该一眼看上去是假的,好让人在客服真的据此行动之前就发现它。

二维码简述

二维码是一个由模块组成的网格,版本从 1 到 40,尺寸从 21x21 到 177x177。载荷长度决定版本,而在印刷尺寸固定的情况下,版本越高模块越小,就越需要更好的印刷质量和更近的扫描距离。把 URL 缩短之后再喂给二维码生成器,并注意字母数字模式只支持大写,所以 HTTPS://EXAMPLE.COM 编码出来比小写形式更小。

纠错有四个等级,能在码被损坏时分别恢复大约 7%(L)、15%(M)、25%(Q)和 30%(H)。冗余会占掉容量,所以等级越高,同样的载荷就被推到更密的版本上。M 是常用的默认值;小尺寸印刷、曲面,或者中间压了一个标志时,选 Q 或 H,之所以能压标志,正是因为冗余把它吸收掉了。

有两条物理要求决定一个码能不能扫出来。静默区是四个模块宽的空白边距,四面都要有,解码器靠它找到边界,所以紧贴边框或照片的码常常失败。对比度必须是真正的深色配真正的浅色:白底浅蓝是常见的失败案例,而且很多解码器拒绝反色的码。