编程 PostgreSQL 19 深度拆解:JIT 被默认关掉、REPACK 收编 pg_repack、SQL/PGQ 图查询落地——一个 30 年老数据库的「自我修正」

2026-08-01 00:50:01 +0800 CST views 12

PostgreSQL 19 深度拆解:JIT 被默认关掉、REPACK 收编 pg_repack、SQL/PGQ 图查询落地——一个 30 年老数据库的「自我修正」

2026 年 6 月 4 日 PostgreSQL 19 Beta 1 发布,7 月 16 日 Beta 2 跟上,正式版预计落在 2026 年 9—10 月。

这个版本最值得玩味的地方不是它加了什么,而是它关掉了什么:JIT 默认关闭、RADIUS 认证直接删除、TOAST 默认压缩算法从用了十几年的 pglz 换成 lz4。一个 30 年历史的数据库,在一个大版本里连续推翻自己三个默认值,这事本身就值得写一篇长文。

如果你只想要一句话总结:PG18 打地基(异步 I/O 子系统),PG19 收利息。18 把 AIO 的骨架搭起来了,19 把它变得自适应、可观测;18 加了时态约束,19 补上时态 DML;18 之后大家还在用第三方 pg_repack,19 直接把 REPACK 做进内核。

下面我按「默认值变更 → 性能内核 → 运维能力 → 开发者语法 → 复制与联邦 → 升级实战」的顺序拆。每一块我都会给能跑的 SQL,以及我认为值得警惕的坑。


一、三个被推翻的默认值:数据库的「认错」时刻

大版本升级里,新增功能你可以不用,但默认值变更是会追着你跑的。PG19 改了三个,每一个都有故事。

1.1 JIT 默认关闭:一个「理论正确、实践翻车」的功能

PostgreSQL 11 引入基于 LLVM 的 JIT 编译,思路无可指摘:对于扫描上千万行的分析型查询,把表达式求值和元组解构编译成机器码,能省掉大量解释执行开销。PG11 之后 jit = on 成了默认值。

问题出在代价模型上。JIT 的触发条件是查询的估算代价超过 jit_above_cost(默认 100000),而不是查询的实际行数。这两者在现实里经常脱节:

-- 典型翻车场景:估算代价很高,实际返回 3 行
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.tenant_id = 42          -- 统计信息认为 tenant 分布均匀
  AND o.created_at > now() - interval '1 hour';

如果 tenant_id 分布严重倾斜,或者多列相关性没有被 CREATE STATISTICS 捕捉到,规划器会给出一个很大的估算代价,JIT 被触发,然后:

  • LLVM 编译 5 个表达式,耗时 80—300ms
  • 查询实际扫了 3 行,执行耗时 0.4ms
  • 净结果:这条 SQL 慢了两个数量级

更糟的是 OLTP 场景下的放大效应。同一条 prepared statement 如果走了 custom plan,每次执行都可能重新 JIT(inlining 和 optimization 阶段尤其贵)。我见过的最离谱的一次线上事故,是一个 QPS 200 的接口在升级到 PG12 后 p99 从 8ms 涨到 600ms,火焰图上 LLVMOrcLLJITAddLLVMIRModule 占了 70%。

PG19 的处理很干脆:jit 默认改为 off

这不是说 JIT 没用,而是承认「默认开启」这个决定错了。真正受益于 JIT 的是长时间运行的 OLAP 查询,这类负载的用户完全有能力自己打开:

-- 数仓库/报表库全局打开
ALTER DATABASE analytics SET jit = on;

-- 或者只给特定角色
ALTER ROLE report_runner SET jit = on;

-- 或者最精细:单条查询
BEGIN;
SET LOCAL jit = on;
SET LOCAL jit_above_cost = 500000;      -- 抬高门槛,避免误伤
SELECT ... ;                            -- 你的重型聚合
COMMIT;

升级检查清单

-- 1. 升级前,找出真正从 JIT 获益的查询
SELECT queryid, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE mean_exec_time > 1000          -- 平均 1 秒以上
ORDER BY total_exec_time DESC
LIMIT 50;

-- 2. 对候选查询逐个验证:jit on/off 的实际差异
EXPLAIN (ANALYZE, BUFFERS, TIMING)  -- 注意看 JIT 段落的 Timing
SELECT ...;

EXPLAIN ANALYZE 输出里的 JIT 段落长这样,重点看 Generation/Inlining/Optimization/Emission 四项之和与 Execution Time 的比值:

 JIT:
   Functions: 12
   Options: Inlining true, Optimization true, Expressions true, Deforming true
   Timing: Generation 2.1 ms, Inlining 18.4 ms, Optimization 121.7 ms, Emission 74.3 ms, Total 216.5 ms
 Execution Time: 243.8 ms

上面这个例子里,216.5 / 243.8 ≈ 89% 的时间花在编译上,这就是典型的应该关掉 JIT 的查询。

1.2 default_toast_compression 改为 lz4:迟到三年的默认值

PostgreSQL 14 就已经支持 lz4 作为 TOAST 压缩算法,但默认值一直是 pglz——一个 PostgreSQL 自己实现的、诞生于上世纪的 LZ 变体。PG19 把默认值改成了 lz4

为什么 pglz 该退休,看它的设计参数就明白了:

维度pglzlz4
滑动窗口4 KB64 KB
压缩速度基准数倍量级更快
解压速度基准显著更快,接近内存带宽
早停机制有(压不动就放弃)
实现来源PG 自研业界标准库

4 KB 的滑动窗口是关键瓶颈。TOAST 的触发阈值是元组超过约 2KB,而真正需要 TOAST 的往往是几十 KB 到几 MB 的 JSONB、长文本、数组。这类数据的重复模式(比如 JSONB 里反复出现的 key 名)经常跨越远大于 4KB 的距离,pglz 的窗口根本够不着。

对 JSONB 重度用户,这个改动的收益尤其明显——读路径的解压开销直接砍掉一大块,而 JSONB 的解压是发生在每一次字段访问上的。

注意:这是「新写入」的默认值,不是自动重写。

-- 查看当前设置
SHOW default_toast_compression;

-- 检查现有列用的是哪种压缩(关键:升级后老数据还是 pglz)
SELECT
    c.relname,
    a.attname,
    CASE a.attcompression
        WHEN 'p' THEN 'pglz'
        WHEN 'l' THEN 'lz4'
        WHEN ''  THEN 'default'
    END AS compression,
    pg_size_pretty(pg_relation_size(t.oid)) AS toast_size
FROM pg_attribute a
JOIN pg_class c   ON c.oid = a.attrelid
LEFT JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE a.attnum > 0
  AND NOT a.attisdropped
  AND a.attstorage IN ('x', 'e')      -- extended / external
  AND c.relnamespace = 'public'::regnamespace
ORDER BY pg_relation_size(t.oid) DESC NULLS LAST;

想让存量数据也吃到红利,需要显式重写。这里正好可以用 PG19 的新武器 REPACK CONCURRENTLY(见第三章):

-- 改列级压缩算法(只影响后续写入)
ALTER TABLE events ALTER COLUMN payload SET COMPRESSION lz4;

-- 重写存量数据(PG19 之前得靠 VACUUM FULL 或 pg_repack)
REPACK CONCURRENTLY events;

一个容易被忽略的坑:如果你的编译环境没有 --with-lz4default_toast_compression = lz4 会启动失败。自建编译、或者用某些精简版容器镜像的同学,升级前先确认:

SELECT name, setting FROM pg_settings WHERE name = 'default_toast_compression';
-- 或者直接试
SET default_toast_compression = 'lz4';   -- 报错说明没编进去

1.3 RADIUS 认证被移除

这个没什么可辩护的。PostgreSQL 的 RADIUS 实现长期存在几个硬伤:协议本身用 MD5 做报文认证、密码长度被限制在 16 字节、不支持 RADIUS 的现代扩展。它的存在更像是一个「看起来支持企业认证」的复选框,而不是一个真能扛生产的方案。

如果你在用,迁移路径

