# 从 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 一个都没用到，用到的通常也只有一两个。关键在于在迁移中途失败之前把它们找出来，而不是失败之后。

![四个 Postgres 18 功能及其在 Postgres 17 上的替代：uuidv7() 改为 SQL 函数，虚拟生成列改为 STORED 或视图，RETURNING old/new 改为带 FOR UPDATE 的 CTE，WITHOUT OVERLAPS 改为配合 btree_gist 的排他约束](/blog/assets/598dbe1d3fe56c42.svg)

## 首先，确认你连接的是哪个版本

```sql
SELECT version();
SHOW server_version_num;   -- 170005 表示 17.5；以 17 开头的都是 Postgres 17
```

如果代码需要在运行时按版本分支，请按数值比较 `server_version_num`（`>= 180000`），不要比较 `version()` 字符串。

## 1. `uuidv7()`

Postgres 18 新增了 `uuidv7()`，用于生成按时间排序的 UUID。它们比随机的版本 4 UUID 更适合作主键，因为新行会落在索引末尾，而不是散落在索引各处。在 17 上：

```text
ERROR:  function uuidv7() does not exist
```

你可以用 SQL 生成同样的格式。版本 7 UUID 由一个以毫秒为单位的 48 位 Unix 时间戳加上随机位组成，并设置了版本位和变体位。`gen_random_uuid()`（自 Postgres 13 起内置，无需扩展）已经提供了随机位和变体，因此这个函数只需用时间戳覆盖前六个字节，再翻转两个位，把版本从 4 改为 7：

```sql
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 上，请自己提取时间戳：

```sql
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 调用它。

## 2. 虚拟生成列

在 18 中，除非另有说明，生成列会在读取时计算。`VIRTUAL` 是新的默认值，`STORED` 是原来的行为。17 只有存储生成列，而且必须写明关键字。下面两条在 17 上都会失败：

```sql
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 常常根本不写关键字。有两种替代方案：

```sql
-- 方案 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。

## 3. `RETURNING` 中的 `OLD` 和 `NEW`

18 允许 `RETURNING` 引用变更前后的行：

```sql
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` 中与它连接：

```sql
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;
```

```text
 before | after
--------+-------
    100 |    70
```

保证正确性的正是 `FOR UPDATE`：它在更新读取该行之前就锁住了它，因此并发修改无法插入两者之间，让 `before` 变成假话。如果没有它，在并发情况下，你可能返回一个在你更新时从未是该行实际状态的「before」值。对于 `DELETE`，普通的 `RETURNING *` 已经返回旧行。对于 `INSERT … ON CONFLICT DO UPDATE`，在 17 上没有获取旧值的干净等价写法。如果需要旧值，请先用 `FOR UPDATE` 查询出来，或者保留一个审计触发器。当行尚不存在时，先查询也无济于事：没有可锁的行，并发插入仍可能插入其间。

## 4. 时态约束：`WITHOUT OVERLAPS`

18 可以把「同一房间不能有两行时间范围重叠」作为主键来强制执行：

```sql
-- 在 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` 权限的角色（所有者就有）都可以在没有超级用户权限的情况下安装它：

```sql
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 …)`）。它没有声明式的等价写法。请在触发器或应用中强制执行，并且要测试，因为在范围的边界处很容易出错。

## 5. 不需要改的部分

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 没有影响。

## 导入在 Postgres 18 上生成的转储

`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 和数据**，不带所有者和授权，因为目标端的角色会不同：

```bash
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 独有的语法**：

```bash
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 中，这找到了两行：

```text
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()` 不会被找到：

```text
ERROR:  function uuidv7() does not exist
```

把默认值改写为 `public.uuidv7()`。然后先创建替换函数，并在一个遇到第一个错误就停止的事务中导入全部内容：

```bash
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 上成本很低：创建一个分支，导入数据，用它的连接字符串运行测试，如果有问题就把它丢掉。

[在分支上试一试](/auth/signin?ref=blog-postgres-18-features-on-17)
