☰
PostgreSQL pg_xact 机制与运维排查完全指南
2026/9/28 7:00:27 网站建设 项目流程

先从我最近排查的一起故障说起。开发反馈某个 PostgreSQL 库在高峰期偶发报错,错误信息里有一行could not access status of transaction 1976823456。我把PGDATA/pg_xact目录列出来以后,发现里面的段文件比预期多了不少。当时的直觉告诉我:这不是简单的磁盘占满问题,而是有人把事务状态“结果登记簿”给拖住了。

pg_xact不是某个插件,也不是可以随便删除的临时文件。它是 PostgreSQL 事务机制里最核心的组成部分之一,直接决定了一条数据在查询时“看得见”还是“看不见”。这篇文章就围绕pg_xact展开,从它是什么、怎么工作,到日常怎么看、出了问题怎么处理,按一个 DBA 实际操作时会遇到的顺序完整写一遍。无论你是刚安装好 PostgreSQL,还是已经在维护线上库,都应该把这一块吃透。

1. pg_xact 到底管什么:先搞懂 MVCC 里的“结果登记簿”

1.1 从一条数据的可见性说起

PostgreSQL 的 MVCC 核心思想是:更新数据时不直接原地覆盖旧值,而是生成一个新版本。每个元组(行版本)的头部都保存了两个关键事务号:xmin表示插入这条数据的 TransactionId,xmax表示删除/更新这条数据的 TransactionId。

举个例子:

BEGIN; INSERT INTO t1 VALUES (1); COMMIT;

这条插入语句拿到一个事务号,比如2000。插入的元组头部xmin就被写成2000。之后另一个事务查询这张表,看到这一行时,必须在心里回答一个问题:事务2000到底提交了没有?

如果事务2000还没结束,那么这行数据对当前查询不可见;如果它最终提交了,那么这行可见;如果它回滚了,那么这行也应该当作不存在。这个查询动作非常频繁,不可能为了判断一行去翻整个事务日志,也不能靠“猜”。PostgreSQL 的做法,就是把每个事务的最终状态集中存在一个小而快的“登记簿”里,这个登记簿就是pg_xact。

你可以把它想象成一个小区的门卫登记本:每张门禁卡(TransactionId)进出后,门卫只在登记本上写“已确认”或者“作废”。查询某条数据时,只需要翻这个登记本确认一下,而不用去回放整段监控录像。

1.2 两比特能表示的四种状态

pg_xact的存储设计非常节省:每个 TransactionId 只占 2 个 bit。2 个 bit 能表达四种状态,正好对应 PostgreSQL 代码里的四类事务状态。

状态二进制值含义
TRANS_STATUS_IN_PROGRESS00事务还在进行中
TRANS_STATUS_COMMITTED01事务已提交
TRANS_STATUS_ABORTED10事务已回滚/中止
TRANS_STATUS_SUB_COMMITTED11子事务已提交,但还要看顶层事务最终结果

因为一个事务 ID 只占 2 bit,所以一个默认 8KB 的数据页可以存放 32768 个事务状态。pg_xact的每个段文件由 32 个页组成,因此一个段文件可以记录 1048576 个事务的状态。段文件的名字是四位十六进制数,从0000开始,第一个文件可以覆盖到1048575号事务,第二个文件从1048576号事务开始。

实际事务号范围里有一些保留位置:0 号、1 号、2 号事务被系统保留,其中 TransactionId 2 是冻结事务 ID,可见性判断里会被直接当成“永远已提交”。业务事务通常从 3 号开始分配。

顺带算一下文件与事务号的对应关系:

段文件序号 = xid / 1048576 段内页号 = (xid % 1048576) / 32768

这个对应关系对排查问题很实用。比如日志里报错说访问事务1976823456失败,你可以立刻算出它大约在1976823456 / 1048576 = 1885,也就是十六进制75D对应的段文件附近。虽然实际还要考虑事务号回卷和段截断,但计算思路完全一致。

1.3 它和 MySQL 那些“回滚日志”不是一回事

从 MySQL 迁移过来的同事经常会用 Undo Log 去理解pg_xact,这是一个容易踩坑的误区。MySQL 的 Undo Log 保存的是行的历史版本,用于回滚和一致性读数据构造;而 PostgreSQL 的历史版本就在堆表自身的数据页里,通过xmin/xmax串联起来,真正的“旧值”不需要另外存一份。

pg_xact更像一个“结果标记簿”:它只记录事务最终是提交还是回滚,不保存任何被修改的旧内容。所以它的体积理论上非常小,每个事务只要 2bit。如果某一天你发现pg_xact目录异常膨胀,往往不是它本身存储量大,而是有东西阻止了系统对前端“历史段”的截断,后面我会专门讲这个场景。

2. 设计思路与核心机制拆解

