从 Postgres 18 过来?在 Postgres 17 上要改什么
Relyt One 运行的是 PostgreSQL 17。如果你的 schema 或使用习惯来自 Postgres 18,本文告诉你哪些写法在 17 上会失败、应该改成什么,以及如何导入在 18 上生成的转储,每个代码片段都在两个版本上运行过。
本页目录
Relyt One 运行的是 PostgreSQL 17。Postgres 18 于 2025 年 9 月发布,如今已有大量新项目、教程和 AI 生成的 schema 依赖 18 独有的功能:uuidv7()、虚拟生成列、RETURNING 中的 OLD 和 NEW、时态主键。在 17 上,这些都会报错。本文列出哪些写法会失败,为每一项给出今天就能在 17 上运行的替代方案,并逐步讲解如何导入在 18 上生成的转储。每个代码片段我们都在 PostgreSQL 17.5 和 18.6 上运行过,文中展示的输出就是我们实际得到的结果。
这些都不算冷门。大多数 schema 一个都没用到,用到的通常也只有一两个。关键在于在迁移中途失败之前把它们找出来,而不是失败之后。
SELECT version();
SHOW server_version_num; -- 170005 表示 17.5;以 17 开头的都是 Postgres 17如果代码需要在运行时按版本分支,请按数值比较 server_version_num(>= 180000),不要比较 version() 字符串。
Postgres 18 新增了 uuidv7(),用于生成按时间排序的 UUID。它们比随机的版本 4 UUID 更适合作主键,因为新行会落在索引末尾,而不是散落在索引各处。在 17 上:
ERROR: function uuidv7() does not exist你可以用 SQL 生成同样的格式。版本 7 UUID 由一个以毫秒为单位的 48 位 Unix 时间戳加上随机位组成,并设置了版本位和变体位。gen_random_uuid()(自 Postgres 13 起内置,无需扩展)已经提供了随机位和变体,因此这个函数只需用时间戳覆盖前六个字节,再翻转两个位,把版本从 4 改为 7:
CREATE FUNCTION public.uuidv7() RETURNS uuid
LANGUAGE sql VOLATILE
AS $$
-- 以毫秒为单位的 48 位 Unix 时间,然后在 v4 UUID 的随机位上设置版本 7
SELECT encode(
set_bit(set_bit(
overlay(uuid_send(gen_random_uuid())
PLACING substring(int8send(floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) FROM 3)
FROM 1 FOR 6),
52, 1), 53, 1),
'hex')::uuid;
$$;
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT public.uuidv7(),
body text
);从 Postgres 15 起,默认只有数据库所有者才能在 public 中创建对象,所以请以所有者身份运行(或者把函数放到你拥有的 schema 里)。我们检查了 10,000 个生成值:每一个都是版本 7,带 RFC 9562 变体,内嵌的时间戳与服务器时钟一致。它使用的是 clock_timestamp() 而不是 now(),因此同一事务中稍后插入的行仍会得到更晚的时间戳。
有一处真正的差别。18 的内置函数还会填入亚毫秒部分,在我们的测试中,18 上同一条语句生成的 1,000 个值严格递增。SQL 版本能保证跨毫秒的顺序,但同一毫秒内不能:同一毫秒内生成的值顺序随机。就索引局部性而言这无关紧要。如果你的代码依赖同一会话生成的 ID 严格递增,请改用序列来排序。
还有一个相关的陷阱:uuid_extract_timestamp() 在 17 中存在,但在 17 上它只认识版本 1 UUID,对版本 7 返回 NULL。版本 7 的支持是 Postgres 18 才加入的。在 17 上,请自己提取时间戳:
CREATE FUNCTION public.uuidv7_timestamp(u uuid) RETURNS timestamptz
LANGUAGE sql IMMUTABLE STRICT
AS $$
SELECT to_timestamp(('x' || substr(replace(u::text, '-', ''), 1, 12))::bit(48)::bigint / 1000.0);
$$;
SELECT public.uuidv7_timestamp('019535d9-3df7-79fb-b466-fa907fa17f9e');
-- 2025-02-24 02:46:24.503+00,与 18 的 uuid_extract_timestamp() 返回的值相同与内置函数不同,这个辅助函数不检查版本:传入版本 4 UUID,它会返回一个毫无意义的时间,而不是 NULL。只对你确定是版本 7 的 ID 调用它。
在 18 中,除非另有说明,生成列会在读取时计算。VIRTUAL 是新的默认值,STORED 是原来的行为。17 只有存储生成列,而且必须写明关键字。下面两条在 17 上都会失败:
CREATE TABLE product (price_cents int, price numeric GENERATED ALWAYS AS (price_cents / 100.0) VIRTUAL);
-- ERROR: syntax error at or near "VIRTUAL"
CREATE TABLE product (price_cents int, price numeric GENERATED ALWAYS AS (price_cents / 100.0));
-- ERROR: syntax error at or near ")"第二条比看上去更要紧:在 18 上它是合法的,表示虚拟列,所以为 18 编写的 schema 常常根本不写关键字。有两种替代方案:
-- 方案 A:存储下来。列相同,查询相同;代价是磁盘空间和写入时间。
CREATE TABLE product (
price_cents int,
price numeric GENERATED ALWAYS AS (price_cents / 100.0) STORED
);
-- 方案 B:保持表精简,在视图中计算。
CREATE TABLE product_base (price_cents int);
CREATE VIEW product AS
SELECT *, price_cents / 100.0 AS price FROM product_base;像这样的简单视图是可自动更新的,所以 INSERT INTO product (price_cents) VALUES (1999) 通过它依然可用。我们在 17 上试过。除非表达式很大或表的写入很频繁,否则选 A。
18 允许 RETURNING 引用变更前后的行:
UPDATE acct SET balance = balance - 30 WHERE id = 1
RETURNING old.balance AS before, new.balance AS after;在 17 上,这会报错 missing FROM-clause entry for table "old"。要拿到原来的值,先在 CTE 中读取并锁定该行,再在 UPDATE 中与它连接:
WITH old AS (
SELECT id, balance FROM acct WHERE id = 1 FOR UPDATE
)
UPDATE acct a SET balance = a.balance - 30
FROM old
WHERE a.id = old.id
RETURNING old.balance AS before, a.balance AS after; before | after
--------+-------
100 | 70保证正确性的正是 FOR UPDATE:它在更新读取该行之前就锁住了它,因此并发修改无法插入两者之间,让 before 变成假话。如果没有它,在并发情况下,你可能返回一个在你更新时从未是该行实际状态的「before」值。对于 DELETE,普通的 RETURNING * 已经返回旧行。对于 INSERT … ON CONFLICT DO UPDATE,在 17 上没有获取旧值的干净等价写法。如果需要旧值,请先用 FOR UPDATE 查询出来,或者保留一个审计触发器。当行尚不存在时,先查询也无济于事:没有可锁的行,并发插入仍可能插入其间。
18 可以把「同一房间不能有两行时间范围重叠」作为主键来强制执行:
-- 在 18 上同样需要 btree_gist,因为 room 是普通 int
CREATE TABLE booking (room int, during tstzrange, PRIMARY KEY (room, during WITHOUT OVERLAPS));
-- 在 17 上:ERROR: syntax error at or near "WITHOUT"这种保证本身多年来一直可以用排他约束实现。在一个 GiST 索引中混合普通的等值列(room)和范围,需要 btree_gist 扩展。它被标记为可信扩展,因此任何在数据库上拥有 CREATE 权限的角色(所有者就有)都可以在没有超级用户权限的情况下安装它:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE booking (
room int NOT NULL,
during tstzrange NOT NULL CHECK (NOT isempty(during)),
EXCLUDE USING gist (room WITH =, during WITH &&)
);
INSERT INTO booking VALUES (101, '[2026-11-10 09:00Z, 2026-11-10 10:00Z)');
INSERT INTO booking VALUES (101, '[2026-11-10 10:00Z, 2026-11-10 11:00Z)'); -- 正常:相邻,不重叠
INSERT INTO booking VALUES (101, '[2026-11-10 09:30Z, 2026-11-10 10:30Z)');
-- ERROR: conflicting key value violates exclusion constraint "booking_room_during_excl"CHECK 很重要:18 会拒绝 WITHOUT OVERLAPS 键中的空范围,但空范围与任何范围都不重叠,所以没有这个检查,排他约束会接受任意多个空范围。用 SELECT * FROM pg_available_extensions WHERE name = 'btree_gist'; 确认你的数据库上有这个扩展。我们确认在测试的 Relyt One 计算实例镜像中自带了它,版本为 1.7。17 做不到的是这个功能的另一半:时态外键(FOREIGN KEY (…, PERIOD …))。它没有声明式的等价写法。请在触发器或应用中强制执行,并且要测试,因为在范围的边界处很容易出错。
18 的几个重点功能改变的是性能或运维,而不是 SQL。你的 schema 和查询中不会提到它们,所以没什么需要重写:
- 异步 I/O 在 18 上加速顺序扫描、位图堆扫描和 vacuum。在 17 上,同样的查询照常运行,使用同步 I/O。
- 跳跃扫描(skip scan) 让 18 即使在查询不过滤首列时也能使用多列 B-tree 索引。如果 17 上某个查询只过滤
(tenant_id, created_at)的第二列,它可能用不上这个索引。用EXPLAIN查看执行计划,如果有影响,就加一个以被过滤列开头的索引。 - OAuth 客户端认证 是 18 的新功能。连接 Relyt One 时,你使用角色的密码通过 TLS 连接,连接字符串取自控制台,并带
sslmode=require。 - 18 中的 MD5 密码弃用 对 17 的 schema 没有影响。
pg_dump 文档说得很明确:它的输出预期可以导入更新的服务器,而导入到更旧的主版本「不保证可行」,并且「可能需要手动编辑转储文件」。从 18 到 17 正是这种情况。下面是我们使用的步骤,以及过程中遇到的问题。
用 17.6 或更高版本的 psql 导入。 当前 pg_dump 生成的转储以一行 \restrict 开头,这是 17.6 为一个安全修复(CVE-2025-8714)新增的 psql 元命令,所有 Postgres 18 版本都包含它。psql 17.5 会以 invalid command \restrict 拒绝它。设置了 ON_ERROR_STOP=1 时,导入会在此处中止;不设置时,psql 会继续执行,并悄悄跳过 \restrict 本应提供的保护。请使用 18.x 客户端或 17.6+:psql 18 连接 17 服务器毫无问题。
分别转储 schema 和数据,不带所有者和授权,因为目标端的角色会不同:
pg_dump "$SOURCE_URL" --schema-only --no-owner --no-privileges --quote-all-identifiers > schema.sql
pg_dump "$SOURCE_URL" --data-only --no-owner --no-privileges --quote-all-identifiers > data.sql--quote-all-identifiers 是 pg_dump 文档为跨版本导入推荐的选项:它可以避免某个词在一个版本中是保留字、在另一个版本中不是所带来的意外。在完整 schema 之后再导入数据,意味着导入期间索引、外键和触发器都已存在。这比单个转储更慢,而且你自己的触发器会对每一行触发。对于大型数据库,请改用 --section=pre-data、data 和 post-data 拆分,只编辑 pre-data 文件。
在导入任何内容之前,先找出 18 独有的语法:
grep -nE 'uuidv7\(|uuidv4\(|WITHOUT OVERLAPS|PERIOD ' schema.sql
grep -nE 'GENERATED ALWAYS AS \(' schema.sql | grep -v STORED # 虚拟列
grep -niE 'RETURNING +(old|new)\.|RETURNING +WITH *\(' schema.sql # 在函数和存储过程中在我们的测试 schema 中,这找到了两行:
56: id uuid DEFAULT uuidv7() NOT NULL,
58: price numeric GENERATED ALWAYS AS (((cents)::numeric / 100.0))注意第 58 行:pg_dump 18 写虚拟列时不带关键字,所以搜索 VIRTUAL 会漏掉它。任何后面没有 STORED 的 GENERATED ALWAYS AS (…) 都是虚拟列,包括后面跟着 NOT NULL 的。给它加上 STORED。
给替换的函数加上 schema 限定。 转储在开头把 search_path 设为空,而在 18 上 uuidv7() 是内置函数,所以转储中以不加限定的方式调用它。导入时,你的 public.uuidv7() 不会被找到:
ERROR: function uuidv7() does not exist把默认值改写为 public.uuidv7()。然后先创建替换函数,并在一个遇到第一个错误就停止的事务中导入全部内容:
psql "$TARGET_URL" -v ON_ERROR_STOP=1 --single-transaction \
-f uuidv7.sql -f schema.sql -f data.sql$TARGET_URL 是你的 Relyt One 连接字符串,以 ?sslmode=require 结尾。做了这两处修改后,我们的测试数据库在 17 上顺利导入。标识列从正确的值继续递增,生成列在导入时重新计算,新行从替换函数获得版本 7 UUID。生成列的值根本不在数据转储中,因此把一列从虚拟改为存储不需要修改任何数据。
- 在目标端执行
SHOW server_version_num,弄清自己所在的主版本 - 用 psql 17.6+ 或 18.x 并带
-v ON_ERROR_STOP=1导入 - 在 schema 转储中
grep以下内容:uuidv7(、uuidv4(、没有STORED的GENERATED ALWAYS AS、WITHOUT OVERLAPS、PERIOD,以及任意大小写的RETURNING old./new. - 在 schema 之前创建替换函数,并加上 schema 限定(
public.uuidv7()) - 用排他约束替换
WITHOUT OVERLAPS;把时态外键移到触发器中 - 应用代码也要搜索:ORM 和查询构建器会生成
RETURNING,仓库中的 schema 文件也可能面向 18 - 在把生产环境指向它之前,先针对一个分支运行测试套件
最后一步在 Relyt One 上成本很低:创建一个分支,导入数据,用它的连接字符串运行测试,如果有问题就把它丢掉。



