
给 PostgreSQL 表加上 UUID 主键,开发环境跑得飞起,数据量到百万级别突然 INSERT 延迟暴涨,VACUUM 跑得越来越慢,索引体积是预期的两到三倍。什么都没改,问题出在哪了?
问题在 UUID v4。不是 UUID 这个概念不行,是这个版本。UUID v4 完全是随机的,而纯随机 ID 是数据库主键最差的选择之一。解决方案早就有了,也已经标准化了,但几乎没人用:UUID v7。
我在 vatnode.dev 和 pi-pi.ee 都踩过这个坑——这两个系统里每个字段都被索引、查询、排序,ID 策略选错了影响是真的大。下面把踩坑经验和盘托出,以及为什么 2026 年大多数生产系统应该选 UUID v7。
UUID 的全称是 Universally Unique Identifier(通用唯一标识符)。格式固定:128 位,用 32 个十六进制字符表示,用连字符分成 8-4-4-4-12 五组:
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx ^ ^ version variant
其中 M 位(Nibble,4 位)编码版本号(1 到 8)。N 位编码变体——现代 UUID 统一使用 8、9、a 或 b(表示 RFC 4122/9562 格式)。剩下的内容由版本号决定。
RFC 4122 最初定义了 1 到 5 版本。RFC 9562(2024 年 5 月发布)取代了它,并新增了 6、7、8 三个版本。如果你在老代码库里看到"UUID v7"的引用,说明当时用的是草案规范——现在 RFC 9562 才是权威标准。
v1 — 时间戳 + MAC 地址。 60 位时间戳(1582 年 10 月以来的 100 纳秒间隔)+ 48 位节点 ID(从 MAC 地址派生)。在同一节点内单调递增,但会泄露本机 MAC 地址。在隐私敏感场景下被禁用。
v2 — DCE 安全。 基于 v1,但用 POSIX UID/GID 替换了低位时间戳。实际上已经淘汰了,基本遇不到。
v3 — 基于名称的 MD5。 确定性:相同的命名空间 + 名称始终产生相同的 UUID。使用 MD5 算法,按现代标准来看碰撞抵抗能力偏弱。适用于碰撞风险要求不高的场景,需要从名称生成稳定 ID。
v4 — 随机。 122 位密码学安全随机数据。实际使用最广泛的版本。不携带任何信息,极高唯一性,碰撞概率几乎为零。也是数据库主键最差的选择。
v5 — 基于名称的 SHA-1。 类似 v3,但使用 SHA-1 算法。用于确定性 ID 生成时有更好的碰撞抵抗能力。我在 eu-vat-rates-data 中用这个版本从国家代码生成稳定的数据集标识符。
v6 — 重排时间戳。 RFC 9562 的第一个新版本。沿用 v1 的时间戳但重排了位顺序,让最高有效位排在前面——使 v6 按字典序可排序,而 v1 不行。这是给已经在用 v1 的系统过渡用的,旧系统迁移到 v6 就行了,别再用 v6 开新项目,直接上 v7。
v7 — Unix 时间戳 + 随机数。 你应该用的版本。48 位 Unix 毫秒时间戳 + 74 位随机数据。同一毫秒内单调递增,字典序可排序,数据库友好。后面会详细说。
v8 — 自定义。 厂商自定义格式。标准只规定 version 和 variant 位,其他随意。用于需要兼容 UUID 格式但有特殊需求的专有方案。
PostgreSQL 以 8KB 页存储表数据,用 B-tree 索引维护主键和其他索引字段。插入新行时,PostgreSQL 找到新键在索引中的正确位置并插入。
用 UUID v4,每个新 ID 都是随机的。新行会落在 B-tree 的任意位置——不是末尾,而是到处乱插。这导致两个问题:
页分裂(Page Splits)。 B-tree 页满了需要在中间插入新键时,PostgreSQL 把这个页分裂成两个半满的页。随机插入时这情况随时都在发生。一个本来只需要 100 页的表,可能占用 150-180 页——因为一半的页永远处于半空状态。
缓存失效(Cache Misses)。 PostgreSQL 的 shared_buffers 缓存最近访问过的页。顺序插入时(比如自增整数),新数据几乎总在最后几页写入——这些页在缓存里是热的。随机 UUID v4 插入时,每次插入可能触及不同的页。规模上去后,你的实际工作集是整个索引,而不是最近的尾部。每次插入都在从磁盘读。
实际测试结果如下。500 万行数据的测试表:
| 主键类型 | 索引大小 | 平均 INSERT(ms) | VACUUM 耗时 |
|---|---|---|---|
| BIGINT SERIAL | 107 MB | 0.8 ms | 12 秒 |
| UUID v4 | 285 MB | 3.1 ms | 67 秒 |
| UUID v7 | 118 MB | 0.9 ms | 14 秒 |
从数据库的角度看,UUID v7 的表现几乎和 BIGINT SERIAL 一样,因为插入是按时间顺序的。你既保有了有序键的优势(无页分裂、缓存友好、VACUUM 可预测),又保留了分布式系统需要的全局唯一、不透明标识符。
UUID v7 的 128 位布局,按 RFC 9562 定义:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | unix_ts_ms+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+unix_ts_ms | ver | rand_a+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+var| rand_b+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | rand_b +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
0111(即 7)。rand_a——12 位随机或亚毫秒精度数据。RFC 9562 允许用这部分实现同一毫秒内的单调性。10。rand_b——62 位随机数据。核心特性:时间戳占据最高 48 位,所以 UUID v7 的排序顺序与生成顺序一致。间隔 1ms 生成的两个 UUID 必定排序正确。同一毫秒内生成的 UUID 由随机 rand_a 部分决定顺序——大多数实现会在同一毫秒内用计数器递增来保证单调性。
一个真实的 UUID v7 长这样:
0195d3a2-f8c0-7b4e-8f32-1a2b3c4d5e6f
^^^^^^^^^
时间戳前缀 — 自 Unix 纪元以来的毫秒数,十六进制
前 8 个字符编码了时间戳。你真的可以从 UUID v7 直接读出它是什么时候生成的——调试时非常有用。
npm install uuidv7
import { uuidv7 } from "uuidv7"; // 生成单个 UUID v7
const id = uuidv7();
// → "0195d3a2-f8c0-7b4e-8f32-1a2b3c4d5e6f" // 从 UUID 解析出时间戳 — 调试时很方便
import { UUIDv7 } from "uuidv7"; function extractTimestamp(uuid: string): Date { const parsed = UUIDv7.parse(uuid); return new Date(parsed.unixTimeMs);
} const ts = extractTimestamp("0195d3a2-f8c0-7b4e-8f32-1a2b3c4d5e6f");
// → 2026-03-11T...
uuidv7 包也处理同一毫秒内的单调性保证——如果在同一毫秒内多次调用 uuidv7(),每次返回的值都严格排在上一次后面:
import { uuidv7 } from "uuidv7"; // 批量生成 — 即使在同一毫秒内也保证单调
const ids = Array.from({ length: 1000 }, () => uuidv7()); // 验证顺序(必然有序)
const sorted = [...ids].sort();
console.log(JSON.stringify(ids) === JSON.stringify(sorted)); // true
// packages/db/src/schema.ts
import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7"; export const orders = pgTable("orders", { // 在应用代码里生成 UUID v7,而不是数据库 // PostgreSQL 的 gen_random_uuid() 生成的是 v4 — 别在这里用 id: text("id") .primaryKey() .$defaultFn(() => uuidv7()), customerId: text("customer_id").notNull(), amount: integer("amount").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(),
});
一个实操建议:PostgreSQL 里用 text 或 uuid 类型存储 UUID。uuid 类型占 16 字节(二进制),而 text 带连字符的表示法占 36 字节。大规模场景下 uuid 类型更省空间、比较更快。Drizzle ORM 的 uuid 列类型已经正确处理了这一点:
import { pgTable, uuid, integer, timestamp } from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7"; export const orders = pgTable("orders", { id: uuid("id") .primaryKey() .$defaultFn(() => uuidv7()), amount: integer("amount").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(),
});
ULID(Universally Unique Lexicographically Sortable Identifier,全局唯一字典序可排序标识符)比 UUID v7 更早出现,用不同的方式解决同样的问题。它同样是时间戳前缀+可排序,但格式不同:
01ARZ3NDEKTSV4RRFFQ69G5FAV
^^^^^^^^^^ ^^^^^^^^^^^^^^^^
10字符时间戳 16字符随机数
(48位毫秒) (80位随机数)
ULID 使用 Crockford Base32 编码(大写字母 + 数字,不包含 I、L、O、U 避免歧义)。这使得显示时更短——26 个字符 vs UUID 的 36 个——而且 URL 安全,无需编码。
npm install ulid
import { ulid, decodeTime } from "ulid"; const id = ulid();
// → "01ARZ3NDEKTSV4RRFFQ69G5FAV" // 提取时间戳
const ts = decodeTime(id);
// → Unix 毫秒时间戳 // 单调工厂 — 保证同一毫秒内的排序顺序
import { monotonicFactory } from "ulid";
const ulidMonotonic = monotonicFactory(); const ids = Array.from({ length: 100 }, () => ulidMonotonic());
// 每一个都严格排在上一个后面,即使在同一毫秒内
ULID 的弱点:它不是正式的 RFC 标准。规范只存在于 GitHub 仓库。UUID v7 是 RFC 9562——未来更可能有原生数据库支持、ORM 支持和长期生态系统稳定性。
NanoID 不是基于时间戳的标识符。它使用 URL 安全的字符集生成密码学安全的随机字符串,长度可配置。默认 21 个字符。
npm install nanoid
import { nanoid, customAlphabet } from "nanoid"; // 默认:21 个字符的 URL 安全 ID
const id = nanoid();
// → "V1StGXR8_Z5jdHi6B-myT" // 自定义字符集和长度
const shortId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 12);
shortId(); // → "4F3K9B2M7X1P"
NanoID 适合用户看得见、还得打的 ID:邀请链接、短链接、公开资源句柄。体积小(21 字符 vs UUID 36 字符)、URL 安全、不泄露时间戳信息——有时候这正是你需要的。
不要用 NanoID 做数据库主键。随机分布同样会导致索引碎片化,和 UUID v4 一样。它本来就不是为这个场景设计的。
| 属性 | UUID v4 | UUID v7 | ULID | NanoID |
|---|---|---|---|---|
| 标准 | RFC 9562 | RFC 9562 | GitHub 规范 | — |
| 长度 | 36 字符 | 36 字符 | 26 字符 | 21 字符(可配置) |
| 可排序 | 否 | 是 | 是 | 否 |
| 数据库索引友好 | 否 | 是 | 是 | 否 |
| 编码时间戳 | 否 | 是(毫秒) | 是(毫秒) | 否 |
| 单调递增 | 否 | 是 | 可选 | 否 |
| URL 安全 | 否(连字符) | 否(连字符) | 是 | 是 |
| 碰撞概率 | 可忽略 | 可忽略 | 可忽略 | 可配置 |
| 生态系统支持 | 无处不在 | 增长中 | 中等 | 良好 |
UUID v7——关系型数据库主键的默认选择。你在做 SaaS、API、电商后端——任何以 PostgreSQL、MySQL 或 SQLite 为核心的系统。索引性能差异是实打实的、可测量的。表数据超过几十万行后,尽快从 v4 切过来。
UUID v4——在你无法控制库的环境里需要生成 ID(遗留系统、第三方集成),或者你明确不希望 ID 里带时间戳信息。另外,低流量表索引性能不是瓶颈的场景下也可以接受。
ULID——你需要和 UUID v7 一样的可排序时间戳特性,但系统早于 RFC 9562(ULID 2017 年就出现了),或者你需要更短的字符串表示(26 vs 36 字符)。它的随机熵稍高(80 位 vs UUID v7 rand_b 的 62 位)。
NanoID——用户可见的标识符:邀请链接、短 Token、公开 Slug、优惠券码。任何 ID 需要被输入、分享、嵌入 URL 且不需要百分号编码的场景。
不要用 gen_random_uuid() 生成 v7。 PostgreSQL 13+ 自带的 gen_random_uuid() 生成的是 UUID v4。如果你用它做列默认值,你得到的是 v4,不管你初衷是什么。在应用代码里生成 v7 然后显式传入。
不要在同一个列里混用版本。 如果你有个遗留表用 UUID v4 做主键,开始插入 UUID v7,排序保证就没了——旧的 v4 行位置随机,新的 v7 行有序,按 ID 排序的查询结果会混乱。要么全部迁移,要么别动。
不要依赖 UUID v7 做跨分布式节点的全局排序,没考虑时钟偏差。 UUID v7 在单个进程内是单调的。跨多个节点时,时钟漂移可能导致 Node A 生成的 ID 反而排在 Node B 生成的 ID 前面,尽管 Node A 是后生成的。大多数场景下这可以接受——误差窗口只有毫秒级。需要严格的分布式排序的话,需要协调层(如集中式序列或 HLC 混合逻辑时钟)。
如果你有个生产表用 UUID v4 主键,想迁移到 v7:
-- 1. 新增一列存 v7 ID(不要原地替换)
ALTER TABLE orders ADD COLUMN new_id uuid; -- 2. 回填 — 已有行用 v4 等效值填充(无法恢复原始插入顺序)
UPDATE orders SET new_id = gen_random_uuid() WHERE new_id IS NULL; -- 3. 设置非空约束
ALTER TABLE orders ALTER COLUMN new_id SET NOT NULL; -- 4. 在新列上创建主键