# pg_hba.conf 旧写法(PG19 会直接拒绝启动)
# host all all 0.0.0.0/0 radius radiusserver=... radiussecret=...

# 推荐替代 1:LDAP(如果你的 RADIUS 后面本来就是 AD/LDAP)
host all all 0.0.0.0/0 ldap ldapserver=ldap.internal ldapbasedn="dc=corp,dc=com"

# 推荐替代 2:证书 + SCRAM(更现代)
hostssl all all 0.0.0.0/0 scram-sha-256 clientcert=verify-full

# 推荐替代 3:GSSAPI / Kerberos(大型企业环境)
host all all 0.0.0.0/0 gss include_realm=0 krb_realm=CORP.COM

顺带说一句 md5:PG19 在 md5 认证成功后会给客户端发一条警告,由新 GUC md5_password_warnings 控制。这是明确的「倒计时信号」——md5 认证的移除只是时间问题。现在就该盘一遍:

-- 找出还在用 md5 存密码的角色
SELECT rolname,
       CASE WHEN rolpassword LIKE 'md5%' THEN 'md5'
            WHEN rolpassword LIKE 'SCRAM-SHA-256%' THEN 'scram'
            ELSE 'other' END AS pw_type
FROM pg_authid
WHERE rolcanlogin
  AND rolpassword IS NOT NULL;

-- 迁移:改 password_encryption 后让用户重设密码
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();
-- 然后 ALTER ROLE xxx PASSWORD 'yyy'; 会自动用 scram 存

注意:password_encryption 不会自动转换存量密码,因为服务端没有明文。必须让每个用户重新设置一次。这是个跨团队协调的活儿,早点开始。


二、性能内核:AIO 自适应、eager aggregation 与 FK 插入翻倍

2.1 异步 I/O 从「固定 worker」到「自适应伸缩」

PG18 引入了 AIO 子系统,提供三种 io_method

  • sync:老行为,同步 I/O
  • worker:用一组后台 I/O worker 进程发起读请求(跨平台可用)
  • io_uring:Linux 原生异步接口,性能最好但依赖内核 5.1+

PG18 的 worker 模式有个明显的工程缺陷:io_workers 是一个固定值。设小了,高并发顺序扫描时 I/O worker 成为瓶颈;设大了,低负载时白白占着进程槽位和内存,还会在 max_worker_processes 上和并行查询抢资源。

PG19 把它改成了自适应:

# postgresql.conf
io_method = worker
io_min_workers = 1        # 空闲时收缩到这个数
io_max_workers = 8        # 压力大时扩张到这个数

调参思路(针对 worker 模式):

  • io_max_workers 的上限参考存储的队列深度。NVMe 本地盘队列深度很深,可以给到 8—16;云盘(EBS gp3、阿里云 ESSD)受限于 IOPS 配额和网络 RTT,通常 4—8 就够,再高只是徒增上下文切换。
  • io_min_workers = 1 基本可以无脑用。收缩到 1 之后,突发负载的扩张延迟在毫秒级,代价可以忽略。
  • 如果你在 Linux 5.1+ 且能接受 io_uring,优先用 io_method = io_uring,它不走 worker 进程池,这两个参数自然就不相关了。但注意很多容器运行时(尤其是加了 seccomp 默认策略的)会屏蔽 io_uring 系统调用,这时候会退化或报错。

PG19 还让 AIO 变得可观测了——EXPLAIN ANALYZE 新增 IO 选项:

EXPLAIN (ANALYZE, BUFFERS, IO)
SELECT count(*) FROM large_fact_table WHERE event_date > '2026-01-01';

这一条在 PG18 时代是缺失的关键拼图:以前你只能通过 pg_stat_io 看到实例级别的聚合数据,没法定位到「就是这条查询在做同步等待」。现在可以直接在计划树上看到每个节点的 AIO 行为。

配套的排查 SQL:

-- 实例级 AIO 视角(PG18 引入,PG19 继续可用)
SELECT backend_type, object, context,
       reads, read_bytes, read_time,
       writes, write_bytes, write_time,
       evictions, hits
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY read_time + write_time DESC;

2.2 eager aggregation:把聚合推到 JOIN 下面

这是 PG19 规划器里我最喜欢的一个改动,新 GUC 是 enable_eager_aggregate

问题:传统计划里,聚合永远在 JOIN 之上。如果 JOIN 会显著放大行数,聚合就得处理一个巨大的中间结果集。

-- 经典场景:一个订单有多个 item,先 JOIN 再聚合
SELECT o.customer_id, sum(i.amount)
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.customer_id;

假设 100 万订单、每单平均 8 个 item,传统计划的数据流是:

HashAggregate                     <- 处理 800 万行
  -> Hash Join                    <- 产出 800 万行
       -> Seq Scan on order_items <- 800 万行
       -> Hash
            -> Seq Scan on orders <- 100 万行

eager aggregation 的思路:先在 order_items 侧按 order_id 做一次部分聚合,把 800 万行压成 100 万行,再去 JOIN,最后做一次 final aggregate 合并:

Finalize HashAggregate
  -> Hash Join                          <- 只处理 100 万行
       -> Partial HashAggregate         <- 800 万 -> 100 万
            -> Seq Scan on order_items
       -> Hash
            -> Seq Scan on orders

这个变换的正确性前提很严格:聚合函数必须可分解(sumcountminmaxavg 可以;array_aggstring_agg 这类有序敏感的不行),并且 JOIN 不能改变分组的语义(比如内连接过滤掉的行不能影响已算出的部分聚合值)。规划器会自己判断,判断不了就不用这个变换。

实战建议:

-- 对比开关效果
SET enable_eager_aggregate = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

SET enable_eager_aggregate = on;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

什么时候会失效甚至变慢:如果 JOIN 键的基数很低(partial aggregate 压不下去多少行),或者中间结果本来就很小,多做一轮 hash 聚合反而是净开销。这也是它做成 GUC 而不是无条件启用的原因。

2.3 外键检查场景下插入性能最高 2 倍

官方给的说法是「inserts with foreign key checks 最高 2x」。这个提升的来源值得说清楚,因为它决定了你能不能吃到。

外键检查的本质是:每插入一行子表,就要去父表确认引用的键存在。实现上走的是 RI_FKey_check 触发器,内部执行一个类似 SELECT 1 FROM parent WHERE pk = $1 FOR KEY SHARE 的查询。在批量插入场景下,这个 per-row 的开销会累积得很夸张——一次 COPY 10 万行,就是 10 万次索引查找 + 10 万次快照/锁操作。

优化方向是减少这条内部查询的重复开销(计划缓存、快照复用、锁获取路径)。所以:

  • 吃得到红利的:宽表批量导入、ETL 落库、有多个外键的事实表插入
  • 吃不到的:单行插入为主的 OLTP(本来外键检查占比就低)

一个可复现的验证脚本:

-- 建表
CREATE TABLE dim_customer (id bigint PRIMARY KEY, name text);
CREATE TABLE dim_product  (id bigint PRIMARY KEY, name text);
CREATE TABLE fact_sales (
    id          bigserial PRIMARY KEY,
    customer_id bigint REFERENCES dim_customer(id),
    product_id  bigint REFERENCES dim_product(id),
    amount      numeric(12,2),
    sold_at     timestamptz
);

INSERT INTO dim_customer SELECT g, 'c'||g FROM generate_series(1, 100000) g;
INSERT INTO dim_product  SELECT g, 'p'||g FROM generate_series(1, 10000)  g;

-- 压测:100 万行带双外键的插入
\timing on
INSERT INTO fact_sales (customer_id, product_id, amount, sold_at)
SELECT (random()*99999)::bigint + 1,
       (random()*9999)::bigint + 1,
       (random()*1000)::numeric(12,2),
       now() - (random()*365)::int * interval '1 day'
FROM generate_series(1, 1000000);

在 PG18 和 PG19 上各跑一遍对比。记得 warm cache(父表索引要在 shared_buffers 里),否则你测的是磁盘 I/O 而不是外键检查路径。