2.1 为什么提交时不需要把每一行都改一遍

先考虑一个反事实设计:如果想让查询知道一行是否可见,最简单的办法是提交时把所有修改过的行都标记成“已提交”。在一个大事务里更新 100 万行,提交时就得上百万次随机写,这样的数据库不可能在高并发下生存。

PostgreSQL 把“每行状态”换成了“每事务状态”。提交时只需要把当前事务在pg_xact里的状态从in_progress改成committed,然后写一条提交 WAL 记录。之后任何事务查询到本事务修改过的元组时,根据元组头部的xmin去pg_xact查状态即可。

这样的设计把提交路径从“O(修改行数)”降到了“O(事务数)”,写入放大极小。这也是 PostgreSQL 能在 TPS 很高的情况下保持稳定提交的重要基础。

也正因为如此,pg_xact的访问路径必须非常快。它在共享内存里有对应的 SLRU 缓冲池,热点事务的状态会先被缓存在内存里;只有读不到时才会真正去读磁盘上的pg_xact文件。绝大多数情况下,查询可见性只是几次内存查找。

2.2 状态什么时候写入、崩溃了怎么办

事务结束时的两条路径会触发状态写入:

  • 提交:TransactionIdCommitTree会把事务状态改成 committed;
  • 回滚/中止:TransactionIdAbortTree会把事务状态改成 aborted。

注意一点:一个事务从in_progress变成aborted同样要写pg_xact。很多新手以为只有提交才写,实际上回滚也必须登记。否则并发事务看到一个已经回滚事务的xmin,可能误判为“仍在进行中”,从而长期阻塞清理。

崩溃恢复场景更关键。数据库宕机时,内存里已经标记了“committed”但还没来得及落盘的pg_xact数据,理论上会丢失。PostgreSQL 用 WAL 解决这个问题:提交过程会写对应的提交 WAL 记录,崩溃恢复时 WAL 重放会把事务状态重新沉积到pg_xact中。所以你在文件系统层面看到的pg_xact内容,是“WAL 重放之后的结果”,而不是只靠直接落盘保证。

子事务也有自己的一套状态处理。一个子事务结束时会先标记为sub_committed,真正的最终可见性取决于顶层事务是否提交。子事务到顶层事务的映射关系存放在pg_subtrans目录里。虽然这两个目录职责不同,但排查问题时经常要一起看,尤其是长事务和子事务很多的应用场景。

2.3 段文件什么时候会被截断

pg_xact并不是无限增长。后台会在安全的前提下把最老的历史段删掉,这个动作通常由检查点触发,调用的是源码里TruncateCLOG这一类逻辑。但“安全”的前提非常关键:如果一个事务的xmin还很老,它未来仍然可能需要判断那些更老事务的提交状态,那么前面的段就不能随便删。

换句话说,只要系统里还存在一个很老的快照或者一个idle in transaction会话,它就把整个pg_xact的截断点“按住”了。我在生产里见过最长的一个故障现场:一个事务在凌晨开启后忘了关闭,客户端挂了一个通宵,第二天检查发现pg_xact段文件比正常情况多家几十个。业务本身没有大量写入,纯粹是老事务挡住了清理。

这里的底层逻辑和age()函数是一致的。你可以用age(backend_xmin)看到某个后台会话持有的最老快照“年龄”。年龄越大,不仅意味着事务号回卷风险越高,也意味着pg_xact前端段越难被截断。

3. 管理员的日常:怎么查、怎么观察、怎么维护

3.1 查看文件和当前事务状态

先看目录文件:

ls -lh $PGDATA/pg_xact

正常环境里这个目录下的文件不会太多,每个文件固定 256KB。文件数量与数据库的写入频率、检查点频率、是否有长事务有关。你也可以在 SQL 里直接读取目录列表:

SELECT * FROM pg_ls_dir('pg_xact') ORDER BY 1 LIMIT 20;

看当前事务号:

-- PostgreSQL 13 以上推荐 SELECT pg_current_xact_id(); -- PostgreSQL 12 及更早版本 SELECT txid_current();

查看某个事务的状态:

-- PostgreSQL 13 以上 SELECT pg_xact_status(123456::xid8); -- PostgreSQL 12 及更早版本 SELECT txid_status(123456);

pg_xact_status返回的文本通常是committed、aborted、in progress。如果事务号已经被回收或者还没有分配,状态也可能不是你想的那样,这一点在排查时要留意。

更常用的手段是从pg_stat_activity里找当前会话的backend_xid和backend_xmin:

SELECT pid, state, backend_xid, backend_xmin, age(backend_xmin) AS xmin_age, now() - state_change AS state_age, query FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC;

这条 SQL 是排查“pg_xact为什么没截断”的第一利器。backend_xmin为很老值的会话,极有可能就是元凶。

