PostgreSQL 子事务的隐患

John Doe 八月 28, 2026

摘要:在本文中,我们将了解使用 PostgreSQL 子事务带来的隐患,以及相关的解决方案。

目录

单个事务如果累积了大量子事务 ID,会显著降低整个 PostgreSQL 集群的吞吐量。此外,即便只读副本仍在持续重放预写日志(WAL),新上线的只读副本也可能因此无法接受查询请求。

如果在负载高峰期间按需扩容只读副本,一个无法对外提供读服务的副本并不能带来额外的处理能力。

下面我们来解析 PostgreSQL 如何维持副本同步,以及如何判断副本何时可以安全地提供读服务。

副本如何保持同步

只读副本是 PostgreSQL 主集群的一份拷贝,它们通过持续重放主节点的**预写日志(Write-Ahead Log,简称 WAL)**保持数据同步。开启热备(hot standby)功能后,副本可以在重放 WAL 的同时对外提供只读查询服务。

PostgreSQL 只读副本基于主库的基础备份创建。要理解副本的同步机制,首先需要了解 PostgreSQL 的数据写入原理:所有数据修改都会先按顺序写入预写日志,之后才会更新数据库的数据文件(索引、表文件等)。WAL 是一份仅追加写入的变更日志,核心作用是崩溃恢复,用于保障数据完整性、避免数据丢失。

为了保持同步,只读副本会与主节点建立流式连接,持续接收原始 WAL 流,解码其中的记录,并将这些物理变更精确地应用到本地数据集上。

解码 WAL 记录

WAL 存储在 PostgreSQL 数据目录下的pg_wal文件夹中。pg_waldump是用于读取这些二进制 WAL 记录并输出可读格式的工具。由于它直接读取 WAL 文件,以下命令必须在 PostgreSQL 运行的主机上执行。命令会先定位数据目录和当前 WAL 段文件:

$ PGDATA=$(psql -d postgres -Atqc "SHOW data_directory")
$ WAL_FILE=$(psql -d postgres -Atqc "SELECT pg_walfile_name(pg_current_wal_lsn())")
$ pg_waldump "$PGDATA/pg_wal/$WAL_FILE"

解码后的 WAL 流示例如下:

$ pg_waldump pg_wal/000000100000000000000001
[...]
rmgr: Heap        len (rec/tot):    207/   207, tx:      23445, lsn: 0/01BE9AA0, prev 0/01BE9A58, desc: INSERT off: 2, flags: 0x01, blkref #0: rel 1663/16384/1259 blk 0
rmgr: Btree       len (rec/tot):     64/    64, tx:      23445, lsn: 0/01BE9B70, prev 0/01BE9AA0, desc: INSERT_LEAF off: 119, blkref #0: rel 1663/16384/2662 blk 2
rmgr: Btree       len (rec/tot):     72/    72, tx:      23445, lsn: 0/01BE9BB0, prev 0/01BE9B70, desc: INSERT_LEAF off: 111, blkref #0: rel 1663/16384/2663 blk 2
[...]
rmgr: Transaction len (rec/tot):    373/   373, tx:      23445, lsn: 0/01BEA360, prev 0/01BEA318, desc: COMMIT 2026-06-02 12:16:49.173692 CEST; inval msgs: catcache 82 catcache 81 catcache 82 catcache 81 catcache 57 catcache 56 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 snapshot 2608 relcache 16389

输出展示了 4 条解码后的 WAL 记录,每条记录都有一个日志序列号(LSN),用于标识该记录在 WAL 中的位置,同时标注了所属的事务 ID。

