UUID 作为主键很方便,但不同 UUID 版本对 PostgreSQL 插入性能的影响并不相同。一次真实迁移显示,在 PostgreSQL 18.4 上将部分表的主键默认值从 UUIDv1 或 UUIDv4 切换到 UUIDv7 后,某些高频多行 INSERT 的平均执行时间从 0.7ms 降到 0.03ms,提升约 23 倍。
这不是所有表都会获得的固定收益。收益大小取决于索引规模、写入频率、缓存命中率、页面分裂情况以及 INSERT 的具体形态。但 UUIDv7 的时间有序特性,确实为 B-tree 主键索引提供了更适合持续写入的数据分布。
随机主键为什么会拖慢写入
PostgreSQL 的主键通常由 B-tree 索引维护。索引项按键值排序,并存放在固定大小的 8KB 页面中。插入一行时,数据库需要找到新 UUID 应该落在哪个索引页面。
UUIDv4 的值高度随机,新值可能出现在索引的任意位置。于是 PostgreSQL 很难持续复用刚刚访问过的“热页面”:
- 新索引项更可能访问不同的页面,降低 buffer cache 命中率。
- 如果目标页面不在 PostgreSQL 或操作系统缓存中,可能需要更慢的磁盘读取。
- 当目标页面已满时,B-tree 需要执行 page split。
- 页面分裂会带来额外的 WAL、CPU 和 IO。
UUIDv1 包含时间信息,因此通常比 UUIDv4 更适合写入,但它并不等同于严格适合索引追加的单调递增键。UUIDv7 将时间戳放在 UUID 的高位,使新生成的值大体按时间顺序增长。连续写入更容易集中到索引末端附近,最近访问的页面也更可能仍在缓存中。
因此,UUIDv7 的优势不仅是“值看起来按时间排列”,还包括更少的随机页面访问和更少的索引页面分裂。
真实负载中的收益并不平均
迁移后,很多表的 INSERT 性能变化并不明显;但少数高频、大规模表出现了显著改善。一个示例结果如下:
| 表 | 调用频率 | 原始平均耗时 | UUIDv7 后 | 提升 |
|---|---|---|---|---|
| Table A | 12,000 次/分钟 | 0.7ms | 0.03ms | 约 23 倍 |
| Table B | 2,000 次/分钟 | 0.6ms | 0.07ms | 约 9 倍 |
| Table C | 9,500 次/分钟 | 0.50ms | 0.08ms | 约 6 倍 |
在每分钟数千次甚至上万次 INSERT 的表上,即使单次只节省几百微秒,累计 CPU 和 IO 也可能很可观。更重要的是,索引写入路径变得更稳定,峰值负载下的抖动可能随之降低。
不过,不能只根据 UUID 版本判断结果。建议在迁移前后同时观察:
pg_stat_statements中 INSERT 的平均时间和总时间。- 主键索引大小和增长速度。
- WAL 生成量。
- buffer cache 命中率和磁盘读取。
- 高峰时段的锁等待与事务延迟。
PostgreSQL 18 中如何切换默认值
在 PostgreSQL 18 中,可以将列默认值改为 uuidv7()。这不会自动修改已有行,也不会把客户端显式传入的 UUID 改写成 UUIDv7。它只影响没有提供该列值的新 INSERT。
下面的命令适合低流量表,执行前请将表名和列名替换为实际对象:
BEGIN;
SET LOCAL lock_timeout = '50ms';
SET LOCAL statement_timeout = '100ms';
ALTER TABLE my_table
ALTER COLUMN id SET DEFAULT uuidv7();
COMMIT;
需要特别注意:ALTER COLUMN ... SET DEFAULT 本身通常执行很快,但仍然需要 ACCESS EXCLUSIVE 锁。该锁会与包括普通 SELECT 在内的读写操作冲突。对持续有流量的表,问题往往不是 SQL 执行时间,而是等待锁的时间。
一种实用做法是设置很短的 lock_timeout,失败后快速重试,而不是让 DDL 长时间排队。可以在 psql 中直接执行:
SET lock_timeout = '100ms';
SET statement_timeout = '1s';
ALTER TABLE my_table
ALTER COLUMN id SET DEFAULT uuidv7();
如果命令因锁超时失败,重新执行即可。成功后,默认值变更会在该事务中提交。
为高并发表增加带退避的重试
对始终繁忙的表,可以使用 PL/pgSQL 在数据库侧完成有限次数重试。下面的示例最多尝试 50 次,每次等待 50 到 250 毫秒的随机时长。随机抖动可以避免多个迁移任务在完全相同的时间再次争抢锁。
运行前应确认当前会话没有设置过短的全局语句超时,并把 my_table 替换为目标表:
SET statement_timeout = 0;
SET lock_timeout = '100ms';
DO $$
DECLARE
attempt integer := 0;
max_attempts integer := 50;
BEGIN
LOOP
attempt := attempt + 1;
BEGIN
EXECUTE
'ALTER TABLE my_table ' ||
'ALTER COLUMN id SET DEFAULT uuidv7()';
RAISE NOTICE 'Succeeded on attempt %', attempt;
EXIT;
EXCEPTION
WHEN lock_not_available THEN
IF attempt >= max_attempts THEN
RAISE EXCEPTION
'Failed to acquire lock after % attempts', attempt;
END IF;
PERFORM pg_sleep(0.05 + random() * 0.2);
END;
END LOOP;
END
$$;
这个方案的边界也很明确:它只是在寻找短暂的锁窗口,并不能消除 ACCESS EXCLUSIVE 锁带来的影响。如果表上存在长事务、连接池持续开启事务,或者持续有更高优先级的锁请求,重试仍可能失败。
迁移框架中的做法通常应与这里保持一致:将 DDL 放入明确事务,设置局部超时,并让失败可以安全重跑。对于特别繁忙的表,可以先手工完成变更,再补充迁移记录,避免部署系统重复执行。
先审计 UUID 的来源
仅修改列默认值并不代表所有新行都会使用 UUIDv7。实际系统中,UUID 可能来自多种渠道:
uuid-ossp扩展提供的uuid_generate_v1()。- PostgreSQL 13 引入的
gen_random_uuid(),即 UUIDv4。 - 客户端应用显式传入的 UUIDv4。
- ORM、批处理脚本或其他服务生成的 UUID。
因此,切换前应检查应用代码、ORM 回调、批量导入程序和 API 请求是否会显式提供 id。如果客户端始终发送 UUIDv4,数据库默认值根本不会生效。
UUIDv7 也不是所有场景的最佳选择。若系统确实需要更强的随机性,或者不希望从主键中推断记录创建时间,UUIDv4 仍有其价值。UUIDv7 的高位包含时间信息,观察者可能据此推测记录的创建时刻。对公开 API、订单号或具有时间敏感性的业务对象,应把这视为数据暴露面的一个设计因素。
采用前的检查清单
- 确认运行环境支持 PostgreSQL 18 的
uuidv7(),包括托管数据库版本。 - 找出 UUID 来源,确认默认值不会被客户端输入绕过。
- 先在有代表性的高写入量表上做基准测试。
- 对 DDL 设置短
lock_timeout,避免长时间阻塞业务查询。 - 为高并发表准备重试、退避和人工停止机制。
- 迁移前后比较 INSERT 延迟、WAL、索引大小和 page split 相关指标。
- 评估 UUIDv7 时间戳是否会暴露不应公开的创建时间。
对于已经全面使用 UUID 主键的系统,UUIDv7 是一个相对低侵入的优化方向:它通常只需要修改默认值,不需要重写已有主键或重建全部索引。但真正的收益应由生产负载验证,而不是由 UUID 版本名称预先保证。对于新系统,也仍然可以优先考虑 bigint 加序列;UUIDv7 更适合确实需要分布式生成、跨服务传递或不希望暴露连续业务编号的场景。