☰
PostgreSQL 15 版本线深度复盘:MERGE、逻辑复制与权限变更实战
2026/11/2 12:54:27 网站建设 项目流程

PostgreSQL 15 这个版本线,从 15.0 一路走到 15.12,跨度差不多两年半。我最早在 15.0 刚发布时就把它拉进了测试环境,中间跟着小版本一路升上来,踩过的坑、验证过的特性、被回滚过的配置,攒了不少。这篇不打算写成官方 Release Notes 的翻译稿,而是按我自己的使用轨迹,把 15 这条线里真正影响日常运维和开发的那几件事拆开讲清楚——哪些特性值得你专门为它规划升级、哪些小版本修复是必须跟的、哪些参数改了之后会悄悄改变执行计划。如果你正在评估要不要从 14 升到 15,或者已经在 15 上跑着但没系统梳理过这条版本线的演进,这篇可以当作一份带经验的对照清单来看。

PostgreSQL 15 的核心关键词其实就几个:MERGE 语句、逻辑复制支持行过滤和列列表、public schema 权限收紧、ICU 排序规则成为默认选项之一、WAL 与检查点相关的性能改进、以及统计信息与并行查询的持续打磨。这些点分布在 15.0 到 15.12 的不同位置,有的是大版本引入、小版本修 bug,有的是小版本才补上的行为调整。下面按“特性价值—实现原理—实操验证—版本线演进”的逻辑逐块展开。

1. 为什么 15 这条线值得单独梳理一遍

很多人对 PostgreSQL 小版本的理解停留在“修安全漏洞和崩溃 bug”,升不升看心情。15 这条线恰恰是个反例:15.0 引入的几个大特性在后续小版本里经历了不止一次行为修正,如果你只升到 15.2 就停住,某些逻辑复制的边界场景和排序规则的行为可能和你以为的不一样。我见过一个团队在 15.1 上跑逻辑复制做行过滤,结果遇到一个在 15.4 才修掉的订阅端重放问题,排查了两天才定位到是版本差异。

1.1 大版本特性与小版本修复的交织关系

PostgreSQL 的版本策略是“大版本加特性、小版本只修 bug”,听起来很干净,但实际使用中,大版本特性的“完整形态”往往要经过几个小版本才稳定。15.0 发布的 MERGE 语句在语法上已经完备,但围绕它的锁行为、与分区表的交互、以及在并发场景下的可见性处理,后续小版本里有若干修正。逻辑复制的行过滤功能同理——15.0 支持了WHERE子句过滤,但列列表(column list)的完整支持、以及过滤条件与发布端 schema 变更的配合,是在后续版本里逐步打磨的。

这意味着一个很实际的结论:如果你要用 15 的新特性,尽量跟到这条线较新的小版本,而不是停在 15.0 或 15.1。我自己的习惯是,大版本首发只用于非关键环境验证,生产环境至少等到该线第 3 到第 4 个小版本再上,这时候主要特性的边角问题基本被社区磨平了。

1.2 从 14 升到 15 时最容易被忽略的三处行为变化

升级评估里,大家都会看新特性,但真正让你半夜被叫起来的是行为变化。15 这条线里,我认为最容易被忽略的有三处:

第一是public schema 的默认权限收紧。从 15 开始,普通用户对 public schema 不再默认拥有 CREATE 权限。这个改动影响面极大——很多老应用的习惯是“连上库直接建表”,升级后直接报权限错误。第二是ICU 排序规则的地位上升,虽然 libc 排序规则仍然可用,但 15 对 ICU 的支持更完整,如果你之前用 libc 且依赖特定的排序行为,升级后要专门验证排序结果是否一致。第三是统计信息收集的默认行为微调,某些场景下 autovacuum 与 analyze 的触发节奏变化会影响执行计划稳定性。

提示:升级前务必在影子库上跑一遍应用的建表、排序、以及关键查询的执行计划对比,不要只看pg_upgrade是否成功。

