资讯动态

PostgreSQL底层故障排查:pageinspect工具实战详解

发布时间:2026/10/21 7:55:18 来源:尧图企业网站定制
前阵子碰到一个挺闹心的故障某张业务表的容量持续上涨磁盘告警都触发了可业务写入量并没有明显增加。跑了一遍VACUUM空间也纹丝不动。我用常规的pg_stat_user_tables、pg_stat_progress_vacuum查了一圈都看不出名堂。最后是靠pageinspect直接抓取数据文件的原始页面一层层掰开来看才定位到大量死行堆叠在某几个页面上触发了VACUUM的延迟清理逻辑。从那之后pageinspect就成了我排查PostgreSQL底层问题时的固定备选工具。如果你也是DBA、数据库内核开发者或者正在为线上PostgreSQL的诡异问题头疼这篇东西就是为你写的。pageinspect不是那种日常运维天天用的工具它更像是一台数据库文件显微镜——能直接读取表文件、索引文件、可见性映射文件里的原始字节并解析成可读的结构化数据。它解决的核心问题是当SQL层面、统计信息层面都找不到答案时如何从存储层面找到真相。1. 为什么需要pageinspect数据库文件页的显微镜先讲清楚pageinspect的定位。PostgreSQL的数据库文件不是文本文件而是按固定大小默认8KB切分的页面块每个页面有页头、行数据、空闲空间、特殊空间等区域。正常情况下你不需要关心这些SQL语句会把底层细节全部封装掉。但当问题出在正常路径之外——比如崩溃恢复异常、索引损坏、死行残留、页内碎片混乱——SQL层就无能为力了。pageinspect干的正是这件事它绕过常规的读写接口直接按文件页的二进制布局读取内容并以行记录的形式把页头字段、行指针、事务ID、数据内容这些细节展示出来。打个比方如果把PostgreSQL比作一栋楼普通运维手段能看的是房间号、住户登记表pageinspect则是直接拆开墙体看里面的钢筋和水泥。1.1 与常规诊断工具的分工差异pg_stat_user_tables、pg_stat_all_tables只能给你汇总数字比如n_dead_tup又涨了多少但不会告诉你死行具体分布在哪一页。VACUUM能清理死行但如果碰到旧快照阻塞、页内碎片、或某些页被跳过你很难知道清理不彻底的直接原因。pg_stat_progress_vacuum能看到VACUUM进行到哪一步但看不到它为什么在这个页面停住。pageinspect能直接咔嚓一刀切开页面告诉你某一页里有多少个有效行、多少个死行、这些行的事务状态是什么、行指针指向哪里。这也是我常用的组合拳先用统计视图发现问题再用pageinspect定位具体页面最后根据页面细节决定是VACUUM、VACUUM FULL、REINDEX还是直接物理重建。1.2 什么时候你会真正需要它不是每个问题都要上pageinspect。我个人遇到的需求大致分几类表文件占用异常增长但VACUUM之后空间不回收。索引扫描结果和顺序扫描不一致怀疑索引损坏。崩溃恢复后某些页数据异常需要确认损坏范围。想研究PostgreSQL内部机制比如事务可见性、MVCC实现细节。排查wal日志、checkpoint相关的底层行为。前两类是生产环境的真实痛点后两类更偏开发和原理研究。无论哪种pageinspect都能给出SQL层拿不到的证据。2. 安装与版本匹配五秒钟装好但细节容易翻车pageinspect的安装本身特别简单一条命令的事CREATE EXTENSION pageinspect;前提是PostgreSQL的contrib包已经装了。用编译安装的源码包在contrib/pageinspect目录下执行make make install用apt/yum装的一般是postgresql-contrib这个包。这些就不展开了生产库上唯一要记住的是——在执行CREATE EXTENSION之前确认好版本。2.1 版本检查这件事为什么重要pageinspect的函数大多直接解析页面结构而页面结构在不同大版本里是有变化的。以最常见的get_raw_page(text, integer, text)为例旧版本里fork参数是整数新版本改成了文本heap_page_items返回的字段版本间也有增删。我曾在一套旧版本实例上直接使用了新版本命令结果函数压根不存在报错信息还特别含糊。所以装扩展前先跑一下SELECT extversion FROM pg_extension WHERE extname pageinspect;然后对照官方发布说明里的变更点。这里给个大概的版本对照经验说明这是基于常见发行版的实际观察不同小版本之间可能存在细微差异PostgreSQL版本pageinspect版本主要变化9.x1.5及以前heap_page_items字段较少无vm_page_items10.x ~ 12.x1.6 ~ 1.7增加page_checksum等功能函数签名调整13.x ~ 14.x1.8 ~ 1.9heap_page_item_attrs的返回字段更完整15.x及以上1.10支持更多新页面格式btree函数调整较大这个表不是官方文档的抄录是我在不同环境里实际操作下来的大致记忆。真到某个具体环境最可靠的办法还是先试一个函数再看报错。2.2 权限模型边界pageinspect的权限问题容易被忽略。默认情况下get_raw_page(text, integer, text)这类函数只允许超级用户执行。普通业务账号即使有表的SELECT权限也可能在执行时收到类似permission denied的报错。原因是它要读取文件系统层面数据风险等级偏高。如果确实需要给某个运维账号放开建议单独建角色只授权特定函数并且严格控制在使用范围。我见过有人在生产库直接给所有业务账号授权后来安全扫描直接报警又灰头土脸逐个回收。游戏规则是pageinspect是诊断工具不是常规巡检工具权限越窄越好。2.3 官方函数的整体地图打开contrib/pageinspect的sql文件你会看到一组函数。我整理了一份最常用的清单后面几节会结合实战逐个细说函数名作用典型场景get_raw_page(text, integer, text)获取指定文件的原始页字节所有检查的起点page_header(bytea)解析页头信息判断页是否损坏heap_page_items(bytea)解析堆表页内的行项死行分析、事务状态排查heap_page_item_attrs(bytea, text, integer)在行项基础上解析字段属性查看行数据具体内容bt_page_stats(text, integer)获取B-tree索引页统计索引页空闲、溢出判断bt_page_items(text, integer)罗列B-tree索引页条目索引结构检查fsm_page_contents(bytea)解析空闲空间映射页空间分配问题vm_page_contents(bytea)解析可见性映射页了解index-only scan线索page_checksum(bytea, integer)计算页校验和校验损坏页先记住它们的关系大部分高层函数heap_page_items、bt_page_stats等都接受低层函数get_raw_page的返回结果作为输入。也就是说get_raw_page相当于一把撬锁工具后面的函数才负责把锁撬开之后的东西分类摆好。3. 从页头开始拆解page_header与heap_page_items的解析逻辑pageinspect最有价值的用法是先定位一个页然后逐层分析。我在测试环境里构造过一张简单的表来做示范你可以照着自己试试。CREATE TABLE demo_page(id integer primary key, name text); INSERT INTO demo_page SELECT g, name_ || g FROM generate_series(1, 1000) AS g; DELETE FROM demo_page WHERE id % 100 0;先拿一个页的原始数据SELECT get_raw_page(demo_page, 0, main) AS raw_page;默认的main fork存储主堆数据。如果你指定0号块就能拿到第一页的8KB原始字节。这串字节直接看是乱码所以要用page_header解析页头SELECT * FROM page_header(get_raw_page(demo_page, 0, main));3.1 页头字段到底在说什么PostgreSQL的页头结构固定为24字节page_header会返回这些字段pd_lsn最近一次修改该页的WAL日志位置它告诉你这一页最后一次被改动是在哪个日志序列号上。pd_flags位掩码标志位常见的是PD_HAS_FREE_LINES等。pd_lower/pd_upper这两个字段非常关键。页面空间布局是两头往中间挤页头从低地址页首往下放行数据从高地址页尾往上放。pd_lower指向空闲区起点pd_upper指向空闲区终点。如果你看到pd_lower接近pd_upper说明页面内存被占得差不多反之则说明页内碎片化严重。pd_special特殊空间的起始位置索引页用它存储内部结构信息。pd_pagesize_version页面大小和格式版本正常是8192字节配页面版本号。pd_prune_xid最近一次页裁剪的XID是VACUUM判断能否裁剪页内死行的依据之一。我在实际排查时最关注的是pd_lower和pd_upper的差值。有一次一张表的页面占用越来越大但行的平均长度没变用page_header看了一轮发现大量页面的空闲空间被拆碎成了很多小片每页的pd_upper与pd_lower之间都残留大量不可合并的空隙——这实际上就是页内碎片化后面DROP掉大量行之后VACUUM并没有把碎片重新整理回页面末尾。3.2 heap_page_items死行和可见性的真相把页头看明白再往下走一步就是行项。行项也叫ItemIdData每一个行指针4字节指向页内某个实际行数据的位置。heap_page_items能把这一页里的所有行指针都解析出来SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid, t_infomask FROM heap_page_items(get_raw_page(demo_page, 0, main));结果里你会看到很多行每行代表一个行项。重点看lp行指针编号1开始。lp_flagsI 表示行指针有效N 表示无效U 表示未使用的行指针。t_xmin/t_xmax插入这条行版本的事务ID和删除/锁定它的事务ID。这是MVCC的核心证据。t_ctid当前行版本的物理位置如果指向自己说明这是最新版本如果指向别的行说明有更新链。t_infomask位掩码记录行版本状态例如t_xmin是否已提交、t_xmax是否已提交等。最常见的排查动作是结合t_xmin和t_xmax判断这一行对某个快照是否可见。举个场景表里某个事务删了一大批行但删除事务还没提交那么这些行对别人依然可见VACUUM也不该动它们。你如果直接用heap_page_items看会发现t_xmax填的是那个删除事务的ID而t_infomask的某个高位是0——表示删除事务未提交。这种情况下空间不回收是完全正常的行为不用瞎折腾。3.3 一个完整的判断案例死行到底死没死回到前面我构造的demo表删除掉id % 100 0的行后跑一次不带有完整清理参数的VACUUM然后再看SELECT count(*) FILTER (WHERE t_xmin 0 AND t_xmax 0) AS dead_rows FROM heap_page_items(get_raw_page(demo_page, 0, main));如果统计出来的dead_rows比预想多就要排查是不是有非常老的只读事务持有了旧快照。这类场景用pageinspect查比逮着pg_stat_activity反复刷要直接得多——你能直接看到死行的t_xmax对应的事务ID然后去pg_stat_activity里按backend_xid签名匹配长时间运行的查询。这在多长事务与VACUUM互相拉扯的案例里是定位根因的关键路径。4. 实战排查表膨胀、VACUUM失效与孤儿数据的典型场景直接进入生产实战最常遇到的几类问题。每个场景我都尽量把为什么会这样用什么函数怎么查查出来的结果怎么解读讲透。4.1 场景一表文件膨胀但VACUUM后容量不降这是我最常被问到的。一个表已经跑了VACUUM但pg_relation_size还是有几十GB甚至比实际数据量大好几倍。很多人第一反应是VACUUM不完整其实更精确的说法是VACUUM回收了行但没有把页面末尾的空间返还给操作系统。PostgreSQL里VACUUM会清理死行、合并页内空闲空间但默认不会把空闲空间截断。只有VACUUM FULL才会重写表文件并收缩。如果你确认清了死行但仍没有降到合理大小用pageinspect看所有页面的pd_upper与pd_lowerSELECT blkno, pd_lower, pd_upper, (8192 - pd_lower) AS free_bytes FROM ( SELECT blkno, (page_header(get_raw_page(demo_page, blkno, main))).pd_lower AS pd_lower, (page_header(get_raw_page(demo_page, blkno, main))).pd_upper AS pd_upper FROM generate_series(0, (pg_relation_size(demo_page) / 8192 - 1)) AS blkno ) t WHERE pd_upper - pd_lower 32 ORDER BY free_bytes DESC LIMIT 20;这串SQL会找出所有空闲区接近耗尽、几乎无法再插入新行的页。如果这些页很多说明表确实处于快满状态——但里面的数据可能是大量死行占着位置VACUUM还没来得及处理。这时候再叠加heap_page_items查看具体页的死行占比就能把膨胀根因和死行位置一起坐实。4.2 场景二确认VACUUM到底有没有清理目标页有一次某客户环境跑完VACUUM之后我顺手抽查了若干页发现某些页面的死行还是原封不动躺在那。原因不是VACUUM偷懒而是页面头部有PD_ALL_VISIBLE标志问题、或者是页内存在准备中的事务事务ID处于in-doubt状态导致清理被跳过。页内死行的判定条件是t_xmin或者t_xmax对应的事务对所有快照都不可见。而要判断这个最直觉的方式就是对比当前事务快照ID和页面里的事务ID。pageinspect给人一种硬核证据的感觉它把每个行版本的事务信息摊开你可以精确到这一页第3个行指针的死行是某个特定事务留下的。对照结果如果发现是prepared transaction导致的那就要去处理两阶段提交残留事务。这也是pageinspect的妙处它把问题从VACUUM怎么这么弱转成某个具体机制阻止了清理方向一换解法立刻清晰。4.3 场景三堆表与索引不一致索引损坏最典型的表现是走索引扫描和走顺序扫描返回的行数不一样。这种问题通常数据文件本身没坏而是索引条目和堆表行对应关系错位。用pageinspect读B-tree索引页再用堆表页交叉比对可以快速确认损坏范围。具体做法通常是用bt_page_stats看索引页的整体状态。用bt_page_items列出索引页内的键值和指向堆表行位置的指针。用heap_page_items检查堆表页里对应的行是否存在。这是一个以索引为中心的排查路径。如果索引条目指向的行在对应页面根本不存在或者键值与堆表行的实际数据对不上那就基本实锤索引损坏。后续动作就是CONCURRENTLY REINDEX避免长时间锁表。5. 深入B-tree索引bt_page_stats与bt_page_items的配合使用索引页和堆表页的布局不完全一样。索引页没有行指针的概念取而代之的是索引元组数组页底有一个特殊的高键/低键结构用于B-tree的层次遍历。pageinspect为B-tree专门设计了两个函数。5.1 bt_page_stats的字段怎么读SELECT * FROM bt_page_stats(demo_page_idx, 1);返回的字段中比较重要的是live_items/dead_items页内有效项和死项数量。死项过多意味着该索引页有大量更新残留。avg_item_size平均项大小如果异常变大可能存入了超大键或者闹鬼了。free_size页内空闲空间。索引页空闲空间低说明页快满可能导致索引分裂。btpo_prev/btpo_nextB-tree兄弟页指针。这两个字段能帮你发现页链断裂——如果相邻页的prev/next对不上索引结构一定有问题。btpo_flags页类型标志。比如root页、leaf页、dead页都能从这里看出来。有一次我排查一个持续增长的索引发现它的leaf页free_size普遍很低但索引列基数并不大。顺着btpo_prev/btpo_next走了一批页发现很多页存在大量重复键才意识到是应用侧重复写入的问题——这又回到应用逻辑排查了但pageinspect提供了最早的线索。5.2 bt_page_items的条目怎么看bt_page_items会返回索引页内每个条目的详细信息SELECT itemoffset, ctid, itemlen, nulls, vars, data FROM bt_page_items(demo_page_idx, 1);ctid指向堆表里的行位置。data是索引键的原始字节。对于整型键可以直接看出来文本键可能是压缩过的。itemlen是条目长度。实际排查中我经常用bt_page_items按ctid分组统计某个堆表页被多少个索引条目引用。如果引用数量和堆表页里的实际行数不一致那就是索引与堆表之间的一致性出了问题。这种层面的一致性校验PostgreSQL平时没有对外暴露直接接口pageinspect基本是唯一选择。5.3 用bt_page_stats判断索引空洞索引的膨胀不像堆表那么直观但同样存在。大量更新会产生死索引条目VACUUM之后会释放空间但如果更新量太大、VACUUM跟不上你就会发现索引文件明显比实际需要的容量大很多。判断是否该REINDEX可以扫描所有索引页SELECT blkno AS page, live_items, dead_items, free_size, avg_item_size FROM bt_page_stats(demo_page_idx, 1);如果某些页面的dead_items比例超过10%且长期保持即使跑了VACUUM也不下降我会建议直接REINDEX CONCURRENTLY。因为索引页的空闲空间如果被死项碎片化插入新项时很可能触发页分裂导致索引体积越滚越大形成恶性循环。这种情况用常规VACUUM已经救不回来必须重建索引才能让空间布局恢复健康。6. 可见性映射与空闲空间两个容易被忽视的底层信息源除了堆表和索引PostgreSQL还有两类文件页不太被日常工具触及可见性映射VM和空闲空间映射FSM。pageinspect能直接读它们这在某些疑难杂症里非常有用。6.1 vm_page_contentsindex-only scan的幕后影响者可见性映射页记录的是哪些堆页面的所有行对所有事务都可见。如果VM被破坏最直接的影响是index-only scan失效——优化器会认为页面上存在不可见行宁愿回表检查也不走索引覆盖扫描。SELECT * FROM vm_page_contents(get_raw_page(demo_page, 0, vm));输出里能看到页面上哪些块被标记为all-visible、哪些块被标记为all-frozen。all-frozen是PostgreSQL在某个版本后引入的机制用于防止事务ID回卷时反复扫描同一批页面。如果VM标记和实际页面状态不一致——比如标记all-visible但实际存在未提交事务——会造成非常隐蔽的查询结果错误。这种事极少发生真要发生多半是底层损坏或某些特殊操作残留。用vm_page_contents和heap_page_items交叉检查能确认到底是页面标记错了还是行状态异常。6.2 fsm_page_contents空间分配不均的谜底FSM是PostgreSQL决定新行插入哪一页的依据。每个表都有一个FSM文件以树形结构记录每个页面的空闲空间大小。如果FSM和实际页面空闲空间不一致可能会产生表还有大量空闲空间但新行总是插到某个特定页导致页分裂的怪象。SELECT * FROM fsm_page_contents(get_raw_page(demo_page, 0, fsm));输出里能看到FSM页各个槽位记录的空间区段0到255之间的一个档位。当你怀疑表页空间分布不均匀时用get_raw_page扫描几个main fork页再对比FSM记录能快速判断是哪一层出了问题。这一层用得少但一旦用上就是救命稻草。我记得某次排查批量插入性能问题表总体空闲空间充足但插入动作频繁触发页分裂。最后查FSM发现上层页的空闲空间信息没有及时更新导致插入路径总是瞄准某些假满页面。虽然最终修复还是靠VACUUM和重建FSM但pageinspect是当时唯一能直接证明问题的工具。7. 踩坑记录版本差异、页面缓存、权限还有那些文档没写的事工具好用坑也不少。尤其在生产环境里操作一个不小心不只是查不到数据还可能把问题放大。7.1 事务快照带来的不一致假象pageinspect直接读文件页不受MVCC快照控制。这意味着你看到的内容可能包含当前事务还没提交的中间状态。比如在大规模UPDATE进行中你读取页面可能看到有些行t_xmax已经设置但事务还没提交也可能看到某些页面的内容在读取过程中被并发修改。这不是bug而是工具本身的设计——它根本不走事务可见性逻辑。所以排查时尽量在业务低峰期执行或者在明确的时间点快速取样。如果连续两次读取同一页得到的结果不一致多半是有并发写入正在发生不要被中间状态误导。7.2 页面缓存导致重复读结果一样但不是真相PostgreSQL有共享缓冲区shared buffers。get_raw_page读出来的数据实际上是从共享缓冲区或文件里获取的一次读取之后后续同一页的读取可能命中缓存。所以当你修完某个问题后复查页面如果发现结果没变先别急着断定修复无效考虑下是不是缓存里还留着旧页。想要真正读到磁盘文件内容比较粗暴的方法是重启实例不推荐生产这样做或者读到不同fork、不同块的页做对比。实践中我更建议这样处理先明确我要查的是共享缓冲区里的状态还是磁盘文件里的状态如果是磁盘文件损坏排查重启后复查会更干净如果只是解运行时的性能问题缓存里的状态恰恰更有参考价值。7.3 函数入参与返回字段的版本差异pageinspect不同版本的函数行为差异很大我见过的翻车现场还真不少。比如get_raw_page(text, integer, text)在旧版本是get_raw_page(text, integer)新版本才加了fork参数没带fork参数时默认读main fork。如果我在新版本实例上习惯性写了旧版语法会直接报function get_raw_page(text, integer, text) does not exist吗不会因为新版保留了兼容重载但反过来如果在旧版本上写带fork参数的版本就会报错。又比如heap_page_items旧版返回的t_infomask和t_infomask2已经够用但新版会增加一些字段帮助你判断行版本状态。写排查脚本时我会先跑一下SELECT * FROM heap_page_items(NULL)看看实际返回的列再写正式语句避免照搬旧笔记白跑一趟。还有一个坑是不同平台页大小的差异。默认8KB没问题但如果你初始化PG时用了不同的块大小比如16KBpageinspect的某些字段值会显得不正常。做对比检查前先确认current_setting(block_size)这一步别省。7.4 权限授权的分寸把握前面提到权限这里再补充一点即使你是超级用户也不要直接在业务高峰期跑全表页扫描。比如用generate_series列出全部分区、对每个分区都调用get_raw_page和page_header这种操作会瞬间产生海量磁盘读取把数据库IO打满。如果想巡检大表的页面健康度建议分页采样一次只抽查几个疑似页面不要一股脑全扫。毕竟pageinspect的目标是定位与取证不是批量巡检。哪怕只是读取也要有章法否则排查问题的过程中又制造了新问题。7.5 VACUUM之后立刻查页面结果可能过早乐观VACUUM本质上是延迟清理机制。它把死行标记为可重用但不代表页面立刻变得整齐——页内空间依然存在碎片。你跑完VACUUM立刻用heap_page_items看可能还是会看到不少lp_flags为N或者死行这不代表VACUUM没用而说明页内这些槽位尚未被新行复用。想确认碎片是否真的被整理需要看pd_lower和pd_upper的位置变化而不是只看行指针有没有清零。这也是我多次提到的点读pageinspect的结果时脑子里要有页面布局这个坐标轴否则容易被字段值带偏。8. 我在实际使用中的一点建议pageinspect不该是你遇到问题第一个上手的工具但也千万别让它只躺在contrib目录里吃灰。常规检查顺序我会这样安排先看统计信息、看等待事件、看锁冲突解决不了再看WAL日志和错误日志都无果才轮到pageinspect出手。它的价值不在于日常高频使用而在于让你在走投无路时有一条能直接看到存储层真相的路。如果你还没用过建议先在测试实例上建个小表插入几万行、删掉一批、更新一批然后用page_header和heap_page_items反复观察页面的变化。把页头字段变化、死行数量变化、pd_lower/pd_upper的移动方向都记下来下次生产环境遇事你就有底了。真正到了线上排查的时候这种底层直觉比任何脚本都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