为什么在数据库主键上 UUIDv7 胜过 UUIDv4
大约十年来,业界的常识是:"主键要用自增整数,因为随机 UUID 会摧毁数据库性能。"这个判断没错,但"UUID 很慢"这个结论被过度泛化了。真正的罪魁祸首是 UUIDv4 的完全随机性,而不是 UUID 这个类别本身。UUIDv7(于 2024 年 5 月在 RFC 9562 中标准化,取代旧的 RFC 4122)在高位放置 Unix 毫秒时间戳,低位放置随机位。相隔 1 毫秒生成的两个值按时间戳排序;同一毫秒内生成的两个值在该毫秒内随机排序。插入的形态由此彻底改变。
这一点之所以重要,原因在于 B-tree。PostgreSQL、MySQL/InnoDB、SQL Server —— 它们都以主键为键、按 B-tree(或 B+-tree)顺序存储行。插入一个主键大于所有现有键的值时,总是落在最右侧的叶子页。该页在缓冲池中处于热状态,下一次插入命中同一页,数据库几乎不需要任何 IO。而使用 UUIDv4 时,每次插入都落在随机的叶子上 —— 每次插入都要从磁盘拉入新页、把它弄脏、淘汰别的页,再把脏页写回。当表大到索引装不进内存时,UUIDv4 的插入可能比自增慢 10 到 50 倍,而且随机插入会在整棵树上到处引发页分裂,导致 B-tree 严重碎片化。
UUIDv7 消除了这个问题。插入按时间有序,因此落在索引的一小块热点区域 —— 并非总是字面意义上的最右叶子(随机低位会带来一些局部抖动),而是一个能一直留在缓存中的小工作集。PlanetScale、AWS 和 Percona 的基准测试均显示,在现代 InnoDB 和 Postgres 上,UUIDv7 的 INSERT 吞吐量与 bigint 自增的差距在 10% 到 20% 以内。困扰 UUIDv4 的碎片化也随之消失,因为新键在时间上总是彼此相邻。
有一个实践细节需要注意。InnoDB 原生以 16 字节 BINARY 存储 UUID —— 请存为 BINARY(16),而不是带连字符的 36 字符十六进制字符串。十六进制形式占 36 字节(去掉连字符是 32 字节),存储和索引占用都超过两倍,这直接意味着每页容纳的键更少、IO 更多。Postgres 有存储 16 字节的原生 uuid 类型;请使用它。在人工调试可读性与存储之间的标准权衡是:以二进制存储,仅在 API 边界渲染为十六进制。
// UUIDv4 layout: 122 random bits, 6 fixed bits
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
^^^^ ^^^^
version 4 variant bits
// UUIDv7 layout: 48-bit Unix ms timestamp + 74 random bits
ttttttttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx
^^^^^^^^^^^^^ ^^^^ ^^^^
unix_ts_ms rand-A rand-B
+ version + variant
// Postgres
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- PG18+
...
);
// MySQL — store as BINARY(16), not VARCHAR(36)
CREATE TABLE orders (
id BINARY(16) PRIMARY KEY,
...
);
// JS — generate UUIDv7 (no deps)
function uuidv7() {
const ts = Date.now();
const tsHex = ts.toString(16).padStart(12, '0');
const rand = crypto.getRandomValues(new Uint8Array(10));
rand[0] = (rand[0] & 0x0f) | 0x70; // version 7
rand[2] = (rand[2] & 0x3f) | 0x80; // variant
const r = [...rand].map(b => b.toString(16).padStart(2, '0')).join('');
return `${tsHex.slice(0,8)}-${tsHex.slice(8)}-${r.slice(0,4)}-${r.slice(4,8)}-${r.slice(8)}`;
}UUID 冲突概率 —— 用数字看生日悖论
"UUID 发生冲突的概率有多大?"是每个团队都会问一次的问题。答案的根基是生日悖论。UUIDv4 有 122 个随机位(另外 6 位固定用于 version 和 variant),所以名字空间有 2^122 ≈ 5.3 × 10^36 个不同的值。朴素的直觉会说:有这么多槽位,冲突不可能发生。但生日悖论告诉我们,冲突风险与 sqrt(N) 而非 N 成比例 —— 粗略的法则是,要达到首次冲突 50% 的概率,大约需要生成 sqrt(2^122) ≈ 2^61 ≈ 2.3 × 10^18 个值。
具体来说:每秒生成十亿个 UUIDv4,连续一百年,冲突概率仍然只在 10^-9 量级。总共生成一万亿个,其中出现任何冲突的概率大约是 10^-15 —— 比你今年被陨石砸中的概率还低约一百万倍。对单个应用而言,冲突风险实际上为零。只有当你把许多以极端规模生成的独立系统(想想运行数十年的全球 IoT 设备群)产生的 UUID 汇集在一起时,数学才开始变得有趣,而即便如此,务实的安全答案仍是:选定一种策略,永远不必担心。
现代 UUID 家族的版本表大致如下:
v1 (1988): 时间戳 + MAC 地址。每台主机唯一,但会暴露主机的 MAC 和时间。基本已退役。
v3 / v5: namespace + name 的确定性哈希。相同输入总是产生相同的 UUID —— 适合派生 ID。
v4: 122 个随机位。2010 至 2024 年的主流选择。
v6: 类似 v1 但字节序可排序。小众。
v7 (2024 RFC 9562): 48 位 Unix 毫秒时间戳 + 74 个随机位。新系统的强烈首选。
v8: 自定义;各位由你自己填充。仅在 v7 无法满足需求时使用。
一条实用结论:不要手搓自己的 UUID 格式。正是那 6 个保留位(version + variant)让工具和数据库能够识别 UUID。一个 version 位设置错误的 v4 会被解释成其他版本,破坏比较、排序或驱动层面的优化。请始终使用经过验证的库,或上面的标准公式。
// Birthday paradox: probability of ANY collision among n values
// drawn from a space of size N is ≈ 1 - exp(-n^2 / (2N))
// For UUIDv4: N = 2^122
// n = 1 billion → P ≈ 4.7 × 10^-20
// n = 1 trillion (10^12) → P ≈ 4.7 × 10^-14
// 50% collision threshold: n ≈ 2.3 × 10^18
// Detect the version of a UUID
function uuidVersion(uuid) {
// version is the first hex digit of the third group
return parseInt(uuid[14], 16);
}
uuidVersion('018fb1c2-...-7abc-...'); // 7
uuidVersion('a1b2c3d4-...-4abc-...'); // 4
// Deterministic UUIDv5 (SHA-1 of namespace + name)
// Same name → same UUID, useful for migration mapping
import { v5 as uuidv5 } from 'uuid';
const NS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // DNS namespace
uuidv5('example.com', NS); // always "cfbff0d1-9375-5685-968a-48ce8b50e3e0"ULID vs UUIDv7 —— 选定一个,继续前进
在 UUIDv7 标准化之前,社区就已经发明了好几种按时间排序的 ID 格式。其中最流行的是 2016 年制定规范的 ULID(Universally Unique Lexicographically Sortable Identifier)。ULID 同样是 128 位 —— 48 位毫秒时间戳后接 80 位随机数 —— 但它用 Crockford Base32 编码,而不是带连字符的十六进制。其视觉格式是 26 个字符,形如 01HX1Y9ZQM7TWBKE6JR5VN8YGB。
在功能上,ULID 与 UUIDv7 非常相似。两者都按时间有序,都有毫秒时间戳前缀,都有充足的随机位,以文本存储时都能按字典序排序。差异在于审美和生态:
编码: ULID 是 Crockford Base32(26 字符)。UUIDv7 是带连字符的十六进制(36 字符)。ULID 字符串更短,并刻意避开易混淆的字符(没有 I、L、O、U)。就 URL 和人眼阅读而言,ULID 胜出。
数据库支持: UUIDv7 就是 UUID,因此所有具备 uuid 类型的数据库都能以 16 字节二进制高效存储它。ULID 没有原生类型 —— 你得存成 CHAR(26) 或 BINARY(16),并在边界处转换。
工具链: UUID 是通用语言。每种语言、每个 ORM、每个数据库都有数十年积累的 UUID 工具。ULID 有不错的库,但工具集成参差不齐。
规范成熟度: UUIDv7 是 RFC 9562(2024 年 5 月,IETF 标准)。ULID 是 GitHub 上的社区规范。
2026 年的默认选择是 UUIDv7,除非你有特定的 URL 美观理由偏爱 ULID。UUIDv7 拥有作为正式 RFC 的制度性势能,原生数据库支持真实存在且不断扩大(Postgres 18+、MySQL 9+、所有主流驱动),而且"直接用 UUID"与每位代码评审者和 DBA 已有的认知完全一致。
如果你需要面向最终用户展示的 URL 友好短 ID(想想 YouTube 的 /watch?v= 或 Stripe 的 cus_),UUIDv7 和 ULID 都不理想 —— 那类场景用的是 NanoID 风格的字母数字令牌:21 个字符、纯随机。不同的工作用不同的工具。在内部用可排序 ID 作主键,对外用 NanoID 作为面向客户的标识符。
// Side-by-side
UUIDv7: 018fb1c2-7abc-7d3e-8f4a-1b2c3d4e5f60 (36 chars, hex)
ULID: 01HX1Y9ZQM7TWBKE6JR5VN8YGB (26 chars, base32)
NanoID: V1StGXR8_Z5jdHi6B-myT (21 chars, alphanumeric)
// Both UUIDv7 and ULID sort by time when stored as strings:
SELECT id FROM orders ORDER BY id; -- chronological without an extra index!
// UUIDv7 Postgres setup
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- for older versions
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- v4
-- or in PG18+: DEFAULT uuidv7()
user_id uuid REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
// Generating ULID
import { ulid } from 'ulid';
ulid(); // "01HX1Y9ZQM7TWBKE6JR5VN8YGB"
ulid(1714560000000); // ULID for a specific timestamp