编程 PostgreSQL 19 深度拆解:当关系数据库决定把图查询、计划提示与在线重组一起收进内核——从 SQL/PGQ 到 REPACK CONCURRENTLY 的架构手术

2026-08-10 01:50:15 +0800 CST views 7

本文基于 PostgreSQL 19 Beta 阶段的公开信息与社区提交记录整理。PG19 于 2026 年 4 月 8 日完成 Feature Freeze,Release Notes 首版草案统计共 212 项更新,正式版按社区规划在 2026 年秋季发布。Beta 到 GA 之间仍可能有语法与默认值调整,生产环境请以官方文档为准。

一、两种叙事:一个"运维小版本",还是"近年最猛的一版"?

PG19 是我近几年见过的、社区口径分歧最大的一个版本。

一派的说法来自资深 PG 顾问圈:PG19 是一个"系统完善型"版本。相比 PG18 的异步 I/O、PG16 的 Standby Logical Replication,它没有那种一句话就能讲清楚的"明星功能",212 项更新里大部分是 Planner 微调、pg_stat_* 视图加字段、边缘 bug 修复。升级动力主要来自运维体感,而不是业务能力。

另一派的说法来自应用开发圈:PG19 是里程碑。SQL/PGQ 把图查询写进了内核,ON CONFLICT DO SELECT 补上了从 9.5 拖到今天的 upsert 缺口,FOR PORTION OF 让 SQL:2011 时态特性成套,pg_plan_advice 打破了社区二十年"绝不做 hint"的政治正确,REPACK (CONCURRENTLY) 把 pg_repack 干的活收进了核心。

这两种说法都对,因为它们看的是同一个版本的两层

底层那一层确实是"完善型":异步 I/O 从静态 worker 改成 worker pool、pgstattuple 接上 Streaming Read、编译标准从 C99 升到 C11、MultiXactOffset 从 32 位扩到 64 位。这些改动没有一个能上头条,但它们决定了 PG18 引入的东西能不能真正"默认可用"。

上层那一层是真的动了语义边界:图查询、时态 DML、官方 hint、在线重组。这四件事在过去十年的社区讨论里,每一件都被拒绝过至少一次。

所以这篇文章我不想按 Release Notes 的顺序念一遍。我想按**"你会在什么场景下被它咬到,以及为什么这么设计"**的顺序,把 PG19 里真正承重的十来个点拆开讲。每个点尽量配可运行的代码、可复现的验证方式,以及踩坑位置。


二、SQL/PGQ:图查询进内核,但它不是 Neo4j

2.1 为什么现在才做,以及它到底做了什么

SQL:2023 标准的第 16 部分定义了 SQL/PGQ(Property Graph Queries)。PG19 把它实现进了内核。

先说结论,避免误会:PG19 没有新增图存储引擎,没有邻接表压缩,没有原生的图遍历算子。 它做的是一层"视图级"的映射 + 语法糖 + 查询重写。

-- 在现有关系表上定义属性图
CREATE PROPERTY GRAPH social_graph
  VERTEX TABLES (
    users LABEL person PROPERTIES (id, name, created_at)
  )
  EDGE TABLES (
    follows
      SOURCE KEY (follower_id) REFERENCES users (id)
      DESTINATION KEY (followed_id) REFERENCES users (id)
      LABEL follows
  );

注意这里没有 CREATE TABLEusersfollows 就是你线上跑了三年的普通表,CREATE PROPERTY GRAPH 只是在系统目录里登记了一份"这张表当顶点、那张表当边、外键怎么连"的元数据。

查询用 GRAPH_TABLE 包起来:

-- 查 Alice 的"朋友的朋友"
SELECT * FROM GRAPH_TABLE (social_graph
  MATCH (a IS person WHERE a.name = 'Alice')
        -[IS follows]->(b IS person)
        -[IS follows]->(c IS person)
  COLUMNS (b.name AS friend, c.name AS friend_of_friend)
);

这段 SQL 会被重写成两次 JOIN。你可以直接 EXPLAIN 验证:

EXPLAIN (COSTS OFF)
SELECT * FROM GRAPH_TABLE (social_graph
  MATCH (a IS person WHERE a.name = 'Alice')
        -[IS follows]->(b IS person)
  COLUMNS (b.name AS friend)
);

你会看到熟悉的 Nested Loop / Hash Join / Index Scan using follows_follower_id_idx没有任何新算子。

2.2 这层糖到底值不值

值,但要看你在解决什么问题。

它解决的问题是"表达力",不是"性能"。

手写等价 SQL 长这样:

SELECT b.name AS friend, c.name AS friend_of_friend
FROM users a
JOIN follows f1 ON f1.follower_id = a.id
JOIN users b     ON b.id = f1.followed_id
JOIN follows f2 ON f2.follower_id = b.id
JOIN users c     ON c.id = f2.followed_id
WHERE a.name = 'Alice';

两跳还行。三跳、四跳、带可变长度路径的时候,手写 SQL 会迅速变成一坨递归 CTE,而且每加一跳就要复制粘贴一组 JOIN,改一个条件要改 N 处。SQL/PGQ 的价值就在这儿:把"跳数"从代码结构里解耦出去

再加上一点:GRAPH_TABLE(...) 的返回值是一张普通的表值表达式,可以直接参与 JOIN、聚合、CTE、窗口函数。这是独立图数据库做不到的——你在 Neo4j 里查完还得把结果搬回 PG 做聚合。

-- 图查询结果直接参与聚合与窗口
WITH fof AS (
  SELECT * FROM GRAPH_TABLE (social_graph
    MATCH (a IS person)-[IS follows]->(b IS person)-[IS follows]->(c IS person)
    COLUMNS (a.name AS root, c.name AS reachable)
  )
)
SELECT root,
       count(DISTINCT reachable) AS reach_2hop,
       rank() OVER (ORDER BY count(DISTINCT reachable) DESC) AS rk
FROM fof
WHERE root <> reachable
GROUP BY root
ORDER BY rk
LIMIT 20;

2.3 什么时候它顶不住

因为底层是 JOIN,所以图查询的性能特征完全等同于 JOIN 的性能特征。这意味着:

  1. 每一跳都需要索引。 边表的 (source_key)(destination_key) 各要一个 B-tree,双向遍历还得两个都建。忘建索引 = 每跳一次 Seq Scan。
  2. 深度遍历会指数级放大中间结果。 社交图上的 4 跳可能覆盖半个图,Hash Join 的内存爆炸和临时文件落盘是必然的。Neo4j 这类原生图库用的是"指针跳转 + 免索引邻接",深度遍历是 O(路径数),而 JOIN 是 O(中间集合笛卡尔膨胀)。
  3. 最短路径、PageRank、社区发现这类图算法,SQL/PGQ 一个都不提供。 它是模式匹配语言,不是图计算框架。