1.3 小版本号背后:哪些修复必须跟、哪些可以缓

15.0 到 15.12 之间,小版本的性质并不一样。大致可以分三类:安全修复类(必须尽快跟,没有商量余地)、数据正确性修复类(涉及逻辑复制、索引一致性、排序结果等,建议跟)、崩溃与稳定性修复类(视你的负载特征决定优先级)。我的做法是维护一张自己的版本跟踪表,把每个小版本里和我环境相关的修复项标出来,安全类无条件跟,正确性类排进最近一次维护窗口,稳定性类看是否命中我的使用模式。

版本区间修复侧重我的跟进策略
15.0 - 15.2首发特性边角修正、崩溃修复仅测试环境
15.3 - 15.6逻辑复制、排序规则、并行查询相关修正生产跟到该区间最新
15.7 - 15.12安全修复叠加稳定性打磨安全项立即跟,其余随维护窗口

这张表不是让你照抄,而是提醒你:小版本不是同质的,跟版本要有判断依据。具体每个版本修了什么,官方发布说明写得很清楚,关键是你要能把自己的使用场景和修复项对上号。

2. MERGE 语句:语法完备不等于用起来省心

MERGE 是 15.0 最受关注的新语法,一条语句里完成“匹配则更新、不匹配则插入”的逻辑,替代了以前INSERT ... ON CONFLICT加额外判断的写法。但我在实际项目里用它的时候发现,语法简单背后有几个必须想清楚的点,否则很容易写出“看起来对、并发下出错”的语句。

2.1 MERGE 的执行语义与并发陷阱

MERGE 的基本形态是这样的:

MERGE INTO target_table t USING source_table s ON t.id = s.id WHEN MATCHED THEN UPDATE SET val = s.val, updated_at = now() WHEN NOT MATCHED THEN INSERT (id, val, updated_at) VALUES (s.id, s.val, now());

看起来很像“upsert”,但它和INSERT ... ON CONFLICT在并发语义上有本质区别。ON CONFLICT依赖唯一索引来保证并发下的原子性,冲突时走更新分支,这个机制在数据库内核层面是有专门处理的。而 MERGE 的“匹配判断”和“执行动作”之间,在默认隔离级别下并不是原子的——两个并发事务可能都判断为“不匹配”,然后都尝试插入,结果撞唯一约束报错。

我实测过一个场景:两个会话同时对同一批 id 执行 MERGE,源数据里存在重复 id 时,其中一个会话会收到唯一约束冲突。解决办法要么是在应用层保证源数据去重,要么是给 MERGE 加上合适的锁策略,要么干脆回到ON CONFLICT。MERGE 适合的是“源数据本身干净、并发压力可控”的批量同步场景,不适合当作高并发 upsert 的万能替代。

2.2 与分区表配合时的锁粒度问题

15 这条线里,MERGE 与分区表的交互是后续小版本重点修正的方向之一。当目标表是分区表时,MERGE 需要对涉及的分区加锁,如果源数据横跨多个分区,锁的范围可能比你预期的大。我在一个按时间分区的表上做过测试,源数据覆盖三个分区,执行期间观察pg_locks,发现相关分区的锁持有时间明显长于单表场景。

实操建议是:对分区表做 MERGE 时,尽量让源数据按分区键聚簇,减少单次语句触及的分区数量;如果业务允许,拆成按分区的多次 MERGE,每次只碰一个分区,锁竞争会小很多。另外,MERGE 的RETURNING子句在 15 早期版本对分区表的支持也有边界情况,如果你依赖返回值做后续逻辑,先在测试环境验证清楚。

2.3 一个真实可复现的 MERGE 写法模板

下面这个模板是我在数据同步任务里沉淀下来的,加了显式的源数据去重和冲突兜底:

