PostgreSQL 18 的 UUIDv7:为什么插入性能最高提升 23 倍

2026-08-26 34 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

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 更适合确实需要分布式生成、跨服务传递或不希望暴露连续业务编号的场景。


相关推荐