建索引的正确姿势:

-- 边表必备的双向索引
CREATE INDEX CONCURRENTLY follows_src_idx ON follows (follower_id) INCLUDE (followed_id);
CREATE INDEX CONCURRENTLY follows_dst_idx ON follows (followed_id) INCLUDE (follower_id);

-- 如果边上带属性且经常过滤,把过滤列也带上
CREATE INDEX CONCURRENTLY follows_src_ts_idx ON follows (follower_id, created_at DESC);

INCLUDE 那一列很关键:它让两跳查询的中间步骤能走 Index Only Scan,不用回表。在千万级边表上,这个差别通常是 3~10 倍。

2.4 与 Apache AGE 的关系

有人会问:已经有 Apache AGE(PG 上的 openCypher 扩展)了,为什么还要 SQL/PGQ?

三点区别:

  • AGE 用自己的存储ag_catalog 下的顶点/边表 + agtype),数据要导进去;SQL/PGQ 直接映射你现有的表,零迁移。
  • AGE 是 Cypher 方言,和 SQL 混写要靠 cypher() 函数包裹,类型系统割裂;SQL/PGQ 是 ISO 标准,返回标准 SQL 类型。
  • AGE 是扩展,托管云上未必装得了;SQL/PGQ 在内核,PG19 开箱即用。

反过来,如果你的工作负载是真·图算法(推荐、风控图谱、反欺诈路径挖掘),AGE 或独立图库依然有价值。SQL/PGQ 的定位是:让 80% 只需要"两三跳关系查询"的业务,不必再引入一个新数据库。


三、ON CONFLICT DO SELECT:一个拖了十年的坑,终于填了

3.1 老写法为什么烂

"存在就返回,不存在就插入并返回"——这是 Web 后端最高频的需求之一。用户注册、标签去重、幂等接口、外部 ID 映射,全是这个模式。

PG 9.5 引入 ON CONFLICT 之后,标准写法是这样的:

-- 经典的 no-op DO UPDATE 变通
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO UPDATE
  SET email = EXCLUDED.email   -- 这行什么也没改
RETURNING *;

为什么要写这句"什么也没改"的 UPDATE?因为 DO NOTHING 不返回冲突行

INSERT INTO users (email, name) VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO NOTHING
RETURNING *;
-- 冲突时返回 0 行,你拿不到已存在那行的 id

于是大家被迫用 no-op UPDATE 来"骗" RETURNING。代价是什么?

MVCC 层面,一次 UPDATE 就是一次 DELETE + INSERT。 即便新旧值完全一样,PG 也会:

  1. 把旧元组打上 xmax,标记为已删除;
  2. 写一个全新的元组版本;
  3. 如果新元组和旧元组在同一页且没有索引列变化,走 HOT update,只污染堆页;否则所有索引都要插新条目;
  4. 写 WAL(如果这页是 checkpoint 后第一次修改,还要写 Full Page Image);
  5. 留下一个死元组,等 autovacuum 来收。

在一个高并发的"幂等写入"接口上,假设 95% 的请求都是冲突,那你 95% 的请求都在纯粹为了拿一个 id 而制造表膨胀和 WAL 流量。我见过一个日均 2 亿次调用的 ID 映射表,因为这个写法,autovacuum 一天要跑三十多轮,主备复制延迟长期在秒级。

3.2 新写法

INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO SELECT
RETURNING *;

冲突时它只读取并返回已存在的那一行,不产生任何新元组版本,不写 WAL(除了插入路径必要的部分),不留死元组。社区基准显示,相比 no-op UPDATE 写法快约 4 倍。

这个 4 倍是纯 CPU/IO 层面的直接收益。真正的收益还得算上:不用 vacuum 那些死元组、不用复制那些 WAL、不用在备库回放那些无意义的变更。在写放大严重的系统上,端到端收益远不止 4 倍。

3.3 锁语义:这里有个必须搞清楚的细节

DO SELECT 会对冲突行加行级锁(类似 SELECT ... FOR ... 的语义),这是保证"获取或创建"原子性的必要条件。否则两个并发事务可能都拿到旧行,然后各自基于旧值去做后续更新,产生 lost update。

这意味着两件事:

  • 它不是"无锁读"。高并发争抢同一行时,仍然会串行化。
  • 锁持有到事务结束。所以别在一个长事务里做一堆 DO SELECT,会拖住别人。

如果你只是想"读一下有没有",那就老老实实 SELECT,别用 DO SELECT 当查询用。

3.4 应用侧代码实战

Go(pgx v5):

package repo

import (
    "context"
    "github.com/jackc/pgx/v5/pgxpool"
)

type User struct {
    ID    int64
    Email string
    Name  string
}

// GetOrCreateUser: PG19 原子获取或创建
func GetOrCreateUser(ctx context.Context, pool *pgxpool.Pool, email, name string) (*User, error) {
    const q = `
        INSERT INTO users (email, name)
        VALUES ($1, $2)
        ON CONFLICT (email) DO SELECT
        RETURNING id, email, name`

    var u User
    err := pool.QueryRow(ctx, q, email, name).Scan(&u.ID, &u.Email, &u.Name)
    if err != nil {
        return nil, err
    }
    return &u, nil
}

对比 PG18 及之前你必须写的版本:

// PG18 及以前:要么 no-op update 制造膨胀,要么两段式 + 重试
const qLegacy = `
    WITH ins AS (
        INSERT INTO users (email, name) VALUES ($1, $2)
        ON CONFLICT (email) DO NOTHING
        RETURNING id, email, name
    )
    SELECT id, email, name FROM ins
    UNION ALL
    SELECT id, email, name FROM users WHERE email = $1
    LIMIT 1`
// 这个 CTE 写法还有个隐蔽 bug:并发下 ins 为空且 SELECT 也看不到新行(快照隔离),
// 需要外层重试才能保证正确性。

那个 CTE 写法的 bug 值得多说一句:CTE 里的 SELECT users 用的是语句开始时的快照,而并发事务刚插入的行对这个快照不可见。所以在极端并发下,ins 返回空 + SELECT 也返回空,整个语句返回 0 行。你必须在应用层加重试循环。DO SELECT 从根上消灭了这个竞态。

Python(psycopg3):

import psycopg
from psycopg.rows import dict_row

GET_OR_CREATE = """
    INSERT INTO tags (name, slug)
    VALUES (%(name)s, %(slug)s)
    ON CONFLICT (slug) DO SELECT
    RETURNING id, name, slug
"""

def get_or_create_tag(conn: psycopg.Connection, name: str, slug: str) -> dict:
    with conn.cursor(row_factory=dict_row) as cur:
        cur.execute(GET_OR_CREATE, {"name": name, "slug": slug})
        return cur.fetchone()

