
很多人从装好 PostgreSQL、建库建表再到用 Navicat、dbx 这类客户端工具把数据查出来可能从头到尾都没注意过数据目录下那个叫 pg_xact 的小文件夹。我第一次真正盯上它是在一个生产库报“事务号即将耗尽”告警的深夜。那次排障让我明白一件事PostgreSQL 后端管理里不少看似诡异的问题最后都会落到这个目录头上。这个目录在 PostgreSQL 10 之前叫 pg_clog很多老教程还沿用这个名字。它本质上是整个集群的“事务提交状态日志”说得再直白一点PostgreSQL 要回答“某个事务到底提交了没有”全靠查这里的两个比特位。无论你是刚接触 PostgreSQL 的开发者还是正在学着管库的 DBA搞懂 pg_xact 都是绕不开的一步。1. pg_xact 到底干了什么它是一本集群级“事务档案册”1.1 从 pg_clog 到 pg_xact换了个名字老毛病还在如果你翻 PostgreSQL 10 之前的资料会看到大量关于 pg_clog 的说法。CLOG 是 Commit Log 的缩写也就是“提交日志”。PostgreSQL 10 做了一次较大规模的目录组织调整把很多内部模块的命名规范理顺了pg_clog 就这样变成了 pg_xact。名字改了但机制一点没变它记录每一个事务最终是成功提交还是被回滚。这个目录位于集群的数据目录根下也就是$PGDATA/pg_xact。注意它和base/下面那些按数据库 OID 划分的子目录完全不一样它不属于任何单独数据库而是整个集群共享的。原因也很简单PostgreSQL 里事务 ID 是集群级分配的不存在“两个数据库各自独立编号”这种事。我第一次在客户现场查这个目录时发现对方把整个数据目录打包备份却没有把pg_xact当回事觉得一个“日志”丢了也无所谓。这个认知非常危险。你可以把pg_xact理解成小区的来访登记簿保安要确认一个访客到底有没有被业主接待过必须翻这本登记簿。没有登记簿整个小区的出入管理都会瘫痪。1.2 两个比特决定一个事务的生死状态pg_xact里存的数据极度“吝啬”每个事务只使用 2 个比特位四种状态00事务正在进行中01事务已提交10事务已回滚11子事务已提交但父事务结果未定2 个比特也就意味着 1 个字节可以存 4 个事务的状态1 个 8KB 的标准数据页能存 32768 个事务一个 256KB 的段文件能覆盖大约 1048576 个事务。为什么需要这么省因为高并发场景下每秒都会分配大量事务 ID如果每个事务状态浪费 1 个字节存储量会非常可观。于是你会在pg_xact下看到一串文件0000、0001、0002……每个文件固定 256KB按事务 ID 顺序排列。数据库本身不会因为“文件太多”而停顿但如果你是第一次打开这个目录看到上百个文件堆在那里多少会有点震撼。那意味着这个库已经经历过极其漫长的事务历史或者某些维护动作没有跟上。1.3 状态记录和 WAL 的关系很多人搞反了顺序这里要澄清一个很容易被误解的点。很多 DBA 以为事务提交记录“写在 pg_xact 里”所以 pg_xact 是持久化的根本。实际流程是反过来的事务提交时PostgreSQL 会先写 WAL 日志pg_walWAL 里包含提交动作的完整记录pg_xact只是把这个提交状态再做一次内存级别的登记便于后续可见性判断时快速检查。换句话说pg_xact更像是 WAL 的“快捷投影”。崩溃恢复时系统会优先重放 WAL并从 WAL 中重建需要的 CLOG 页面。这也是为什么手册上会强调备份时不能只拷数据文件而丢掉pg_wal也不能在崩溃恢复后随意删除pg_xact里的旧段文件因为某些尚未重放完毕的事务状态仍然要依赖它。2. 事务状态是怎么被记录和读取的核心机制拆解2.1 事务 ID 的分配只有写事务才“上户口”PostgreSQL 的事务 ID 是一个 32 位无符号整数理论可分配数量约 42 亿个。实际使用中系统不会像掏公交卡一样频繁为每个只读查询分配事务 ID。只读事务通常只需要获取一个快照不真正写数据也就不占用事务 ID。判断逻辑非常直白事务是否分配了真实的 XID取决于有没有产生任何写操作。一旦执行INSERT、UPDATE、DELETE这类语句事务就会从共享内存的nextXid计数器里取一个号码同时把这个新值记录到控制文件pg_control。以后所有这个 XID 参与的状态判断都要到pg_xact里去查。这里有个需要留意的细节PostgreSQL 默认的隔离级别是读已提交Read Committed同一个事务内多条语句会各自取快照但并不会因此分配多个 XID。只有产生了写动作XID 才会被真正分配。所以只读负载再高也不会消耗事务 ID。2.2 写路径提交与回滚如何更新状态当事务决定提交时流程是这样的把提交记录写入 WAL 缓冲区刷到pg_wal在内存中把事务状态从IN_PROGRESS更新为COMMITTED按事务 ID 定位到pg_xact的对应页写入这 2 个比特更新pg_control中的相关位置信息。回滚路径基本对称只是状态改成ABORTED。这里要注意写出事务状态时会涉及加锁操作这也是某些极端高并发写入场景下pg_xact会成为轻微瓶颈的原因之一。不过相比pg_wal的 fsync 开销pg_xact的锁竞争通常不会成为首要问题。另外子事务的状态比主事务复杂。PostgreSQL 内部会使用SUB_COMMITTED标记子事务先提交但父事务未定的情况。这样做的目的是减少主事务提交时级联刷状态的压力。这个设计细节很容易被忽略但当你排查“为什么子事务很多时数据库会变慢”这类问题时就会想起这 2 比特设计并不简单。2.3 读路径一条查询到底什么时候会碰 pg_xact在 PostgreSQL 中一条普通 SELECT 要判断某一行是否对自己可见需要查元组头部的xmin和xmax字段。当系统需要确认“创建该元组的事务是否已提交”时就会调TransactionIdDidCommit()最终落到pg_xact查询。不过实际执行时系统会先在元组的t_infomask里看有没有HEAP_XMIN_COMMITTED或HEAP_XMIN_INVALID标记。如果标记已经存在就不需要再查pg_xact。这些标记是上一次访问元组时留下的“缓存”大大减少了重复查状态的次数。所以你会看到一种现象某些频繁读取的旧数据查询性能稳定而大量刚刚写入的新数据第一次读取时可能因为状态判断而去访问pg_xact。如果此时pg_xact相关页面不在共享内存里就会产生一次额外的 IO 读取。2.4 SLRU 缓存凭什么随机查也还挺快pg_xact使用的是 PostgreSQL 内部通用的 SLRUSimple Least Recently Used缓存机制。简单理解就是共享内存里固定放了一批 8KB 的页面槽位用来缓存最近访问过的 CLOG 页。每次读取事务状态时先看对应页面在不在共享内存里如果不在就加载加载不下的旧页面被按 LRU 策略淘汰。默认情况下pg_xact相关缓存槽位数并不大只有几十个页面。为什么这么少也基本够用因为正常健康的数据库里所有活跃事务的 XID 都集中在最近的一小段区间最常被查询的状态页其实就那么几页。只要 vacuum 和 freeze 机制正常工作事务年龄跨度不会失控缓存命中率就会保持在很高水平。反过来一旦数据库里出现极老的长事务或者很久没有做有效的 freeze 清理事务年龄跨度拉大pg_xact需要查询的文件范围变广缓存命中率下降某些查询就会出现莫名其妙的 IO 延迟。3. 实操篇日常怎么巡检、怎么监控、怎么调参3.1 五分钟定位集群当前事务状态我在接手一个新 PostgreSQL 环境时第一件事就是看事务年龄。命令很简单pg_controldata $PGDATA | grep -i -E oldest|checkpoint|next然后看一眼pg_xact目录里的文件数量ls -lh $PGDATA/pg_xact | tail -20文件数量本身不是绝对标准但突然增多或突然出现大量新段文件时就值得留意了。更关键的判断要回到 SQL 层面下面这段查询是必须背下来的SELECT datname, age(datfrozenxid) AS xid_age, datfrozenxid FROM pg_database ORDER BY age(datfrozenxid) DESC;age(datfrozenxid)表示最老未冻结事务的年龄。这个值越大说明离“事务 ID 回卷”风险越近。再往表级别查一层SELECT relname, age(relfrozenxid) AS xid_age FROM pg_class WHERE relfrozenxid IS NOT NULL ORDER BY age(relfrozenxid) DESC LIMIT 20;一般定位到这里就能看出问题出在哪张表上。如果某张表的年龄远高于其他表大概率是这张表过大、autovacuum 一直没扫完或者某些长事务拖住了清理进度。3.2 关键参数事务年龄的“安全阀”PostgreSQL 本身有一套防止事务 ID 回卷的机制核心参数就是autovacuum_freeze_max_age默认值是 200000000也就是 2 亿。当某张表的age(relfrozenxid)超过这个值autovacuum 会强制对该表执行防回卷清理也就是把足够老的事务标记为 Frozen。Frozen 的意思是事务 ID 被替换成一个特殊常数FrozenTransactionId此后判断可见性时根本不需要再查pg_xact相当于把状态判断永久提前“写入”了元组本身。这样旧的事务状态就可以被安全遗忘pg_xact里的对应段文件也就有了清理空间。参数调优上我不建议无脑调小。autovacuum_freeze_max_age调小确实能让系统更早地做 freeze但会造成更频繁的全表扫描在 IO 紧张的机器上反而引发性能抖动。保持默认值然后加强年龄监控比粗暴改参数更实际。3.3 手动 VACUUM FREEZE 的正确打开方式当你确认某张表年龄逼近危险区可以手动触发冻结清理VACUUM (FREEZE, VERBOSE) my_table;如果情况紧急需要处理整个库可以执行VACUUM (FREEZE, VERBOSE);但这里必须提醒一句全库VACUUM FREEZE不是靠“跑一下”就完事它会把大量表从头扫到尾可能持续数小时而且会对 CPU、内存、IO 产生明显压力。我在生产环境遇到年龄告警时通常不会直接全库跑而是先通过上面的查询把年龄最高的前 20 张表找出来分批处理每批挑业务低峰期跑。这样既控制风险又能最快降低数据库整体年龄。3.4 备份恢复时别忘了 pg_xact 的联动关系用pg_basebackup做物理备份时pg_xact会被完整包含。PITR时间点恢复场景下恢复进程会重放pg_wal并在这个过程中重建所需的 CLOG 页面。实操经验里比较隐蔽的坑是有人为了省空间手动删掉pg_xact里的旧段文件结果恢复时报如下错误PANIC: could not access status of transaction xxxx这基本上就是删除pg_xact段文件导致的典型惨案。请永远记住pg_xact文件的清理是检查点checkpoint和清理进程的职责DBA 手工去删属于越权操作没有任何收益只有风险。4. 常见故障与排查实录4.1 故障一数据库突然“拒收命令”这是我遇到过的、与 pg_xact 最直接也最棘手的故障之一。现象是应用层开始报错ERROR: database is not accepting commands to avoid wraparound data loss翻译一下数据库为了不让事务 ID 回卷造成数据丢失拒绝执行命令了。触发条件是最老事务的年龄超过了安全阈值默认大约是 2^3121.4 亿减掉一些预留余量。一旦走到这一步常规连接基本已经进不去通常需要停止数据库然后以单用户模式single-user mode启动并执行 VACUUMpg_ctl stop -D $PGDATA postgres --single -D $PGDATA postgres然后在单用户模式的命令行里执行VACUUM;完成后退出再正常启动数据库。这个动作非常考验操作熟练度一旦操作不当数据库会更长时间不可用。真正的解决方案永远是提前监控age(datfrozenxid)在它逼近百万级别就该介入而不是等到拒收命令。4.2 故障二pg_xact 目录文件数量异常暴增正常系统里pg_xact段文件通常只有几个到十几个。如果你看到几十个甚至上百个文件先不要慌张但也别忽视。这类情况最常见的根因是有一个超长事务一直不结束。打个比方你觉得某个访客还在小区里门口的登记簿就不能扔。哪怕这个访客其实早就走了只要系统不知道怎么确认登记簿就得一直留着。对应到数据库只要还有未结束的旧事务清理机制就无法推进需要移除的最老事务边界于是pg_xact段文件越积越多。排查用这个 SQLSELECT pid, xact_start, state, now() - xact_start AS xact_duration FROM pg_stat_activity WHERE state idle ORDER BY xact_start ASC LIMIT 10;找到最老的xact_start确认是误开的空闲事务还是代码里未关闭的事务然后处理掉。长事务结束后再手工跑一次VACUUM FREEZE或等待 autovacuum 推进段文件就会逐渐清掉。4.3 故障三误删 pg_xact 文件导致实例起不来有一次在客户现场对方说“我们清理了一下数据目录里没用的文件数据库就崩了”。我过去一看pg_xact被删了好几个文件。日志里大量出现ERROR: could not read status of transaction 123456789这种错误没有任何在线修复手段。被删掉的 CLOG 页只能从 WAL 重放恢复如果周期已经太久、相关 WAL 已回收那唯一出路就是做备份恢复。我在无数次排障里最想强调的就是数据目录下所有不以.tmp、.bak结尾的目录默认都先假设有用删之前必须确认真实用途。pg_xact尤其如此它确实是日志性质的目录但它不是日志文件它是元数据。4.4 故障四高并发写入时偶发卡顿还有一类更隐蔽的问题不体现在应用报错上而是表现为高并发阶段出现偶发延迟。如果你的库事务吞吐量极高且大量事务状态落在不同的pg_xact页面上SLRU 缓存频繁换页就会造成额外 IO。这种情况我会先确认是否有长事务拉大了事务年龄边界再检查是否有表长时间没做 freeze。你还可以通过pg_stat_statements观察是否有大量 SQL 在等待缓冲区相关事件。实际操作中多数场景最后都能收敛到长事务或过度膨胀的已删除数据把这两个问题解决掉pg_xact相关的偶发卡顿基本会消失。4.5 排查速查表现象可能原因优先处理方式数据库拒收命令最老事务年龄接近 2^31单用户模式执行 VACUUM同时检查长事务pg_xact 文件大量堆积存在长时间未结束事务查pg_stat_activity.xact_start结束长事务报错 could not read statuspg_xact 文件缺失或损坏从备份恢复无在线修复手段高并发偶发延迟SLRU 换页频繁清理长事务、推进 freeze、降低事务年龄跨度age(datfrozenxid) 快速上涨autovacuum 未及时清空大表定位大表分批 VACUUM FREEZE5. 最后分享一点我自己的实操体会管理 pg_xact 这件事表面上看是一个“目录管理”问题本质上一直在和“事务年龄”这一件事打交道。我是从一次事故里彻底记住这个结论的当时有个业务系统连续跑了近一年没做任何 freeze 相关维护最老表的事务年龄已经进入倒计时而监控面板上看到的只是磁盘和 CPU 一切正常。等告警真的拉响时留给你的处理窗口已经非常狭窄。所以如果你今天只带走一件东西我希望是那几条监控 SQL。把它们写进巡检脚本每周跑一次重点盯age(datfrozenxid)和pg_xact目录文件数量。另外再重复一遍永远不要手动删除pg_xact下面的任何文件。清理动作交给数据库自己完成这是它设计好的职责边界。数据库管理这一行走得越深越会发现很多看似神秘的问题背后都藏着一个很朴实的设计。pg_xact 名字看着高冷拆开看不过就是那 2 个比特位的事。把事务状态管理这件小事理顺了PostgreSQL 的整体运行状态也就掌握了一大半。