PostgreSQL 18 的 UUIDv7:终于能放心用 UUID 当主键了
UUIDv4 随机写导致 B-Tree 索引碎片化,自增 ID 又难用于分布式。PostgreSQL 18 原生支持 UUIDv7:时间戳前缀天然有序,索引性能接近自增 ID 且全局唯一。本文讲清原理、用法、时间戳提取和五条使用限制。
主键选型是个经典两难:自增整数性能好,但 ID 暴露在 URL 里、分布式环境难生成;随机 UUIDv4 全局唯一、不可猜测,却把 B-Tree 索引写得千疮百孔。PostgreSQL 18 原生支持的 UUIDv7(RFC 9562 标准)终结了这个两难:时间戳放在最高有效位,UUID 天然按时间有序,同时保住全局唯一。
UUIDv4 到底慢在哪
随机值作为主键,每次插入都落在索引的随机位置,而不是像自增 ID 那样追加到末尾:
- 页分裂暴增:自增 ID 每百万行约 10-20 次页分裂,UUIDv4 高达 5000-10000+ 次,差了 500 倍,每次分裂都是额外 I/O;
- 写放大:单次插入因级联页分裂和 B-Tree 再平衡,物理 I/O 放大 5-10 倍;
- 索引膨胀:随机写入导致页平均填充率只有约 69%,浪费磁盘;
- 缓存失效:写入分散在大量页上,热点页集中不起来,缓冲池效率下降。
有工程团队实测:从随机 UUID 切换到时间有序 UUID 后,WAL 生成量下降 50%。表超过内存容量后,差距会放大到无法忽视。
UUIDv7 的解法:最高位放时间戳
UUIDv7 把 Unix 毫秒时间戳编码进前 48 位,其余位放版本号和随机数。于是生成的 ID 随时间单调递增,插入时总是追加到索引末尾——行为接近自增 ID,但无需中心发号器。
PostgreSQL 18 还有一个增强实现:12 位 rand_a 字段存亚毫秒精度计数器而非纯随机数。同一毫秒内、同一后端进程生成的多个 UUIDv7 保证严格递增,即使系统时钟小幅回拨也能维持单调性。注意该保证仅限单连接内——不同连接的 UUID 之间不保证严格递增,不过靠毫秒时间戳通常也是有序的。
上手:uuidv7() 一个函数就够
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuidv7(),
name TEXT NOT NULL,
price NUMERIC(10, 2),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO products (name, price) VALUES
('Wireless Keyboard', 79.99),
('Gaming Mouse', 59.99);
查询结果:
id | name
--------------------------------------+-----------------
01941c04-4185-7ea3-ab00-82c8a75adf41 | Wireless Keyboard
01941c04-4185-7efe-b171-12949bdf8bd8 | Gaming Mouse
前缀高度相似(01941c04-418...)——那就是时间戳部分,相邻插入只差几毫秒。也可以脱离表直接生成:SELECT uuidv7()。
从 UUID 反查时间戳
uuid_extract_timestamp() 已支持 v7,调试和审计很方便:
SELECT id, uuid_extract_timestamp(id) AS id_timestamp, created_at
FROM products LIMIT 1;
-- id_timestamp: 2025-01-31 10:27:41.122+00
-- created_at: 2025-01-31 10:27:41.122049+00
提取值与 created_at 基本一致(UUID 内只到毫秒精度,TIMESTAMPTZ 列有微秒)。还可以用 uuid_extract_version() 校验版本号。
五条限制,用之前看清楚
- 暴露创建时间:任何人都能从 ID 解出时间戳。如果你用 UUID 就是为了掩盖记录创建时序,v7 恰恰反着来;
- 随机位更少:74 位随机(v4 是 122 位),对绝大多数应用足够,但理论碰撞概率高于 v4——实践中依然是天文数字级的低;
- 单调性仅限单连接:多连接并发生成不保证严格递增,跨连接排序靠时间戳兜底;
- 依赖系统时钟:NTP 校时或人工调整导致时钟大幅回拨时,新 UUID 可能排到旧值之前。亚毫秒计数器能缓解小幅回拨,挡不住剧变;
- 仍不如自增整数快:v7 远好于 v4,但纯插入性能还是输给 BIGINT 自增;16 字节也让索引和外键比 8 字节 BIGINT 占更多空间。
选型建议
- 新应用:需要 UUID 就默认 UUIDv7,没有理由再用 v4 当主键;
- 存量 v4 系统:新表直接上 v7;存量表评估迁移成本——写密集负载下,WAL 和索引维护的节省常常能覆盖迁移代价;
- 纯单体、内网系统:自增 BIGINT 依然是性能与存储的最优解,不必为换而换。
观测云对照
主键策略的优劣最终体现在数据库写入指标上。观测云 DataKit 采集 PostgreSQL 的 WAL 写入量、索引膨胀、插入吞吐、缓冲命中率等指标,迁移 UUIDv7 前后做对比,收益一目了然;对索引膨胀率配置监控器告警,也能在早期发现主键策略不适配的信号。 1 2
常见问题(FAQ)
Q:UUIDv7 还需要 created_at 列吗?
建议保留。UUID 内时间戳只有毫秒精度且属于"ID 生成时刻"语义;业务时间戳是独立字段,查询、索引、时区处理都更正规。
Q:分布式环境下多个应用节点同时生成会撞吗?
每个 ID 含 74 位随机数,碰撞概率可忽略;且 v7 的意义正是无需中心发号。担心的话可在应用侧生成 v7 再写入,数据库只存即可。
Q:可以把 UUIDv7 存成文本吗?
不要。用原生 UUID 类型,16 字节定长、索引效率最高;存文本既占空间又丢了类型校验。
Q:从 v4 迁移到 v7 怎么平滑做?
新写入用 v7,旧数据不动(UUID 类型兼容混存),主键索引随新数据自然偏向有序区域;如需彻底替换,再排期做批量重写。