在项目里用 UUID 还是自增 ID,每次都能争论半天。有人说 UUID 性能差,有人说自增 ID 暴露数据量有安全风险。但 UUID 本身就有好几个版本,不同版本的原理和适用场景差异很大。这篇文章把 v1、v3、v4、v5、v7 全部拆一遍,梳理每个版本的生成方式、优缺点和适用场景,最后给些实践建议。
UUID 的标准格式
UUID 是一个 128 位的数字标识符,标准格式写成 32 个十六进制字符,按 8-4-4-4-12 分成五段,段之间用连字符分隔:
123e4567-e89b-12d3-a456-426614174000
128 位里有 4 位是版本号,位于第三组的开头。2 位是变体标识,位于第四组的开头。剩下的 122 位才是有效数据。版本号决定了 UUID 的生成方式——这是区分不同版本的核心标志。变体位固定为二进制 10,标识这是一个 RFC 4122 标准 UUID。
从位的分布来看,第三组的第一个十六进制字符就是版本号。所以看到 xxxxxxxx-xxxx-1xxx-xxxx-xxxxxxxxxxxx 就知道是 v1,xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx 是 v4,xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx 是 v7。
这种格式设计的巧妙之处在于:只看字符串就能知道 UUID 的版本,不需要额外元数据。而且版本号和变体位固定在特定位置,不影响其他位的分配。
版本 1:时间戳加 MAC 地址
v1 是最早的 UUID 版本,RFC 4122 里定义的第一种。它的 128 位分布如下:
- 60 位时间戳:以 100 纳秒为单位,从 1582 年 10 月 15 日开始计数
- 14 位时钟序列:解决时间回溯问题,系统时间回拨时递增
- 48 位节点:通常是设备的 MAC 地址
时间戳加上 MAC 地址的组合,理论上全局唯一性非常高。同一台机器不可能在同一时间生成两个相同的 UUID。
但这里有一个严重的安全问题——v1 直接暴露了设备的 MAC 地址。MAC 地址是全球唯一的硬件标识,通过它可以追踪设备甚至定位到具体用户。如果 API 用 v1 UUID 作为用户 ID,攻击者可以从 UUID 反推出用户的 MAC 地址。
2018 年有个真实的案例。有个公司的 REST API 用 v1 UUID 作为用户资源标识符,安全研究员发现可以从 UUID 中提取 MAC 地址,进而关联到同一用户的不同请求,实现用户追踪。这件事之后很多项目开始从 v1 迁移到 v4。
很早做内部日志系统时试着用过一次 v1。需求很简单——多个服务并发写日志,ID 需要全局唯一,且不需要中心化协调。v1 完美满足,时间戳加 MAC 地址保证跨服务器唯一。但后来安全审计发现日志里包含了服务器的 MAC 地址,虽然内网影响不大,为了合规还是换成了 v4。
如果你决定用 v1,务必搞清楚 MAC 地址暴露的风险。有些实现允许手动指定节点 ID,用随机值代替真实 MAC 可以规避这个问题。Linux 上可以通过 /sys/class/net/<iface>/address 拿到 MAC,UUID 库一般会从这个路径读取。
v1 还有一个限制:高并发场景下,同一时刻可能生成超过时钟序列容量的 UUID,导致冲突。不过这个概率在实际系统中极低。
版本 3 和 5:基于名字的哈希
v3 和 v5 的生成思路一致:对某个名字(比如 URL、域名、命名空间 + 名字)计算哈希,截取 128 位,按要求设置版本和变体位。
两个版本的区别只在哈希函数——v3 用 MD5,v5 用 SHA-1。
共同特点是确定性:相同的输入永远生成相同的 UUID。你在任意机器上对 "example.com" 算 v5,结果都一样。不需要存储,不需要协调,随时随地都能算出同一个 UUID。
这个特性适合一些特殊场景。最典型的例子是文件去重——对文件路径计算 UUID 做标识符,同一路径永远映射到同一个 UUID。另一个场景是数据迁移——在源系统里基于业务主键哈希生成 UUID,迁移到目标系统后 UUID 保持不变,省去映射表的维护成本。
项目中这两个版本用得不多。MD5 和 SHA-1 在密码学上已被认为不够安全,但 UUID 只靠唯一性不靠抗碰撞,安全问题不大。更大的原因是大多数场景不需要"确定性"——同一名字永远映射到同一个 UUID 反而限制了灵活性。而且 v5 把 SHA-1 的 160 位摘要截断到 128 位,理论上有额外碰撞风险。
如果你的场景确实是"需要可重现的 UUID",v5 比 v3 更推荐。SHA-1 的输出分布比 MD5 更均匀,碰撞概率更低。
版本 4:随机生成
v4 是目前最常用的 UUID 版本,没有之一。它的逻辑极简单:直接生成 122 位随机数,加上 4 位版本号(0100)和 2 位变体位(10),拼成 128 位的 UUID。除此之外没有任何额外信息——没有时间戳,没有 MAC 地址,没有名字哈希。
122 位随机数的质量完全取决于系统的随机数发生器。Linux 的 /dev/urandom、Windows 的 CryptGenRandom、macOS 的 /dev/random 都是密码学安全的伪随机数生成器(CSPRNG),质量有保障。JavaScript 的 crypto.randomUUID() 底层调的就是系统的 CSPRNG。
UUID v4 的生成过程在 RFC 4122 里有精确定义。如果你在代码里写一个 UUID 生成函数,核心逻辑就是:生成 16 个随机字节,把第 7 个字节的高 4 位设成 0100(版本 4),把第 9 个字节的高 2 位设成 10(变体),然后格式化成 8-4-4-4-12。
很多人觉得这太简单了,不值得用现成的库——这就是常见的踩坑点。下面专门说说碰撞概率和自实现的陷阱。
碰撞概率
122 位随机数的可能取值是 2^122 ≈ 5.3 × 10^36。这个数字大到什么程度?地球上的沙粒数量大约是 10^18 级别,UUID v4 的取值空间比沙粒总数还多 10^18 倍。
计算碰撞概率用到生日悖论的公式。对于 n 个可能的取值,生成 k 个 UUID 后至少发生一次碰撞的概率大约是:
P ≈ 1 - e^(-k² / 2n)
代入 n = 2^122,代几个实际数字:
- 生成 100 万个 UUID:概率几乎为 0(远小于 10^(-30))
- 生成 10 亿个(约 2^30):概率约 10^(-20)
- 生成 1 万亿个(约 2^40):概率约 10^(-14)
日常开发中完全不用担心碰撞。如果你的系统真的遇到了 UUID v4 碰撞,概率比一次性中 10 次彩票头奖还低。
自己实现的陷阱
永远不要自己实现 UUID 生成函数。 之前参与过一个项目,代码里有这样一段——用 Math.random() 生成 16 进制字符串,拼成 UUID 格式。上线三个月后开始出现重复 ID 报错。
查了才发现问题出在 Math.random() 上。JavaScript 的 Math.random() 不是密码学安全的,种子空间只有 2^64 左右,远小于 v4 的 2^122。而且不同浏览器对 Math.random() 的实现差异很大,Chrome 用 128 位种子,Safari 只有 64 位,碰撞率比预期高好几个数量级。
另外,Math.random() 生成的是浮点数,转换为十六进制字符串时还会损失精度。如果你用 Math.random().toString(16) 之类的 hack,实际的有效随机位数更少。
解决方案很简单——用 crypto.randomUUID()(浏览器和 Node.js 都支持)或 crypto.getRandomValues() 生成随机字节。现代浏览器从 Chrome 49、Firefox 43、Safari 11 就开始支持了。Node.js 从 19.0 开始内置 crypto.randomUUID(),更早版本可以用 uuid 包。
所以在任何项目里都不允许手写 ID 生成函数,全部用标准库。
版本 7:面向数据库的新方案
UUID v7 是相对较新的方案,2021 年 IETF 提出草案,2024 年正式标准化为 RFC 9562。它专门为解决 v4 在数据库场景下的性能问题而设计。
v4 作为主键的问题
v4 的随机性太强了。作为数据库主键时,每个新插入的 UUID 在 B-tree 索引上的位置都是随机的。
MySQL InnoDB 用聚簇索引(clustered index),主键值决定了数据行在磁盘上的物理存放顺序。v4 UUID 的插入位置完全随机,导致:
- 索引页频繁分裂:新数据要插入到已满的页中间,页必须拆分成两个
- 空间利用不足:页分裂后每个页只有约 69% 的填充率(InnoDB 的统计数据)
- 写入性能下降:实测 v4 的随机插入比自增 ID 慢 2-5 倍
- 数据碎片化:大量页分裂导致索引碎片,需要定期重建
v7 的设计
v7 的解决思路不复杂——在 UUID 开头嵌入毫秒级时间戳。因为时间戳在最高位,新生成的 UUID 按时间递增,插入 B-tree 时主要追加到右侧,页分裂大幅减少。
v7 的结构如下:
- 48 位时间戳:Unix 毫秒时间戳
- 4 位版本号:
0111(十六进制 7) - 2 位变体位:
10 - 74 位随机数:保证同一毫秒内的唯一性
格式上看起来像这样:
tttttttt-tttt-7vvv-vvvv-vvvvvvvvvvvv
t 是时间戳,7 是版本号,v 是随机位。
对比一下 v4 和 v7 的插入性能差异。某 PostgreSQL 性能测试显示:在百万级数据量下,v4 UUID 作为主键的插入吞吐量约为自增 ID 的 40%,而 v7 能达到自增 ID 的 90% 以上。差距主要来自索引页分裂的频率——v4 几乎每次插入都触发分裂,v7 只在跨毫秒边界时触发。
数据库支持状态
数据库厂商已经在跟进:
- PostgreSQL:
pgcrypto扩展的gen_random_uuid()默认生成 v4,uuid-osp扩展支持 v7 - MySQL:8.0+ 有
UUID_TO_BIN()和BIN_TO_UUID()函数,可以优化 UUID 存储 - SQLite:3.31+ 原生支持 UUID 类型
- ORM:Prisma 从 5.x 开始支持
uuidv7()函数
JavaScript 生态的 uuid 包(npm 周下载量 5000 万+)从 v9 开始支持 v7,用法很简单:
import { v7 as uuidv7 } from "uuid";
const id = uuidv7(); // 生成 v7 UUID
浏览器原生 crypto.randomUUID() 目前只支持 v4,生成 v7 需要借助第三方库。
时钟回拨问题
v7 依赖系统时钟,这是它最大的风险点。如果系统时间被 NTP 往回调整(时钟回拨),可能生成"过去"的时间戳,导致新 UUID 与已有的冲突。
好的 UUID 库会处理这个问题:缓存上次生成的时间戳,检测到回拨时等待直到时间追上,或者在时间戳不变的情况下递增随机位。但如果你在 IoT 设备或虚拟机上运行——这些环境的时间可能非常不稳定——v4 比 v7 更安全。
之前做物联网平台时就遇到过这个问题。边缘设备的系统时间经常不准,每次 NTP 同步都会跳变好几秒。如果用 v7,时间回拨后生成的 UUID 大概率已经存在。最后全部用的 v4。
碰撞概率汇总
把各版本的碰撞情况放在一起对比:
v1:同一台机器不会碰撞。不同机器间如果时间戳不同步或 MAC 地址重复(虚拟机克隆场景常见),理论上有冲突可能。时钟序列只有 14 位(16384 个值),如果高频生成且频繁触发时钟回拨,序列号可能耗尽。
v3/v5:取决于哈希函数的碰撞特性。MD5 和 SHA-1 的碰撞概率在截断到 128 位后,理论上约 2^(-64)(生日悖论下 50% 碰撞需要 2^64 个输入)。实际使用中极低。
v4:2^122 的随机空间,概率前面算得很清楚。如果你的系统达到每秒生成 10 亿个 UUID 的量级并持续运行 100 年,碰撞概率约 50%。大多数项目连这个量级的亿分之一都达不到。
v7:48 位时间戳保证了大方向唯一。同一毫秒内靠 74 位随机数(2^74 空间),每秒最多生成 1000 个毫秒间隔,碰撞概率比 v4 还低——前提是时间戳分布均匀且不同节点的时间没有严重偏差。
UUID 作为主键的实践考量
关于 UUID 做主键,需要看几组数据:
存储对比:
- UUID 二进制(BINARY 16):16 字节
- 自增 BIGINT:8 字节
- 自增 INT:4 字节
- UUID 存为 CHAR(36):36 字节(不要这样做)
主键大一倍影响的不只是主键本身。InnoDB 的二级索引会复制主键值——UUID 主键意味着所有二级索引都比自增 ID 大 16 字节。如果有 10 个二级索引,额外存储开销就是 160 字节每行。
性能对比(百万级数据,PostgreSQL 实测):
- 自增 BIGINT 顺序插入:基准 100%
- UUID v7 顺序插入:约 92%
- UUID v4 随机插入:约 35%
- UUID v4 + 排序后批量插入:约 68%
碎片对比(InnoDB 页填充率):
- 自增 ID:约 99%
- UUID v7:约 85%
- UUID v4:约 69%
怎么选
传统企业应用,单表千万级以下: 自增 ID 最省事。API 暴露自增 ID 存在枚举风险——用户 ID 是 10001,说明系统至少有一万个用户——可以用哈希 ID 或 ULID 对外展示。
分布式系统、微服务: UUID 几乎是必选。各服务独立生成 ID,不需要中心化发号器。优先选 v7。自增 ID 在分布式环境下需要全局发号器(比如 Redis、数据库自增列、Snowflake),引入额外的依赖和单点风险。
离线/在线同步: 客户端和服务端都可能产生数据,自增 ID 必然冲突。UUID 是唯一选择。
IoT 设备数据: 设备离线生成 ID,回传后与服务端数据合并。考虑时钟不稳定的情况,v4 比 v7 更安全。如果设备有硬件随机数生成器,v4 质量有保障;如果没有(低端 MCU),考虑用固定节点 ID 加计数器的方案。
合规审计场景: 避免用 v1(暴露 MAC 地址)。v4 或 v7 都不会泄露硬件信息。
其他方案:ULID、NanoID、Snowflake
除了标准 UUID,还有一些值得关注的标识符方案:
ULID(Universally Unique Lexicographically Sortable Identifier):
- 26 字符,Crockford Base32 编码
- 48 位毫秒时间戳 + 80 位随机数
- 按字典序可排序,和 UUID v7 设计思路类似
- 大小写不敏感,URL-safe
- 比 UUID 短 10 个字符,可读性更好
- 没有原生数据库支持,需要存储在 CHAR(26) 或 BINARY(16)
- 适合想要可排序但 UUID v7 支持不完善的场景
NanoID:
- 默认 21 字符,支持自定义长度
- 只用 URL-safe 字符(
A-Za-z0-9_-) - 基于 CSPRNG(
crypto.randomBytes) - 21 字符的 NanoID 空间约 2^126,足够大
- 体积极小,零依赖,GitHub 上 25k+ star
- 需要额外安装包,不像 UUID 有 Node.js 原生 API
- 适合需要短 ID 的场景——URL 短链接、邀请码、订单号
- 注意:不是标准的 UUID 格式,不能直接用在期待 UUID 的接口中
Snowflake(雪花算法):
- Twitter 开源(2010 年),64 位整数
- 41 位时间戳 + 10 位机器 ID + 12 位序列号
- 体积只有 UUID 的一半
- 需要配置和协调机器 ID,有运维成本
- 64 位整数在数据库索引性能上最优
- 适合高并发分布式 ID 生成(如美团 Leaf、百度 uid-generator)
- 缺点:依赖机器 ID 的配置管理,系统时钟回拨可能导致 ID 重复
选择建议速览
- 浏览器端随手生成一个 ID →
crypto.randomUUID()直出 v4 - 新项目的数据库主键 →
uuid包的 v7 或 ULID - 需要短 ID 展示给用户 → NanoID
- 高并发分布式发号 → Snowflake 或其变体(美团 Leaf、百度 uid-generator)
- IoT 离线生成,时钟不稳定 → v4 或 NanoID
- 和老系统对接,接口要求 UUID 格式 → v4 最通用
实践建议
新项目优先用 v7。 v7 兼顾了唯一性和数据库索引友好度,是当前 UUID 方案中的最优选择。JavaScript 的 uuid 包、Go 的 github.com/google/uuid、Python 的 uuid 标准库都已支持 v7。
用二进制格式存 UUID。 数据库里用 BINARY(16) 而不是 CHAR(36) 或 VARCHAR(36)。一是节省一半存储,二是 CHAR(36) 的字符串比较不如二进制比较快。MySQL 用 UUID_TO_BIN(uuid, 1) 转换(参数 1 会交换时间位,配合 v7 或 v1 的排序优化),PostgreSQL 有原生 UUID 类型直接用。
浏览器端生成用 crypto.randomUUID()。 所有现代浏览器都支持,底层是操作系统 CSPRNG。不要自己写 UUID 函数,不要在浏览器端用 Math.random()。
API 传输考虑压缩。 可以用无分隔符的 32 位十六进制字符串(去掉连字符),或者 Base64 编码压缩到 22 字符。HTTP Header 或 URL 参数中使用时,Base64 格式更紧凑。
注意时钟回拨。 v1 和 v7 都依赖系统时钟。如果服务器时钟不稳定(虚拟机迁移、NTP 大跨度同步、IoT 设备劣质 RTC),优先选 v4。物联网平台边缘设备的系统时间每个月会被 NTP 纠正好几次,每次回跳几秒到几分钟不等。v7 在这种环境下生成的 UUID 很容易和已有数据冲突。
对于UUID 生成器除了能生成单个UUID还可以批量生成,都计算在浏览器本地完成,还是很方便的 ~