UUID生成器:什么是UUID?批量生成唯一标识符
选 ID 方案是一件"当时只花五分钟、以后要还三年债"的决定。它写进表结构、写进接口契约、写进日志和缓存键,一旦线上跑起来,改造成本远高于做决定时的想象。自增整数、UUID v4、UUID v7、ULID、雪花 ID、数据库序列都能"生成一个不重复的标识",但它们在可排序性、存储开销、索引友好度和可猜测性上的差异,会在数据量过千万之后以完全不同的方式显现出来。
本文不重复 UUID 的版本定义与格式拆解,那部分内容属于UUID 生成器工具页。这里只回答一个工程问题:在你的场景下该选哪一种 ID,选完之后要付出什么代价。文中的碰撞概率与体积数据全部由生日悖论公式和字节数直接实算,可以自行复现。
一、用五个维度筛掉不合适的方案
把候选方案放在同一套尺子下,只需要依次问五个问题:
- 可排序性:ID 本身是否携带时间顺序。它决定了 ID 能否直接充当聚簇索引,也决定了范围查询和游标分页要不要额外的时间列。
- 长度与存储:整数 8 字节、二进制 16 字节、字符串 36 字节,差距会在索引里被放大数倍。
- 分布式生成:能否在任意一台机器上独立生成,而不依赖中心发号器。它决定了多活架构、边缘写入和离线导入是否可行。
- 可猜测性:拿到一个 ID,能否推出相邻 ID 或业务规模。这既是隐私问题,也是越权访问的入口。
- 索引友好度:新值是追加到索引末尾,还是随机插进已有页的中间。这是数据库层面代价最大的一个维度。
五个维度里,可排序性与可猜测性天然对立:有序的 ID 更容易被枚举,不可猜测的随机 ID 天生无序。UUID v7 与 ULID 的价值,正是在这对矛盾之间取折中——前段用毫秒时间戳保证有序,后段用随机位保证同一毫秒内不可预测。
二、六种 ID 方案的横向对比
下表把这六个候选方案放到同一组维度上。长度一栏同时给出字符串形态与二进制存储形态,因为这两者的差别会直接落到索引体积上:
| 方案 | 长度 | 可排序性 | 分布式生成 | 可猜测性 | 索引友好度 |
|---|---|---|---|---|---|
| BIGINT 自增 | 8 字节 | 完全有序 | 需要中心发号 | 极高,加一即可枚举 | 最好,尾部顺序追加 |
| 数据库序列 | 8 字节 | 完全有序 | 需要中心,可用步长分摊 | 极高 | 好,多节点步长会留下空洞 |
| UUID v4 | 36 字符 / 16 字节 | 完全无序 | 完全独立 | 极低,122 位随机 | 最差,随机插入触发页分裂 |
| UUID v7 | 36 字符 / 16 字节 | 毫秒级有序 | 完全独立 | 低,生成时间可读 | 好,近似尾部追加 |
| ULID | 26 字符 / 16 字节 | 毫秒级有序 | 完全独立 | 低,生成时间可读 | 好,同上 |
| 雪花 ID | 8 字节 | 毫秒级有序 | 完全独立,需分配机器号 | 中,时间与机器号可读 | 好,但同一毫秒内可能乱序 |
表里最容易被忽略的是最后一行和第三行。雪花 ID 虽然只有 8 字节,却并不是严格有序的:同一毫秒内靠 12 位序列号递增,一旦序列号回绕或发生时钟回拨,新生成的 ID 反而可能小于已经存在的 ID。而 UUID v4 的"完全无序"也不是一个小缺点,它意味着每一次插入都可能落在索引树的随机位置,这是第三节要展开的核心代价。
另一个反直觉之处是:有序性带来的收益比长度带来的收益更大。16 字节的 v7 与 8 字节的雪花 ID 在体积上相差一倍,但如果两者都近似顺序插入,它们在索引上的表现都会明显好于同样 16 字节、却随机插入的 v4 二进制形态。
三、随机主键为什么拖慢数据库:页分裂与二级索引放大
InnoDB 的聚簇索引按主键顺序组织数据,每一页默认 16 KB。当新主键总是大于已有主键时,插入只会追加到最右侧的页,写满后再开新页,此时页的平均填充率可以维持在 15/16,也就是约 93.75%。而当主键完全随机时,每次插入都要先找到它该去的那一页;如果目标页已经写满,数据库必须把这一页拆成两半,这就是页分裂。分裂之后两页各占约一半空间,平均填充率掉到约 50%。
同一个索引、同样多的记录,随机插入的理论体积约为顺序插入的 93.75% ÷ 50% ≈ 1.875 倍;此外每次分裂都要额外写 redo 日志与 undo 记录,随机读页也会增加缓冲池的换入换出。所以结论不是"UUID 不能用",而是"随机顺序的主键要单独付一笔账"。
第二笔账落在二级索引上。InnoDB 的每个二级索引条目都要附带主键值,主键有多宽,二级索引就得跟着宽多少。以一张 1 亿行的订单表、外加 5 个二级索引为例:
| 主键存储形态 | 单条主键占用 | 1 亿行主键列 | 5 个二级索引额外携带 | 合计 |
|---|---|---|---|---|
| BIGINT | 8 字节 | 0.8 GB | 4 GB | 4.8 GB |
| BINARY(16) | 16 字节 | 1.6 GB | 8 GB | 9.6 GB |
| CHAR(36),utf8mb4 字符集 | 实际值 36 字节;按行格式每字符 4 字节计的上限为 144 字节 | 3.6 至 14.4 GB | 18 至 72 GB | 21.6 至 86.4 GB |
表里的数字只统计主键值本身的字节,不含 B+ 树的页开销与填充率损失;把上一条的 1.875 倍也算进去,随机主键的实际占用还要再放大一轮。三条缓解手段按效果排序:第一,改用 UUID v7 或 ULID 这类时间有序的版本,从源头恢复顺序插入;第二,数据库里一律存 BINARY(16) 而不是 CHAR(36),相关索引体积直接减半以上;第三,如果必须保留随机主键,就把它降级为唯一索引列,另外用一个自增整数充当聚簇主键。
四、版本语义如何决定选型边界
UUID 各版本的差别不只是"怎么算出来的",更是"算出来的结果泄露了什么"。这一点在选型时比生成算法本身更重要。
v1 的问题在今天的云上被放大了。它把生成时刻和节点标识一起编进 ID。在物理机上,这泄露的是一台设备的硬件地址;在云主机上,网卡是虚拟的,泄露的往往是宿主机分配的虚拟标识,它能告诉你"这两条记录来自同一台实例",在按量计费的场景里足以被用来推断实例数量和扩容节奏。另外 v1 的时间戳精度是 100 纳秒,时钟一旦回拨,新生成的 ID 可能小于旧 ID,依赖它排序的逻辑会静默出错。今天仍然适合 v1 的场景很少:纯内网、需要严格时间序、并且可以接受硬件标识在边界内可见。
v4 的边界在"是否需要顺序"和"是否泄露时间"。如果这个 ID 不进入任何 B+ 树索引,v4 是最省心的选择:对外 API 的资源标识、幂等键、日志追踪号、分布式锁的持有者标识,全都适合。反过来,如果它会成为数据库主键或日志主键,那么选 v4 就是选错了版本。这里还有一个常见误解需要澄清:v4 的 122 位随机性不等于它天然是一个安全令牌。随机性够不够,取决于随机源是不是密码学安全的。用 crypto.randomUUID() 或语言标准库的密码学随机接口没有问题;用 Math.random()、C 的 rand() 或任何可预测的伪随机数生成器拼出来的"UUID",位数看着一样,强度完全不值一提。随机数内部到底发生了什么,可以看随机数生成器使用指南;需要按数量级生成随机串时,用随机数生成器并确认它基于安全随机源。
v5 的价值与风险是同一件事。它用"命名空间加名称"做哈希,同样的输入永远得到同样的输出,因此特别适合把稳定的自然键(一个 URL、一个用户名、一个外部系统的单号)映射成内部 ID,做数据同步时天然去重。反过来说,确定性就意味着可预测:只要外部知道你的命名空间和名称规则,他就能自己算出你的 ID。所以 v5 绝不能用于邀请码、兑换码、重置令牌这类"必须猜不到"的标识。另外命名空间一旦投入使用就不能再改,改了等于全部派生 ID 重算一遍。
v7 是 2024 年发布的 RFC 9562 正式纳入的版本(该标准取代了 2005 年的 RFC 4122)。它的结构是 48 位毫秒时间戳加 74 位随机数:前 48 位保证按时间递增、近似顺序插入,后 74 位保证同一毫秒内生成的多条 ID 互不冲突且不可预测。它一次性解决了 v4 的两个痛点——索引随机插入与不可排序——代价是生成时间对外可读,一条 ID 就能被推出"这条记录是什么时候创建的"。如果创建时间本身属于敏感信息(比如用户注册时间、订单创建时间就是商业情报),v7 需要配合额外的处理。
雪花 ID 的坑不在算法,在运维。它把 64 位拆成 41 位毫秒时间戳、10 位机器号、12 位序列号:41 位毫秒足够用约 69.7 年,12 位序列号意味着每台机器每毫秒最多生成 4096 个 ID。第一个风险是时钟回拨,机器时间被 NTP 往回校正时,新的时间戳会小于上一个,如果实现里没有检测逻辑,就可能生成重复 ID。第二个风险是机器号重复:用虚拟机克隆、容器快照恢复出来的实例会带着同一份配置,两台机器同时使用同一个机器号,碰撞立刻从概率问题变成必然事件。这两个风险都必须靠配置管理和显式检测来消除,而不是靠运气。
五、碰撞概率的真实数量级
UUID v4 有 122 位随机数,总量是 2 的 122 次方,约 5.317×10^36 种可能。但"每生成一条 ID 就撞车的概率"和"生成一大批之后至少撞一次的概率"是两个完全不同的数,后者要用生日悖论来算:p ≈ 1 - e^(-n² / 2N),其中 n 是累计生成数量,N 是随机空间大小。下表按这个公式以 N = 2^122 实算:
| 累计生成数量 n | 至少碰撞一次的概率 p | 现实对照 |
|---|---|---|
| 10^9(十亿) | 约 9.4×10^-20,约 10^19 分之一 | 一个中型电商平台一年的订单量级 |
| 10^12(一万亿) | 约 9.4×10^-14 | 大型平台数年积累的记录总量级 |
| 10^15(一千万亿) | 约 9.4×10^-8,约一亿分之一 | 已超过绝大多数单一系统的数据规模 |
| 3.26×10^15 | 10^-6,百万分之一 | 工程上仍属于可忽略区间 |
| 10^18(一百亿亿) | 约 9.0% | 已经不能当作"不会发生" |
| 2.71×10^18 | 约 50% | 生日悖论的理论抛硬币点 |
| 3.16×10^18 | 约 61% | 相当于每秒生成 10 亿个连跑 100 年,任何现实系统都达不到 |
这组数字要说清两件事。第一,在 10^15 这个量级以下,随机碰撞是可以忽略的:10^15 条记录意味着即使每条只占 100 字节,总量也已经是一亿 GB 级别,没有系统会跑到那里。第二,"UUID 不会重复"是有前提的,前提是随机源真的提供了 122 位熵。一旦熵源退化,位数看着没变,安全边际却会塌方式下降。
下面这张对照表才是真正值得记住的部分。假设某个"UUID"实际上只提供 32 位随机性——这是 8 位十六进制短码的宽度,也是不少语言里简易随机接口的实际强度:
| ID 的有效随机位数 | 生成 1 万条时的碰撞概率 | 生成 10 万条时的碰撞概率 |
|---|---|---|
| 122 位,v4 的标准形态 | 约 9.4×10^-30 | 约 9.4×10^-28 |
| 32 位,8 位十六进制短码 | 约 1.2% | 约 69% |
把 UUID 截断成短码、或者直接用 8 位十六进制随机串当邀请码,等价于把随机空间从 2^122 砍到 2^32,只剩约 43 亿种可能。这个空间不但让碰撞概率迅速抬高,枚举成本也低到可以用一台普通机器在合理时间内跑完。短码必须另外设计:更长的字符集、服务端唯一性校验,两者缺一不可。
六、三个真实算例
算例一:1 亿行订单表,三种主键方案的索引占地。取 1 亿行订单,主键之外建 5 个二级索引。方案 A 用 BIGINT 自增:主键列 8 字节乘 1 亿等于 0.8 GB,每个二级索引附带 8 字节主键,5 个共 4 GB,合计 4.8 GB。方案 B 用 CHAR(36) 存 UUID v4:主键列 3.6 GB,5 个二级索引各带 36 字节共 18 GB,合计 21.6 GB。方案 C 用 BINARY(16) 存 UUID v7:主键列 1.6 GB,二级索引共 8 GB,合计 9.6 GB。单看这一项,方案 B 是方案 A 的 4.5 倍、是方案 C 的 2.25 倍。再把页分裂的填充率损失算进去:方案 B 的主键随机插入,索引体积还要再乘约 1.875,实际接近 40 GB;方案 C 近似顺序插入,填充率接近顺序主键,几乎不承担这部分损失。这就是"同样是 UUID,v4 和 v7 的账差一倍"的具体形态。
算例二:每毫秒的生成上限。雪花 ID 每台机器每毫秒只有 4096 个序列号,超过就必须阻塞到下一毫秒,因此单机峰值吞吐被硬性限制在约 409.6 万个每秒,而且这个数字无法靠加 CPU 提升,只能靠增加机器号分摊。ULID 在同一毫秒内有 80 位随机数,空间约 1.209×10^24;UUID v7 在同一毫秒内有 74 位随机数,空间约 1.889×10^22。按生日悖论估算,v7 在同一毫秒内生成 100 万条,碰撞概率约为 2.6×10^-11。也就是说,时间有序的随机版本没有"每毫秒上限"这个约束,代价是把机器号和序列号的管理复杂度,换成了对随机数质量的要求。
算例三:雪花 ID 进了 JSON 会变。雪花 ID 是 64 位整数,取值通常在 10^18 量级,而 JavaScript 的 Number 只能精确表示到 2^53 - 1,即 9007199254740991。前端解析接口返回的雪花 ID 时,超过这个上限的部分会被静默抹平:1234567890123456789、1234567890123456790、1234567890123456800 这三个不同的整数,在 JavaScript 里会全部变成同一个值 1234567890123456800。它不报错,只是把两笔不同订单的 ID 合并成一个,然后在缓存键、前端路由或幂等判断上引发难以复现的错乱。解法只有一条:64 位整数 ID 在 JSON 接口里必须序列化成字符串,这条规则同样适用于其他超过 2^53 的整数,比如金额最小单位、纳秒时间戳。排查这类问题时,把原始响应贴进JSON 格式化与校验工具看一眼通常比打断点更快;同时记住比较 ID 一律按字符串比较,任何先转成数字的写法都会踩同一个坑。
七、常见问题(FAQ)
- 生成多少条 UUID v4 才需要担心碰撞?
- 按生日悖论实算,累计生成 3.26×10^15 条时至少碰撞一次的概率约为百万分之一,到 10^18 条才升到约 9%。现实中任何系统的记录量都远低于这个量级,所以真正需要防的不是随机碰撞,而是重复写入、熵源退化和把 UUID 截断成短码这三类人为问题。
- 用 UUID 当 MySQL 主键到底有什么代价?
- 两笔账。一是随机插入导致页分裂,页填充率从约 93.75% 掉到约 50%,索引体积约为顺序插入的 1.875 倍,并额外产生 redo 写入;二是宽度放大,在 1 亿行加 5 个二级索引的规模下,CHAR(36) 主键形态约 21.6 GB,而 BINARY(16) 形态约 9.6 GB。改用 v7 或 ULID 恢复顺序性、并统一存 BINARY(16),可以把大部分代价消掉。
- UUID v4 和 v7 应该怎么选?
- 只问一句:这个 ID 会不会进入数据库索引。会,就选 v7,因为它的前 48 位是毫秒时间戳,插入近似顺序追加;不会,就选 v4,它不泄露生成时间也不依赖任何输入。要注意 v7 会把创建时间写在 ID 里,如果这个时间本身敏感,就该用 v4。
- ULID 和 UUID v7 有什么区别,该选哪个?
- 两者的前 48 位都是毫秒时间戳,都可排序,区别在书写形式:ULID 用 Crockford Base32 写成 26 个字符,UUID v7 是标准 36 字符格式。ULID 更短、可读性更好,但无法直接存进数据库的原生 UUID 类型;v7 是 RFC 9562 标准,几乎所有语言的 UUID 库和数据库类型都认。要在数据库层原生支持就选 v7,只在应用层传递且长度敏感就选 ULID。
- 把 UUID 截断成 8 位当邀请码安全吗?
- 不安全。截断到 8 个十六进制字符等于只剩 32 位随机性,空间约 43 亿种,实算生成 1 万条时碰撞概率约 1.2%、生成 10 万条时约 69%;同时枚举成本低到用普通机器就能跑完。短码必须另外设计:更长的字符集、服务端唯一性校验,并且不能把 UUID 截断当作生成方式。
八、四个常见误区
误区一:UUID 是绝对唯一的。它是概率唯一,不是数学唯一。真正把重复带进生产环境的,几乎都不是随机碰撞,而是同一个 ID 被写了两遍、随机源退化成伪随机、或者为了让 ID 好看而做了截断。
误区二:用 UUID 就等于分布式友好,而且没有代价。分布式友好是真的,没有代价是假的。代价集中在索引:随机主键的插入代价、字符串主键的宽度代价、二级索引的放大代价。选 v7 而不是 v4,选 BINARY(16) 而不是 CHAR(36),都是在不放弃分布式能力的前提下把这笔账压下去。
误区三:v5 比 v4 更高级,因为它可复现。可复现意味着可预测。只要外部知道命名空间和名称的构造规则,他就能自己算出你的 ID,因此 v5 绝不能用于任何需要"猜不到"的标识。它真正的用武之地是数据同步:同一个自然键在不同系统里派生出同一个 ID,天然去重。
误区四:ID 长一点只影响外观,不影响性能。36 字符和 16 字节的差别,在 1 亿行加 5 个二级索引的规模下就是 21.6 GB 与 9.6 GB 的差别;再叠加随机插入的填充率损失,同一条查询要读的页数会明显变多。ID 宽度是架构级参数,不是格式偏好。
九、五条实用建议
- 建议一:先判断这个 ID 会不会进 B+ 树。会进索引的,比如数据库主键、日志主键、时序表键,一律选有序方案(UUID v7、ULID、雪花 ID);不进索引的,比如对外资源标识、幂等键、追踪号、锁标识,用 v4 最省心。
- 建议二:数据库里统一存二进制。UUID 用 BINARY(16) 而不是 CHAR(36),读写时在应用层转换。这一步不需要更换 ID 方案,就能把相关索引的占用砍掉一半以上。
- 建议三:内部主键与对外标识分开。内部用紧凑有序的主键保证连接与索引效率,对外暴露另一列不可猜测的随机 ID。这样既不用为了对外安全牺牲索引性能,也不用为了索引性能暴露可枚举的编号。
- 建议四:给 ID 生成加两道保险。一是数据库层保留唯一约束,让碰撞以报错的形式暴露而不是静默覆盖;二是确认随机源是密码学安全的,比如
crypto.randomUUID()这类接口,而不是Math.random()或语言自带的伪随机函数。 - 建议五:如果必须用雪花 ID,把机器号纳入配置管理。机器号从配置中心统一下发,禁止写死在镜像里,并对时钟回拨做显式处理:检测到时间倒退时拒绝生成或等待追上,而不是继续发号。虚拟机克隆和容器快照是机器号重复最常见的来源。
把 ID 当成数据契约的一部分来看,选型其实只有三步:先确定它是否进入索引,再确定它是否必须不可猜测,最后按宽度和运维成本挑具体实现。需要批量生成一批 ID 用于导入或压测,可以直接用UUID 生成器;如果这批 ID 之后还要参与签名或传输编码,可以先看一眼Base64 编解码说明,避免在编码环节引入额外的体积和转义问题。