WITH dedup_source AS ( SELECT DISTINCT ON (id) id, val, updated_at FROM staging_table ORDER BY id, updated_at DESC ) MERGE INTO target_table t USING dedup_source s ON t.id = s.id WHEN MATCHED AND t.updated_at < s.updated_at THEN UPDATE SET val = s.val, updated_at = s.updated_at WHEN NOT MATCHED THEN INSERT (id, val, updated_at) VALUES (s.id, s.val, s.updated_at);

这里有两个细节值得说:DISTINCT ON保证源数据每个 id 只留最新一条,避免同批次内自冲突;WHEN MATCHED AND加上时间戳比较,避免用旧数据覆盖新数据。这两个处理在纯语法文档里不会强调,但在实际同步任务里是刚需。踩过一次坑之后,我现在写 MERGE 默认带上这两个保护。

3. 逻辑复制的行过滤与列列表:能力变强,边界也变多

逻辑复制在 15 里最大的增强是发布端支持行过滤(row filter)和列列表(column list)。以前要做“只同步某张表的部分行、部分列”,得靠视图或者触发器绕,现在直接在CREATE PUBLICATION里声明就行。这个特性对多租户场景和敏感字段隔离特别有用,但用下来发现边界条件不少。

3.1 行过滤的表达式限制与索引配合

行过滤的写法是在发布里加WHERE子句:

CREATE PUBLICATION pub_tenant FOR TABLE orders WHERE (tenant_id = 1001);

限制在于,过滤条件里不能引用用户自定义函数、不能做跨表子查询、表达式必须是发布端可求值的。更关键的是性能:订阅端应用变更时,发布端需要根据过滤条件判断哪些行该发,如果过滤列上没有合适的索引,大批量变更时发布端会有额外的扫描开销。我在一张千万级表上试过,过滤列没索引时,一次批量更新让发布端的 WAL 发送进程明显变慢,补上索引后恢复正常。

注意:行过滤的列如果后续被ALTER TABLE改了类型或删了,发布定义会失效,需要重建发布。升级或做 schema 变更前,先检查pg_publication_tables里的过滤定义。

3.2 列列表同步与订阅端 schema 的耦合

列列表的用法是:

CREATE PUBLICATION pub_cols FOR TABLE users (id, name, email);

只同步这三列。好处是敏感字段(比如手机号、身份证)可以留在发布端不同步。但耦合点在于:订阅端的表结构必须能接受“只来部分列”的插入和更新。如果订阅端这些列有 NOT NULL 约束又没有默认值,复制会失败。我的处理方式是订阅端对应列要么给默认值,要么允许 NULL,并且在应用层明确“这些列的数据来源不是复制”。

还有一个容易忽略的点:列列表和行过滤可以组合使用,但组合后的表达式求值顺序和 NULL 处理在 15 早期版本有过修正。如果你的过滤条件涉及 NULL 判断,务必在目标小版本上验证一遍实际同步结果,别只看文档描述。

3.3 双向复制场景下 15 这条线的稳定性观察

15 对逻辑复制的另一个隐性改进是双向复制场景下的冲突处理与序列同步。虽然双向复制严格来说不是 15 才有的能力,但 15 这条线里围绕订阅端的错误恢复、冲突检测的日志清晰度有持续改进。我在一个双写测试环境里跑过一段时间,感受是:冲突发生时,15 较新小版本的错误信息比早期版本更能定位到具体是哪一行、哪个操作冲突,这对排查帮助很大。

但要说清楚:双向复制仍然需要应用层做冲突规避设计,数据库层面只是把冲突暴露得更清楚,不会替你解决业务语义上的冲突。序列(sequence)的同步在双向场景下依然要手动处理,别指望自动对齐。

4. public schema 权限收紧:一次影响面被低估的变更

这个变更我在升级评估时差点漏掉,因为它不在“新特性”列表里,而是藏在行为变化中。15 之前,新建数据库的 public schema 默认对PUBLIC角色有 CREATE 权限,任何能连库的用户都能在里面建表。15 开始,这个默认权限被收回,只有 schema 的 owner 和显式授权的角色才能建。