2.4 其他规划器/执行器改进

这几项官方只给了一句话,但每一条都对应着真实场景:

Anti-join 优化NOT EXISTS / NOT IN 这类反连接的执行路径得到改进。这类查询在「找出没有下单的用户」「对账差异」场景里极常见:

SELECT u.id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

更广泛地使用增量排序(incremental sort)。当数据已经按 (a) 有序,你要 ORDER BY a, b 时,增量排序只需在每个 a 相同的小组内排 b,内存占用和延迟都远低于全量排序。PG13 引入,PG19 扩大了它的适用范围——这对ORDER BY ... LIMIT 的分页查询是实打实的收益。

并行顺序扫描的存储读取更快。多个并行 worker 扫同一张大表时的块分配策略优化,配合 AIO 效果更明显。

IS DISTINCT FROM / IS NOT DISTINCT FROM 化简。当规划器能证明两侧输入都不可能为 NULL(比如都是 NOT NULL 列),会把它们直接简化成 <>=

-- 假设 a.status 和 b.status 都是 NOT NULL
SELECT * FROM a JOIN b ON a.status IS NOT DISTINCT FROM b.status;
-- PG19 化简为 a.status = b.status

这个化简的意义远超「省几个 CPU 周期」——= 能用哈希连接和索引,IS NOT DISTINCT FROM 不能。所以这可能是从嵌套循环变成哈希连接的量级差异。很多 ORM(尤其是处理可空字段时)会无脑生成 IS NOT DISTINCT FROM,这个化简等于自动修了它们的锅。

LISTEN/NOTIFY 多通道场景的扩展性改进。老实现里所有通知共用一个全局队列和一把锁,通道一多就成了争用热点。用 PG 做轻量消息总线(比如配合 pg_notify 做缓存失效广播)的项目会直接受益。


三、REPACK:官方收编 pg_repack,膨胀治理终于进内核

3.1 为什么这是 PG19 最实用的运维特性

PostgreSQL 的 MVCC 会产生表膨胀,这是老生常谈。治理手段一直很尴尬:

手段阻塞额外空间问题
VACUUM不阻塞只回收到 freespace map,不还给操作系统
VACUUM FULLACCESS EXCLUSIVE 全程锁表1x 表大小生产环境基本不可用
CLUSTERACCESS EXCLUSIVE1x同上
pg_repack基本不阻塞1x + 日志表第三方扩展,需要单独安装、版本要匹配、有自己的 bug 历史

现实是几乎所有严肃的 PG 生产环境都装了 pg_repack,它变成了一个「事实标准但非官方」的关键组件。这种状态很别扭:一个数据库最基础的空间治理能力,居然要靠外部扩展。

PG19 把它做进了内核

-- 阻塞式(类似 VACUUM FULL/CLUSTER,但实现更现代)
REPACK my_table;

-- 非阻塞式:读写照常,这才是生产环境要用的
REPACK CONCURRENTLY my_table;

-- 按索引顺序物理重排(等价于 CLUSTER 的效果)
REPACK CONCURRENTLY my_table USING INDEX my_table_created_at_idx;

3.2 CONCURRENTLY 的代价与前置检查

CONCURRENTLY 不是魔法,它的基本套路是:建一个新的物理文件,把数据搬过去,期间用变更捕获机制记录并回放增量,最后在一个极短的窗口里拿排他锁做切换。

这意味着几个硬性约束,执行前必须检查

-- 1. 磁盘空间:至少需要 1 倍表 + 索引的空闲空间
SELECT
    pg_size_pretty(pg_total_relation_size('my_table')) AS need_at_least,
    pg_size_pretty(pg_relation_size('my_table'))       AS heap_only,
    pg_size_pretty(pg_indexes_size('my_table'))        AS indexes;

-- 2. 先确认这张表真的膨胀了,别做无用功
--    (需要 pgstattuple 扩展;注意 pgstattuple 会全表扫描,大表用 pgstattuple_approx)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple_approx('my_table');
-- 关注 approx_free_percent / dead_tuple_percent

-- 3. 检查有没有长事务会拖住切换窗口
SELECT pid, now() - xact_start AS xact_age, state,
       left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '1 minute'
ORDER BY xact_age DESC;

第 3 点是最容易翻车的地方。切换阶段需要拿 ACCESS EXCLUSIVE 锁,如果这时候有个跑了 20 分钟的分析查询占着 ACCESS SHAREREPACK 会排队等待——而它在等待时,后续所有对这张表的查询都会排在它后面,瞬间造成雪崩式阻塞。

标准防护姿势:

-- 用 lock_timeout 保护切换窗口,拿不到锁就放弃,而不是排队
SET lock_timeout = '3s';
REPACK CONCURRENTLY my_table;
-- 失败了就重试,配合退避

Beta 2 修了一个相关 bug:「REPACK worker 在 FATAL 退出时没有被清理」。这提醒我们——REPACK CONCURRENTLY 是新代码,即使正式版发布,我也建议前几个 minor 版本先在非核心表上跑,核心大表继续用久经考验的 pg_repack,观察 2—3 个月再切换。新功能的可靠性是靠时间赚出来的,不是靠 release note。

3.3 一个可用的自动化 REPACK 脚本

#!/usr/bin/env bash
# repack_bloated.sh —— 找出膨胀超过阈值的表并在维护窗口内重组
set -euo pipefail

PGURL="${PGURL:?need PGURL}"
BLOAT_PCT="${BLOAT_PCT:-30}"        # 空闲空间占比阈值
MIN_SIZE_MB="${MIN_SIZE_MB:-1024}"  # 小于 1GB 的表不折腾
MAX_TABLES="${MAX_TABLES:-3}"       # 单次窗口最多处理几张

mapfile -t TABLES < <(psql "$PGURL" -At -F'|' <<SQL
SELECT c.oid::regclass::text
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog','information_schema')
  AND pg_relation_size(c.oid) > ${MIN_SIZE_MB} * 1024 * 1024
ORDER BY pg_relation_size(c.oid) DESC
LIMIT 50;
SQL
)

count=0
for t in "${TABLES[@]}"; do
  [ "$count" -ge "$MAX_TABLES" ] && break

  free_pct=$(psql "$PGURL" -At -c \
    "SELECT round(approx_free_percent) FROM pgstattuple_approx('${t}')")

  if [ "${free_pct:-0}" -lt "$BLOAT_PCT" ]; then
    echo "skip ${t}: free ${free_pct}% < ${BLOAT_PCT}%"
    continue
  fi

  echo "repacking ${t} (free ${free_pct}%)..."
  if psql "$PGURL" -v ON_ERROR_STOP=1 <<SQL
SET lock_timeout = '5s';
SET statement_timeout = '4h';
REPACK CONCURRENTLY ${t};
SQL
  then
    echo "ok: ${t}"
    count=$((count+1))
  else
    echo "FAILED: ${t} (will retry next window)" >&2
  fi
done

四、Autovacuum 的三处进化

Autovacuum 是 PostgreSQL 最容易「配错了也不知道」的子系统。PG19 在这块做了三个改动,含金量都很高。

4.1 并行 autovacuum

autovacuum_max_parallel_workers = 4

在此之前,手动 VACUUM (PARALLEL n) 是支持并行索引清理的,但 autovacuum 不行——每个 autovacuum worker 只能单线程处理一张表。对于一张有 10 个索引、几亿行的大表,vacuum 的索引清理阶段可能跑好几个小时,期间 dead tuple 持续堆积,形成「越 vacuum 越落后」的恶性循环。

PG19 让 autovacuum 也能拉起并行 worker 处理索引。参数关系要理清楚:

-- 三层预算,取最小值
SHOW max_worker_processes;              -- 集群总的后台进程池
SHOW max_parallel_workers;              -- 其中可用于并行的
SHOW autovacuum_max_parallel_workers;   -- 其中可用于 autovacuum 的
SHOW autovacuum_max_workers;            -- autovacuum 的表级并发数

