前阵子碰到一个挺闹心的故障:某张业务表的容量持续上涨,磁盘告警都触发了,可业务写入量并没有明显增加。跑了一遍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.x | 1.5及以前 | heap_page_items字段较少,无vm_page_items |
| 10.x ~ 12.x | 1.6 ~ 1.7 | 增加page_checksum等功能,函数签名调整 |
| 13.x ~ 14.x | 1.8 ~ 1.9 | heap_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_flags:I 表示行指针有效,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_lower:
SELECT 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_next:B-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_contents:index-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时用了不同的块大小(比如16KB),pageinspect的某些字段值会显得"不正常"。做对比检查前先确认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的移动方向都记下来,下次生产环境遇事你就有底了。真正到了线上排查的时候,这种"底层直觉"比任何脚本都管用。