1. 项目概述:为什么需要 UUID,以及怎么选
1.1 从一次主键冲突说起:什么是 UUID / GUID
做前端的时间长了,你迟早会遇到一个需求:给某个订单、某次上传、某条日志生成一个全局唯一标识。我第一次正儿八经研究 UUID,就是因为生产环境里出现了主键冲突。那个项目一开始用的是"时间戳加随机数"拼出来的字符串,平时没什么问题,并发量一上来,重复就成了必然。后来我花了整整一个下午把 UUID 这套规范啃了一遍,才发现自己当初造的轮子有多业余。
UUID 的全称是 Universally Unique Identifier,中文常译作"通用唯一标识"。GUID 是微软阵营对它的叫法,全称 Globally Unique Identifier,两者本质上是同一套东西,只是历史来源和个别字节序处理上有微小差异。本质上,UUID 是一个 128 位的数值,用 32 个十六进制字符加 4 个连字符表示出来,形如550e8400-e29b-41d4-a716-446655440000。这套格式由 RFC 4122 规范定义,核心目标只有一个:在不依赖中心节点协调的情况下,让不同机器、不同时刻生成出来的 ID 都极难重复。
为什么是"极难重复"而不是"绝对不重复"?因为 128 位空间实在太大,对随机型 UUID 来说,扣除版本位和保留位之后还有 122 位有效随机空间,总数大约是 5.3 乘以 10 的 36 次方。这个数字大到什么程度?就算你每秒生成 100 万个 ID,连续生成几万年,撞车概率都趋近于零。"绝对不重复"在工程上是不可能的承诺,"碰撞概率低到可以忽略"才是现实可行的目标。理解这一点,后面很多无谓的焦虑都可以放下。
1.2 生成方案的选型清单:原生 API、手写、第三方库
开始写代码之前,先把方案摆清楚。目前 JavaScript 生态里生成 UUID 有三种主流路径,针对不同环境各有取舍,我直接整理成一张对照表:
| 方案 | 适用环境 | 优点 | 注意点 |
|---|---|---|---|
crypto.randomUUID() | 现代浏览器(HTTPS)、Node 14.17+ | 原生支持,代码最简,加密级安全随机 | 老浏览器不支持,需要降级 |
基于crypto.getRandomValues()手写 | 所有支持 Web Crypto 的环境 | 可控性强,不依赖新 API | 要自己处理版本位、变体位的置位逻辑 |
npmuuid包 | 浏览器 + Node 通用 | 功能全(v1/v3/v4/v5 都支持),生态成熟 | 多一个依赖,老环境无脑可用 |
我见过不少人一上来就引第三方库,其实如果项目跑在较新环境里,原生crypto.randomUUID()已经完全够用。手写实现的意义更多在于理解格式背后的比特级细节,以及在浏览器升级滞后、安全上下文受限的场景里,你还能拿getRandomValues顶上去。第三方库则适合那些需要 v1、v3、v5 等非随机版本,或者想让老环境无脑可用的项目。下面我会把这三条路都铺开讲清楚,直接照着抄就行。
2. UUID 格式与版本机制深度拆解
2.1 拆开一个 UUID:8-4-4-4-12 背后的一百多个比特
先拿一个标准的 v4 UUID 来开刀:550e8400-e29b-41d4-a716-446655440000。去掉连字符后一共 32 个十六进制字符,分成 5 组:
550e8400 - e29b - 41d4 - a716 - 446655440000 32bit 16bit 16bit 16bit 48bit按 RFC 4122 的定义,这 128 位依次分配给 8 个字段:time_low(32 位)、time_mid(16 位)、time_hi_and_version(16 位)、clock_seq_hi_and_reserved(8 位)、clock_seq_low(8 位)、node(48 位)。虽然 v4 的所有位本质上都是随机生成的,但格式仍然按这套布局来排,好处是任何语言、任何系统拿到一个 UUID 字符串,都可以精准解析出版本和变体这些元信息。
十六进制的一个字符对应 4 个比特,所以每一组字符数乘以 4 就是这一段的比特数。第三组开头那位,也就是整个字符串的第 13 个字符,是版本号所在位置。看上面的例子,第三组是41d4,第一位是4,说明它是 v4。第四组a716的第一位是a,转成二进制是1010,最高两位是10,代表它遵循 RFC 4122 标准变体。这套"看两个字符就能识别版本和规范"的设计,在调试和校验场景里非常实用。
还有两种特殊的 UUID 值得一提。一个是 nil UUID,全部 32 位都是 0,写作00000000-0000-0000-0000-000000000000,在很多协议里表示"没有值";另一个是微软 GUID 历史上那种"变体位不标准"的旧格式,老代码里可能看到第四组开头是1、2或3的 GUID,那是厂商私有变体,按今天的规范去解析时要注意别把它当成非法值直接丢掉。
2.2 版本位与变体位:v1、v4 到底差在哪
RFC 4122 定义了多个版本,版本号统一写在第三组的第一位,用十六进制数字表示。我挑几个常见的做成表:
| 版本 | 生成方式 | 典型场景 |
|---|---|---|
| v1 | 基于时间戳 + 时钟序列 + 节点标识 | 需要时间可追踪,注意隐私风险 |
| v3 | 基于命名空间和名称的 MD5 哈希 | 相同名字映射到相同 UUID,如对象去重 |
| v4 | 随机数,除版本位/变体位外全部随机 | 绝大多数通用场景,本文主角 |
| v5 | 基于命名空间和名称的 SHA-1 哈希 | 同 v3,推荐用 v5 替代,更安全 |
v1 是时间型生成器,取当前时间换算成 100 纳秒为一个 tick 的数值,加上一个 14 位的时钟序列,再加上 48 位的节点标识。节点标识在早期实现里直接取网卡 MAC 地址,这带来一个隐私问题:UUID 会把机器的硬件标识间接暴露出去。所以后来规范允许用随机数填充节点,并把组播位置 1,避免和真实 MAC 混淆。
v4 是最省心的版本:除了把版本位固定为4、变体位固定为10,其余 122 位全部使用加密级随机数填充。这意味着每次调用都是独立的随机事件,没有时间参数,也就没有"同一毫秒生成会不会撞"这种担忧。v3 和 v5 是确定型方案:把命名空间(一个 UUID)和名称拼起来做哈希,再截取、置位。它们的价值在于"确定性幂等"——比如你要给某个业务实体生成一个稳定 ID,只要输入不变,输出永远不变,这在跨系统同步时特别管用。
2.3 为什么自造随机字符串不是好主意
聊完格式,再回到很多新手的第一直觉:"我不懂 UUID,自己拼个随机字符串不也一样吗?"我在开头说过,我自己第一次就是这么踩坑的。这种自造方案的问题通常出在两个层面。
第一是随机熵不够。Math.random()在大多数引擎里只有约 52 位的有效随机精度,而且它不保证加密安全性,种子是可能被预测的。你把它和Date.now()拼在一起,实际能变化的位远不到 122 位。当年项目里并发一高就撞车,根子就在这里。UUID v4 之所以强调用crypto.getRandomValues这类 CSPRNG,是因为它在操作系统层面采集真随机种子,安全性和均匀性都不是Math.random()能比的。
第二是语义丢失。一个标准 UUID 字符串,任何人拿到都能凭第 13 个字符判断版本、凭第 4 组开头判断变体,还能解析出 v1 的时间戳字段。你自造的a1b2c3d4e5f6或者order_20250101123045_xxx,各个系统之间没有约定就无法互通。你还得自己处理字符集、长度、大小写、连字符等一堆边缘格式问题。所以我的建议是:除非你明确知道自己在做什么,否则不要自创格式,直接按 RFC 4122 生成 UUID,生态里的解析工具、数据库类型全都认这套标准。
3. 实战:用原生 JavaScript 生成 v4 UUID
3.1 首选方案一:crypto.randomUUID() 的用法与兼容性
如果项目环境允许,下面这行代码就是全部答案:
const id = crypto.randomUUID(); console.log(id); // 输出示例:a6f1c2c9-6f2a-4b61-9c8c-7e1e2a3b4c5d注意输出里第 3 组开头是4,第 4 组开头是9(二进制1001,最高两位是10),完全符合 v4 规范。浏览器端crypto.randomUUID()在较新版本里都是原生方法,但有一个隐藏条件:它要求页面运行在安全上下文中,也就是 HTTPS 或 localhost,明文 HTTP 站点上可能拿不到这个方法。本地开发用http://localhost不受影响,但部署到内网 IP 的明文站点时就要小心了。
Node.js 这边的入口略有不同。Node 14.17 之后,require('crypto').randomUUID()可用;Node 16.7 之后,globalThis.crypto.randomUUID()也可以直接用,写法上浏览器和 Node 趋于统一。我在 3.3 节会把 Node 环境下的写法集中列一遍。另外要提醒一句:这个方法在有些 WebView 壳里虽然存在,但可能因为 secure context 判定失败而返回 undefined,所以封装时最好做特性检测,而不是无脑调用。
3.2 方案二:基于 crypto.getRandomValues() 手写 v4
原生randomUUID虽好,但总有一些环境暂时没跟上,比如某些旧版 WebView、老版本移动端浏览器。这时可以用crypto.getRandomValues()自己实现一个。这个方法从 Web Crypto API 诞生起就存在,兼容面比randomUUID宽得多。核心逻辑分三步:取 16 个随机字节、置位版本和变体、按 8-4-4-4-12 拼字符串。
function uuidv4() { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); // 设置版本号:第 7 个字节的高 4 位改成 0100(即十六进制 4x) bytes[6] = (bytes[6] & 0x0f) | 0x40; // 设置变体位:第 9 个字节的最高 2 位改成 10(结果是 8x、9x、ax、bx 之一) bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); return ( hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20) ); }这段代码里最容易被忽略的是字节下标。bytes[6]和bytes[8]不是随意挑的:按 2.1 的字段布局,第 3 组覆盖第 7 个字节的高 4 位,第 4 组覆盖第 9 个字节的高 2 位。搞错下标,生成出来的字符串可能看起来是 UUID,但任何正规解析器都能看出它不是合法 v4。
两个位运算的意思也不难:bytes[6] & 0x0f把高 4 位清零、保留低 4 位;| 0x40再把高 4 位写成二进制0100。bytes[8] & 0x3f保留低 6 位;| 0x80把最高两位写成10。置位顺序必须是"先清后设",反过来写会污染其他位。
再说个性能细节。上面的Array.from每次调用都会创建数组、逐字节转字符串,低频生成无所谓,但如果你要在循环里批量生成几千几万个 ID,建议把字节到十六进制串的映射表提前算好:
const HEX = []; for (let i = 0; i < 256; i++) { HEX.push(i.toString(16).padStart(2, "0")); } function uuidv4Fast() { const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; let s = ""; for (let i = 0; i < 16; i++) { s += HEX[bytes[i]]; } return s.slice(0, 8) + "-" + s.slice(8, 12) + "-" + s.slice(12, 16) + "-" + s.slice(16, 20) + "-" + s.slice(20); }再进一步,批量生成可以一次申请16 * n字节的随机数组,循环里按 16 字节一组切片、置位、格式化,把系统调用次数从 n 次降到 1 次。不过说实话,绝大多数业务每秒顶多生成几百个 ID,优化到这份上属于锦上添花,别为了性能把代码可读性牺牲掉。
3.3 Node.js 环境下的三种写法对照
Node 项目里方案更丰富,我按版本新旧整理成一张表:
| 写法 | 最低 Node 版本 | 备注 |
|---|---|---|
require('crypto').randomUUID() | 14.17 | 经典 CommonJS 入口,兼容性好 |
globalThis.crypto.randomUUID() | 16.7 | 浏览器同款写法,后续主推方向 |
npmuuid包的v4() | 任意较老版本 | 需要安装依赖,老项目兜底 |
// 写法一:CommonJS const { randomUUID } = require("crypto"); const id1 = randomUUID(); // 写法二:全局 Web Crypto const id2 = globalThis.crypto.randomUUID(); // 写法三:npm uuid 包 const { v4: uuidv4 } = require("uuid"); const id3 = uuidv4();三种写法生成的都是标准 v4,格式上没有任何区别。选型要点在于:如果项目 Node 版本陈旧又没有升级计划,npmuuid包是最省事的选择;版本足够新的话,直接用第一种或第二种,还能少一个依赖。使用 npm 包时注意安装命令:
npm install uuid另外提醒一句,Node 的globalThis.crypto和浏览器里的crypto并不完全等价,Node 端crypto模块的方法更丰富,Web Crypto 则严格按浏览器规范来。跨端项目里写兼容代码时,不要只用typeof crypto !== "undefined"判断,还要判断具体的方法是否存在。
4. 进阶:v1 时间戳 UUID 与自定义实现
4.1 v1 的时间戳、时钟序列、节点三大组成部分
v4 解决的是"我要一个随机 ID",v1 解决的是"我要一个随时间有序、同时全局唯一"的 ID。v1 的数据结构由三部分组成:60 位时间戳、14 位时钟序列、48 位节点标识。
这里的时间戳不是 Unix 毫秒时间戳,而是从 1582 年 10 月 15 日(格里高利历起点)开始,以 100 纳秒为 tick 计数。为什么是这个奇怪的基准?因为 UUID 规范脱胎于一套分布式计算系统,那套系统的时钟基准就定在这。换算关系是:1 毫秒 = 10000 个 tick,Unix 时间戳和 UUID 时间基准之间差一个固定偏移量122192928000000000个 tick。
时钟序列字段是个 14 位随机数,作用有两个:一是防止系统时钟回调后生成重复 ID,二是同一 tick 内生成多个 UUID 时的兜底。只要时钟序列保持随机且不随进程重启随意重置,时间戳回拨时生成结果仍不会重复。节点标识是 48 位,早期取 MAC 地址,现代实现推荐随机生成并将最低位的组播位置 1,避免撞上真实 MAC,也避免泄漏硬件信息。
4.2 字节序细节:时间戳的 reorder 是怎样发生的
初次手写 v1 的人最容易在字节序上栽跟头。60 位时间戳不是按顺序整体输出,而是被拆成三段放进字段布局:
time_low(32 位) => 第 1 组 time_mid(16 位) => 第 2 组 time_hi_and_version(12 位 + 4 位版本号)=> 第 3 组也就是说,如果你算出一个完整的 60 位时间戳数值,要先把低 32 位放前面、中 16 位放中间、高 12 位放后面,再拼上版本号。这个"从低到高拆分再换序输出"的过程,在 UUID 语境里被称为时间戳字节重排。当年 GUID 和 UUID 正是在这几个字节的端序处理上互相打架,才衍生出了两个名字下的历史差异。
写代码时用 BigInt 处理 60 位时间戳,才能避免普通 Number 的精度溢出:
const NS100_TICKS_PER_MS = 10000n; const GREGORIAN_OFFSET = 0x01b21dd213814000n; // 1582 年到 1970 年的 tick 数 function uuidv1Demo() { const timestamp = BigInt(Date.now()) * NS100_TICKS_PER_MS + GREGORIAN_OFFSET; const timeLow = Number(timestamp & 0xffffffffn); const timeMid = Number((timestamp >> 32n) & 0xffffn); const timeHigh = Number((timestamp >> 48n) & 0x0fffn); const timeHighVersion = (timeHigh & 0x0fff) | 0x1000; // 高四位写版本号 1 const clockSeq = crypto.getRandomValues(new Uint8Array(2)); const clockSeqHi = (clockSeq[0] & 0x3f) | 0x80; // 变体位 const clockSeqLo = clockSeq[1]; const node = crypto.getRandomValues(new Uint8Array(6)); node[0] |= 0x01; // 置组播位,区分随机节点和真实 MAC const hex = (num, len) => num.toString(16).padStart(len, "0"); return ( hex(timeLow, 8) + "-" + hex(timeMid, 4) + "-" + hex(timeHighVersion, 4) + "-" + hex(clockSeqHi, 2) + hex(clockSeqLo, 2) + "-" + Array.from(node, (b) => hex(b, 2)).join("") ); }注意timeHighVersion那一行:先用& 0x0fff把高 4 位清掉,再用| 0x1000写入版本号1。这里如果写成| 0x0001,版本号就跑到低 4 位去了,生成的 UUID 第 13 个字符会是别的值,一验就露馅。
4.3 简化版 v1 生成器的实现与边界问题
上面这段是教学版,能跑,但离生产级 v1 还有距离。真正严谨的 v1 要实现时间回拨检测、同一 tick 内的序号递增、时钟序列跨进程持久化,否则进程重启后时钟序列又随机生成,理论上会增加碰撞风险。这些细节不是不能手写,而是很容易写出边界问题。我的建议是:生产环境需要 v1 时直接用 npmuuid包的v1()方法,它把边缘情况都处理过了;自己写版本拿来做学习验证,或者用在不必过于较真的内部系统里,问题不大。
顺带提一个近几年讨论得比较多的思路:v6、v7。它们本质上是 v1 时间戳思想的改进版,把时间戳字段换了个字节序布局,让 UUID 可以按时间排序,从而在数据库里作为主键时减少随机 IO。MySQL 8 有原生的UUID_TO_BIN()函数可以把标准字符串转成二进制,配合 v7 这类可排序 UUID,能显著改善大表主键性能。如果你的项目主键就是 UUID 而且表数据量很大,值得去调研 v7,而不是死守 v4。
5. 常见问题与排查技巧实录
5.1 碰撞:v4 到底会不会重复,重复概率怎么算
"UUID 会不会碰撞"是每个接手这个需求的开发者必问的问题,我用数学把这件事说透。v4 的有效随机空间是 122 位,也就是约 5.3 乘以 10 的 36 次方种可能。根据生日悖论近似公式,当生成了 n 个 ID 时,至少发生一次碰撞的概率大约为n² / (2 × 2^122)。要让这个概率达到 50%,需要 n 约等于 2 的 61 次方,也就是 2.3 乘以 10 的 18 次方个 ID。
这个数量级是什么概念?假设你每秒生成 100 万个 v4 UUID,连续不间断地生成,也要大约 7 万多年才能撞到那 50% 的概率线。所以结论很明确:在现实业务里,v4 的碰撞概率可以当作零处理。真正要防的,是代码写错导致的"低熵"重复,比如用了Math.random()、漏了一个字节、或者把同一个静态值反复返回。线上如果出现重复 UUID,先排查生成逻辑本身,而不是怀疑 v4 算法。
5.2 兼容性与降级:老浏览器、非安全上下文怎么办
我在 3.1 提过crypto.randomUUID()对安全上下文有要求。这里给一个完整的兼容判断模板,可以放在工具函数里统一出口:
function generateUuid() { // 浏览器 + 较新 Node:原生方法 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } // Node 老版本(v14.17 之前) try { const nodeCrypto = require("crypto"); if (nodeCrypto && typeof nodeCrypto.randomUUID === "function") { return nodeCrypto.randomUUID(); } } catch (e) { // 浏览器环境没有 require,会走到这里 } // getRandomValues 手写 v4 if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { return uuidv4(); // 用 3.2 节的实现 } return legacyUuidv4(); // 终极兜底,见下方说明 }注意require("crypto")在浏览器里会抛错,所以要用try/catch包起来。这里没有处理 ES Module 场景,实际项目里请按你的模块体系调整。兜底的legacyUuidv4()用Math.random()拼字符串,网上到处都是这种写法,但请务必只把它当作"跑起来再说"的最后手段,生产环境能不用就不用——2.3 节已经论证过随机熵不足的后果。
提示:WebView 环境判断特性时,别用
navigator.userAgent猜浏览器,用特性检测更可靠。某些壳浏览器 UA 伪装很严重,猜版本十猜九错。
5.3 格式校验、大小写约定与数据库存储
拿到一个 UUID 字符串,怎么判断它是不是合法?很多人用宽松正则/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}/i,这只能校验格式,拦不住版本号错误。生产环境里我建议用带版本约束的正则:
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function isValidUuidV4(value) { return typeof value === "string" && UUID_V4_RE.test(value); }这段正则里4[0-9a-f]{3}锁定第 3 组第一位必须为4,[89ab]锁定第 4 组第一位必须在8/9/a/b范围,也就是二进制最高两位为10。如果想兼容 v1 等其他版本,把4换成[1-8]即可。
大小写方面,规范建议使用小写,实际工程里大多数库也输出小写。数据库里如果要用 UUID 做主键,别存成 36 字符的字符串,那是CHAR(36)的玩法,索引效率差。MySQL 可以用UUID_TO_BIN()转成 16 字节二进制存储,查询时用BIN_TO_UUID()转回可读格式;PostgreSQL 则有原生的uuid类型。早期系统已经用字符串存的,量小无所谓,量大再考虑迁移。
6. 经验心得:我在生产环境里的几点观察
6.1 什么时候该用 UUID,什么时候该换别的
做了这么久,我的习惯是:默认优先考虑使用 v4。它实现简单、语义干净、没有隐私顾虑、碰撞概率可以忽略,绝大多数场景都够用。如果你的主键既要唯一又要可排序,认真考虑 v7 或者 ULID 这类时间有序方案,别硬上 v1——它虽然也能排序,但那套字段布局和端序处理是历史包袱,直接用并不优雅,而且还有 MAC 泄漏风险。
反过来,有些场景真的不该用 UUID。比如短链接、优惠券码,需要短且可读,那应该用 Base62 编码的自增或随机方案;再比如鉴权令牌、临时会话 ID,虽然可以用 CSPRNG 生成的 UUID,但它们通常还需要过期时间、撤销逻辑等配套能力,单独一个 ID 解决不了全部问题。选型时先想清楚"我要的是唯一性,还是不可猜测性,还是可排序性",再去选具体方案,会少走很多弯路。
6.2 我的几个封装习惯
最后分享几个代码层面的小习惯。第一,所有生成 UUID 的入口统一收敛到一个工具函数里,比如工具包里的uuid.js,内部做特性检测和降级,业务层永远只调用一个函数。这样以后要换算法、加格式校验、加日志,只改一个文件。
第二,批量生成时顺手做去重校验。虽然 v4 碰撞概率极低,但代码逻辑错误可能造成重复,我在批量场景里习惯用Set做一层去重,重复了立刻抛异常。成本几乎为零,却能在开发期尽早暴露问题。
第三,生成后不要对大写、连字符做二次加工。有人喜欢uuid.toUpperCase()或者干脆去掉连字符,这会在系统对接时制造麻烦。标准格式就是小写加连字符,数据库要压缩存储就交给UUID_TO_BIN()这类官方函数,不要手动删减。
这些经验大多来自踩坑之后的复盘。UUID 本身不算复杂,但它背后那套跨系统的格式约定、版本设计和随机性要求,值得花一个下午好好弄明白。弄明白之后,你会发现这个"小小的工具函数"在分布式系统里其实是个非常可靠的基础设施。