UUID 生成器 — 用浏览器自己的随机字节做出 v4、v7、v1,并把字段读回来
在浏览器里做出 v4、v7 或 v1 的 UUID,字节来自 crypto.getRandomValues,每个值再当面拆开:版本半字节、变体位、v7 或 v1 里装着的那一刻、真正随机的有几位。四种写法都能出,大小写都行,也能和你自己浏览器的 crypto.randomUUID() 摆在一起比。UUID 还能粘回来读,包括你的库按另一种字节顺序写出来的。你做出、粘进、存下的都不发往任何地方。
| # | 字节自己说什么 | 携带的时间 | 随机位 |
|---|
和你自己浏览器的生成器并排放着
| 查了什么 | 这台生成器 | crypto.randomUUID() |
|---|
读你已经有的那些 UUID
| 进去的 | 读出来的 |
|---|
十六个字节与四种写法
十六个字节、三十二个数字,还有那六个不归你管的位
一个 UUID 就是十六个字节。其余的一切——连字符、大写、urn: 前缀——都只是把这十六个字节写成文本的办法。十六个字节是三十二个十六进制位,规范的那种写法把它们切成 8-4-4-4-12,所以带连字符的形态是 36 个字符:其中四个是标点,一个信息都不携带。
有两个位不归你,归规范。第十三位是版本——随机的写 4,按时间排序的写 7,还在用的最老那一套写 1。第十七位装着变体,而 RFC 4122 这个变体把它的最高两位钉成 10,于是这一位只能读作 8、9、a、b 四个之一。所以一个 v4 有 128 减 4 减 2 = 实实在在随机选出来的 122 位,而每一句“独一无二”的说法,依据都是这个数字。输出框下面那张表会按你要的那个版本把它重算一遍,因为对 v7 和 v1 来说它更小:一个时钟字段不是熵。
| 版本 | 128 位被什么占着 | 留给运气的位数 |
|---|---|---|
| v4 | 122 位随机,6 位钉死 | 122 |
| v7 | 48 位 Unix 毫秒,4 位版本,2 位变体,然后是 12 位加 62 位随机 | 按这里的写法是 62,因为那 12 位是排序计数器;一个把它们留给运气的 v7 是 74 |
| v1 | 60 位以 1582 为基准的计数,4 位版本,2 位变体,14 位时钟序列,48 位节点 | 61,而那 48 位里有一位花在声明这个节点不是真地址上 |
字节从哪儿来,以及跟着它们一起到手的那句承诺
字节取自 window.crypto.getRandomValues,浏览器是从操作系统的熵池里把它灌满的。它不用 Math.random(),也不会退回去用时钟。理由不是清高:Math.random() 是一个伪随机生成器,它的全部状态就住在运行它的那台引擎里,所以由它做出来的值,任何能观察到同一个生成器连续两次输出的人都能复现,而在有些引擎上种子只有 32 位宽——一个 v4 本该有的熵的五分之一,还是一个脚本在一台笔记本电脑上一分钟就能穷举的数。
getRandomValues 给你买到的是不可预测。它买不到的是唯一,而这两者的分别,正是绝大多数 UUID 民间说法出错的地方。没有人来查。没有登记机构,没有中心权威,没有握手:地球两边的两个服务可以在同一个下午造出同一个 v4,而且谁也不会被告知。这套机制能用,是因为 122 个随机位宽到碰撞成了一件从来没有人观察到过的统计事件,而不是因为有什么东西在协调它。所以诚实的那句话是:随机 UUID 是一个重复的可能性低到你可以把它当唯一值来用的东西,前提是每一台生产它的设备都有一个正常工作的熵源。这个前提不是装饰。当一个平台的随机源每次启动都从同一个状态重新开始——这种事在已经发布的产品里、在密钥库里、在嵌入式固件里都发生过——那些设备给出的每一个“唯一”标识符都是成对相同的,而 UUID 这个格式是无辜的那一方。如果你的 ID 是在一批克隆出来的虚拟机上、或者是在不是你选的硬件上做出来的,该审的是熵源,不是那个字符串。
v7:一个能排序的时间戳,也是一句你没打算说的秘密
v4 不告诉你它是什么时候做的。这是设计如此,它同时也是一张以 v4 为主键的表会变慢的原因:存储引擎没法追加写,它必须在一个看上去随机的位置插入,于是每一次写都弄脏一页昨天没人碰过的数据,索引也不再装得进内存。v7 把这件事修好了,办法是在这个值的最前面放一个 48 位的 Unix 毫秒。因为那十二个十六进制位是字符串里的头一件事,对文本做一次普通的字典序排序——ORDER BY id、shell 里的一次 sort、一门没有 UUID 类型的语言里的字符串比较——就免费还你时间顺序。这 48 位大约在公元 10889 年用完,远远超出这些值能被派上的任何用场。
然后是第二个后果,一般的页面很少提:v7 是一个时间戳,而时间戳是一种泄露。把一个 v7 交给任何人,你就告诉了他你创建它的那一毫秒;如果你每开一个新账号做一个,你还告诉了他你开了多少账号、你的流量什么时候在跳。输出框下面那一行把它做出来的每一个值的时刻都解出来,用 UTC,所以你能看清楚自己正要公布的是什么。如果这一点让你的用法不舒服,就去做 v4。如果排序是你需要的、而暴露是不能接受的,答案不是一个把时钟藏起来的 UUID——这两个要求互相矛盾,现在发现比以后发现好。
在同一毫秒之内,运气不足以让值保持有序,所以版本半字节底下那十二位被花在一个计数器上:一毫秒里做出来的第一个值拿到 0,下一个拿 1,如此往前。这就是 v7 的“随机位”那一列读作 62 而不是 74 的原因——那十二位不是随机的,而它们换来的是一条可证的顺序。一个十二位的计数器在一毫秒里装得下 4,096 个值,这远过一次按下被允许要求的数量(500 个),而如果某次会话真的在同一毫秒里按了九次生成,计数会回绕到零,框下面那句说明会把这件事讲出来,因为回绕之后的两个值就不再保证有序。一句守不住的承诺,比没有承诺更糟。
v1:同一只时钟切成三段,还有一个谁也拿不到的节点 id
v1 比描述它的那份 RFC 更老。它的时间戳是一个 60 位的计数,以 100 纳秒为步长,从 1582 年 10 月 15 日数起,而且它是散开放着的:低 32 位先来,接着的 16 位在中段,最高的 12 位藏在版本半字节底下。把一列 v1 当文本排序,你得到的是一个毫无意义的顺序,因为字符串的开头是这个时间戳的结尾。就是这一个古怪,让 MySQL 一直把它 UUID() 的输出按字段重排之后写出来,也让 RFC 9562 后来把那个重排标准化成了 v6——同一个 60 位计数,半字节转了个方向,好让字符串能排序。两种都能读回来:贴进一个 v1 和一个从同一个瞬间做出来的 v6,读出来会告诉你对得上。
v1 是按构造唯一来设计的,而不是按概率唯一:时间戳加上这台机器的 48 位网络地址,再用一个 14 位的时钟序列去应付时钟倒着走的情形。浏览器里的一个页面这两样都拿不到。没有任何 API 把网络地址暴露出来,也没有任何东西被拿来装扮成一个。替代的做法是另抽一个随机节点 id,并且把它的头一个字节的组播位置起来,这正是那句“这个地址不是真的,别把帧路由到它那儿去”的约定。所以这里的 v1 结构上合法,而在恰好一个方面比真正的 v1 更弱:它们的唯一性靠 47 个随机的节点位加 14 个随机的时钟序列位——61 位,而不是一个出厂分配的地址——而且每个值都重新抽一个节点,所以它们根本不指向某一台机器。除非你确实需要为一个期待 version-1 布局的系统做出那个形状,否则要 v4。
四种写法,一个值,还有库与页面争执不下的那个字节顺序
同样的十六个字节,可以交出去成带连字符的那种,可以成三十二位不带连字符的那种,可以成外面套一对花括号的那种,也可以成 urn:uuid: 开头的那种。这四种说的是同一个值,而且每一种都能从读取框里完整走回来。大写是一种显示上的选择:十六进制位 A-F 和 a-f 表示的是同样那四位,不过 urn 那一种写法规范里定的是小写,所以一个大写的 urn:uuid: 值是一个提示:写下它的东西当时也没在读规范——框看到一条就会指出来。
真正会咬人的分歧不是文本,是字节。规范的那种字符串把字段按 big-endian 顺序摆;而 GUID 所依据的那个结构,在 COM 里、在 COM 下游的一切东西里,把头三个字段按相反的顺序装着。所以同一个值可以以一个 16 字节数组的形式到来,它的前四个字节是倒过来的,而一个假设错了的库会递给你一串看起来完全合法、内容却完全不同的 UUID。检查是机械的:把字节 0-3 倒过来,然后 4-5,然后 6-7,如果两个数组互相变过去了,那问题就是这个。两种顺序都是从同一批字节里写出来的,所以拿去和你的语言给你的那一个比,是一次比较,而不是一句信任。
要存起来的时候,字符串形态是贵的那一个:36 个字符,ASCII 下 36 字节,一旦牵扯到排序规则还要更多,而对它建的索引就是那么宽。一个 16 字节的列用一半的体积装着同一个值,这就是为什么 Postgres 的 uuid 和 MySQL 的 BINARY(16) 在任何大到值得在意的表上都赢过 CHAR(36)——不过请注意,MySQL 自己的 UUID() 函数产出的是 v1,不是 v4,这件事每隔几年就要惊到人一次。
被拒绝的四件事,以及改做的那笔算术
你生成、粘贴或保存的东西都不发往任何地方:值是在这个标签页里做出来的,也留在这个标签页里。它不会做的事,全部集中放在这里:
- 它不能告诉你一个 UUID 是不是已经有人在用。 唯一性是概率性的,不是登记过的;没有任何服务持有全部 UUID 的那个集合,而任何声称能查这一项的页面都是在编一个答案。
- 它不能生成 v3 或 v5。 那两种是对一个命名空间常量加上一个名字做摘要,而这两个输入都是你的:它们出自你的系统,而且本该和你的代码早就算出来的东西一致。猜一个看着顺眼的命名空间,会产出一个在一种你看不出来的意义上错了的值,所以 v3 和 v5 只被读回来,做不到这件事也照直说。
- 它读不到你的网卡,所以它不会做出一个带着你的真实地址的 v1。去看上面那个组播位。
- 它不会假装一个 v4 有创建时间。 那个检查器宁可写“不带时间”,也不会从值自己的数字里编出一个时刻来。
它改做的,是你自己可以验的那笔算术。框下面第四个数是那些真正随机的位上的生日界限:n 个值、每个 b 个随机位,其中两个撞上的机会大约是 n(n-1)/2 除以 2^b。500 个 v4 时,这大致是十的三十二次方分之一,这与其说是一句安慰,不如说是一次测量——它告诉你这套机制没问题,同时也告诉你该担心的那个数是熵源,不是算术。它旁边的会话计数器是经验性的:从这个标签页打开起发出去的每一个值都被留着,重复也在其中数出来,而清空不会抹掉那个计数,因为“它做出过多少个”是关于这次会话的一个事实,不是关于框里那段文本的。
问与答
问:我生成的这些 UUID 会被发走吗? 不会。字节是从你自己的浏览器的生成器里读的,字符串是在这个标签页里拼起来的,而保存是经过你的浏览器写一个文件,中间没有网络。
问:它们真的是唯一的吗?
不可预测,是的,来自 crypto.getRandomValues。而“保证从来没有存在过”意义上的唯一,不是任何一个生成器能承诺的东西——那是一个概率,框旁边摆出来的是这个概率,不是一句承诺。它只在机器的熵源坏掉的时候才坏,那是硬件和平台的问题,不是格式的问题。
问:我该用哪个版本? 除非有具体的东西把你往别处拉,就用 v4:一个应当按插入顺序排序的数据库主键,或者一个你在好几台机器上同时生成的键——v7。一个期待 version-1 布局的老系统——v1,并且先把节点那一条警告读完。v3 和 v5 是用来从一个你已经有的名字推出一个稳定标识符的,而那个名字不会被猜出来。
问:我的控制台里 crypto.randomUUID() 显示未定义,是生成器坏了吗?
缺的是那个原生方法,而这在一个不是安全上下文的页面上是正常的:浏览器在明文 http 上把 randomUUID() 扣住了,而 getRandomValues 照常工作。这就是为什么那张对照表还能把左边一列填满、把右边一列留空,也是为什么在一个内置方法用不了的 URL 上,v4 照样出得来。
问:为什么说明写一个 v7 只有 62 个随机位,我本来以为是 74 个? 因为版本半字节底下那十二位被当成计数器用了,好让这一批保持有序。有序和尽可能随机是两个目标;说明会告诉你它买到的是哪一个。
问:一次能做多少个,我能把一整列粘进来读吗? 一次按下五百个,一次读两千行贴进来的。这两个限度都是关于可读性的,不是关于格式的:第 501 个并不比第 1 个更独一无二。