调参陷阱autovacuum_max_parallel_workers 和并行查询共享 max_parallel_workers 这个池子。如果你给 autovacuum 分了 8 个,白天业务高峰时并行查询可能就抢不到 worker 了。稳妥做法:

-- 白天保守
ALTER SYSTEM SET autovacuum_max_parallel_workers = 2;
-- 夜间维护窗口调高(用 cron + pg_reload_conf)

4.2 Autovacuum 评分调度

老的 autovacuum 调度逻辑是「扫一遍表,谁超过阈值就 vacuum 谁」,没有优先级概念。结果是一张刚刚超过阈值的小表,可能排在一张已经严重膨胀、XID 年龄逼近 wraparound 的大表前面。

PG19 引入了评分系统来给表排优先级。这对以下场景是刚需:

  • 表数量很多(多租户、每租户一套表)
  • 表大小分布极不均匀
  • 有接近 autovacuum_freeze_max_age 的表

Beta 2 修了一个相关 bug:「autovacuum 的 multixact-age 评分计算可能变成无穷大」。这种 bug 的后果是调度完全错乱——一张表的分数如果是 inf,它会永远排第一,饿死其他所有表。Beta 期发现这类问题正是 beta 的价值。

配套监控:

-- 找出 XID 年龄危险的表
SELECT c.oid::regclass AS tbl,
       age(c.relfrozenxid) AS xid_age,
       mxid_age(c.relminmxid) AS mxid_age,
       pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
       current_setting('autovacuum_freeze_max_age')::bigint AS freeze_max
FROM pg_class c
WHERE c.relkind IN ('r','m','t')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;

4.3 查询时顺手标记可见页

这个改动很巧妙:在页面被查询访问时,如果发现它已经全部可见,就顺手在 visibility map 里标记上,从而减少未来 vacuum 的工作量。

理解它的价值需要知道 visibility map(VM)的两个作用:

  1. vacuum 可以跳过 all-visible 的页——不用重扫
  2. index-only scan 依赖 VM——只有页在 VM 里标记为 all-visible,才能不回表

传统上,只有 vacuum 才会设置 VM 位。这就产生了一个「冷启动」问题:一张刚批量导入完的表,在第一次 vacuum 跑完之前,index-only scan 全都会退化成回表

PG19 让读路径也能设置 VM 位,等于把这个成本分摊到了正常查询里。对只追加写、大量走 index-only scan 的表(日志表、事件表、时序数据)收益最直接。

-- 验证 VM 覆盖率
CREATE EXTENSION IF NOT EXISTS pg_visibility;
SELECT
    relname,
    (pg_visibility_map_summary(c.oid)).*
FROM pg_class c
WHERE relname = 'events';
-- all_visible 越接近总页数越好

-- 验证 index-only scan 是否真的不回表
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE created_at > now() - interval '1 day';
-- 看 "Heap Fetches: 0" —— 不为 0 说明 VM 没覆盖到

五、pg_plan_advice:PostgreSQL 终于有了「计划提示」

5.1 一场持续十几年的争论

「PostgreSQL 要不要支持 query hint」是社区最持久的争论之一。反对方的理由一直很硬:

Hint 是给规划器打补丁,掩盖真正的问题(统计信息不准、代价模型有缺陷)。一旦用户开始写 hint,他们就不会再报告规划器的 bug,规划器就永远不会变好。而且 hint 会随着数据分布变化而过期,变成定时炸弹。

这个论证在学术上很站得住。但在工程现实里,它忽略了「可预测性」的价值

  • 一个电商大促前夜,你需要的不是「平均最优的计划」,而是「不会突然变糟的计划」
  • 一次 ANALYZE 之后计划翻转,p99 从 20ms 涨到 3 秒——这时候「去修统计信息」是个几小时的活儿,而业务只能等
  • 从 Oracle/SQL Server 迁移过来的团队,手上有一堆依赖 hint 的 SQL

十几年里,社区的妥协产物是 pg_hint_plan(日本 NTT 维护的第三方扩展)——功能可用,但不是官方的,版本跟进有滞后,很多托管数据库不提供。

PG19 给出了官方答案:pg_plan_advice 扩展,以及配套的 pg_stash_advice

5.2 「advice」而不是「hint」:一个重要的措辞

官方描述用的词是「stabilize and control planner decisions」——稳定并控制规划器决策。措辞上刻意避开了 "hint",我觉得这不只是文字游戏,它反映了设计取向:

  • 传统 hint 是侵入式的:把 /*+ IndexScan(t idx) */ 写进 SQL 文本里,代码和优化策略耦合
  • pg_plan_advice 走的是外挂式路线:pg_stash_advice 通过 query identifier(也就是 pg_stat_statements 里那个 queryid)来自动匹配和应用建议

这个差别很关键。它意味着:

  1. 不用改应用代码。SQL 在 ORM 里生成的、在存储过程里的、在第三方组件里的,全都能治
  2. 可以集中管理。所有 advice 存在一个地方,能审计、能批量清理、能版本化
  3. pg_stat_statements 天然打通。发现慢查询 → 拿 queryid → 挂 advice,形成闭环

典型工作流大致是这样:

CREATE EXTENSION pg_plan_advice;
CREATE EXTENSION pg_stash_advice;
CREATE EXTENSION pg_stat_statements;   -- 提供 queryid

-- 1. 定位问题查询
SELECT queryid, calls, mean_exec_time, stddev_exec_time,
       left(query, 100) AS q
FROM pg_stat_statements
ORDER BY stddev_exec_time DESC       -- 按抖动排序,找计划不稳定的
LIMIT 20;

-- 2. 对目标查询采集/生成 advice,绑定到 queryid
-- 3. 后续执行自动套用

具体的函数签名和 advice 语法在 beta 期间仍可能调整,正式版发布时请以 pgplanadvice.htmlpgstashadvice.html 文档为准。这也是我建议不要在 beta 期就把 advice 写进部署脚本的原因。

5.3 我的使用建议:把它当止血带,不当拐杖

作为一个踩过 hint 坑的人,我的态度是:pg_plan_advice 是止血带,不是长期方案。

推荐的使用纪律:

-- 每一条 advice 都应该有配套记录(自己建张表管起来)
CREATE TABLE plan_advice_registry (
    queryid       bigint PRIMARY KEY,
    reason        text NOT NULL,      -- 为什么加:统计信息倾斜?代价模型缺陷?
    added_by      text NOT NULL,
    added_at      timestamptz NOT NULL DEFAULT now(),
    review_before date NOT NULL,      -- 强制过期复查日期
    root_cause_ticket text            -- 关联的根因修复工单
);

三条红线

  1. 每条 advice 必须写明根因和复查日期。没有过期时间的 hint 就是技术债黑洞。
  2. 加 advice 的同时必须开一个根因修复工单。绝大多数计划问题的根因是统计信息——多列相关性没建 CREATE STATISTICSdefault_statistics_target 太低、或者表根本没被 analyze 过。
  3. 升级大版本前,清空所有 advice 重新评估。规划器改了,老 advice 可能从「救命」变成「拖后腿」。

先试试这些,再考虑 advice:

-- 多列相关性(最常见的根因)
CREATE STATISTICS s_orders (dependencies, ndistinct, mcv)
  ON tenant_id, status, created_at FROM orders;
ANALYZE orders;

-- 提高单列统计精度(针对高基数或严重倾斜的列)
ALTER TABLE orders ALTER COLUMN tenant_id SET STATISTICS 1000;
ANALYZE orders;

六、开发者体验:一批「早该有了」的语法

6.1 SQL/PGQ:属性图查询进内核

PG19 实现了 SQL:2023 标准第 16 部分(SQL/PGQ, Property Graph Queries)。核心能力是:把已有的关系表暴露成属性图,然后用模式匹配语法查询

-- 在现有表上定义图,不需要迁移数据
CREATE PROPERTY GRAPH social_graph
  VERTEX TABLES (
    users LABEL person PROPERTIES (id, name, city)
  )
  EDGE TABLES (
    follows
      SOURCE KEY (follower_id) REFERENCES users (id)
      DESTINATION KEY (followed_id) REFERENCES users (id)
      LABEL follows
  );