4.1 为什么这个改动会让老应用直接报错

很多应用的历史习惯是:用应用账号连上库,初始化脚本直接CREATE TABLE,不指定 schema,默认落在 public。升级到 15 后,如果应用账号不是 public schema 的 owner,也没有被显式授予 CREATE,初始化直接失败。报错信息是权限不足,但很多人第一反应会去查连接权限、查角色成员关系,绕一圈才发现是 schema 权限。

修复方式很直接:

GRANT CREATE ON SCHEMA public TO app_role;

或者在初始化时显式建一个应用专属 schema 并授权。我的建议是后者——借这次升级把“应用对象放专属 schema”这件事做掉,比继续往 public 里堆表更清晰,也避免未来再遇到类似的默认权限变化。

4.2 升级前的权限盘点清单

升级前我会跑一遍这样的盘点,把受影响的角色和对象列出来:

-- 查 public schema 当前权限 \dn+ public -- 查哪些角色能在 public 建对象 SELECT nspname, nspacl FROM pg_namespace WHERE nspname = 'public'; -- 查应用账号的成员关系 SELECT r.rolname, m.rolname AS member_of FROM pg_auth_members am JOIN pg_roles r ON r.oid = am.member JOIN pg_roles m ON m.oid = am.roleid;

盘点完,对每个需要在 public 建对象的角色做显式授权,或者迁移到专属 schema。这一步做完再执行升级,能省掉升级后一堆“莫名其妙”的权限报错。

4.3 与 pg_upgrade 配合时的注意点

用pg_upgrade从 14 升 15 时,schema 权限是随对象一起迁移的,所以升级本身不会丢权限。但问题在于升级后新建的数据库——如果你有“升级后新建库给新业务用”的流程,新库的 public schema 默认权限已经是收紧后的状态,初始化脚本要相应调整。我踩过的坑是:升级验证时只测了老库,新库初始化脚本没改,上线新业务时才发现建表失败。现在我的检查清单里专门有一条“新建库初始化脚本在 15 上验证”。

5. ICU 排序规则:默认选项变化带来的排序一致性验证

15 对 ICU 排序规则的支持更完整,CREATE COLLATION和CREATE DATABASE ... LOCALE_PROVIDER让 ICU 成为和 libc 并列的一等选项。这个变化的价值在于:ICU 的排序行为跨平台、跨系统版本更一致,而 libc 的排序结果依赖操作系统 locale 版本,换个系统版本排序可能就变了。

5.1 libc 与 ICU 的排序差异实测

我做过一个对比测试,同一批含中文、英文、数字、符号的字符串,分别用 libc(zh_CN.UTF-8)和 ICU(zh)排序,结果在大部分情况下一致,但在混合大小写、带重音符号、以及某些标点符号的相对位置上有差异。如果你的业务依赖“排序结果稳定可复现”(比如分页查询的稳定顺序、报表的固定排序),ICU 是更稳的选择。

但要注意:从 libc 切到 ICU 不是改个参数就完事,已有数据的索引是按旧排序规则建的,切换后需要重建相关索引,否则查询可能走不到索引或者结果顺序不对。切换步骤大致是:新建 ICU 排序规则的 collation、ALTER TABLE ... ALTER COLUMN ... TYPE触发重写、重建索引、验证排序结果。

5.2 排序规则变更后索引重建的必要性

这一点值得单独强调,因为它是“看起来没事、实际有坑”的典型。排序规则影响的是字符串比较的结果,而 B-tree 索引的键顺序依赖比较结果。如果列的排序规则变了但索引没重建,索引里的键顺序还是旧的,查询时优化器按新规则估算,可能选择走索引但返回顺序错误,或者干脆不走索引。

