从 CTE 到 MERGE:PostgreSQL 11–18 值得掌握的 SQL 演进

2026-07-28 18 预计阅读时间: 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.

预计阅读时间:13 分钟

从 2018 年的 PostgreSQL 11 到 2025 年的 PostgreSQL 18,社区连续七年保持每年一个大版本,每个版本都带来约 150 到 200 项用户可见变化。运维、性能和复制占据了更新列表的大头,但 SQL 层也在稳定补齐能力:有些新语法减少了样板代码,有些功能让数据约束更贴近业务,还有一些改进直接改变了查询优化方式。

真正值得关注的不是“又多了多少关键字”,而是哪些能力可以删掉应用层代码、改善数据模型,并让查询意图更容易被数据库理解。下面按开发场景梳理 PostgreSQL 11–18 之间几类具有代表性的 SQL 变化。

SQL 更新不多,但往往离业务代码最近

按历年新功能分类统计,PostgreSQL 11–18 期间大约有 35 项 SQL 更新,少于性能、运维和其他类别。运维与管理累计约 54 项,性能约 48 项,复制与高可用则从 PostgreSQL 11 的 3 项增长到 PostgreSQL 17 的 8 项。

这组分布反映了 PostgreSQL 的现实使用环境:云数据库厂商需要它更容易扩展、复制、监控和恢复。然而,对应用开发者而言,SQL 类更新虽少,影响通常更直接。一个新的 DML 语句可能替代一段并发敏感的“先查再写”代码;一个新的约束选项可能消除触发器;优化器行为的变化则可能让同一条查询在升级后采用完全不同的计划。

因此,评估新版本时可以把 SQL 变化分成四类:

  • 表达能力:以前需要多条语句或应用代码,现在能否用一条 SQL 表达。
  • 数据建模:约束、生成列和数据类型能否更准确地表示业务规则。
  • 执行策略:CTE、排序和连接等结构是否获得更好的优化空间。
  • 可迁移性:新语法是否更接近 SQL 标准,从而降低跨数据库迁移成本。

从优化边界到声明式数据处理

CTE 不再天然意味着物化

旧版本中,公共表表达式常被当作优化边界。开发者使用 WITH 改善可读性时,也可能无意中阻止谓词下推。PostgreSQL 12 开始能够内联满足条件、只引用一次且没有副作用的 CTE,并提供 MATERIALIZEDNOT MATERIALIZED 让开发者明确意图。

-- 允许优化器把外层条件推入 CTE
WITH recent_orders AS NOT MATERIALIZED (
    SELECT customer_id, total_amount, created_at
    FROM orders
    WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
)
SELECT customer_id, sum(total_amount)
FROM recent_orders
WHERE total_amount >= 100
GROUP BY customer_id;

-- 明确要求只计算一次,适合昂贵且会被重复引用的结果集
WITH expensive_summary AS MATERIALIZED (
    SELECT customer_id, sum(total_amount) AS total
    FROM orders
    GROUP BY customer_id
)
SELECT *
FROM expensive_summary
WHERE total > 1000;

NOT MATERIALIZED 不是通用加速开关。CTE 被多次引用时,内联可能造成重复扫描;查询升级后应配合 EXPLAIN (ANALYZE, BUFFERS) 检查实际执行次数、I/O 和行数估算。

FETCH FIRST WITH TIES 保留并列结果

PostgreSQL 13 支持 FETCH FIRST ... WITH TIES。排行榜、竞赛成绩和销售排名经常需要“取前十名,但不能随意丢掉与第十名同分的人”,这比单纯的 LIMIT 更符合业务语义。

SELECT driver_id, points
FROM championship_standings
ORDER BY points DESC
FETCH FIRST 10 ROWS WITH TIES;

这里必须提供确定业务排名的 ORDER BY。如果额外加入唯一 ID 作为最后一个排序键,并列关系也会随之被打破,所以排序列应与“什么算同分”的业务定义一致。

SEARCH 与 CYCLE 让递归查询更可控

递归 CTE 很适合组织结构、物料清单和图关系,但手工维护访问路径与环检测容易出错。PostgreSQL 14 引入 SQL 标准的 SEARCHCYCLE 子句,可以声明遍历顺序并标记循环。

WITH RECURSIVE org AS (
    SELECT employee_id, manager_id, name
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    SELECT e.employee_id, e.manager_id, e.name
    FROM employees AS e
    JOIN org AS parent ON e.manager_id = parent.employee_id
)
SEARCH DEPTH FIRST BY employee_id SET traversal_order
CYCLE employee_id SET is_cycle USING traversal_path
SELECT employee_id, manager_id, name, is_cycle
FROM org
ORDER BY traversal_order;

需要注意,CYCLE 能帮助查询识别异常关系,但不能替代写入时的数据完整性设计。对于不允许成环的数据,仍应在写入路径中阻止非法边进入数据库。

MERGE 与约束:把一致性逻辑放回数据库

PostgreSQL 15 加入了标准化的 MERGE,此后版本继续扩展相关能力。它适合批量同步、暂存表导入以及根据匹配状态执行不同动作的任务。

下面的示例可以直接在 PostgreSQL 15 及以上版本运行:

DROP TABLE IF EXISTS product_updates;
DROP TABLE IF EXISTS products;

