PostgreSQL 的堆表数据最终会落到操作系统文件,但普通 SQL 查询无法看到数据页内部的物理结构。pageinspect 是官方 contrib 模块,它通过调用底层函数把页头、行指针、元组头等二进制内容解析成可读行集,适合排查死元组、表膨胀和更新链问题。启用扩展后,可以快速验证数据是否真正被清理、更新是否产生新版本。

数据页物理布局与 page_header 输出解读
PostgreSQL 默认页大小是 8KB,每一页最前面是页头,用来记录该页的元数据。page_header 函数接收某个关系的原始页内容,并返回 lsn、checksum、flags、lower、upper、special、pagesize、version、prune_xid 等字段。其中 lower 表示行指针数组的末尾位置,upper 表示最新元组的起始位置,这两者之间的区域就是可插入新元组的空闲空间。理解这种从两端向中间分配空间的布局,是判断页面是否有碎片以及空间是否充足的基础。
假设有一张测试表 test,可以这样查看它的第 0 页头部信息。注意 get_raw_page 会返回 bytea 类型的原始页数据,通常是后续解析函数的第一步。
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT * FROM page_header(get_raw_page('test', 0));
执行后会得到类似 lsn 为十六进制字符串、lower 和 upper 为整数、pagesize 固定为 8192 的结果。对于刚创建的空表,页头中 lower 通常较小,upper 接近页尾;而对于已经写入大量数据并删除部分行的页,lower 和 upper 之间的空间可能变得分散,但页头并不会直接告诉你有多少碎片,还需要结合行指针和元组列表继续分析。
除了 page_header,page_checksum 函数可以单独计算某页的校验和,heap_page_items 专门解析堆表页内所有条目。索引页则使用 bt_page_stats 和 bt_page_items 等函数,本次重点放在堆表。
heap_page_items 解析行指针与元组头
heap_page_items 是 pageinspect 中最常用的函数之一,它把页内每一行指针以及指向的元组都展开成一条记录。每个行指针用 lp 表示编号,从 1 开始;lp_off 表示元组的字节偏移;lp_flags 说明行指针状态,0 表示普通、1 表示重定向、2 表示死元组、3 表示未使用;lp_len 是元组长度。真正决定一行数据可见性的是元组头字段:t_xmin 记录插入这个元组的事务 ID,t_xmax 记录删除或更新它的新事务 ID,值为 0 时表示还没有被删除。
下面这条 SQL 可以列出第 0 页所有条目,并观察几个关键字段。
SELECT lp, lp_off, lp_flags, lp_len,
t_xmin, t_xmax, t_ctid, t_infomask, t_hoff
FROM heap_page_items(get_raw_page('test', 0));
返回结果中 t_ctid 非常实用,它表示该元组当前版本的物理位置,格式为 (页号,行指针编号)。如果元组没有被更新,t_ctid 就指向自己的 (0,1) 之类位置;如果发生非 HOT 更新,就会指向新版本的 (页号,新行指针)。而 t_hoff 表示元组头长度,真实数据从 lp_off + t_hoff 开始。读取这些底层值可以让你对 PostgreSQL 的 MVCC 机制建立更具体的认识。
元组头里还有大量位标志存储在 t_infomask 和 t_infomask2 中,例如 HEAP_XMIN_COMMITTED、HEAP_XMAX_COMMITTED、HEAP_HOT_UPDATED 等。直接查看这个整数会看到类似 2306 这样的值,如果要细看,可以借助 heap_tuple_infomask_flags 函数,不过它需要较高一些的 PostgreSQL 版本支持。很多情况下,结合 t_xmin、t_xmax 和 t_ctid 就足以判断一条元组是活跃的还是一个旧版本。
从 ctid 定位到具体页与元组
堆表中的每一行都有一个隐藏列 ctid,它正是由页号和行指针编号组成的,但普通查询只能看到值,看不到页内二进制结构。pageinspect 的价值就在于把 ctid 的值与 page_header、heap_page_items 的结果串联起来。比如先插入两行数据,然后查看它们落在哪个页。
CREATE TABLE test(id integer, val text); INSERT INTO test VALUES (1, 'hello'), (2, 'world'); SELECT ctid, id, val FROM test;
查询结果可能显示 (0,1) 和 (0,2),说明两行都在第 0 页。然后调用 heap_page_items(get_raw_page('test', 0)) 就能看到这两条元组的完整头部信息。如果想定位到某一条具体记录,可以先根据 id 查出它的 ctid,再按页号调用 pageinspect,不需要扫描整个表文件。
接下来观察一次更新操作。执行 UPDATE test SET val = 'updated' WHERE id = 1; 后再次查询 ctid 和页面内容,会发现页内出现了第二条关于 id=1 的元组。旧元组的 t_xmax 不再是 0,而是执行更新事务的 ID;新元组的 t_xmin 是同一个事务 ID。如果更新发生在同一页内,旧元组的 t_ctid 会指向新元组的行指针编号,这就是 HOT 更新的典型特征。若新元组无法放在同一页,就会写到另一个页,t_ctid 的页号也会随之变化。
通过这种方法可以非常直观地看到 MVCC 并不在更新时直接覆盖原数据,而是保留旧版本并写入新版本。结合 VACUUM 操作,还能观察旧元组被清理后行指针状态的变化:删除或更新产生的旧版本在 VACUUM 后可能变成行指针状态 2 或 3,从而释放空间供后续插入使用。
利用 pageinspect 排查死元组与膨胀
表膨胀通常和死元组没有被及时清理有关。借助 heap_page_items 可以统计单个页面内 lp_flags = 2 的死行指针数量。例如对第 0 页执行下面的查询,就能知道该页还有多少行指针被标记为死状态。
SELECT count(*) AS dead_item_count
FROM (
SELECT * FROM heap_page_items(get_raw_page('test', 0))
) AS items
WHERE lp_flags = 2;
如果连续多个页都出现大量死行指针,说明这张表可能已经积累了较多死元组,需要安排 VACUUM 或考虑调整 autovacuum 参数。虽然 pg_stat_user_tables 中的 n_dead_tup 也能反映死元组数量,但 pageinspect 能看到页面级别的分布,很适合定位某些页面为什么空间利用率极低。
需要提醒的是,pageinspect 访问的是数据库文件原始内容,调用 get_raw_page 和 heap_page_items 都需要足够的权限,通常要求超级用户或者具有 pg_read_server_files 角色的账号。生产环境不建议大规模扫描所有页,因为每次调用都会产生磁盘读取,可能影响在线业务。更稳妥的做法是选择几个关键页或者通过 ctid 定位到问题记录所在页进行分析。
除了堆表,pageinspect 还支持索引页检查。比如 B-Tree 索引可以用 bt_page_stats 查看页面类型和分裂情况,用 bt_page_items 查看索引项。但无论是堆表还是索引,核心思路都是先获取原始页,再交给对应解析函数输出可读字段。掌握了 pageinspect 的基本用法,排查存储层面的问题会更有依据。
pageinspect数据页头元组结构修改时间:2026-09-18 08:12:32