# UUID 和 ULID 怎么选：随机、有序与数据库索引的取舍

> 比较 UUID v4、UUID v7 和 ULID 的可排序性、索引友好度、URL 表示和分布式场景，给出新项目的选择建议。

**快速答案:** 需要按时间排序、减少数据库索引碎片时，优先用 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 更短、更好读：

```text
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

```js
// UUID v4（随机）
const id = crypto.randomUUID();

// ULID（时间有序），使用库
import { ulid } from "ulid";
const id = ulid();
```

UUID v7 应该使用实现 RFC 9562 的库，不要自己拼时间戳位。位布局出错时，生成的值看起来合法，但排序会错。

[UUID/ULID 生成工具](/tools/uuid-ulid) 可以生成两种格式用于测试。[GUID 生成工具](/tools/guid-generator) 适合需要 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 因为近似顺序，碎片更少。应该在自己的数据库上测量影响，而不是假设。

## 参考资料

- [RFC 9562 - UUID 规范](https://www.rfc-editor.org/rfc/rfc9562)
- [ULID 规范](https://github.com/ulid/spec)
- [RFC 4122 - UUID URN 命名空间](https://www.rfc-editor.org/rfc/rfc4122)
- [Crockford Base32](https://www.crockford.com/base32.html)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/uuid-vs-ulid
- Locale: zh-CN
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: uuid-ulid, guid-generator, random-token-generator, base32