每条记录都归属于特定的资源管理器(rmgr。解码 WAL 记录时,对应的资源管理器负责解析记录内容,并将变更同步到对应的数据库子系统:

  • 第一条记录属于Heap资源管理器,代表对标准数据页的修改。描述(desc)字段显示,一条新元组被插入到表空间 OID / 数据库 OID / 关系 OID 为1663/16384/1259的关系的第0号块、偏移量2的位置。
  • 第二、三条记录属于Btree资源管理器,负责处理索引更新。描述字段显示,索引元组被分别添加到两个独立 B 树索引关系(26622663)的叶子节点(INSERT_LEAF)中。
  • 第四条记录属于Transaction资源管理器,标识事务23445在指定时间点成功提交。该记录还包含失效消息(inval msgs),用于通知集群内其他节点清理内部元数据缓存(catcacherelcache),因为系统表pg_class发生了修改。

这些 WAL 记录会持续从主节点流式传输到副本,在副本上重放以保持数据同步。

但重放物理变更只是副本提供读服务的前提之一,PostgreSQL 还需要知道哪些事务处于活跃状态,才能构建出一致性快照。

关于快照和数据可见性的原理,可以参考我们之前发布的《PostgreSQL 多版本并发控制(MVCC)详解》。为了保证事务的原子性,数据库引擎必须确保:在一次扫描操作执行期间,并发事务所做的修改要么全部可见,要么全部不可见。因此,即便只读副本上正在运行查询,应用新的 WAL 记录也不能改变该查询看到的数据。

通过 WAL 记录追踪运行中的事务

在持续处理 WAL 流的过程中,副本可以动态追踪活跃事务:当新的事务 ID 出现在日志中时,标记事务开始;当处理到对应的COMMITABORT记录时,标记事务结束。

但在对外提供任何查询之前,备库需要知道所有正在运行事务的全局状态,才能构建出一致的 MVCC 快照。由于只读副本是从备份的重做检查点开始处理 WAL 流的,它最初并不清楚那些在检查点之前就已启动、但尚未提交的事务的状态。

为了解决这个问题,PostgreSQL 会周期性地将运行中事务的状态信息注入 WAL 流,这一过程由Standby资源管理器管理:

rmgr: Standby     len (rec/tot):     50/    50, tx:          0, lsn: 0/01C080D0, prev 0/01C08058, desc: RUNNING_XACTS nextXid 23477 latestCompletedXid 23475 oldestRunningXid 767

这类RUNNING_XACTS记录相当于主节点活跃事务状态的快照,记录了关键的事务边界信息,包括当前最老的运行中事务 ID(oldestRunningXid)、下一个待分配的事务 ID(nextXid),以及最近完成的事务 ID(latestCompletedXid)。

此外,记录中还包含一个内部数组,列出了所有活跃的顶层事务 ID 和子事务 ID。pg_waldump会将这些数组输出为xactssubxacts。通过读取这条记录,只读副本可以立即判断哪些旧数据行仍处于未提交状态、对查询不可见,从而安全地开启只读服务。

只读副本启动时,只需要处理一条这样的记录,之后依靠常规 WAL 记录就足以维护运行中事务的内部状态,并构建正确的快照。

子事务缓存上限

子事务是嵌套在顶层事务内部的事务,应用可以通过SAVEPOINT显式创建子事务;PL/pgSQL 中包含EXCEPTION子句的代码块也会自动创建子事务。

每个 PostgreSQL 后端进程都会在共享内存中,为当前顶层事务保存已分配且未中止的子事务 ID 列表,这个列表被称为子事务缓存,最多可容纳PGPROC_MAX_CACHED_SUBXIDS个 ID,默认值通常为 64。

PostgreSQL 会将每个子事务的父事务 ID 单独存储在pg_subtrans系统表中。pg_subtrans存储在磁盘上,通过简单的最近最少使用(SLRU)缓存访问。如果存在多层嵌套子事务,PostgreSQL 会沿着父事务链路逐层回溯,直到找到顶层事务。

构建快照时,PostgreSQL 会收集所有后端进程中运行的顶层事务 ID,以及缓存中的子事务 ID。当查询读取某一行数据时,PostgreSQL 会将行的xminxmax字段中的事务 ID 与快照进行比对,以此判断该行是否可见。

如果某个后端进程累积的子事务 ID 超出了缓存容量,PostgreSQL 会将该缓存标记为溢出状态。此时,在该事务运行期间构建的快照或RUNNING_XACTS记录,无法包含全部子事务 ID,同样会被标记为溢出。

如果查询遇到了因溢出而未被列入快照的子事务 ID,PostgreSQL 会去pg_subtrans中查找其对应的顶层事务 ID,再用顶层事务 ID 与快照进行校验。单条查询可能会执行多次pg_subtrans查找,反复获取 SLRU 读锁,甚至需要从磁盘读取数据。

子事务缓存溢出后,PostgreSQL 如何回退到 pg_subtrans 查询

到这里,子事务缓存和pg_subtrans回退机制看起来可能只是实现细节,但其中隐藏着一个会严重损害高可用集群的问题。正如我们接下来会讲到的,一个溢出的事务就可能拖慢整个集群的查询速度,还会导致新副本无法接受读请求。

要触发子事务缓存溢出,只需要一个事务创建超过PGPROC_MAX_CACHED_SUBXIDS个子事务即可,例如:

CREATE TABLE IF NOT EXISTS subxid_test (id int);
BEGIN;
SAVEPOINT s1;
INSERT INTO subxid_test VALUES (1);
SAVEPOINT s2;
INSERT INTO subxid_test VALUES (2);
[...]
SAVEPOINT s70;
INSERT INTO subxid_test VALUES (70);

显式创建保存点并非达到上限的唯一方式。PL/pgSQL 中每一个包含EXCEPTION子句的代码块都会创建一个子事务。下面的循环无需显式调用SAVEPOINT,就能创建超过PGPROC_MAX_CACHED_SUBXIDS个子事务:

BEGIN;

DO $$
BEGIN
  FOR i IN 1..70 LOOP
    BEGIN
      INSERT INTO subxid_test VALUES (i);
    EXCEPTION WHEN OTHERS THEN
      RAISE;
    END;
  END LOOP;
END $$;

全集群范围的性能问题

当某个后端进程创建新快照时,只要有另一个后端进程报告其子事务缓存已溢出,整个快照都会被标记为溢出状态。也就是说,一个正在运行的事务会影响其他连接上的查询 —— 哪怕这些查询访问的是不同的表、运行在不同的数据库中。

使用溢出状态的快照扫描数据时,对于每一个事务 ID 落在快照 xmin/xmax 范围内的元组,都必须执行一次成本更高的pg_subtrans SLRU 查找。而 SLRU 查找需要先获取读锁,这可能引发锁竞争;如果 SLRU 缓存未命中,还需要进行慢速的磁盘读取。这是一个全集群级别的性能断崖,很多 PostgreSQL 管理员都对此感到意外。

⚠️ 警告:单个事务如果累积了超过PGPROC_MAX_CACHED_SUBXIDS个已分配、未中止的子事务 ID,就可能拖慢整个 PostgreSQL 集群的查询速度,包括那些本身不使用子事务的会话。

基准测试

这种性能下降可以通过基准测试清晰复现。下面的实验显示,当发生子事务溢出时,由简单INSERTSELECTDELETE组成的工作负载的每秒事务数(TPS)会立即暴跌。

$ createdb mydb
$ cat prepare.sql
DROP TABLE IF EXISTS bench_mvcc;

CREATE TABLE bench_mvcc (
    id     bigserial PRIMARY KEY,
    grp    integer NOT NULL,
    val    integer NOT NULL
);

$ psql -f prepare.sql mydb

$ cat bench.sql
\set grp random(1, :ngroups)
\set v   random(1, 1000000)

BEGIN;
INSERT INTO bench_mvcc (grp, val) VALUES (:grp, :v);
SELECT count(*) FROM bench_mvcc WHERE grp = :grp;
DELETE FROM bench_mvcc WHERE grp = :grp;
COMMIT;

$ pgbench -n -f bench.sql -D ngroups=10000 -c 16 -j 4  -T 600 -P 5 mydb

pgbench (18.4)
progress: 90.0 s, 8640.2 tps, lat 1.847 ms stddev 0.504, 0 failed
progress: 95.0 s, 8162.2 tps, lat 1.955 ms stddev 0.461, 0 failed
progress: 100.0 s, 7745.0 tps, lat 2.061 ms stddev 0.654, 0 failed
progress: 105.0 s, 7257.0 tps, lat 2.199 ms stddev 0.752, 0 failed
-- Start of the subtransaction query
progress: 110.0 s, 5504.0 tps, lat 2.898 ms stddev 1.656, 0 failed
progress: 115.0 s, 1376.2 tps, lat 11.609 ms stddev 3.048, 0 failed
progress: 120.0 s, 904.2 tps, lat 17.674 ms stddev 3.370, 0 failed
progress: 125.0 s, 674.6 tps, lat 23.731 ms stddev 7.014, 0 failed
progress: 130.0 s, 379.4 tps, lat 41.964 ms stddev 14.276, 0 failed
progress: 135.0 s, 265.6 tps, lat 60.356 ms stddev 7.362, 0 failed
progress: 140.0 s, 356.6 tps, lat 44.759 ms stddev 19.705, 0 failed
progress: 145.0 s, 242.2 tps, lat 66.121 ms stddev 4.826, 0 failed
progress: 150.0 s, 205.2 tps, lat 77.794 ms stddev 7.277, 0 failed
progress: 155.0 s, 218.0 tps, lat 73.525 ms stddev 5.612, 0 failed
progress: 160.0 s, 218.4 tps, lat 73.434 ms stddev 4.875, 0 failed
progress: 165.0 s, 199.6 tps, lat 79.816 ms stddev 3.554, 0 failed
progress: 170.0 s, 174.8 tps, lat 91.367 ms stddev 9.229, 0 failed
progress: 175.0 s, 157.2 tps, lat 101.702 ms stddev 3.264, 0 failed
progress: 180.0 s, 166.0 tps, lat 96.803 ms stddev 8.565, 0 failed
progress: 185.0 s, 183.6 tps, lat 87.153 ms stddev 3.773, 0 failed
-- End of the subtransaction query
progress: 190.0 s, 5562.4 tps, lat 2.893 ms stddev 9.413, 0 failed
progress: 195.0 s, 8147.0 tps, lat 1.959 ms stddev 0.467, 0 failed
progress: 200.0 s, 7634.2 tps, lat 2.091 ms stddev 0.703, 0 failed
progress: 205.0 s, 8208.2 tps, lat 1.944 ms stddev 0.559, 0 failed
progress: 210.0 s, 8210.0 tps, lat 1.945 ms stddev 1.176, 0 failed
progress: 215.0 s, 8253.0 tps, lat 1.934 ms stddev 0.560, 0 failed

子事务缓存溢出后,pgbench 吞吐量出现断崖式下跌

预热阶段 TPS 下降,是因为该工作负载通过未建索引的grp列扫描不断增长的表,同时删除操作会产生死元组。当吞吐量稳定在约 7200 TPS 后,开启一个溢出事务会让 TPS 骤降至约 160;事务结束后,吞吐量恢复正常。

PostgreSQL 中的 SLRU 锁机制

下面的火焰图显示,溢出发生后,查询的大部分执行时间都消耗在XidInMVCCSnapshot函数中,具体是通过LWLockAcquire获取 pg_subtrans 的 SLRU 轻量级锁的过程 —— 火焰图顶部两个巨大的XidInMVCCSnapshot块清晰地反映了这一点。

火焰图:子事务溢出后,查询时间主要被 XidInMVCCSnapshot 获取 pg_subtrans SLRU 轻量级锁的操作占据

为何新建副本会拒绝连接

溢出的子事务缓存还会引发第二个问题。在 PostgreSQL 中,RUNNING_XACTS类型的 WAL 记录用于向只读副本同步主节点上的运行中事务状态。由于缓存溢出,这些 WAL 记录无法包含全部子事务,因此会被标记为subxid overflowed(子事务 ID 溢出)。

rmgr: Standby     len (rec/tot):     54/    54, tx:          0, lsn: 0/01C0B988, prev 0/01C0B960, desc: RUNNING_XACTS nextXid 766 latestCompletedXid 694 oldestRunningXid 695; 1 xacts: 695; subxid overflowed

溢出的 RUNNING_XACTS 记录如何阻止新副本启用热备模式

处理只读副本上的快照溢出问题

当新的只读副本基于主库的基础备份启动,并读取到一条溢出的RUNNING_XACTS记录时,它无法获知该时间点主节点上所有活跃事务的完整列表。由于无法基于这些信息构建正确的快照,PostgreSQL 会将副本的复制状态初始化为STANDBY_SNAPSHOT_PENDING(待快照就绪),并推迟启用热备模式。

要解除这种快照待就绪状态,只读副本必须等待以下任一事件发生,之后它就能掌握所有运行中事务的信息,构建出正确的快照,并将状态标记为STANDBY_SNAPSHOT_READY(快照就绪):

  1. 收到完整的RUNNING_XACTS记录:记录未被标记为溢出,包含完整的活跃事务状态。
  2. 重放关闭检查点:主节点关闭(或重启)时产生的检查点,可以保证该时间点没有任何活跃事务。从这个点开始追踪活跃事务,就能得到完整的运行中事务列表。
  3. 潜在缺失的事务全部结束:副本会记录第一条溢出记录中的nextXid值。当后续记录的oldestRunningXid超过这个边界值时,所有可能被遗漏的事务都已经结束,而更新的事务都已经通过 WAL 被副本完整追踪。

推动副本从 STANDBY_SNAPSHOT_PENDING 状态转为 STANDBY_SNAPSHOT_READY 状态的三个事件

为何副本无法使用 pg_subtrans

你可能会疑惑:为什么只读副本不能像主节点那样,在快照溢出时直接从pg_subtrans SLRU 中读取数据?

原因有两点:

  1. pg_subtrans的修改不会被写入 WAL,因此主节点上的pg_subtrans数据不会同步到只读副本。
  2. PostgreSQL 在启动时会将当前活跃的pg_subtrans页面清零。

因此,副本无法依赖本地的pg_subtrans数据,只能通过 WAL 来追踪运行中的事务。

高可用架构的盲区

⚠️ 警告:在副本到达一致恢复点、可以安全构建快照之前,PostgreSQL 无法启用热备模式 —— 也就是在恢复过程中接受只读连接的模式。副本可能已经部署完成、正在重放 WAL,但仍然无法承接读流量。在负载高峰期间添加这样的副本,并不能带来额外的处理能力。

$ psql --port 5434
psql: error: connection to server on socket "/tmp/.s.PGSQL.5434" failed: FATAL:  the database system is not yet accepting connections
DETAIL:  Recovery snapshot is not yet ready for hot standby.
HINT:  To enable hot standby, close write transactions with more than 64 subtransactions on the primary server.

正如提示所说,直接的解决方法是关闭主节点上引发溢出的写事务。事务可能会自行结束,但等待期间副本将一直处于不可用状态。故障处置时,运维人员可能需要主动识别并终止该事务。

检测与预防溢出

PostgreSQL 18 并未提供内置的子事务缓存溢出防护机制。运维人员可以通过保持事务简短、监控子事务与pg_subtrans活动来降低风险。

保持事务简短

监控集群中最老运行事务的存活时长。事务执行得越快,新启动的副本就能越快补齐溢出RUNNING_XACTS记录中缺失的事务状态,达到一致状态。

保持事务简短同时也是良好的运维实践。长事务会阻碍 VACUUM 清理,还会增加事务 ID 回卷的风险。可以通过以下 SQL 查询最老的未关闭事务时长:

SELECT COALESCE(EXTRACT(EPOCH FROM max(now() - xact_start)), 0) AS oldest_tx_seconds
  FROM pg_stat_activity
  WHERE xact_start IS NOT NULL;
 oldest_tx_seconds
-------------------
        432.346806
(1 row)

transaction_timeoutidle_in_transaction_session_timeout参数可以自动强制限制事务时长:前者限制事务的总执行时长,后者会终止在事务中处于空闲状态的会话。

检测子事务缓存溢出

此外,你可以使用 PostgreSQL 的pg_stat_get_backend_subxact()函数,查看当前服务器上运行中的子事务概览,以及子事务缓存是否发生了溢出:

SELECT pg_stat_get_backend_pid(id), s.* FROM pg_stat_get_backend_idset() id
  JOIN LATERAL pg_stat_get_backend_subxact(id) AS s ON TRUE
  WHERE s.subxact_count > 0;

 pg_stat_get_backend_pid | subxact_count | subxact_overflowed
-------------------------+---------------+--------------------
                   962964 |            64 | t
(1 row)

监控 pg_subtrans 活动

当快照溢出时,PostgreSQL 会读取pg_subtrans SLRU。PostgreSQL 的累计统计系统会追踪这些读取操作。通过监控pg_stat_slru视图中subtransaction类型 SLRU 的blks_hitblks_read指标,可以了解查找活动的情况。指标值快速增长,强烈说明正在发生子事务溢出。

SELECT * FROM pg_stat_slru WHERE name = 'subtransaction';
      name      | blks_zeroed | blks_hit | blks_read | blks_written | blks_exists | flushes | truncates |          stats_reset
----------------+-------------+----------+-----------+--------------+-------------+---------+-----------+-------------------------------
 subtransaction |         890 |    88474 |         0 |          754 |           0 |       6 |         6 | 2026-06-22 20:27:57.036583+02
(1 row)

PostgreSQL 内核层面的改进方向

要从根本上解决这个问题,PostgreSQL 本身可以做哪些改进?

一种方案是编译 PostgreSQL 时调大PGPROC_MAX_CACHED_SUBXIDS常量,这会增加每个后端进程的内存占用,但能降低子事务 ID 溢出的概率。不过,某些工作负载最终仍可能突破更大的阈值。

Redrock Postgres 的方案是用逻辑时间戳(logical time)快照替代活跃事务列表,同时基于回滚日志记录位置实现子事务的特性。

子事务缓存溢出的影响不会局限于引发溢出的事务本身:在主节点上,它会迫使可见性检查回退到pg_subtrans查询,而 SLRU 锁竞争会拉低整体吞吐量;在新建副本上,不完整的RUNNING_XACTS记录会延迟热备模式的启用,导致副本无法承接读连接。

保持事务简短、监控子事务缓存与pg_subtrans活动,能够有效降低这类风险。

参考

PlanetScale:PostgreSQL 子事务的隐患

PostgreSQL 子事务被认为有害