3.5 怎么量化你现在被这个坑吃掉了多少

升级前先测一下你的膨胀来源:

-- 找出 n_tup_upd 高但 n_tup_hot_upd 占比也高、且更新前后无实际变化的可疑表
SELECT relname,
       n_tup_ins,
       n_tup_upd,
       n_tup_hot_upd,
       round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 1) AS hot_pct,
       n_dead_tup,
       autovacuum_count
FROM pg_stat_user_tables
WHERE n_tup_upd > 100000
ORDER BY n_tup_upd DESC
LIMIT 20;

如果某张表 n_tup_upd 极高、autovacuum_count 也高,但业务逻辑上"这张表几乎不更新",那它八成就是被 no-op upsert 打的。


四、FOR PORTION OF:时态数据不用再手写三段拆分

4.1 场景

价格表、保单、员工岗位、房源可租区间、优惠券有效期……凡是"某个属性在某段时间内成立"的数据,都是时态数据。

PG18 引入了 WITHOUT OVERLAPS 时态约束,能保证同一个 key 的区间不重叠:

CREATE TABLE product_prices (
    product_id  int         NOT NULL,
    price       numeric(10,2) NOT NULL,
    valid_range daterange   NOT NULL,
    PRIMARY KEY (product_id, valid_range WITHOUT OVERLAPS)
);

但 PG18 只解决了"约束",没解决"修改"。想把某个产品 Q3 的价格改掉,你得手写:

-- PG18 时代的三段式手术,写错一个边界就数据错乱
BEGIN;
-- 1. 取出被覆盖的行
-- 2. 删掉它
-- 3. 插回左侧残段
-- 4. 插入新值段
-- 5. 插回右侧残段
COMMIT;

边界的开闭、daterange[) 语义、跨多行时的循环处理、并发下的锁顺序——每一处都是坑。我见过因为 [) 写成 [] 导致的保单双计费事故。

4.2 PG19 的写法

-- 原始行:产品 1 在 2026 全年价格 29.99
INSERT INTO product_prices VALUES (1, 29.99, daterange('2026-01-01','2027-01-01'));

-- 只改 Q3
UPDATE product_prices
FOR PORTION OF valid_range FROM '2026-07-01' TO '2026-10-01'
SET price = 34.99
WHERE product_id = 1;

执行后自动变成三行:

 product_id | price |       valid_range
------------+-------+--------------------------
          1 | 29.99 | [2026-01-01,2026-07-01)
          1 | 34.99 | [2026-07-01,2026-10-01)
          1 | 29.99 | [2026-10-01,2027-01-01)

DELETE 同理:

-- 挖掉 8 月这一段(比如临时下架)
DELETE FROM product_prices
FOR PORTION OF valid_range FROM '2026-08-01' TO '2026-09-01'
WHERE product_id = 1;

内核负责拆行、保留未触及部分、维护 WITHOUT OVERLAPS 约束。这是 SQL:2011 时态特性集的最后一块拼图。

4.3 需要注意的语义细节

  1. FOR PORTION OF 的区间与行区间求交,交集为空的行不受影响。 所以 WHERE 子句仍然重要,别指望时间范围能替代过滤条件。
  2. 拆分出来的新行会触发 INSERT 触发器还是 UPDATE 触发器? 这一块在 Beta 期间社区讨论过多轮,升级前务必在你的表上实测一遍,尤其是有审计触发器的表。
  3. RETURNING 返回的是哪几行? 同上,实测确认。
  4. 性能上它是"行放大"操作:一次 UPDATE 可能变成 1 删 3 插。高频修改时态表时,膨胀速度比普通表快,autovacuum 参数要相应调激进。

给时态表的推荐配置:

ALTER TABLE product_prices SET (
    autovacuum_vacuum_scale_factor = 0.02,
    autovacuum_vacuum_threshold    = 500,
    autovacuum_analyze_scale_factor = 0.01,
    fillfactor = 80          -- 留空间给 HOT update
);

五、pg_plan_advice:社区二十年的"不做 hint"破防了

5.1 为什么以前不做

PostgreSQL 社区拒绝查询提示的理由非常一致,而且我认为大部分是对的:

  • Hint 会把优化器 bug 冻结成应用逻辑,数据分布变了也不会自动跟进;
  • Hint 写在 SQL 注释里,ORM 生成的 SQL 根本没地方塞;
  • 有了 hint,用户就不再报优化器问题,社区失去改进动力。

Oracle 的 /*+ INDEX(t idx) */、MySQL 的 FORCE INDEX、以及第三方的 pg_hint_plan,都是"注释即指令"的路子。

5.2 PG19 怎么做的

pg_plan_advice 是一个 contrib 模块,不是语法。它的核心设计是两段式 + 反馈闭环

第一步,从一个已知的好计划里"提取建议":

EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id;

-- 输出末尾多一段:
-- JOIN_ORDER(o c) HASH_JOIN(c) SEQ_SCAN(o c)

第二步,把这串建议设回 GUC,锁定计划:

SET pg_plan_advice.advice = 'JOIN_ORDER(o c) HASH_JOIN(c) SEQ_SCAN(o c)';

关键差异在于三点:

第一,建议在 GUC 里,不在 SQL 里。 这意味着 ORM 生成的 SQL 也能被约束——你在连接池的 session 初始化里 SET 一下就行,不用改一行应用代码。也意味着你可以按连接、按角色、按数据库粒度施加,ALTER ROLE reporting SET pg_plan_advice.advice = '...'

第二,有反馈机制。 模块会告诉你每一条建议是否被采纳。传统 hint 最恶心的地方是写错了就静默失效,你以为锁住了,其实优化器压根没理你,等到线上出事才发现。PG19 让你能主动检测"hint 失效"。

第三,它是显式的临时手段,不是长期方案。 建议内容是位置敏感的(JOIN_ORDER(o c) 依赖别名),表结构一变就该重新生成。这个"脆弱性"其实是设计意图:逼你定期回来重新评估,而不是一劳永逸地把烂计划焊死。

5.3 生产使用范式

我建议这样用:

-- 1. 建一张计划基线表
CREATE TABLE plan_baseline (
    query_id     bigint PRIMARY KEY,
    query_text   text   NOT NULL,
    advice       text   NOT NULL,
    captured_at  timestamptz NOT NULL DEFAULT now(),
    reason       text
);

-- 2. 从 pg_stat_statements 找到那条抖动的查询,人工确认好计划后入库
INSERT INTO plan_baseline (query_id, query_text, advice, reason)
VALUES (
  -8123456789012345678,
  'SELECT ... FROM orders o JOIN customers c ...',
  'JOIN_ORDER(o c) HASH_JOIN(c) SEQ_SCAN(o c)',
  '2026-08-09 大促期间统计信息漂移导致误走 nestloop,P99 从 40ms 涨到 8s'
);

