site logo

Marico's space

PostgreSQL 性能基准测试:UUID v7 vs v4 主键选择

前端技术 2026-06-05 11:31:48 3

给 PostgreSQL 表加上 UUID 主键,开发环境跑得飞起,数据量到百万级别突然 INSERT 延迟暴涨,VACUUM 跑得越来越慢,索引体积是预期的两到三倍。什么都没改,问题出在哪了?

问题在 UUID v4。不是 UUID 这个概念不行,是这个版本。UUID v4 完全是随机的,而纯随机 ID 是数据库主键最差的选择之一。解决方案早就有了,也已经标准化了,但几乎没人用:UUID v7。

我在 vatnode.dev 和 pi-pi.ee 都踩过这个坑——这两个系统里每个字段都被索引、查询、排序,ID 策略选错了影响是真的大。下面把踩坑经验和盘托出,以及为什么 2026 年大多数生产系统应该选 UUID v7。

What UUID Actually Is

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 统一使用 89ab(表示 RFC 4122/9562 格式)。剩下的内容由版本号决定。

RFC 4122 最初定义了 1 到 5 版本。RFC 9562(2024 年 5 月发布)取代了它,并新增了 6、7、8 三个版本。如果你在老代码库里看到"UUID v7"的引用,说明当时用的是草案规范——现在 RFC 9562 才是权威标准。

All Eight Versions

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 格式但有特殊需求的专有方案。

Why UUID v4 Destroys Your B-tree Index

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 in Detail

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 
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • 第 0-47 位:Unix 毫秒时间戳。可覆盖到公元 10889 年。不存在 Y2K38 问题。
  • 第 48-51 位:版本字段,固定为 0111(即 7)。
  • 第 52-63 位:rand_a——12 位随机或亚毫秒精度数据。RFC 9562 允许用这部分实现同一毫秒内的单调性。
  • 第 64-65 位:变体位,固定为 10
  • 第 66-127 位:rand_b——62 位随机数据。

核心特性:时间戳占据最高 48 位,所以 UUID v7 的排序顺序与生成顺序一致。间隔 1ms 生成的两个 UUID 必定排序正确。同一毫秒内生成的 UUID 由随机 rand_a 部分决定顺序——大多数实现会在同一毫秒内用计数器递增来保证单调性。

一个真实的 UUID v7 长这样:

0195d3a2-f8c0-7b4e-8f32-1a2b3c4d5e6f
^^^^^^^^^
时间戳前缀 — 自 Unix 纪元以来的毫秒数,十六进制

前 8 个字符编码了时间戳。你真的可以从 UUID v7 直接读出它是什么时候生成的——调试时非常有用。

Generating UUID v7 in Node.js

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

Using UUID v7 with Drizzle ORM

// 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 里用 textuuid 类型存储 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: The Alternative Worth Knowing

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: Fast, Small, Not for Primary Keys

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 一样。它本来就不是为这个场景设计的。

Side-by-Side Comparison

属性 UUID v4 UUID v7 ULID NanoID
标准 RFC 9562 RFC 9562 GitHub 规范
长度 36 字符 36 字符 26 字符 21 字符(可配置)
可排序
数据库索引友好
编码时间戳 是(毫秒) 是(毫秒)
单调递增 可选
URL 安全 否(连字符) 否(连字符)
碰撞概率 可忽略 可忽略 可忽略 可配置
生态系统支持 无处不在 增长中 中等 良好

When to Use What

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 且不需要百分号编码的场景。

What Not to Do With UUID v7

不要用 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 混合逻辑时钟)。

Migrating an Existing Table

如果你有个生产表用 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. 在新列上创建主键