CREATE TABLE products (
    sku text PRIMARY KEY,
    name text NOT NULL,
    price numeric(10, 2) NOT NULL CHECK (price >= 0),
    updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TEMP TABLE product_updates (
    sku text,
    name text,
    price numeric(10, 2)
);

INSERT INTO products (sku, name, price) VALUES
    ('KB-001', 'Compact Keyboard', 79.00),
    ('MS-001', 'Wireless Mouse', 39.00);

INSERT INTO product_updates VALUES
    ('KB-001', 'Compact Keyboard V2', 89.00),
    ('HD-001', 'USB-C Hub', 59.00);

MERGE INTO products AS target
USING product_updates AS source
ON target.sku = source.sku
WHEN MATCHED THEN
    UPDATE SET
        name = source.name,
        price = source.price,
        updated_at = now()
WHEN NOT MATCHED THEN
    INSERT (sku, name, price)
    VALUES (source.sku, source.name, source.price);

TABLE products;

MERGE 的价值不只是缩短代码。它把“匹配时更新、不匹配时插入”的意图集中在一个数据库语句中,也便于审查同步规则。但它不是并发问题的自动解决方案:目标表仍应有主键或唯一约束,源数据也应避免同一个键出现多行。具体锁行为和冲突结果需要针对实际隔离级别测试。

PostgreSQL 15 还允许唯一约束使用 NULLS NOT DISTINCT,把多个 NULL 视为相同值。这适合“字段可以暂时为空,但空值也只能出现一次”这类少见而明确的业务规则:

CREATE TABLE reserved_identifiers (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    external_id text,
    UNIQUE NULLS NOT DISTINCT (external_id)
);

INSERT INTO reserved_identifiers (external_id) VALUES (NULL);
-- 再插入 NULL 将违反唯一约束
-- INSERT INTO reserved_identifiers (external_id) VALUES (NULL);

默认唯一约束允许多个 NULL,因为 SQL 的空值语义并不把两个 NULL 判定为相等。启用这个选项前应确认业务真正需要“至多一个空值”,不要仅为了追求表面上的严格。

JSON 和分析查询逐渐成为一等公民

PostgreSQL 12 引入 SQL/JSON 路径查询能力,使嵌套 JSON 的筛选不必完全依赖操作符拼接。后续版本继续推进 SQL/JSON 标准支持;到 PostgreSQL 17,JSON_TABLE 可以把 JSON 文档投影为关系行列,便于与普通表连接、聚合和校验。

可以这样实践一个 PostgreSQL 17 及以上版本的导入查询:

WITH payload AS (
    SELECT '{
      "orders": [
        {"id": 101, "customer": "Ada", "amount": 125.50},
        {"id": 102, "customer": "Lin", "amount": 48.00}
      ]
    }'::jsonb AS doc
)
SELECT item.order_id, item.customer_name, item.amount
FROM payload,
     JSON_TABLE(
         doc,
         '$.orders[*]'
         COLUMNS (
             order_id integer PATH '$.id',
             customer_name text PATH '$.customer',
             amount numeric(10, 2) PATH '$.amount'
         )
     ) AS item;

这类能力适用于进入暂存区的 API 数据或事件载荷,但不意味着核心业务表都应该改成 JSON。需要频繁连接、排序、约束和更新的稳定字段,通常仍应建模为普通列;JSON 更适合结构可变或处于接入边界的数据。

建一个可重复的版本实验环境

评估跨版本 SQL 行为时,最实用的办法是同时启动多个 PostgreSQL 实例,对同一份 schema、数据与查询执行测试。下面的 Compose 文件可用于比较 PostgreSQL 15、17 和 18;如需研究 PostgreSQL 11–14,可按相同方式增加服务并换用对应镜像标签。

services:
  pg15:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: lab
    ports:
      - "5415:5432"

  pg17:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: lab
    ports:
      - "5417:5432"

  pg18:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: lab
    ports:
      - "5418:5432"

保存为 compose.yaml 后运行:

docker compose up -d

for port in 5415 5417 5418; do
  PGPASSWORD=postgres psql \
    -h localhost -p "$port" -U postgres -d lab \
    -c "SELECT version();"
done

不要只验证语法是否可执行。对于关键查询,至少保存以下结果:

PGPASSWORD=postgres psql \
  -h localhost -p 5418 -U postgres -d lab \
  -c "EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT * FROM products WHERE sku = 'KB-001';"

生产升级前还应使用接近真实规模的数据,因为小样本可能让优化器选择顺序扫描,掩盖索引、排序和连接策略的差异。

升级时,按业务收益而不是版本号采用功能

PostgreSQL 11–18 的 SQL 演进更像持续打磨,而不是一次语法革命。采用这些能力时,可以使用一份简短清单:

  • 标注应用支持的最低 PostgreSQL 版本,避免在共享 SQL 中误用新语法。
  • 优先采用能删除应用层竞态逻辑、触发器或重复查询的功能。
  • 对 CTE、JSON 和 MERGE 使用 EXPLAIN 与并发测试,不凭语法简洁程度判断性能。
  • 把主键、唯一约束和检查约束作为 DML 新功能的基础,而不是替代品。
  • 在测试环境加载具有真实分布和规模的数据,并比较升级前后的执行计划。
  • 阅读每个中间大版本的发布说明;跨多个版本升级时,行为变化往往比新增关键字更重要。

SQL 是历次发布中数量最小的更新类别之一,却是最容易转化为应用收益的类别。选对功能,升级带来的就不只是更高的版本号,而是更少的自定义代码、更清晰的数据规则,以及更容易解释和维护的查询。


相关推荐