配套一个巡检脚本,定期检查建议是否还被采纳、是否还有必要:

#!/usr/bin/env bash
# plan_advice_audit.sh —— 定期回归检查计划基线是否仍然必要
set -euo pipefail
PSQL="psql -X -q -A -t -d ${PGDATABASE:-app}"

$PSQL -c "SELECT query_id, advice FROM plan_baseline" | while IFS='|' read -r qid advice; do
  echo "== query_id=$qid"
  # 不加建议时的计划
  raw=$($PSQL -c "EXPLAIN (COSTS OFF) $(get_sql_by_id "$qid")" | tr -d '\n')
  # 加建议后的计划
  hinted=$($PSQL -c "SET pg_plan_advice.advice='$advice'; EXPLAIN (COSTS OFF) $(get_sql_by_id "$qid")" | tr -d '\n')
  if [ "$raw" = "$hinted" ]; then
      echo "  [可退休] 优化器已能自主选出目标计划,建议移除基线"
  else
      echo "  [仍需要] 计划仍有差异"
  fi
done

核心原则:每一条 advice 都要有过期时间和退休条件。 否则三年后你会有一张三百行的基线表,没人知道哪条还有用。

5.4 顺带:Memoize 的估算终于可见了

PG14 引入 Memoize 节点(参数化 nestloop 内侧的结果缓存)之后,一直有个诊断黑洞:你看不到优化器为什么认为 Memoize 划算。

PG19 的 EXPLAIN 把估算摊开了:

->  Memoize  (cost=... rows=...)
      Cache Key: t.id
      Estimates: capacity=2  distinct keys=2  lookups=1000  hit percent=99.80%

这四个数直接告诉你优化器的心理活动。当实际命中率远低于 hit percent 时,问题几乎总是出在 n_distinct 估算上:

-- 修正 n_distinct(负值表示"相对于行数的比例")
ALTER TABLE t ALTER COLUMN id SET (n_distinct = -0.8);
ANALYZE t;

六、REPACK (CONCURRENTLY):pg_repack 的活,内核接了

6.1 历史包袱

在线重组表这件事,PG 折腾了很久:

  • VACUUM FULL:重写整表,回收空间给 OS,但全程 ACCESS EXCLUSIVE 锁,读都读不了。
  • CLUSTER:按索引物理重排,同样 ACCESS EXCLUSIVE
  • pg_repack(第三方扩展):用触发器 + 日志表 + 最后短暂换名的方式做在线重组。能用,但要装扩展、要额外磁盘、崩溃后清理麻烦、和逻辑复制配合有坑。

PG19 把这三条路合成一条:

-- 回收空间(等价 VACUUM FULL)
REPACK orders;

-- 按索引重排(等价 CLUSTER)
REPACK orders USING INDEX orders_created_at_idx;

-- 在线模式:重组期间表保持可读可写
REPACK (CONCURRENTLY) orders USING INDEX orders_created_at_idx;

CONCURRENTLY 模式下,ACCESS EXCLUSIVE 锁只在最后的文件交换瞬间短暂持有,绝大部分时间表是可读可写的。

6.2 这个"短暂"到底多短,取决于什么

这是实操中最容易翻车的地方。文件交换需要拿到 ACCESS EXCLUSIVE,而拿锁本身会排队。PG 的锁队列是 FIFO 且会阻塞后来者:

时刻 T0: 长事务 A 持有 AccessShareLock(一条跑了 20 分钟的报表)
时刻 T1: REPACK 请求 AccessExclusiveLock → 排队等待
时刻 T2: 新来的 SELECT 请求 AccessShareLock → 排在 REPACK 后面,被阻塞

结果就是:REPACK 等 20 分钟报表,而这 20 分钟里所有新查询全被堵死。这不是 REPACK 的锅,是 PG 锁队列的固有行为,但它会让你的"在线重组"变成一次全站故障。

正确做法:给最终交换阶段加锁超时,失败就重试。

-- 会话级设置,让抢锁失败快速返回而不是拖垮全站
SET lock_timeout = '3s';
SET statement_timeout = 0;   -- REPACK 本身可能跑很久,别被语句超时砍掉

REPACK (CONCURRENTLY) orders USING INDEX orders_created_at_idx;

配套的重试脚本:

#!/usr/bin/env bash
# repack_safe.sh —— 带锁超时与退避重试的在线重组
set -euo pipefail
TABLE="${1:?usage: repack_safe.sh <table> [index]}"
INDEX="${2:-}"
MAX_RETRY=10
DELAY=30

USING=""
[ -n "$INDEX" ] && USING="USING INDEX $INDEX"

for i in $(seq 1 "$MAX_RETRY"); do
  echo "[$(date '+%F %T')] attempt $i/$MAX_RETRY"
  if psql -X -v ON_ERROR_STOP=1 -d "${PGDATABASE:-app}" <<SQL
SET lock_timeout = '3s';
SET statement_timeout = 0;
SET idle_in_transaction_session_timeout = '10s';
REPACK (CONCURRENTLY) ${TABLE} ${USING};
SQL
  then
    echo "[$(date '+%F %T')] repack ok"
    exit 0
  fi
  echo "[$(date '+%F %T')] failed, sleep ${DELAY}s"
  sleep "$DELAY"
  DELAY=$(( DELAY * 2 > 600 ? 600 : DELAY * 2 ))
done

echo "repack failed after ${MAX_RETRY} attempts" >&2
exit 1

跑之前先确认没有长事务:

-- 找出可能阻塞 REPACK 的长事务
SELECT pid,
       now() - xact_start AS xact_age,
       state,
       wait_event_type,
       left(query, 80) AS q
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '1 minute'
ORDER BY xact_age DESC;

6.3 空间与 WAL 成本

REPACK 本质是"重写一份新的表文件再换过去",所以:

  • 峰值磁盘 = 原表 + 新表 + 所有索引的新副本。 一张 500GB 带 4 个索引的表,重组期间可能要 1TB+ 的空闲空间。
  • WAL 量约等于整表大小wal_level=logical 时更多)。如果你有跨机房备库,先算一下带宽。
  • 归档目录、复制槽 lag 都要提前盯住。
-- 重组前先估算体积
SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total,
       pg_size_pretty(pg_relation_size('orders'))       AS heap,
       pg_size_pretty(pg_indexes_size('orders'))        AS idx;

七、并行 autovacuum:木桶效应决定收益

7.1 它并行的是什么

先明确边界:并行的是索引清理(index vacuum)和索引清理阶段,不是堆扫描。

autovacuum 的三个阶段:

  1. 扫描堆,收集死元组 TID 列表(单进程)
  2. 遍历每个索引,删掉指向死元组的索引条目 ← PG19 在这里并行
  3. 回到堆,真正标记空间可重用(单进程)

