需要按时间排序、减少数据库索引碎片时,优先用 UUID v7 或 ULID;更看重随机性和生态兼容时用 UUID v4。两者都是 128 位,UUID 通常表示为带连字符的 36 字符,ULID 表示为 26 个 Crockford Base32 字符。
- UUID v4 是随机的;UUID v7 和 ULID 内嵌时间戳,近似按创建时间排序。
- 时间有序 ID 能改善 B 树局部性,让最近写入和查询更快。
- ULID 使用 Crockford Base32,大小写不敏感,文本更短。
- 这些 ID 都不是密钥,不能把不可猜测当作授权。
- 数据库尽量存二进制形式,接口层再暴露规范文本。
选择 ID 格式看起来是小事,直到表变大。随机主键会造成索引碎片,有序主键会暴露创建时间,文本表示又会影响 URL、日志和排查体验。
这篇文章从实际影响最大的几个维度比较 UUID v4、UUID v7 和 ULID。
格式对照
| 属性 | UUID v4 | UUID v7 | ULID |
|---|---|---|---|
| 位数 | 128 | 128 | 128 |
| 时间有序 | 否 | 是 | 是 |
| 文本长度 | 36(含连字符) | 36(含连字符) | 26 |
| 字母表 | 十六进制 | 十六进制 | Crockford Base32 |
| 大小写敏感 | 否 | 否 | 否 |
| 标准 | RFC 9562 | RFC 9562 | ULID 规范 |
| 库支持 | 非常广 | 增长中 | 较好 |
UUID v4 使用 122 位随机数。UUID v7 使用 48 位毫秒时间戳加随机位。ULID 使用 48 位毫秒时间戳加 80 位随机数,再用 Crockford Base32 编码。
位数分配直接决定取舍:
| 格式 | 时间戳位 | 随机或计数位 | 文本长度 |
|---|---|---|---|
| UUID v4 | 0 | 122 | 36 |
| UUID v7 | 48 | 74(12 位 rand_a + 62 位 rand_b) | 36 |
| ULID | 48 | 80 | 26 |
48 位毫秒时间戳到公元 10889 年才用完,实际项目不用担心溢出。同一毫秒内的碰撞概率由随机部分决定:UUID v7 有 74 位,ULID 有 80 位,都远远够用;就算每秒生成百万个 ID,生日碰撞概率也可以忽略。真正的差别在文本长度和生态支持,不在“够不够唯一”。
ULID 的第一个字符一定在 0 到 7 之间。这不是约定,而是编码结果:48 位时间戳占 10 个 Base32 字符(50 位),首位只有低 3 位有效。一个校验器如果接受 Z 开头的 ULID,它接受的就是一个解码不出合法 48 位时间戳的值。
为什么可排序很重要
B 树索引按顺序存储键。新键随机分布时,插入会落到树的各个位置,导致页分裂和写放大。新键近似顺序时,插入会追加到最右侧页面。
这是团队从 UUID v4 迁移到 UUID v7 或 ULID 的主要原因。收益最大的场景:
- 每秒大量插入的表。
- 聚簇索引,例如 MySQL InnoDB 主键。
- 最近数据查询最频繁的时序类数据。
低写入表或使用独立自增键的表,收益较小。
机制值得说清楚,因为它决定了建议什么时候成立。B 树里插入一个随机键,要先找到一个可能已经满了的叶子页,分裂它,再更新所有祖先;新页散落在文件各处。顺序插入通常直接追加到最右侧叶子,树的其余部分不动。InnoDB 的聚簇主键让这个差异尤其明显:整行数据就住在主键 B 树里,随机键会同时搬动行数据、二级索引项和 undo 记录。PostgreSQL 的堆表和索引是分开的,主键索引仍然会碎,但行插入本身是追加的,影响真实存在,只是小一些。
应该用监控确认,而不是凭感觉。PostgreSQL 里 pg_stat_user_tables.n_dead_tup 和索引膨胀估算涨得比行数快,通常说明随机键在制造 churn;MySQL 里可以观察 INFORMATION_SCHEMA.INNODB_TABLESTATS,以及插入时 Innodb_buffer_pool_pages_free 压力的突然上升。在表的副本上做前后对比,比引用一篇博客里的百分比可靠。
可排序也不是万能。如果查询本来就按 ID 随机读,时间有序键不会让查找更快;表小到能全部放进内存时,页分裂很便宜;应用已经按独立的 created_at 索引排序时,主键顺序只影响插入成本。时间有序键还可能把写入集中到一个热点页,在极高插入速率下变成争用点。带单调计数器的 UUID v7 能缓解这个问题,但代价是随机性下降。
随机性的价值
时间有序 ID 会暴露创建时间。ID 公开后,任何人都能估算记录创建时间。通常可以接受,但要看场景:
- 公开订单号可能暴露业务量。
- 连续 ID 会暴露记录总数。
- 时间有序 ID 会暴露大致创建时间。
UUID v4 能隐藏这些信息。如果时间戳敏感,应该用 v4,或者另外生成一个不透明的公开 ID。
UUID v7 和 ULID 会把创建时间暴露到毫秒。拿到一个公开 ID,就能推出记录创建的具体秒数;拿到一串 ID,还能估算注册速率、订单量,以及哪些账号是在同一时间段创建的。截断时间戳再编码不是标准操作,也会破坏格式的唯一性保证,所以实际做法是再加一个标识:时间有序值继续做内部主键,另外生成随机 public_id 暴露在 URL 和 API 里。代价是每行多 16 字节和一个唯一索引,通常比泄露业务指标或让跨租户数据可关联便宜得多。
这些格式都不是密钥。ID 不是授权凭证。无论用哪种格式,服务端都必须做访问控制。
文本表示和 URL
ULID 更短、更好读:
UUID v4: 550e8400-e29b-41d4-a716-446655440000
UUID v7: 018f4f8e-7c2a-7c3e-9b1a-2f6d8e4a1b3c
ULID: 01JCH6X8Y4Q2V7M3N9K5R1T6B8
ULID 使用 Crockford Base32,去掉了容易混淆的 I、L、O、U,并且大小写不敏感。复制粘贴、电话支持和人工转录都更友好。
UUID 更常见,库、数据库和第三方系统支持更广。如果集成很多外部服务,UUID 是更稳妥的默认值。
Crockford Base32 有一个容易把校验器写错的细节。编码时排除了 I、L、O、U,但解码时应该把 i、l 当成 1,把 o 当成 0,因为这些正是人工转录时最容易看错的字符。ULID 大小写不敏感,01jch6x8... 和 01JCH6X8... 是同一个值;但把两种写法都存进大小写敏感的列,就会出现“看起来重复”的行和失效的等值查询。写入时统一转大写,必须存文本时用二进制排序规则;大小写和重音都不敏感的排序规则会让 A 和 a 比较相等,对 UUID 来说方便,但也会掩盖“规范形式没有被保留”这件事。
校验应该拒绝整个值,而不是修补它。ulid 包的 decodeTime("not-a-ulid") 抛 ULIDError: Malformed ULID (DEC_TIME_MALFORMED),isValid 则返回 false;两种都比一个宽松的正则好——正则可能放过 26 个字符,等到后面才失败。UUID 的规范 v4/v7 字符串是 36 个字符、连字符位置固定;32 位十六进制串很多解析器也接受,但它不是规范形式。SQL Server 的 uniqueidentifier 排序字节序和规范字符串顺序不同,按列排序和按文本排序结果会不一致。边界上统一规范化,不要拿不同来源的原始字符串直接比较。
URL 里的占用差异比字符数看起来更大。UUID 要 36 个字符,放进路径还有编码开销;ULID 只要 26 个,没有连字符,双击选中和 CSV 导出都更省事。两者都不够短,不能替代人类可读的 slug,也都不应该拿来当密码重置 Token。
数据库存储
两种格式都是 128 位。可以存成:
- 数据库原生
BINARY(16)或UUID类型。 - 没有 128 位类型时,用两个
BIGINT。 - 只有在便利性明显大于存储和索引成本时,才存文本。
文本存储会让体积增加两到三倍,比较也更慢。如果存文本,使用固定长度类型并统一大小写。
具体数字能让代价更直观。BINARY(16) 每个值 16 字节;InnoDB 里 utf8mb4 的 CHAR(36) 最多预留 144 字节,索引项也按同样宽度计算。一千万行的表,光多出来的索引数据就接近 1.4 GB,还没算页开销。文本列无法避免时,CHAR(36) CHARACTER SET ascii COLLATE ascii_bin 是比较合理的折中:ASCII 字符每个只占 1 字节,二进制排序规则也能给出确定的比较结果。
数据库的具体行为比通用建议更重要。MySQL 的 UUID_TO_BIN(uuid, 1) 会交换 time-low 和 time-high 字段,让二进制形式按时间排序;不加交换标志时,二进制顺序和 UUID v1 的文本顺序不一致。到了 UUID v7,时间戳本来就在最前面,再做交换反而会破坏想要的时间顺序。PostgreSQL 18 增加了原生 uuidv7() 和 uuidv4(),生成时间有序 ID 不必再绕到应用层。SQLite 里应该存 16 字节的 BLOB,或者存规范文本并加 CHECK(length(id) = 26) 约束;一个没有约束、大小写混用的 TEXT 列,迟早会积累出不一致的数据。
索引类型也要一起考虑。InnoDB 的 BINARY(16) 主键是聚簇的,这 16 字节会重复出现在每个二级索引里;换成 36 字节文本主键,重复的就是 36 字节。行数上千万、二级索引又有好几个时,索引体积和缓存压力的差距会比单行算术大得多。
生成 ID
// UUID v4(随机)
const id = crypto.randomUUID();
// ULID(时间有序),使用库
import { ulid } from "ulid";
const id = ulid();
UUID v7 应该使用实现 RFC 9562 的库,不要自己拼时间戳位。位布局出错时,生成的值看起来合法,但排序会错。
UUID/ULID 生成工具 可以生成两种格式用于测试。GUID 生成工具 适合需要 Windows GUID 格式的系统。
生成环节有几个运维细节。浏览器里的 crypto.randomUUID() 只在安全上下文可用,局域网 HTTP 页面会抛 TypeError: crypto.randomUUID is not a function;要么用库,要么用 uuid 包的降级实现。大多数运行时的 randomUUID() 生成的是 v4,不是 v7,所以“用内置的”并不能满足时间有序需求。需要 v7 时用 uuid 的 v7() 之类的库,需要 ULID 时用 monotonicFactory():它会处理位布局,并在时钟没有前进时递增随机部分,让同一进程产生的 ID 仍然可排序。
单调性只在本进程、本时钟内成立。两台应用服务器的时钟差 500 毫秒,生成的 ID 顺序就可能和真实时间相反;ID 仍然唯一,但“最新记录”查询和游标分页可能漏行或重复行。NTP 能减小偏差,不能消除偏差;时钟回拨还会产生比已经发出的值更早的 v7。如果严格的全局顺序是正确性要求,就不要从墙上时钟 ID 推导顺序,改用中心化序列、数据库生成值,或者服务器分配的时间戳加稳定的 tiebreaker。
什么时候用哪种
用 UUID v4:
- 需要最大化的库和数据库兼容性。
- 不想暴露创建时间。
- 表写入量低,或使用独立自增键。
- 第三方系统只接受 UUID。
用 UUID v7:
- 想要时间有序插入,同时保持 UUID 兼容。
- 数据库或 ORM 支持 v7 生成。
- 需要标准定义的位布局。
用 ULID:
- 想要紧凑、大小写不敏感的文本 ID。
- 能控制完整技术栈并引入 ULID 库。
- 想要时间有序且人类友好的表示。
决策树很短。系统已经在说 UUID、表又有大量插入时,选 UUID v7;能控制完整技术栈、文本形式对人类重要时,选 ULID;更看重不可预测性,或者生态只接受 UUID,就用 v4。都不合适时,可以换一个标识符家族:
| 格式 | 大小 | 有序 | 适合场景 |
|---|---|---|---|
自增 BIGINT | 8 字节 | 是 | 单库内部键 |
| Snowflake 类 ID | 8 字节 | 是 | 有 worker ID 的分布式系统 |
| KSUID | 20 字节 | 是 | 27 字符 Base62、按时间排序 |
| NanoID | 约 21 字符 | 否 | UI 或文件名用的短随机 ID |
| UUID v7 | 16 字节 | 是 | 标准工具链 + 索引局部性 |
| ULID | 16 字节 | 是 | 能端到端控制的紧凑文本 ID |
这套建议在两种情况下是错的。第一,ID 必须不可预测且创建时间敏感:v7 和 ULID 都会泄露时间,正确做法是另加随机公开标识,而不是换一种时间戳编码。第二,数据库就是单个 PostgreSQL 实例,用 BIGINT 序列、没有分布式写入者:64 位整数比任何 128 位格式都更小、更快、更简单。按你实际拥有的系统选,而不是按演讲里的架构选。
常见错误
- 应用生成 UUID,又用另一个可能漂移的时间戳排序。
- 把 UUID 当密码重置 Token 或 API Key。应该使用专门的随机 Token,并且只存哈希。
- UUID 按大小写敏感文本存储,却用不同大小写比较。
- 假设时间有序 ID 严格单调。时钟调整会产生乱序;部分 ULID 实现有单调模式,但不应依赖它保证正确性。
- 公开连续或时间有序 ID,却不做访问控制。
其中两个错误有很具体的症状。大小写不敏感存储 + 大小写敏感比较,会产生很难复现的工单:用户从邮件里复制了大写 ID,API 却按小写查,行明明存在,却报找不到。集成测试里用两种大小写各查一次。单调性假设会在游标分页里暴露:两行落在同一毫秒,随机 tiebreaker 又不稳定时,WHERE id > $cursor ORDER BY id 可能漏掉第二行,也可能把它返回两次。用 (id, created_at) 这样的确定 tiebreaker,或者同时按两列分页。
第三个错误是把 ID 当秘密。随机 UUID v4 确实猜不到,但如果它同时是唯一的访问检查,链接一泄露就等于永久授权。公开 ID 要配合鉴权、过期和撤销;标识符格式不是安全控制。
实用建议
新项目如果使用现代数据库,UUID v7 是很好的默认值:标准化、时间有序、兼容 UUID 工具链。想要更短的文本 ID 并能控制技术栈时,ULID 是不错的替代。兼容性或不可预测性是首要要求时,用 UUID v4。
无论选哪种,都要写清楚格式,尽量存二进制,并把授权和 ID 生成分开。
把已有表从 v4 迁到 v7 不是改个格式,而是重建索引。先加新列,按旧 created_at 排序分批回填,让新键基本顺序;再灰度切换读写,确认稳定后重建主键,最后删掉旧列。迁移期间表和索引都会膨胀,要在维护窗口或用在线 DDL 工具做。如果回填时生成的 v7 是乱序的,换格式本来想要的索引局部性就没了。
落地前用一张短清单验证:生成一千万个 ID,确认无重复且顺序符合预期;插入生产表的副本,测量索引大小、写入吞吐和 EXPLAIN 计划;确认所有消费方接受文本长度和字母表;检查大小写归一化和输入校验;记录时间戳范围和时钟假设。如果实测收益在噪音范围内,就选更简单的格式。
常见问题
ULID 比 UUID 更好吗?
没有绝对的好坏。ULID 和 UUID v7 都提供时间有序 ID。UUID 的库和数据库支持更广,ULID 的文本更短且大小写不敏感。应该根据生态选择。
UUID v4 能按创建时间排序吗?
不能。UUID v4 是随机的,字典序和创建时间没有关系,必须另建 created_at 字段排序。UUID v7 和 ULID 内嵌时间戳,可以近似按时间排序。
UUID 可以安全地作为公开 ID 吗?
可以作为公开 ID,但它不是密钥。公开 UUID 不会暴露连续数量,但也不能代替权限校验。服务端必须做访问控制,不能把不可猜测的 ID 当作授权凭证。
UUID v4 的碰撞概率高吗?
实际系统中可以忽略。UUID v4 有 122 位随机数。ULID 和 UUID v7 把时间戳和随机位组合,只要随机部分足够大,碰撞概率同样可以忽略。
UUID 适合做数据库主键吗?
可以,但随机 UUID 可能造成聚簇索引碎片。UUID v7 和 ULID 因为近似顺序,碎片更少。应该在自己的数据库上测量影响,而不是假设。