3.2 和 freeze、事务 ID 回卷的配合

如果只有pg_xact没有 freeze,PostgreSQL 还是没有解决根本问题。事务 ID 是 32 位整数,总共约 42 亿个。一旦事务号循环回绕,旧事务可能被误判成新事务,可见性就会全部错乱。为了防止这种情况,PostgreSQL 会把足够老的元组xmin标记为 frozen(冻结),冻结后的元组不需要再查pg_xact就能判断为已提交。

因此,pg_xact的“截断”和“freeze 进度”是强绑定的。你可以用下面几条 SQL 检查数据库和表的冻结进度:

SELECT datname, age(datfrozenxid) AS frozenxid_age FROM pg_database ORDER BY 2 DESC; SELECT relname, age(relfrozenxid) AS relfrozenxid_age FROM pg_class WHERE relkind IN ('r', 'm') ORDER BY 2 DESC LIMIT 20;

如果某个库的age(datfrozenxid)已经很接近autovacuum_freeze_max_age,就要警惕了。默认参数下,这个值通常为 2 亿。超过阈值后 autovacuum 会被强制启动做防回卷清理。最极端的情况是数据库进入保护模式,报错阻止新事务分配,因为再不处理就可能出现数据损坏。

相关参数需要一起理解:

参数默认值作用
autovacuum_freeze_max_age200000000触发防回卷 autovacuum 的最大事务年龄
vacuum_freeze_min_age50000000执行 vacuum 时尝试冻结多少事务之前元组
vacuum_freeze_table_age150000000全表扫描式 vacuum 的触发阈值

在企业环境里,我会建议监控脚本把age(datfrozenxid)达到autovacuum_freeze_max_age * 80%设为告警线,而不是等真的撞到 2 亿才处理。

3.3 什么时候该手动干预

最典型需要手动干预的场景,就是前面说的长期不结束的事务。

排查并终止:

SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND now() - state_change > interval '1 hour';

这条 SQL 会直接杀掉空闲事务,但生产环境一定要先和业务确认。事务被杀后,对应的backend_xmin才会消失,pg_xact前端段才能被安全截断。然后再执行一次:

CHECKPOINT;

检查点会触发对pg_xact的截断动作。安全操作顺序是:先清掉老事务,再执行 checkpoint,最后观察目录文件数量。如果文件数量没有明显下降,再回头检查是否有其他会话持有更老的backend_xmin。

另外还有一个容易忽略的场景:连接池中的连接虽然看起来没有活动查询,但如果是idle in transaction状态,它一样会持有xmin。连接池清理、应用超时设置,都会直接影响到pg_xact的维护。

4. 故障排查:pg_xact 相关事故实录

4.1 故障一:pg_xact 目录文件多,磁盘占用异常

我曾处理过一个“看起来是磁盘问题”的工单。监控告警显示某实例的pg_xact目录占用比同规格其他实例大很多,文件已经排到了04A0之后。刚开始怀疑是大量短事务导致段文件过多,但查了xact_commit指标,TPS 并不高。

真正原因是一个应用连接通过长连接开启事务后,客户端代码抛异常但忘记回滚,连接一直停留在idle in transaction。它的backend_xmin非常老,导致 PostgreSQL 一直无法截断它之前的 CLOG 段。后台 autovacuum 因为那个事务还活着,也无法把相关元组彻底冻结清理,整个清理链路被卡住了。

处理方法是先确认这个会话所属应用,随后由业务方允许后执行pg_terminate_backend,再执行CHECKPOINT。目录文件数量在两个检查点后逐步回落,故障解除。

这个案例给我们的经验很简单:pg_xact文件多,不要第一反应是“删文件”,要先去查“谁挡住了截断”。

4.2 故障二:状态访问报错 / 文件损坏

如果你在日志或客户端看到下面这类错误:

ERROR: could not access status of transaction 3232235777

说明某个事务 ID 对应的 CLOG 页读不到,或者读出来的内容不符合预期。常见原因包括:

  • 磁盘坏道导致pg_xact文件物理损坏;
  • 有人手动删除或覆盖了pg_xact目录下的文件;
  • 数据目录是从运行中的实例直接拷贝,没有经过备份工具,导致 SLRU 文件与 WAL 不匹配;
  • 文件系统异常导致页面校验失败。

出现这种情况,正确的恢复优先级如下:

动作是否可以执行说明
立即停库应该避免更多并发查询放大损坏
找到最近的一致性备份应该全量备份 + WAL 归档恢复是最安全路径
调用 pg_resetwal极不推荐它会重置 WAL 和对事务状态的认知,可能掩盖问题并造成数据不一致
手动删除 pg_xact 文件绝对禁止等于告诉数据库“这些事务不存在”,结果完全是未知的
用 zero_damaged_pages 修复不适用该参数只针对堆表页面,不负责 CLOG 损坏