-- 查询:找「朋友的朋友」
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 (a.name AS src, b.name AS mid, c.name AS dst)
);

这里必须泼一盆冷水:不要指望它取代 Neo4j。

关键在于它是语法层的能力,不是存储层的重构。图数据库真正的性能优势来自「免索引邻接」(index-free adjacency)——节点直接持有邻居的物理指针,遍历一跳是 O(1) 的指针跳转。而 SQL/PGQ 底层还是要走 B-tree 索引查找,遍历一跳是 O(log n)。

所以正确的期待是:

场景适合 SQL/PGQ适合专用图库
1—3 跳的关系查询也行,但没必要
权限继承、组织架构树过度设计
数据本来就在 PG 里,只是偶尔要图查询✅ 强烈推荐引入同步链路,得不偿失
6 度以上深度遍历❌ 会爆炸
图算法(PageRank、社区发现)❌ 不支持
亿级边的实时路径查询

真正的价值在于「消除同步链路」。以前一个「用户关注关系」的功能,你要么在 PG 里写痛苦的递归 CTE,要么把数据同步一份到 Neo4j——后者意味着一条 CDC 链路、一致性问题、双份运维。SQL/PGQ 让 80% 的浅层图需求可以留在 PG 里解决。

跟递归 CTE 的对比也很直观:

-- 老办法:递归 CTE,可读性堪忧
WITH RECURSIVE fof AS (
  SELECT followed_id AS id, 1 AS depth
  FROM follows WHERE follower_id = (SELECT id FROM users WHERE name='Alice')
  UNION
  SELECT f.followed_id, fof.depth + 1
  FROM follows f JOIN fof ON f.follower_id = fof.id
  WHERE fof.depth < 2
)
SELECT DISTINCT u.name FROM fof JOIN users u ON u.id = fof.id;

同样的语义,SQL/PGQ 版本短了一半,意图也清晰得多。

Beta 2 里有「several fixes for the new SQL/PGQ property graph feature」——这是全新的大特性,正式版初期建议只在非关键路径上用。

6.2 FOR PORTION OF:时态表 DML 补完

PG18 加了时态约束(PERIOD、带时间范围的主键/外键),但只解决了「怎么存」,没解决「怎么改」。PG19 补上了 UPDATE/DELETEFOR PORTION OF

这个功能解决什么问题:假设你存商品价格的历史区间:

CREATE TABLE product_price (
    product_id int,
    valid_period daterange,
    price numeric(10,2),
    PRIMARY KEY (product_id, valid_period WITHOUT OVERLAPS)
);

INSERT INTO product_price VALUES
  (1, daterange('2026-01-01', '2027-01-01'), 99.00);

现在业务要求:2026 年 6 月这一个月搞活动,价格改成 79。手写 SQL 的话,你得:

  1. 把原区间 [2026-01-01, 2027-01-01) 删掉
  2. 插入 [2026-01-01, 2026-06-01) 价格 99
  3. 插入 [2026-06-01, 2026-07-01) 价格 79
  4. 插入 [2026-07-01, 2027-01-01) 价格 99

四步操作,还得包在事务里,还得处理各种边界情况(区间不重叠怎么办?跨越多个已有区间怎么办?)。这是典型的又繁琐又容易写错的逻辑,几乎每个做过 SCD Type 2(缓慢变化维)的人都手写过一遍。

FOR PORTION OF 把这四步压成一句:

UPDATE product_price
  FOR PORTION OF valid_period FROM '2026-06-01' TO '2026-07-01'
  SET price = 79.00
  WHERE product_id = 1;

-- 数据库自动完成区间裁剪和分裂:
--   [2026-01-01, 2026-06-01) -> 99.00
--   [2026-06-01, 2026-07-01) -> 79.00   <- 新分裂出来的
--   [2026-07-01, 2027-01-01) -> 99.00

DELETE 同理:

-- 只删掉某个时间片段,两边自动保留
DELETE FROM product_price
  FOR PORTION OF valid_period FROM '2026-06-01' TO '2026-07-01'
  WHERE product_id = 1;

适用场景:价格历史、员工岗位变动、保险保单期间、租约、配置项的生效期、任何 SCD Type 2 维度表。这是那种「用过一次就回不去」的功能。

Beta 2 有「several fixes for the new FOR PORTION OF temporal table syntax」,同样属于新特性,观察期建议保留。

6.3 ALTER TABLE MERGE/SPLIT PARTITIONS

分区表在 PG 里一直有个尴尬:分区粒度定死了就很难改

场景:你按月分区存了三年数据,现在发现 2024 年的老数据查询很少,12 个月分区的元数据开销和规划时间不划算,想合并成 1 个年度分区。PG19 之前的办法是:建新分区 → INSERT INTO ... SELECT 搬数据 → detach 老分区 → attach 新分区 → drop 老分区。手写一堆 DDL,中间任何一步失败都要人工收拾。

-- PG19:合并
ALTER TABLE events
  MERGE PARTITIONS (events_2024_01, events_2024_02, events_2024_03)
  INTO events_2024_q1;

-- PG19:拆分(比如某个月突然数据量暴涨,要拆成周)
ALTER TABLE events
  SPLIT PARTITION events_2026_07 INTO (
    PARTITION events_2026_07_w1 FOR VALUES FROM ('2026-07-01') TO ('2026-07-08'),
    PARTITION events_2026_07_w2 FOR VALUES FROM ('2026-07-08') TO ('2026-07-15'),
    PARTITION events_2026_07_w3 FOR VALUES FROM ('2026-07-15') TO ('2026-07-22'),
    PARTITION events_2026_07_w4 FOR VALUES FROM ('2026-07-22') TO ('2026-08-01')
  );

注意:这两个操作会拿强锁并且要搬数据,不是「元数据级」的秒级操作。放维护窗口执行,并且提前算好磁盘空间。

6.4 ON CONFLICT DO SELECT:终结「插入或获取」的经典难题

这个功能小,但它解决的是一个每个后端工程师都遇到过的问题:get-or-create

需求很朴素:插入一个 tag,如果已存在就返回已有的那行。老办法的三种写法都有毛病:

-- 写法 1:DO NOTHING —— 冲突时 RETURNING 什么都不返回
INSERT INTO tags (name) VALUES ('postgres')
ON CONFLICT (name) DO NOTHING
RETURNING id;
-- 已存在时返回空集,应用层还得再发一条 SELECT(两次往返 + 竞态窗口)

-- 写法 2:DO UPDATE 假更新 —— 能拿到 id,但代价很脏
INSERT INTO tags (name) VALUES ('postgres')
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
-- 问题:产生一个新版本元组(dead tuple)、写 WAL、触发 UPDATE 触发器、
--       在高并发下形成行锁热点。为了读一个 id 付出写的代价。

-- 写法 3:CTE 套娃 —— 能work,但没人愿意维护
WITH ins AS (
  INSERT INTO tags (name) VALUES ('postgres')
  ON CONFLICT (name) DO NOTHING RETURNING id
)
SELECT id FROM ins
UNION ALL
SELECT id FROM tags WHERE name = 'postgres' AND NOT EXISTS (SELECT 1 FROM ins);

PG19 的写法

INSERT INTO tags (name) VALUES ('postgres')
ON CONFLICT (name) DO SELECT
RETURNING id, name;

一行搞定,没有假更新,没有 dead tuple,没有第二次往返。写法 2 那种「为了读而写」的模式在高并发下的杀伤力被严重低估了——同一个热门 tag 被大量并发请求命中时,DO UPDATE 会让所有请求在同一行上排队等行锁,而 DO SELECT 不会。

6.5 GROUP BY ALL

-- 老写法:SELECT 列表和 GROUP BY 必须手动保持同步
SELECT region, product_category, date_trunc('month', sold_at),
       sum(amount), count(*)