PG19 之前,一张有 8 个索引的表,阶段 2 是串行的:8 个索引依次扫。现在可以:

-- 全局启用(默认 0 = 禁用)
ALTER SYSTEM SET autovacuum_max_parallel_workers = 4;
SELECT pg_reload_conf();

-- 针对索引特别多的表单独放开
ALTER TABLE events SET (autovacuum_parallel_workers = 6);

7.2 收益模型

每个 worker 处理一个索引。 所以阶段 2 的耗时从"所有索引之和"变成"最大那个索引的耗时"(在 worker 数 ≥ 索引数的前提下)。

假设一张表有 5 个索引,扫描耗时分别是 200s / 180s / 60s / 40s / 20s:

  • 串行:200+180+60+40+20 = 500s
  • 4 个 worker:worker 各领一个,最后一个索引由先空闲的 worker 接手 → 关键路径 ≈ 200s(最大索引)
  • 8 个 worker:还是 200s,多出来的 worker 白给

这就是木桶效应:收益上限由最大的那个索引决定,worker 数超过索引数毫无意义。

所以配置原则很清楚:

-- 查每张表的索引数和索引大小分布,据此决定 worker 数
SELECT
    t.relname AS table_name,
    count(*)  AS idx_count,
    pg_size_pretty(sum(pg_relation_size(i.indexrelid))) AS idx_total,
    pg_size_pretty(max(pg_relation_size(i.indexrelid))) AS idx_max,
    round(100.0 * max(pg_relation_size(i.indexrelid))
          / NULLIF(sum(pg_relation_size(i.indexrelid)), 0), 1) AS max_pct
FROM pg_index i
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relkind = 'r' AND t.relnamespace::regnamespace::text NOT IN ('pg_catalog','information_schema')
GROUP BY t.relname
HAVING count(*) >= 3
ORDER BY sum(pg_relation_size(i.indexrelid)) DESC
LIMIT 20;

如果 max_pct 接近 100%(一个索引占了绝大部分体积),并行几乎没用,你该做的是拆索引或者删掉没用的索引。如果 max_pct 在 20~30% 且索引数 ≥ 4,并行收益最明显。

7.3 别忘了 worker 也要吃资源

autovacuum_max_parallel_workers 的 worker 来自 max_parallel_workers 池子,和你的并行查询抢资源。配置时注意层级:

max_worker_processes             (总池子)
  └─ max_parallel_workers        (并行相关的池子)
       ├─ max_parallel_workers_per_gather  (单个查询能用几个)
       └─ autovacuum_max_parallel_workers  (autovacuum 能用几个)

在 OLTP 主库上,我的建议是从 2~3 起步,观察 pg_stat_progress_vacuum 的实际收益再往上调。

7.4 PG19 的 vacuum 可观测性

pg_stat_progress_vacuum 新增了 modestarted_by

SELECT pid,
       relid::regclass AS tbl,
       phase,
       mode,        -- normal / aggressive / failsafe
       started_by,  -- auto / manual / wraparound
       heap_blks_scanned, heap_blks_total,
       round(100.0 * heap_blks_scanned / NULLIF(heap_blks_total,0), 1) AS pct
FROM pg_stat_progress_vacuum;

mode = failsafe 是最需要报警的信号:它表示 PG 已经进入"回卷紧急模式",会跳过索引清理、无视 cost delay 疯狂跑,此时数据库通常已经在悬崖边上了。

顺带,PG19 支持按进程类型分级日志,终于可以只把 autovacuum 的日志调详细而不淹没其他日志:

# postgresql.conf
log_min_messages = 'warning, autovacuum:debug1, archiver:debug5'

八、异步 I/O 从"要调参"到"能默认开"

PG18 引入异步 I/O 时,用的是静态 io_workers:默认 3,最大 32,靠人肉调。问题是这个值和负载强相关——低了打不满设备队列,高了空转烧 CPU 和内存。

PG19 改成 Worker Pool:

参数作用
io_min_workers池子下限,常驻 worker 数
io_max_workers池子上限
io_worker_idle_timeout空闲多久后回收 worker
io_worker_launch_interval两次创建 worker 的最小间隔,防止突发负载下进程风暴

最后那个 io_worker_launch_interval 是我最欣赏的设计。没有它,一个瞬时的流量尖峰会让 postmaster 在几毫秒内 fork 出几十个进程,而这些进程创建本身的开销可能比它们节省的 I/O 等待还大。加了创建速率限制,池子的扩张变成"平滑爬坡"而不是"雪崩式扩张"。

配置示例(云盘环境,网络延迟高,适合更激进的 AIO):

io_method = worker
io_min_workers = 4
io_max_workers = 24
io_worker_idle_timeout = '60s'
io_worker_launch_interval = '100ms'
effective_io_concurrency = 200      # NVMe/云盘可以更高
maintenance_io_concurrency = 100

验证是否真的在用:

-- 看 IO worker 进程
SELECT pid, backend_type, wait_event_type, wait_event
FROM pg_stat_activity
WHERE backend_type LIKE '%io worker%';

-- 看 IO 统计(PG18+ 的 pg_stat_io)
SELECT backend_type, object, context,
       reads, read_bytes, read_time,
       writes, write_bytes, write_time
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY read_bytes + write_bytes DESC;

为什么这事在云上尤其重要: 云盘走网络,单次 I/O 延迟通常是本地 NVMe 的 520 倍。同步 I/O 下,顺序扫描一张大表的时间几乎全花在网络往返上,CPU 空转。异步 I/O 把请求批量发出去,延迟被并发掩盖。社区早期基准在读密集场景下报告过 23 倍的提升——这个数字在本地 NVMe 上看不到,在云盘上才是常态。


九、Planner 与执行器的几处静默改进

9.1 更多 LEFT JOIN 被改写成 ANTI JOIN

这个模式大家都写过:

-- 找出"没有订单的客户"
SELECT c.*
FROM customers c
LEFT JOIN orders o ON o.cust_id = c.id
WHERE o.id IS NULL;

语义上它等价于:

SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.id);

后者优化器能直接用 Anti Join(找到第一条匹配就短路),前者在 PG19 之前只在有限情况下能被识别。PG19 扩大了识别范围。

升级后要注意:一些历史 SQL 的执行计划会变。 大部分变好,但如果你的统计信息不准,Anti Join 的行数估算也可能翻车。升级前后跑一遍计划 diff:

#!/usr/bin/env bash
# plan_diff.sh —— 升级前后批量对比执行计划
# 用法: plan_diff.sh queries.sql old_conn new_conn
QUERIES="$1"; OLD="$2"; NEW="$3"
n=0
while IFS= read -r sql; do
  [ -z "$sql" ] && continue
  n=$((n+1))
  a=$(psql -X -q -A -t "$OLD" -c "EXPLAIN (COSTS OFF, FORMAT TEXT) $sql" 2>/dev/null)
  b=$(psql -X -q -A -t "$NEW" -c "EXPLAIN (COSTS OFF, FORMAT TEXT) $sql" 2>/dev/null)
  if [ "$a" != "$b" ]; then
    echo "=== [#$n] PLAN CHANGED ==="
    echo "--- SQL: ${sql:0:120}"
    diff <(echo "$a") <(echo "$b") || true
    echo
  fi
done < "$QUERIES"
echo "checked $n queries"

pg_stat_statements 里 Top 200 的查询导出成 queries.sql,升级前跑一遍,心里就有数了。

9.2 pgstattuple 接上 Streaming Read

pgstattuple 是查表膨胀最准的工具(pg_stat_user_tables.n_dead_tup 只是估算),但它要逐页读整张表,大表上慢到只能在维护窗口跑。

PG19 把它接进 Streaming Read API,能吃到 prefetch、AIO 的红利。这意味着膨胀诊断可以搬到业务时间做了

-- 精确膨胀率(PG19 上大表也能忍受)
SELECT * FROM pgstattuple('orders');
--  table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count |
--  dead_tuple_len | dead_tuple_percent | free_space | free_percent

-- 批量扫描 Top 大表
SELECT c.relname,
       pg_size_pretty(pg_relation_size(c.oid)) AS size,
       (pgstattuple(c.oid)).dead_tuple_percent AS dead_pct,
       (pgstattuple(c.oid)).free_percent       AS free_pct
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname = 'public'
  AND pg_relation_size(c.oid) > 1024*1024*1024   -- > 1GB
ORDER BY pg_relation_size(c.oid) DESC
LIMIT 10;

(仍然建议错峰跑,Streaming Read 让它变快了,不代表它免费。)


十、COPY、逻辑复制与工具链

10.1 COPY TO ... FORMAT JSON

以前想把表导成 JSON,得这么写:

-- 老写法:内存里 json_agg 聚合,大表直接 OOM
COPY (SELECT json_agg(row_to_json(t)) FROM users t) TO '/tmp/users.json';

json_agg 会把整个结果集在内存里拼成一个巨大的 jsonb,千万行表能把 work_mem 撑爆。

PG19 原生支持:

-- NDJSON(每行一个对象),流式输出,内存恒定
COPY users TO STDOUT WITH (FORMAT JSON);
-- {"id":1,"email":"alice@example.com","name":"Alice"}
-- {"id":2,"email":"bob@example.com","name":"Bob"}

-- JSON 数组形式
COPY users TO STDOUT WITH (FORMAT JSON, FORCE_ARRAY);

NDJSON 是数据管道的通用格式(Kafka、ClickHouse、DuckDB、jq 全都吃),这个特性直接省掉一层 ETL 转换。

配合 jq 的流式处理:

psql -X -c "COPY (SELECT * FROM events WHERE created_at >= now() - interval '1 day') \
  TO STDOUT WITH (FORMAT JSON)" \
  | jq -c 'select(.status == "failed") | {id, err: .payload.error}' \
  > failed_events.ndjson

10.2 分区表可以直接 COPY

-- PG19 之前:报错,必须包一层子查询
COPY partitioned_sales TO '/tmp/sales.csv' WITH (FORMAT csv);

现在直接可用,而且比 COPY (SELECT * FROM ...) TO 快约 7~8%(省掉了一层查询执行器开销)。

10.3 逻辑复制的三个痛点修复

序列同步。 这是我个人最想要的一个。以前逻辑复制不同步序列值,主备切换后新写入直接主键冲突,只能在切换脚本里手动 setval。现在:

CREATE PUBLICATION my_pub FOR ALL TABLES, ALL SEQUENCES;

EXCEPT TABLE FOR ALL TABLES 很方便但太粗暴,以前想排除审计表就只能改成显式列表,然后每加一张新表都要记得 ALTER PUBLICATION ADD TABLE。现在:

CREATE PUBLICATION prod_pub FOR ALL TABLES
  EXCEPT (TABLE audit_log, temp_imports, staging_raw);

动态 WAL 级别。 以前从 replica 切到 logical 必须改配置 + 重启。PG19 引入 effective_wal_level,它会根据是否存在逻辑复制槽自动调整:

SHOW wal_level;            -- 你配的
SHOW effective_wal_level;  -- 实际生效的

创建第一个逻辑槽时自动升级,删掉最后一个槽后自动降回来。"为了以后可能要用逻辑复制,先把 wal_level 常年设成 logical" 这个常见的浪费(额外的 WAL 量、额外的 FPI)可以退休了。

10.4 DDL 提取函数

SELECT pg_get_database_ddl('mydb');
SELECT pg_get_role_ddl('app_user');
SELECT pg_get_tablespace_ddl('fast_ssd');

以前拿这些 DDL 只能 pg_dumpall --globals-only 然后正则切。现在有正经函数了,写自动化脚本干净很多。

pg_dumpall 也终于支持非文本格式:

pg_dumpall -Fc -f full-dump              # custom
pg_restore --globals-only full-dump       # 只恢复角色/表空间

十一、64 位 MultiXactOffset:一类线上事故的绝迹

这个改动没什么话题度,但它消灭的是最难排查的一类 PG 事故

先解释 MultiXact 是什么。PG 的元组头里只有一个 xmax 字段来记录"谁锁了这行"。当多个事务同时持有同一行的共享锁(典型场景:多个事务并发 SELECT ... FOR SHARE,或者外键检查隐式加的锁)时,一个 xmax 塞不下多个事务 ID,PG 就分配一个 MultiXactId,指向一个"成员列表",列表存在 pg_multixact/members 里。

问题在于,成员空间的偏移量 MultiXactOffset 以前是 32 位,也就是约 40 亿个成员的上限,会回卷。

回卷时会发生什么?写入直接失败,报 multixact "members" limit exceeded,然后你被迫做紧急 vacuum,而紧急 vacuum 在大表上要跑几个小时。业务这几个小时就写不进去。

什么场景容易触发?

  • 外键密集的 schema(每次插入子表都要给父表行加共享锁)
  • 大量并发 SELECT FOR UPDATE / FOR SHARE
  • 长事务阻止 MultiXact 冻结推进

PG19 把 MultiXactOffset 扩到 64 位,这个回卷上限实质上消失了。

升级前想看看自己离悬崖多远:

-- 当前 MultiXact 使用情况
SELECT datname,
       age(datminmxid) AS mxid_age,
       round(100.0 * age(datminmxid) / current_setting('autovacuum_multixact_freeze_max_age')::numeric, 1) AS pct_to_forced_vacuum