-- 查看列的排序规则 SELECT a.attname, c.collname FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid LEFT JOIN pg_collation c ON c.oid = a.attcollation WHERE t.relname = 'your_table' AND a.attnum > 0; -- 重建索引(示例) REINDEX INDEX CONCURRENTLY idx_your_table_name;

REINDEX CONCURRENTLY在 15 里已经比较成熟,可以在线重建不阻塞写入,大表上尤其有用。我的经验是:排序规则变更和索引重建要放在同一个维护窗口里做完,中间不要留“规则已改、索引未建”的窗口期。

5.3 多语言场景下 collation 选型的经验判断

多语言业务里,collation 选型没有标准答案,但有判断依据。如果业务主要是单一语言且对排序一致性要求不高,libc 够用且迁移成本低。如果业务涉及多语言混合排序、或者对“同一份数据在不同环境排序结果一致”有硬要求,ICU 更合适。还有一个折中方案:关键排序列用 ICU,非关键列保持 libc,按列粒度控制,避免全库切换的风险。

6. WAL 与检查点:那些不显眼但影响吞吐的改进

15 这条线里,WAL 和检查点相关的改进不像 MERGE 那样有存在感,但对高写入负载的环境影响很实在。主要包括:WAL 预写日志的压缩相关优化、检查点期间 I/O 行为的调整、以及wal_compression相关参数的细化。

6.1 wal_compression 参数的实际收益测算

wal_compression在 15 里支持更多压缩算法选项,开启后 WAL 记录在写入前做压缩,减少 I/O 量,代价是 CPU 开销。我在一个写密集的测试库上做过对比:开启压缩后,WAL 目录的增长速度下降明显,但 CPU 使用率上升。收益是否划算,取决于你的瓶颈在 I/O 还是 CPU。

粗略的测算方法是:观察pg_stat_wal里的 WAL 生成量,对比开启前后单位时间内的 WAL 字节数,再结合磁盘写入带宽和 CPU 余量判断。如果磁盘是瓶颈、CPU 有余量,开压缩划算;反过来就不划算。这个参数没有“默认最优值”,必须按环境实测。

6.2 检查点频率与写入放大的平衡

检查点相关的调优是老话题,但 15 里围绕检查点 I/O 平滑的改进让参数组合的效果有变化。核心还是那几个:checkpoint_timeout、max_wal_size、checkpoint_completion_target。我的经验是:不要孤立地调某一个参数,max_wal_size调大能减少检查点频率,但会让崩溃恢复时间变长;checkpoint_completion_target调高能平滑 I/O,但拉长了检查点持续时间。

一个我常用的起步组合(中等写入负载、SSD 环境):max_wal_size设为内存的 1/4 到 1/2,checkpoint_timeout保持默认或适度调大,checkpoint_completion_target设 0.9。然后观察pg_stat_bgwriter里的检查点触发原因分布,如果频繁因为 WAL 量触发(checkpoints_req偏高),说明max_wal_size还不够,继续调大。

6.3 从 15.0 到 15.12 的 WAL 相关修复追踪

这条线里 WAL 相关的修复主要集中在边界场景:比如特定并发写入模式下 WAL 记录的完整性、崩溃恢复时的重放正确性、以及某些 DDL 操作产生的 WAL 量异常。这些修复对日常运行影响不大,但如果你在做高可用切换演练或者崩溃恢复测试,建议跟到较新小版本,早期版本在这些边界场景下的行为可能和文档描述有出入。我的做法是:每次做恢复演练前,确认当前小版本在 WAL 重放相关修复上的状态,避免把版本 bug 误判成配置问题。

7. 统计信息与并行查询:执行计划稳定性的持续打磨

15 这条线对优化器的改进是渐进的,没有单一的大特性,但累积起来对执行计划稳定性有实际影响。主要包括:扩展统计信息的改进、并行查询在某些算子上的行为调整、以及pg_stat_statements相关的观测增强。

7.1 扩展统计信息在多列相关性场景的用法