FROM sales
GROUP BY region, product_category, date_trunc('month', sold_at);

-- PG19
SELECT region, product_category, date_trunc('month', sold_at),
       sum(amount), count(*)
FROM sales
GROUP BY ALL;

GROUP BY ALL 自动把所有非聚合、非窗口的输出列加入分组。DuckDB 和 Snowflake 早就有了,写探索性分析 SQL 的人会很爱。

但请只在交互式分析里用。写进应用代码是危险的:将来有人往 SELECT 列表加一列,分组语义就悄悄变了,而且不会报错——只会给出不同的结果。这是「隐式行为」,隐式行为不适合长期维护的代码。

6.6 jsonpath 字符串函数

jsonpath 一直缺字符串处理能力,导致很多操作必须先把 JSON 解出来再用 SQL 函数处理。PG19 补了一批:lower()upper()initcap()replace()split_part(),以及 trim() 系列。

-- 在 jsonpath 内部做大小写不敏感匹配,不用先展开
SELECT * FROM docs
WHERE jsonb_path_exists(
  data,
  '$.tags[*] ? (lower(@) == "postgresql")'
);

-- 提取域名部分
SELECT jsonb_path_query(
  data,
  '$.contacts[*].email ? (@ like_regex "@") . split_part(@, "@", 2)'
) FROM docs;

好处是过滤可以下推到 jsonpath 引擎内部,避免把整个 JSON 文档物化出来再过滤——对大文档场景是实打实的性能差异。

6.7 WAIT FOR LSN:读写分离的「读己之写」

这个功能解决的是读写分离架构里最经典的一致性问题。

场景:用户提交表单 → 写主库 → 页面跳转 → 读从库 → 数据还没同步过来,用户看到旧数据

现有的解决办法都不理想:

  • 强制读主库:放弃了读写分离的意义,主库压力上不封顶
  • 应用层 sleep:玄学调参,睡短了不够,睡长了浪费延迟
  • 应用层轮询 pg_last_wal_replay_lsn():可行但要自己实现退避逻辑,每次轮询都是一次网络往返

PG19 的 WAIT FOR LSN

-- 主库:写入后拿到当前 LSN
BEGIN;
INSERT INTO orders (...) VALUES (...);
COMMIT;
SELECT pg_current_wal_insert_lsn();   -- 比如 0/1A2B3C4D

-- 从库:先等回放到这个位置,再查
WAIT FOR LSN '0/1A2B3C4D';
SELECT * FROM orders WHERE user_id = 42;

关键优势是等待发生在服务端——一次网络往返内完成,不需要客户端轮询。

工程化封装(伪代码):

class ReadYourWritesRouter:
    def __init__(self, primary, replicas):
        self.primary = primary
        self.replicas = replicas
        self.session_lsn = {}   # 实际应存在 session/cookie 里

    def write(self, session_id, sql, params):
        with self.primary.cursor() as cur:
            cur.execute(sql, params)
            cur.execute("SELECT pg_current_wal_insert_lsn()")
            self.session_lsn[session_id] = cur.fetchone()[0]

    def read(self, session_id, sql, params, timeout_ms=300):
        lsn = self.session_lsn.get(session_id)
        replica = self.pick_replica()
        with replica.cursor() as cur:
            if lsn:
                cur.execute(f"SET LOCAL statement_timeout = {timeout_ms}")
                try:
                    cur.execute("WAIT FOR LSN %s", (lsn,))
                except TimeoutError:
                    return self.read_from_primary(sql, params)  # 降级
            cur.execute(sql, params)
            return cur.fetchall()

务必配 statement_timeout 和降级路径。如果从库因为长事务冲突或网络问题卡住了回放,WAIT FOR LSN 会一直等——没有超时保护的话,你会把「读到旧数据」这个小问题,升级成「请求全部挂死」这个大问题。

6.8 DDL 提取函数与其他小改进

PG19 加了一批返回「重建对象所需 DDL」的 SQL 函数,覆盖 role、tablespace、database。以前这些只能靠 pg_dumpall --globals-only 然后自己解析文本,现在可以直接在 SQL 里拿:

-- 直接拿到重建某个角色的 DDL(函数名以正式版文档为准)
SELECT pg_get_roledef('app_readonly');

对做多环境同步、灾备演练、迁移脚本生成的场景很实用。

其他零碎:

  • random() 现在支持 date/timestamp 类型,造测试数据不用再写 now() - (random()*365)::int * interval '1 day' 这种拼接了
  • PL/Python 支持事件触发器
  • vacuumdb --analyze-only 现在默认会分析分区表(这是行为变更,如果你的脚本依赖旧行为,注意执行时间会变长)

七、逻辑复制:本版最被低估的一块

7.1 免重启开启逻辑复制

这是我认为 PG19 里对运维最友好的一个改动,但它藏在 release note 的中后段,很容易被略过。

老世界的痛点:要用逻辑复制,必须 wal_level = logical。而 wal_levelpostmaster 级参数,改它必须重启数据库

这就造成一个典型的两难:

  • 选项 A:一开始就设 logical。代价是所有集群、所有时候都在写更多的 WAL(额外的行标识信息),存储和 I/O 常年多付一份钱,哪怕你一年才用一次逻辑复制。
  • 选项 B:设 replica,需要时再改。代价是要用的那天必须重启主库。而你想用逻辑复制的场景,往往恰恰是「要做零停机迁移」——为了实现零停机,先来一次停机,荒谬。

PG19 的解法

-- wal_level 保持 replica
SHOW wal_level;              -- replica

-- 需要时创建逻辑复制槽,无需重启,WAL level 自动提升
SELECT pg_create_logical_replication_slot('migration_slot', 'pgoutput');

-- 查看当前实际生效的 WAL level
SHOW effective_wal_level;    -- logical

新增的只读 GUC effective_wal_level 用来报告当前实际生效的级别,和配置值区分开。

这个改动的实际意义:「按需付费」取代「常年预付」。绝大多数集群可以放心地跑在 wal_level = replica,只在做大版本升级、跨云迁移、临时数据同步时才承担逻辑解码的开销,用完删掉 slot 自动降回去。

⚠️ 老规矩:逻辑复制槽必须监控。一个被遗忘的、不再消费的 slot 会让 WAL 无限堆积,最终撑爆磁盘、数据库宕机。这是 PG 运维事故排行榜上的常客。

-- slot 监控(应该进你的告警)
SELECT slot_name, plugin, active,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
       ) AS wal_retained,
       wal_status,
       safe_wal_size
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;

Beta 2 里提到了 max_retention_duration(不允许负值)——这暗示 PG19 在 slot 保留策略上给了新的时间维度控制手段,可以给 slot 设一个最大保留时长而不只是 max_slot_wal_keep_size 的大小维度。这对「防止被遗忘的 slot 打爆磁盘」是个更符合直觉的保护。

7.2 序列值现在会被复制

这是逻辑复制多年来最大的一个坑,终于填了。

老行为:逻辑复制只复制表数据,不复制序列(sequence)的当前值。这意味着用逻辑复制做在线升级/迁移时,切换前你必须手动同步所有序列:

-- 老办法:切换前手动同步每一个序列(漏一个就出事)
SELECT format(
  'SELECT setval(%L, %s);',
  schemaname||'.'||sequencename,
  (SELECT last_value FROM pg_sequences s2
   WHERE s2.schemaname = s.schemaname AND s2.sequencename = s.sequencename)
)
FROM pg_sequences s WHERE schemaname = 'public';

漏掉一个序列的后果是灾难性的:新库的序列从 1 开始,而表里已经有几百万行——切换后第一批 INSERT 全部主键冲突,业务直接挂。这个坑我见过至少三个团队踩过。

PG19 让逻辑复制复制序列值,在线大版本升级的最后一块拼图补上了

7.3 CREATE PUBLICATION ... EXCEPT

-- 老写法:发布除了几张表之外的所有表 —— 只能枚举
CREATE PUBLICATION p FOR TABLE t1, t2, t3, ..., t497;