FROM pg_database
ORDER BY age(datminmxid) DESC;

-- members 目录实际占用(超过几 GB 就该警惕)
SELECT pg_size_pretty(sum(size)) AS members_size
FROM pg_ls_dir('pg_multixact/members') AS f,
     LATERAL (SELECT (pg_stat_file('pg_multixact/members/' || f)).size) AS s(size);

十二、在线开关数据校验和

data_checksums 能在页级别检测静默数据损坏(坏盘、内存位翻转、存储固件 bug)。它的问题一直是:只能在 initdb 时决定,事后想开必须停库跑 pg_checksums,或者 dump/restore 全量数据。

结果是绝大多数存量集群都没开。

PG19:

-- 在线启用,集群保持可用
SELECT pg_enable_data_checksums(cost_delay := 10, cost_limit := 1000);

-- 观察进度
SHOW data_checksums;   -- 'off' → 'inprogress-on' → 'on'

cost_delay / cost_limit 的语义和 vacuum 的 cost-based delay 一致,用来限流,避免把生产 IO 打满。保守起见先用大 delay 小 limit 慢慢跑:

-- 极度保守,跑得慢但几乎无感
SELECT pg_enable_data_checksums(cost_delay := 50, cost_limit := 200);

开启后监控校验和错误:

SELECT datname, checksum_failures, checksum_last_failure
FROM pg_stat_database
WHERE checksum_failures > 0;

注意:校验和只是"检测",不是"修复"。 发现错误意味着你需要从备份或备库恢复。所以开校验和的前提是你的备份体系是可用的。


十三、破坏性变更:升级前的完整检查清单

这是全文最该被复制走的一节。PG19 的破坏性变更比往年多。

13.1 默认值变化

变更影响处理
jit 默认改为 off分析型查询可能变慢OLAP 负载显式 SET jit = on,OLTP 维持默认(原本 JIT 在短查询上就是负收益)
TOAST 默认压缩改为 LZ4新数据压缩率略降、CPU 降存量数据不受影响;若磁盘紧张可对特定列 ALTER TABLE ... ALTER COLUMN c SET COMPRESSION pglz
max_locks_per_transaction 默认 64 → 128共享内存占用上升检查 shared_memory_size_in_huge_pages,小内存机器注意
log_lock_waits 默认开启日志量上升检查日志盘容量与采集成本;这其实是好事,别关
standard_conforming_strings 强制开启普通字符串里的反斜杠转义失效全量扫描代码里的 '\n''\\' 写法

standard_conforming_strings 这条最容易咬人。以前 'a\nb' 在某些配置下会被解释成"a 换行 b",现在它就是字面的 a\nb。要转义必须用 E''

SELECT E'a\nb';        -- 换行
SELECT 'a\nb';         -- 字面量反斜杠 + n
SELECT 'it''s';        -- 标准 SQL 的单引号转义

扫描办法:

# 粗筛代码里可能受影响的字符串字面量
grep -rnE "'[^']*\\\\[nrt\\\\][^']*'" --include='*.py' --include='*.go' \
     --include='*.java' --include='*.sql' --include='*.rb' . \
  | grep -vE "E'" | head -50

13.2 移除与阻断项

  • RADIUS 认证已移除。radius 的必须先迁到 LDAP / GSSAPI / 证书认证,否则升级后所有用户登不上。
  • MULE_INTERNAL 编码已移除pg_upgrade 会直接拒绝。
  • btree_gistinet / cidr 操作符类退役(已知返回错误结果)。基于 gist_inet_ops / gist_cidr_ops 的索引必须升级前删除,否则 pg_upgrade 阻断。
  • 数据库、角色、表空间名里不允许 CR/LF,含这类名字的集群会被阻断。
  • MD5 密码会告警,尽快迁 SCRAM-SHA-256。

预检脚本:

-- 1. 查有没有 btree_gist inet/cidr 索引
SELECT n.nspname, c.relname AS index_name, t.relname AS table_name, am.amname
FROM pg_index i
JOIN pg_class c   ON c.oid = i.indexrelid
JOIN pg_class t   ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_am am     ON am.oid = c.relam
JOIN pg_opclass op ON op.oid = ANY(i.indclass::oid[])
WHERE am.amname = 'gist'
  AND op.opcname IN ('inet_ops','cidr_ops','gist_inet_ops','gist_cidr_ops');

-- 2. 查还在用 MD5 的角色
SELECT rolname FROM pg_authid
WHERE rolpassword LIKE 'md5%';

-- 3. 查名字里含 CR/LF 的对象
SELECT 'database' AS kind, datname AS name FROM pg_database
WHERE datname ~ '[\r\n]'
UNION ALL
SELECT 'role', rolname FROM pg_roles WHERE rolname ~ '[\r\n]'
UNION ALL
SELECT 'tablespace', spcname FROM pg_tablespace WHERE spcname ~ '[\r\n]';

-- 4. 查 RADIUS 认证配置
SELECT line_number, type, database, user_name, auth_method
FROM pg_hba_file_rules
WHERE auth_method = 'radius';

-- 5. 查编码
SELECT datname, pg_encoding_to_char(encoding) AS enc
FROM pg_database
WHERE pg_encoding_to_char(encoding) = 'MULE_INTERNAL';

13.3 监控与工具适配

  • 等待事件类 BUFFERPIN 改名为 BUFFER 所有按 wait_event_type = 'BufferPin' 过滤的监控看板、告警规则都要改。
  • EXPLAIN 输出格式有变化。 直接正则解析 EXPLAIN 文本的工具(有些国产 APM 就这么干)需要回归测试。官方一直不建议解析文本,但现实是很多人在解析。
  • postgres_fdw 现在遵守本地 READ ONLY 声明 SET TRANSACTION READ ONLY 的事务不能再通过外部表写入。有些数据同步作业依赖这个"漏洞",会直接报错。
  • 系统目录有调整。 直接读 catalog 的扩展需要重新验证。

13.4 编译标准 C99 → C11

普通用户无感,但如果你维护 PG 扩展:

# 检查你的 Makefile 里有没有硬编码 -std=c99
PG_CPPFLAGS = -std=c99   # ← 这行删掉或改成 c11

企业内部构建环境如果还在用很老的 GCC(< 4.9)需要升级工具链。RHEL 8+、主流 Linux 发行版、macOS 都已经原生支持 C11。


十四、一套可执行的升级演练流程

理论说完,给一套我实际会跑的流程。

第一步:建影子实例,用真实流量回放