需要特别强调:pg_resetwal确实是社区里最后的手段,它能让数据库“启动”起来,但代价是丢失所有早期事务状态,可能导致原本已提交的数据变成看不到,或者原本回滚的数据被当作已提交。这个工具只适合测试环境验证,生产环境不到万不得已不要碰,碰完也必须立刻重建实例并从备份恢复业务。

4.3 故障三:备库和复制场景里的状态差异

物理复制环境下,备库也有自己的pg_xact,但它不是从主库直接拷贝文件,而是通过 WAL 重放维护的。所以备库的pg_xact内容,永远和当前重放位置一致。如果主库采用异步复制,主库上已经返回给客户端提交成功的事务,在备库上可能还没有重放到,备库查询也就看不到这行“已提交”的数据。

这时候不要怀疑备库pg_xact坏了。正确排查顺序是先看复制延迟:

SELECT pid, state, replay_lsn, replay_lag, now() - pg_last_xact_replay_timestamp() AS replay_age FROM pg_stat_replication;

如果pg_last_xact_replay_timestamp()已经很旧,优先检查主库 WAL 发送、备库 I/O 和网络带宽,而不是去动pg_xact文件。备库上的pg_xact目录完全由重放过程接管,任何手工增删文件的行为都会破坏一致性,这一点和主库一样敏感。

5. 性能与高并发场景的观察建议

5.1 pg_xact 会不会成为瓶颈

在很长一段时间里,PostgreSQL 的 CLOG 相关共享锁(源码里常听到的CLOGControlLock或类似锁竞争)在高并发提交场景下都是被关注的点。大量小事务疯狂提交时,每个事务都要改pg_xact缓冲页,核心页上的锁竞争可能让提交路径变慢。

现代版本通过减少锁粒度、优化 SLRU 页管理和 group commit 机制,已经把这个热点降得很低,但在极端场景下仍然可能观测到:

  • pg_stat_database里xact_commit很高,但 TPS 上不去;
  • 系统态 CPU 占用偏高,共享内存相关锁等待严重;
  • 调用栈停在 CLOG/SLRU 相关函数上。

处理思路不是“绕过 pg_xact”,而是减少无谓的小事务数量。常见优化包括:

  • 把应用里的“每行一条 INSERT + 立刻 COMMIT”改成合理批量提交;
  • 检查是否使用了不必要的BEGIN/COMMIT包裹只读操作;
  • 对于报表类会话,尽量使用较短事务,避免长事务持有旧快照;
  • 如果确认是磁盘 fsync 压力,可以评估commit_delay/commit_synchronous_priority等参数,但要结合可靠性要求,不要为了性能牺牲持久化语义。

5.2 给监控系统加的几个指标

我建议在监控大盘里长期保留下面几个和pg_xact相关的指标:

  • pg_database里每个库的age(datfrozenxid);
  • pg_stat_activity中backend_xmin为最老事务的年龄,以及idle in transaction会话数量;
  • pg_xact目录下的段文件数量;
  • 最近检查点时间和 WAL 产生速度;
  • xact_commit、xact_rollback的比值。

其中“目录文件数量”可以用定时任务读pg_ls_dir('pg_xact')得到。一旦发现文件数量出现阶梯式上涨,优先去查活动会话和事务快照年龄,通常都能找到结论。我个人的经验是:pg_xact的问题绝大多数不是它自己的问题,而是“长事务 + 清理停滞”的连锁反应。

6. 实践里最管用的几条管理习惯

6.1 接手新库先做一次“事务卫生”体检

刚接手一套 PostgreSQL 实例时,我习惯第一时间跑三组 SQL:查pg_database的冻结年龄,查活跃会话里的最老backend_xmin,查pg_xact目录文件数量。这三组数据能快速告诉我这个库是不是埋了雷。尤其要关注那些“看起来没业务量但 xmin 很老”的连接,它们往往是未来的故障源。

6.2 让开发团队理解“事务要短”

很多pg_xact相关故障,翻到根上都是业务代码把事务开得太长。一个事务内不要做外部 API 调用、不要等待用户输入、不要跨多个微服务同步等待。事务里所有操作应该在几十毫秒内完成。这个约定比任何数据库参数都管用,因为它能直接减少大量旧快照和未提交事务。

6.3 备份和演练比修复更重要

pg_xact一旦物理损坏,几乎没有无损修复的手段。真正能兜底的,是定期做过验证的备份和 WAL 归档。我在团队里立了一条规矩:每个季度至少做一次备份恢复演练,恢复时间目标恢复点目标都要明确。演练的重点之一,就是看恢复后的pg_xact状态能否正常查询。只有恢复出来的库能被业务真实查询验证,备份才算有效。

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

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

立即咨询