多列之间存在相关性时(比如城市和邮编),单列统计信息会让优化器低估或高估选择率,导致执行计划选错。扩展统计信息(CREATE STATISTICS)就是解决这个的:

CREATE STATISTICS stat_city_zip (dependencies, ndistinct) ON city, zip FROM addresses; ANALYZE addresses;

dependencies捕捉列间的函数依赖,ndistinct捕捉多列组合的 distinct 值数量。我在一个按“省份+城市”过滤的查询上试过,建扩展统计信息后,优化器对结果行数的估算从偏差一个数量级收敛到接近实际,执行计划从全表扫描切回了索引扫描。这个特性的价值在于它不改变查询写法,只改善优化器的判断依据,属于低成本高回报的调优手段。

7.2 并行查询在 15 上的行为变化与验证方法

15 对并行查询的调整主要体现在某些算子(如并行 hash join、并行聚合)的代价估算和 worker 分配策略上。实际感受是:同样的查询和同样的max_parallel_workers_per_gather设置,15 上选择的并行度可能和 14 不同。这不一定是坏事,但升级后要做执行计划回归对比,尤其是那些对延迟敏感的关键查询。

验证方法是:升级前后分别对关键查询跑EXPLAIN (ANALYZE, BUFFERS),对比并行 worker 数量、实际执行时间、以及 buffer 命中情况。如果发现某个查询在 15 上并行度异常高导致资源争抢,可以通过SET LOCAL max_parallel_workers_per_gather在会话级控制,或者调整代价参数。

7.3 pg_stat_statements 观测口径的细节差异

pg_stat_statements在 15 里的观测口径有细微调整,比如某些查询的归一化方式、以及并行查询的统计归属。这些差异会影响你基于历史数据做的容量规划。我的建议是:升级后重置统计(pg_stat_statements_reset()),重新积累一段时间的基线数据,不要直接拿升级前的数据做同比,否则可能得出错误结论。重置前把旧数据导出留档,方便需要时对照。

8. 版本线跟进的个人方法论

跟 PostgreSQL 小版本这件事,我摸索出一套适合自己的节奏,不一定适合所有人,但思路可以借鉴。

8.1 建立自己的版本跟踪表

我用一张简单的表跟踪每个小版本:版本号、发布日期、与我环境相关的修复项、跟进优先级、实际跟进日期。修复项不是全抄发布说明,而是只记“命中我使用模式”的那些。比如我大量用逻辑复制,那逻辑复制相关的修复就标高优先级;我不用某些扩展,那相关修复就标低。这张表让我在维护窗口到来时能快速决定“这次升不升、升到哪个版本”。

8.2 升级窗口的选择与回滚预案

小版本升级(比如 15.6 到 15.8)通常风险较低,但仍要有回滚预案。我的做法是:升级前做一次基础备份、记录当前版本号和关键配置、准备降级路径(小版本降级一般可行,但涉及数据目录格式变化时要谨慎)。升级窗口选在业务低峰,升级后立即跑一遍冒烟测试:连接、关键查询、复制状态、备份任务。冒烟通过再放开流量。

8.3 什么时候该停在这条线上、什么时候该规划下一个大版本

15 这条线走到 15.12,已经相当成熟。如果你的环境稳定、没有命中未修复的问题,停在这条线的较新小版本是合理的。但如果下一个大版本有你需要的特性(比如更强的分区能力、或者某些性能改进),就该开始规划升级路径了。我的判断标准是:当“新特性带来的收益”超过“升级和验证的成本”时,就启动规划,而不是等到当前版本停止支持才被动升级。

最后分享一个我一直在用的小习惯:每次升级或改配置后,把“改了什么、为什么改、验证结果如何”记在一个运维日志里。这个日志在半年后回头看,比任何文档都管用——因为它是针对你自己环境的、带上下文的记录。版本线会一直往前走,但你自己环境的行为基线,只能靠这样一点点积累出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询