# 用 PGDG 快照包或源码构建 PG19
docker build -t pg19-dev - <<'DOCKERFILE'
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y curl ca-certificates gnupg lsb-release \
 && install -d /usr/share/postgresql-common/pgdg \
 && curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
      https://www.postgresql.org/media/keys/ACCC4CF8.asc \
 && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \
      http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg-snapshot main 19" \
      > /etc/apt/sources.list.d/pgdg.list \
 && apt-get update && apt-get install -y postgresql-19
USER postgres
CMD ["/usr/lib/postgresql/19/bin/postgres", "-D", "/var/lib/postgresql/19/main"]
DOCKERFILE

docker run -d --name pg19 -p 5433:5432 pg19-dev

第二步:跑完整的预检 SQL

把上面 13.2 的五段查询存成 pg19_precheck.sql,在生产只读副本上跑:

psql -X -f pg19_precheck.sql -d prod_replica > precheck_report.txt

任何一段返回非空,都是升级阻断项。

第三步:计划回归

# 导出 Top 200 查询
psql -X -A -t -d prod -c "
  SELECT query FROM pg_stat_statements
  WHERE calls > 1000 AND query NOT LIKE '%pg_stat%'
  ORDER BY total_exec_time DESC LIMIT 200
" > queries.sql

./plan_diff.sh queries.sql "host=prod-replica" "host=localhost port=5433"

重点看:

  • 有没有从 Index Scan 退化成 Seq Scan 的
  • 有没有 nestloop 与 hashjoin 互换的
  • Anti Join 改写导致的行数估算变化

第四步:分阶段打开新特性

不要在升级当天就把所有新特性打开。 我的顺序:

  1. 升级即生效的(无法关闭):破坏性变更、Planner 改动 → 必须在演练阶段吃透
  2. T+0 就开effective_wal_level(自动)、并行 autovacuum(保守值 2)
  3. T+1 周ON CONFLICT DO SELECT 替换热点 upsert 路径
  4. T+2 周:异步 I/O worker pool 调优
  5. T+1 月REPACK (CONCURRENTLY) 在非核心表上试跑
  6. 按需:SQL/PGQ、FOR PORTION OFpg_plan_advice(这三个属于新增能力,不急)
  7. 业务低谷期pg_enable_data_checksums

十五、十条踩坑清单

  1. SQL/PGQ 忘建边表双向索引。 图查询语法好看不代表计划好看,每一跳都是 JOIN,EXPLAIN 里看到边表 Seq Scan 就是灾难开始。
  2. GRAPH_TABLE 当图算法用。 它不做最短路径、不做 PageRank。深度 ≥ 4 的遍历中间结果会炸,先用 LIMIT 探路。
  3. ON CONFLICT DO SELECT 当查询用。 它会加行锁并持有到事务结束,在长事务里批量用会把并发写全堵住。只读场景老实用 SELECT
  4. FOR PORTION OF 的触发器语义没实测就上生产。 拆分产生的新行到底触发 INSERT 还是 UPDATE 触发器,会直接影响审计表和物化视图刷新逻辑。
  5. REPACK (CONCURRENTLY) 不设 lock_timeout 最终换文件要拿 ACCESS EXCLUSIVE,PG 锁队列 FIFO,一个长事务会让后续所有查询排在 REPACK 后面被堵死。必须 SET lock_timeout + 退避重试。
  6. REPACK 前没算磁盘。 峰值需要 原表 + 新表 + 全部索引副本 的空间。500GB 表带 4 索引可能要 1TB+ 空闲。
  7. autovacuum_max_parallel_workers 拍脑袋设很大。 每个 worker 只领一个索引,收益上限是"最大那个索引的耗时"。索引数 3 个的表设 8 个 worker 是纯浪费,还挤占并行查询资源。
  8. 忽略 jit 默认改 off。 你的报表查询可能一夜之间慢一倍,而 Release Notes 里这行字很不起眼。
  9. 忘了 BUFFERPINBUFFER 改名。 监控看板上等待事件曲线突然归零,运维以为系统变好了,其实是过滤条件失配。
  10. standard_conforming_strings 强制开启后没扫代码。 反斜杠转义的静默行为变化不会报错,只会让数据变成脏数据。这类 bug 通常要几周后才被发现。

十六、总结:这一版真正改变了什么

回到开头那个分歧。我的判断是:PG19 是一个"把已有能力兑现"的版本,而不是"开辟新战场"的版本。

看这些改动的共同点:

  • 异步 I/O:PG18 造好了,PG19 让它不用调参就能用
  • 时态约束:PG18 给了 WITHOUT OVERLAPS,PG19 给了能修改它的 DML
  • 表重组:pg_repack 干了十年,PG19 把它变成内核命令
  • 图查询:AGE 和一堆 recursive CTE 干了很久,PG19 给了标准语法
  • 计划稳定:pg_hint_plan 干了很久,PG19 给了带反馈闭环的官方方案
  • MultiXact:这个坑埋了二十年,PG19 把它填了

这是一个成熟系统的典型演进模式:不再追求"我们有了 XX 功能",而是追求"XX 功能终于不需要专家才能用了"。

从工程决策角度,我给三条建议:

第一,如果你在 PG16 或更早,PG19 值得直接跳过去。 你会一次性拿到 PG17 的 vacuum 内存优化、PG18 的异步 I/O + 时态约束、PG19 的全部内容。跨大版本升级的痛苦是一次性的,分三次升不会更轻松。

第二,如果你已经在 PG18,升级的核心理由是运维成本。 ON CONFLICT DO SELECT 消灭的膨胀、并行 autovacuum 省下的维护窗口、REPACK CONCURRENTLY 替掉的 pg_repack、64 位 MultiXactOffset 消灭的事故类型——这些加起来能明显降低 DBA 的心智负担。新语法反而是次要的。

第三,破坏性变更这次真的多,别裸奔。 RADIUS 移除、standard_conforming_strings 强制、btree_gist inet/cidr 退役、JIT 默认关,这四条里任何一条踩到都是生产事故。把第十三节那套预检 SQL 跑一遍,成本是半小时,收益是不用在凌晨三点回滚。

至于 SQL/PGQ——我认为它的真正意义不在于"PG 现在能当图数据库用了",而在于它降低了一类架构决策的门槛。以前团队里有人提"我们的关系查询有点复杂,要不要上个图库",这个讨论要牵扯选型、运维、双写一致性、数据同步链路。现在答案可以是"先在 PG 里用 CREATE PROPERTY GRAPH 试试,一行数据都不用搬"。

能把一个架构决策推迟到有真实数据支撑的时候再做,这本身就是巨大的价值。 这也是我判断一个数据库版本是否成熟的标准:不是它增加了多少能力,而是它减少了多少你必须提前做对的决定。

推荐文章

Go语言中实现RSA加密与解密
2024-11-18 01:49:30 +0800 CST
使用 node-ssh 实现自动化部署
2024-11-18 20:06:21 +0800 CST
程序员茄子在线接单