-- PG19
CREATE PUBLICATION p FOR ALL TABLES EXCEPT audit_log, temp_staging, huge_blob_table;

看起来只是语法糖,实际解决的是**「新建表默认行为」**的问题。老写法下,业务新加一张表,你得记得手动 ALTER PUBLICATION ... ADD TABLE——这种「需要人记住」的运维步骤 100% 会被遗忘。EXCEPT 语义下,新表自动纳入,需要排除的才显式列出,默认行为是安全的那一边

7.4 CREATE SUBSCRIPTION ... SERVER

-- 老写法:连接串(含密码)硬编码在订阅定义里
CREATE SUBSCRIPTION sub
  CONNECTION 'host=primary.internal port=5432 dbname=app user=repl password=SECRET'
  PUBLICATION p;
-- 密码明文存在 pg_subscription 里,改密码要 ALTER SUBSCRIPTION 重写连接串

-- PG19:用 foreign server + user mapping 管理凭据
CREATE SERVER primary_srv FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (host 'primary.internal', port '5432', dbname 'app');
CREATE USER MAPPING FOR CURRENT_USER SERVER primary_srv
  OPTIONS (user 'repl', password 'SECRET');

CREATE SUBSCRIPTION sub SERVER primary_srv PUBLICATION p;

凭据集中到 user mapping 管理,改密码不用动订阅本身。对有合规审计要求的团队是刚需。

7.5 postgres_fdw 的两个提速

数组操作下推。以前 WHERE id = ANY(ARRAY[...]) 这类条件无法下推到远端,导致把整表拉回本地再过滤。现在可以下推,网络传输量能差好几个数量级。

使用外部表统计信息。以前 postgres_fdw 对远端表的行数估算基本靠猜,导致本地规划器经常选错 JOIN 顺序和方法。现在能拉取并使用远端统计信息。

-- 拉取远端统计信息(Beta 2 修了这块的一个 bug)
ANALYZE foreign_table_name;

-- 验证下推效果:看 Remote SQL
EXPLAIN (VERBOSE, ANALYZE)
SELECT * FROM remote_orders WHERE id = ANY(ARRAY[1,2,3,...]);
-- 关注输出里的 "Remote SQL:" 行,条件出现在里面 = 下推成功

八、可观测性:把黑盒继续拆开

PG19 在监控上加了一堆东西,逐个说太啰嗦,我挑几个真正会改变排查方式的。

8.1 pg_stat_lock:按锁类型的统计

以前排查锁问题只有 pg_locks——它是瞬时快照,你看到的是"此刻谁持有什么锁"。但真实的锁问题往往是间歇性的:一天抖动三次,每次 200ms,你去查的时候早就过去了。

pg_stat_lock 提供按锁类型的累计统计,让你能回答「过去一小时里哪类锁的争用最严重」这种问题。

SELECT * FROM pg_stat_lock ORDER BY /* 争用相关列 */ DESC;

-- 配合传统的瞬时视图定位具体阻塞链
SELECT
    blocked.pid       AS blocked_pid,
    blocked.query     AS blocked_query,
    blocking.pid      AS blocking_pid,
    blocking.query    AS blocking_query,
    now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

8.2 pg_stat_recovery:备库回放的白盒

备库回放延迟一直是个半黑盒。以前只能算 pg_last_wal_receive_lsn()pg_last_wal_replay_lsn() 的差值,知道「落后多少字节」,但不知道为什么落后

  • 是接收慢(网络问题)?
  • 是回放慢(备库 I/O 打满)?
  • 是被查询冲突阻塞了(max_standby_streaming_delay)?

pg_stat_recovery 提供恢复操作的详细状态,让这三种情况可以区分开。对跑读写分离、依赖备库延迟做流量调度的架构,这是刚需——你需要知道延迟是暂时的还是会持续恶化的

8.3 其余

  • stats_reset 列铺开到大量统计视图。看起来微不足道,实际上解决了一个真实的坑:你看到 pg_stat_user_tables.seq_scan = 5,这个数字是「这张表一共只被顺序扫了 5 次」还是「统计信息 3 分钟前被重置过」?以前无从判断,写监控只能靠外部记录重置时间。
  • pg_stat_progress_vacuum / pg_stat_progress_analyze 新增 started_bypg_stat_progress_vacuum 还加了 mode 列。可以区分「这是 autovacuum 还是有人手动跑的」「是普通 vacuum 还是 aggressive/wraparound 模式」。半夜看到一个跑了两小时的 vacuum,能立刻知道它是不是防 wraparound 的紧急清理——这个信息决定了你该不该 kill 它。
  • log_min_messages 支持按进程类型配置。可以只给 autovacuum worker 开 DEBUG 而不淹没整个日志文件。排查 vacuum 问题时,这能省下大量 grep 时间。
  • VACUUM/ANALYZE 日志输出 WAL 全页写字节数。定位「哪个维护操作把 WAL 写爆了」——尤其是 checkpoint 后第一次 vacuum 会产生大量全页写,这个数字能帮你判断是否该调 checkpoint_timeout
  • data checksums 支持在线开关。以前开启数据校验和必须 initdb 时决定,或者停库跑 pg_checksums。现在可以在线切换。对于「当年建库时没开,现在想开」的历史包袱集群,这是个大解放。

8.4 SNI 支持:pg_hosts.conf

PG19 支持服务端 SNI(Server Name Indication),通过新的 pg_hosts.conf 文件,让一个 PostgreSQL 实例根据客户端请求的 hostname 返回不同的 TLS 证书。

# pg_hosts.conf(示意,正式语法以文档为准)
# hostname              certificate           key
tenant-a.db.example.com /certs/a.crt          /certs/a.key
tenant-b.db.example.com /certs/b.crt          /certs/b.key
*.internal.example.com  /certs/wildcard.crt   /certs/wildcard.key

谁需要这个:多租户 SaaS,每个租户要用自己域名连数据库;或者数据库同时对内网和公网暴露,需要用不同 CA 签发的证书。以前只能靠前面架 pgbouncer/HAProxy 做 TLS 终止,现在 PG 自己就能处理。

另外,password_expiration_warning_threshold 默认 7 天,会在密码过期前提前警告客户端。这个功能的价值是把「密码过期」从故障变成可预期事件——以前是某天凌晨批处理任务突然全部认证失败,现在提前一周就有信号。


九、升级实战:一份可执行的路线图

9.1 时间线判断

正式版预计 2026 年 9—10 月。我的建议节奏:

阶段时间动作
现在(Beta 2)2026 Q3搭测试环境,跑兼容性测试,不碰生产
19.0 发布2026 Q4继续观察,跟进 open items,测试环境长跑
19.1大约 19.0 + 2—3 个月非核心业务可以上
19.3+2027 上半年核心业务考虑

为什么不建议追 19.0REPACK CONCURRENTLY、SQL/PGQ、FOR PORTION OF、并行 autovacuum 都是全新代码路径,Beta 2 已经在这几块修了 bug。历史规律是,大特性的边缘 case 通常要到 x.2/x.3 才收敛。你的数据不是社区的 beta 测试样本。

9.2 兼容性检查清单

按风险从高到低:

-- ① RADIUS 认证(会导致启动失败,最高优先级)
--    检查 pg_hba.conf
SELECT * FROM pg_hba_file_rules WHERE auth_method = 'radius';

-- ② JIT 依赖(性能回退风险)
--    找出可能受影响的长查询,逐个验证
SELECT queryid, calls, mean_exec_time
FROM pg_stat_statements
WHERE mean_exec_time > 500
ORDER BY total_exec_time DESC LIMIT 100;

-- ③ md5 密码(警告,尚不阻断,但要开始迁移)
SELECT count(*) FROM pg_authid
WHERE rolcanlogin AND rolpassword LIKE 'md5%';

-- ④ lz4 编译支持(自建编译/精简镜像的重点)
SELECT name, setting, boot_val FROM pg_settings
WHERE name = 'default_toast_compression';

-- ⑤ 扩展兼容性(第三方扩展往往滞后一个大版本)
SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE installed_version IS NOT NULL;

扩展兼容性是升级的头号拦路虎,不是 PG 本身。重点确认:pg_repack(可以考虑换成内置 REPACK)、timescaledbpostgispg_cronpgvectorcituspg_stat_statements(内置,一般没问题)。这些扩展对新大版本的支持通常滞后 1—3 个月,升级窗口实际是由最慢的那个扩展决定的

9.3 升级后的观察指标

-- 1. 查询性能对比(升级前后各采集一次,对比 top 50)
SELECT queryid, calls, mean_exec_time, stddev_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 50;

-- 2. 计划稳定性(stddev 突然变大 = 计划在翻转)
SELECT queryid, calls,
       mean_exec_time,
       stddev_exec_time,
       round((stddev_exec_time / NULLIF(mean_exec_time,0))::numeric, 2) AS cv
FROM pg_stat_statements
WHERE calls > 100
ORDER BY cv DESC LIMIT 30;

-- 3. autovacuum 是否跟得上(并行开启后应该改善)
SELECT relname, n_dead_tup, n_live_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
       last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_pct DESC;

-- 4. AIO 行为(worker 模式是否在合理伸缩)
SELECT backend_type, object, context, reads, read_time, writes, write_time
FROM pg_stat_io WHERE reads > 0 OR writes > 0;

采集基线要在升级前做。升级后才想起来「以前是多快」,就只能靠感觉吵架了。

9.4 分阶段落地建议

第 1 阶段:只升级,不改配置
  - pg_upgrade 到 19
  - 保持所有 GUC 与旧版一致(包括显式 jit = on 如果你之前依赖它)
  - 跑 1—2 周,确认基线无回退
  - 目的:把「版本变更」和「配置变更」两个变量分开

第 2 阶段:接受新默认值
  - 去掉 jit 的显式设置,让它默认 off
  - default_toast_compression = lz4
  - 逐表 ALTER ... SET COMPRESSION lz4 + REPACK CONCURRENTLY
  - 目的:吃到默认值改进的红利,同时可单独回滚

第 3 阶段:启用新能力
  - 并行 autovacuum
  - io_min_workers / io_max_workers 调优
  - enable_eager_aggregate 验证
  - 目的:性能挖潜,每项独立验证

第 4 阶段:采用新语法(可选,按需)
  - ON CONFLICT DO SELECT 替换假 UPDATE(低风险,收益明确,优先做)
  - WAIT FOR LSN 改造读写分离
  - FOR PORTION OF 重构 SCD 逻辑
  - SQL/PGQ / pg_plan_advice 谨慎评估

每个阶段之间留至少一周观察期。 一次改一件事,出问题才能快速定位。


十、冷思考:PG19 到底是个什么版本

10.1 它不是一个「亮点驱动」的版本

如果非要选一个「头条特性」,官方大概会选 SQL/PGQ。但说实话,图查询对绝大多数 PG 用户是「知道有这么回事就行」的功能。

真正会改变日常的,是那些不上头条的:

  • REPACK CONCURRENTLY——让每个 DBA 少装一个第三方扩展,少一个运维盲区
  • 逻辑复制免重启——让「零停机迁移」真正零停机
  • 逻辑复制复制序列值——填掉一个能直接搞挂业务的历史大坑
  • ON CONFLICT DO SELECT——让最常见的 get-or-create 模式不再需要写脏 SQL
  • JIT 默认关闭——让无数不知道 JIT 存在的用户,被动躲过一个坑

这些都是减法,或者说是把外部方案收编进内核。一个成熟系统的版本迭代,本来就应该更多地长这样。

10.2 「承认错误」比「增加功能」更难

我特别想强调 JIT 默认关闭这件事。

一个开源项目,在一个功能上线 8 年、写进无数文档和教程、被无数 benchmark 引用之后,承认「默认开启这个决定是错的」并且改回去——这需要的组织成熟度,比加十个新特性都高。

同样的还有 RADIUS 的移除。它的用户不多,但不是零。删掉它意味着要面对那批用户的抱怨。选择删除而不是"保留但不维护",是在主动承担技术债的清偿成本,而不是留给后人。

这两件事传递的信号是一致的:PostgreSQL 在有意识地管理自己的复杂度。对一个 30 年、几百万行 C 代码的系统来说,这可能比任何单个新功能都重要。反面教材我们见得太多了——那些什么都不敢删、什么默认值都不敢改的项目,最后都变成了自己的博物馆。

10.3 pg_plan_advice 是一个值得关注的转折

社区在 hint 问题上坚持了十几年,PG19 松口了。这是好事还是坏事,我觉得要看三年后:

  • 乐观情况pg_plan_advice 成为「诊断工具 + 应急止血」,用户在生成 advice 的过程中反而更了解规划器,社区也从 advice 的使用模式里发现规划器的系统性缺陷,最终推动规划器变好。
  • 悲观情况:advice 泛滥成灾,每个团队都攒下几百条谁也不敢删的 advice,规划器改进因为「会破坏用户的 advice」而更难推进,PostgreSQL 走上 Oracle 的老路。

决定走向哪边的不是技术,是工程文化。 所以我在第五章特别强调那三条纪律——每条 advice 都要有根因、有工单、有过期时间。这不是形式主义,这是防止你的数据库在三年后变成一个没人敢碰的黑盒。

10.4 一句话

PG18 是一个「补基础设施」的版本,PG19 是一个「收拾旧账」的版本。 它没有那种让你想立刻升级的杀手级特性,但它清掉的每一笔旧账——第三方 repack 依赖、逻辑复制的重启要求、序列不同步的坑、JIT 的默认值错误——都是你在生产环境里真实付过学费的地方。

这种版本不性感,但它是一个系统能活到第四个十年的原因。


附:快速索引

破坏性变更(必须处理)

  • RADIUS 认证移除 → 迁 LDAP / SCRAM+证书 / GSSAPI
  • JIT 默认 off → 重型 OLAP 查询需显式开启
  • default_toast_compression = lz4 → 确认编译支持
  • vacuumdb --analyze-only 默认分析分区表 → 脚本执行时间变长

值得立刻用(低风险高收益)

  • INSERT ... ON CONFLICT DO SELECT RETURNING
  • REPACK CONCURRENTLY(正式版稳定后)
  • 逻辑复制免重启 + 序列复制
  • CREATE PUBLICATION ... EXCEPT

需要评估(有学习/风险成本)

  • pg_plan_advice / pg_stash_advice
  • WAIT FOR LSN 读写分离改造
  • enable_eager_aggregate
  • 并行 autovacuum 的 worker 预算分配

观察即可(新特性,等成熟)

  • SQL/PGQ 属性图
  • FOR PORTION OF 时态 DML
  • MERGE PARTITIONS / SPLIT PARTITIONS

参考

  • PostgreSQL 19 Beta 1 公告:https://www.postgresql.org/about/news/postgresql-19-beta-1-released-3313/
  • PostgreSQL 19 Beta 2 公告:https://www.postgresql.org/about/news/postgresql-19-beta-2-released-3350/
  • PostgreSQL 19 Release Notes:https://www.postgresql.org/docs/19/release-19.html
  • PG19 Open Items:https://wiki.postgresql.org/wiki/PostgreSQL_19_Open_Items

本文基于 PostgreSQL 19 Beta 2(2026-07-16)撰写。Beta 期间部分特性的语法、参数名和默认值仍可能调整,正式版发布后请以官方文档为准。文中的调优建议和踩坑清单来自通用工程经验,具体数值请在你自己的负载上验证——任何没有在你的数据上测过的性能数字,都只是别人的数字。

推荐文章

Vue3中如何处理跨域请求?
2024-11-19 08:43:14 +0800 CST
nginx反向代理
2024-11-18 20:44:14 +0800 CST
mendeley2 一个Python管理文献的库
2024-11-19 02:56:20 +0800 CST
16.6k+ 开源精准 IP 地址库
2024-11-17 23:14:40 +0800 CST
程序员茄子在线接单