☰
PostgreSQL慢查询排查:用pageinspect揪出HOT链拥堵的元凶
2026/10/21 7:54:25 网站建设 项目流程

前阵子某生产库出了件怪事:一张不到2000万行的订单表,单条主键查询却稳定地跑出300毫秒。索引在、统计信息在、磁盘IO也没瓶颈,重启、重建索引折腾一圈,过几天又打回原形。最后是一个没啥存在感的contrib扩展——pageinspect——把问题钉死在了页面字节级。这篇文章我把整个排查过程沉淀了一下,顺便把这个工具从入门到实战讲透。如果你是PostgreSQL DBA、内核爱好者,或者正被“索引失效”“表膨胀”“慢点查”折磨,这篇值得认真读一遍。

1. 为什么要把数据库页面当成“案发现场”来翻

1.1 先交代一个让我开始怀疑人生的现场

当时业务方反馈的很直接:主键点查变慢,而且不是偶发,是持续性的。我第一反应是看执行计划,结果走的就是主键索引,cost也正常;再看锁,没有阻塞;看统计信息,刚刚收集过;看缓存命中率,99.99%。所有常规指标都正常,但查询就是慢。

后来我把表rebuild了一次,问题消失,但一周后复发。这种“重建就恢复、过几天又坏”的现象,说明问题不在逻辑层,而在物理层。数据库表在磁盘上不是一张“表”,而是一堆固定大小的页面,索引也不是一个单纯的树形概念,而是由一个个物理页组成的B树。常规工具都工作在逻辑层,只有把物理页扒开看,才能看到真实发生了什么。

pageinspect就是干这个的。它是PostgreSQL自带的contrib扩展,功能一句话:把表和索引的底层页面结构以SQL可查的形式暴露出来。它能看页面头部、行指针、元组的事务信息、索引页的统计和索引项,还能看空闲空间映射和可见性映射。说人话就是:它把数据库的“硬盘字节”翻译成了人能读的表格。

1.2 PostgreSQL 的页面模型没那么神秘

要理解pageinspect的输出,先得知道页面长什么样。PostgreSQL的表和索引最终都落在8KB的页面(block)上,这个大小由编译参数BLCKSZ决定,绝大多数发行版都是8192字节。每个页面的结构是固定的,从前往后大致是这样:

  • 页面头(PageHeader):24字节固定头,记录最近修改的LSN、校验值、空闲空间边界、页面版本号等。
  • 行指针数组(Line Pointer Array):从页面头部之后开始往后分配,每个行指针4字节,记录行数据的偏移、长度和状态。
  • 行数据(Heap Tuple / Index Tuple):从页面尾部往前分配,真正的数据。
  • 特殊空间(Special Space):索引页面用来存放页面私有信息,堆表页面通常不用。

听起来抽象,拿仓库货架类比就顺了:页面头是货架侧面的铭牌,记录货架编号和剩余空间;行指针数组是货架上的编号标签,告诉你第几格放着货;行数据是货本身;特殊空间是给特定货架类型预留的固定区域。数据插入时,货从货架末端往前摆,标签从货架前端往后贴,中间剩下的就是空闲区。

页面头的关键字段,pageinspect的page_header函数会原样返回:pd_lsn、pd_checksum、pd_flags、pd_lower、pd_upper、pd_special、pd_pagesize_version、pd_prune_xid。其中pd_lower代表行指针数组结束的位置,pd_upper代表行数据开始的位置,两者之间就是空闲空间。判断一个页面是不是快满了,看upper和lower之间的距离就够了。

1.3 pageinspect 的定位与边界

很多刚接触的人容易把pageinspect和另外两个扩展搞混:pgstattuple、pg_freespacemap。它们确实都和物理存储有关,但分工不同:

工具/扩展看什么典型输出
pageinspect原始页面字节的逐字段解析行指针、元组头、索引项
pgstattuple页面级别的统计汇总元组占比、死元组占比、空闲率
pg_freespacemap每个页面的空闲空间分布空闲空间占总空间的比例

简单说,pgstattuple给你体检报告,pageinspect给你CT片子。pgstattuple告诉你“这表膨胀了10%”,pageinspect告诉你“第1000号页面里有37个死元组,集中在某个偏移区间”。

pageinspect也不是全能的。它只读页面结构,不解析所有类型的索引,比如GIN、BRIN只有部分函数支持;它不能直接告诉你“这条SQL为什么慢”,需要你结合业务和标准查询去判断。它的价值在于,当你已经怀疑物理页面有问题,又不想用外部工具去解析数据文件时,它是PostgreSQL体系内最顺手的“显微镜”。

2. 一分钟装好它,再把第一张页面“读”出来

2.1 激活扩展与权限

安装非常简单,一条SQL搞定:

CREATE EXTENSION pageinspect;

注意两点。第一,这条命令需要超级用户权限;第二,和很多contrib扩展一样,pageinspect在PostgreSQL发行版的contrib目录里,标准的云数据库和二进制发行版基本都自带,不需要额外编译。装完后可以确认一下:

SELECT name, default_version, installed_version FROM pg_available_extensions WHERE name = 'pageinspect';

权限方面需要说清楚。默认情况下,pageinspect的所有函数都只允许超级用户执行。如果你在托管数据库上,通常只有主用户有权限。在比较新的PostgreSQL版本里,权限模型有调整,可以把函数执行权限授予非超级用户,但工具内部会检查调用者是否对目标表有相应权限。实操中我的建议是:日常排查用超级用户,生产环境的只读账号如果要用,需要明确授权,并且只给必要的函数,不要一把梭把整个扩展的EXECUTE都放开。

2.2 get_raw_page + page_header

pageinspect最底层的函数是get_raw_page,它把指定表的某个物理页当作bytea读出来。光读出来是乱码,所以通常配合page_header来解析页面头。页面编号从0开始,main表示主数据分支。

SELECT * FROM page_header(get_raw_page('t_user_order', 'main', 0));

我自己在测试表上跑出来的结果是这个样子的:

lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid ------------+----------+-------+-------+-------+---------+----------+---------+------------ 0/16F4A20 | 0 | 0 | 232 | 800 | 8192 | 8192 | 4 | 0

逐列解释一下:

  • lsn:这个页面最后一次被修改时写入WAL的位置。如果两个页面的lsn相差很远,说明近期修改过。
  • checksum:校验值。如果开启了data checksums,这里会是实际校验值;没开启就是0。
  • flags:页面标志位组合。0表示普通状态。
  • lower和upper:分别对应行指针区末尾和行数据区起点。upper-lower=568字节,就是当前页面的空闲空间。
  • special:特殊空间起点。对堆表来说,这个值通常等于pagesize,也就是没有特殊空间。
  • pagesize和version:页面大小和页面版本号,正常都是8192和4。

这套输出最常用的场景,是判断页面是不是“半满”。如果upper和lower的距离接近0,说明页面快满了,插入可能触发页分裂或者行迁移。

2.3 空页面与数据页面的对比实验

建议你亲自动手做一次“空页对照实验”,比看一百篇文档都直观。先建一张空表:

CREATE TABLE t_page_demo (id int, name text); SELECT * FROM page_header(get_raw_page('t_page_demo', 'main', 0));

这时候你会看到lower=24,upper=8192,空闲空间高达8168字节。插入几千行后再查,upper会明显下降,lower会上升。如果删除一部分行但不vacuum,upper和lower几乎不变,因为删除只是把行标记为不可见,并没有物理回收空间。这时候就能直观理解“死元组”为什么会让表膨胀。

我建议把这个实验做完整:空表 → 插入数据 → 删除数据 → VACUUM → 再查一遍。每次记录lower和upper的变化。做完你就明白为什么vacuum之后表文件大小不变、为什么vacuum full才能回收空间,这些概念的肌肉记忆比背命令重要得多。

3. 堆表体检:heap_page_items 全解

3.1 关键列一次说清

page_header只是开胃菜,heap_page_items才是堆表页面最核心的函数。它把页面上每一行的行头和行指针都拆成字段返回。用法:

SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid, to_hex(t_infomask) AS infomask_hex FROM heap_page_items(get_raw_page('t_user_order', 'main', 0)) LIMIT 10;

输出大致长这样:

lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | infomask_hex ----+--------+----------+--------+--------+--------+--------+-------------- 1 | 8160 | 1 | 184 | 123456 | 0 | (0,1) | 902 2 | 7976 | 1 | 184 | 123457 | 0 | (0,2) | 902 3 | 0 | 0 | 0 | | | |

逐个看关键字段:

  • lp:行指针编号,相当于“第几个槽位”。更新行时,如果旧槽位被清理,编号可能被复用。
  • lp_off:行数据在该页面的偏移量。
  • lp_flags:行指针状态,1表示正常,2表示HOT重定向,3表示dead,0表示未使用。
  • lp_len:行数据的长度。
  • t_xmin:插入该行的事务号。结合事务快照,可以判断这行是什么时候插入的。
  • t_xmax:删除或更新该行的事务号。如果是0,说明这行没有被删除或更新过。
  • t_ctid:行自身的物理位置,或者指向更新后新版本的物理位置。这是追踪HOT链的关键。
  • t_infomask:行头部的状态位,判断事务提交状态、是否被冻结、是否有锁等。

这里要区分两个概念:行指针(lp_)和元组头(t_)。行指针是页面级的“目录项”,元组头是行数据自己的“身份证”。同一个行可能被更新多次,每次更新都会生成一个新元组,但索引可能只指向其中某一个。

3.2 t_infomask 与行状态推断

t_infomask是十六进制状态位,直接看数字没意义,需要按位解析。最常用的几位:

位掩码含义
0x02HEAP_XMIN_COMMITTED,插入该行的事务已提交
0x04HEAP_XMIN_FROZEN,插入事务已冻结,对VACUUM免疫
0x08HEAP_XMAX_INVALID,没有删除/更新事务,行可见
0x10HEAP_XMAX_COMMITTED,删除/更新事务已提交
0x20HEAP_XMAX_IS_MULTI,xmax是MultiXactId,涉及锁或共享

实操里判断一行是否“活着”,最直观的方式是看t_xmax是不是0,以及t_infomask的第0x08位是否为1。如果t_xmax是0,这行从未被删除或更新,肯定是活行;如果t_xmax非0且对应事务已提交,这行就是死元组。

我写过一个简单的“页面体检SQL”,翻译了常见的状态位:

SELECT lp, CASE lp_flags WHEN 1 THEN 'normal' WHEN 2 THEN 'redirect' WHEN 3 THEN 'dead' ELSE 'unused' END AS lp_status, t_xmin, t_xmax, t_ctid, (t_infomask & 2)::int > 0 AS xmin_committed, (t_infomask & 8)::int > 0 AS xmax_invalid, (t_infomask & 4)::int > 0 AS xmin_frozen FROM heap_page_items(get_raw_page('t_user_order', 'main', 0)) ORDER BY lp;

这套SQL用来回答一个高频问题:“vacuum为什么没把这页清干净?”你往往能在这页里发现某一行t_xmax非0,但对应事务还在运行,导致vacuum认为这个版本仍然可能被可见性判断需要,于是选择跳过。这种事用别的工具很难看出来。

3.3 用 HOT 链追踪行版本

HOT链是理解“更新导致索引查询变慢”的核心概念。PostgreSQL有一个优化机制叫Heap-Only Tuple:当UPDATE后的新版本能放进同一个页面时,新元组不新增索引条目,而是让旧元组的t_ctid指向新元组的位置。

比如一个页面里,一个行的历史版本可能长这样:

(0, 1) -> t_ctid = (0, 5) (0, 5) -> t_ctid = (0, 9) (0, 9) -> t_ctid = (0, 20)

索引指向(0,1),也就是链头。每次扫描索引后还要顺着链往下走,如果链很长,点查就会从“一次索引访问”退化成“一次索引访问加N次堆访问”。pageinspect的价值在于,你可以直接统计一个页面里的HOT链长度:

WITH items AS ( SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('t_user_order', 'main', 0)) ) SELECT count(*) AS chain_len, min(t_xmin) AS oldest_xmin, max(t_xmin) AS newest_xmin FROM items WHERE t_ctid != ('(0,' || lp || ')')::tid;

这是我后面实战环节真正派上用场的查询。它统计页面里那些“指向了其他行”的元组数量,基本就是当前页面的HOT链长度。链长超过几十,查询性能就会出现肉眼可见的退化。

4. 索引页同样能拆:btree 三件套

4.1 bt_metap 看索引的“总目录”

B树索引由元页(metapage)、根页、内部页和叶页组成。元页是索引的总目录,记录了B树的根页位置、树的高度、版本号等。pageinspect里对应的函数是bt_metap:

SELECT * FROM bt_metap('idx_user_order_id');

它的输出包括magic、version、root、level、fastroot、fastlevel等。magic和version用于判断索引格式是否与当前版本兼容;root告诉你根页在哪;level表示树高。level是排查索引问题的第一指标,如果一张大表的索引level异常高,比如超过4层,访问路径会变长,查询自然变慢。

bt_metap的价值更多是“建档”。我会在索引性能出现波动之前记录下root、level、fastroot这些基线值,之后出现异常时对比,能快速判断是索引结构变了,还是数据分布变了。

4.2 bt_page_stats 看页面健康度

bt_page_stats返回指定索引页面的统计信息:

SELECT * FROM bt_page_stats('idx_user_order_id', 0);

输出里值得关注的是live_items、dead_items、free_size这三列。live_items是页面里有效索引项数量,dead_items是需要清理的死亡项数量,free_size是页面空闲空间。

这个函数在诊断索引膨胀时极其好用。如果一个叶页dead_items很大,说明vacuum还没来得及回收,或者回收进度跟不上删除频率;如果free_size整体偏小,说明索引页面填充率过高,删除后又没有回收空间,这时重建索引比单纯vacuum更合适。

它还有一个用途:检查索引页是否出现了“类型错误”。B树索引页有四种类型:root、internal、leaf、empty。组合查询btpo_flags和type列,能很快定位到某个页面是不是被写坏了。

4.3 bt_page_items 还原索引项

bt_page_items把索引页面里的索引项逐条列出来,包括itemoffset、ctid、dead、data。用它能看到每个索引项指向哪个堆表位置,以及索引键的数据部分。最典型的检查场景是所谓“zero page”报错。

有一次某测试库报错信息是:index contains unexpected zero page。这种错误通常是某个索引页面全部都是0字节,可能是崩溃恢复不完整或者磁盘问题。我的排查顺序是:

-- 1. 先看元页,确认B树结构是否完整 SELECT * FROM bt_metap('idx_user_email'); -- 2. 逐页检查统计,找到异常页 SELECT blkno, type, live_items, dead_items FROM bt_page_stats('idx_user_email', 0); -- 3. 对可疑页逐项查看 SELECT itemoffset, ctid, dead, data FROM bt_page_items('idx_user_email', 1) LIMIT 20;

如果bt_page_stats显示某一页的type是empty或者live_items异常,基本可以圈定损坏范围。处理方式不是手工改页面,而是对这个索引做REINDEX CONCURRENTLY。pageinspect在这里的作用是确认范围、评估影响,避免盲目在全库REINDEX。

5. 实战复盘:主键慢查询背后的 HOT 链拥堵

5.1 现象与常规手段失效

回到开头说的生产事故。那张订单表的主键点查从1ms劣化到300ms,索引和统计信息都正常。我把常规手段依次试过:analyze、vacuum、更新统计信息、重建索引。每次重建之后能好一两天,然后又劣化。

这个“周期性复发”的现象让我意识到,问题一定和数据变更模式有关。查了一下业务,发现这张表的UPDATE频率非常高,而且每次UPDATE都会修改一个很短的字段。由于表比较窄,更新后的新版本经常能留在同一个页面内,于是PostgreSQL走HOT机制,不停在页内追加新版本。从逻辑层看,索引条目数量没怎么涨,表的总行数也没涨,但页面内部的版本链却在悄悄拉长。

5.2 pageinspect 锁定根因

我选了一个经常被点查的索引,先用bt_page_stats确认叶页状态,再对对应堆页面用heap_page_items做抽样:

SELECT lp, t_xmin, t_xmax, t_ctid, to_hex(t_infomask) FROM heap_page_items(get_raw_page('t_user_order', 'main', 267)) ORDER BY lp;

结果让我很不舒服:一个页面上存在大量t_ctid指向后续偏移的元组,有的链长达到一百多个版本。索引项虽然还在,但每次点查都要沿着这条链走完,300毫秒就是这么来的。单独看每一条SQL都合理,叠加起来就成了稳定慢查询。

这里有个知识点值得强调:HOT机制本身是优化,不是问题,问题出在“长链”。HOT链太长时,它的负面影响超过了省掉索引更新的收益。官方文档和社区讨论普遍认为超过几十个版本的链就应该警惕,而当时那个页面已经远超阈值。

5.3 修复与后续预防

短期修复是VACUUM FULL加上REINDEX,把表重写一遍,所有长链归零。但这个操作会锁表,必须在维护窗口做。做完查询立刻回到1ms,但我知道只要业务继续高频UPDATE,链还会重新长出来。

长期方案分两步。第一步是业务层面,把这种“频繁更新短字段”改成批量合并更新,减少UPDATE次数。第二步是数据库层面,调高表的fillfactor,从默认的100降到80左右,让更新时有更多空间留在当前页面反而促成HOT?这里要慎重:fillfactor调低其实会增加页面数量,但减少页内行数,HOT链不一定会更短。真实有效的预防是把UPDATE分解、降低频率、避免在同一行上反复打转,同时用pageinspect定期抽样监控长链。

这次事故给我的经验是:常规的慢查询排查手段解决的是“索引有没有用”,而pageinspect解决的是“索引引用的那条数据链到底发生了什么”。两者缺一不可。

6. 使用边界、权限与生产纪律

6.1 权限不全是你想象的那样

很多文档只说“需要超级用户权限”,但实际使用里有几个细节值得单独记一下。第一,pageinspect在较新的PostgreSQL版本中调整了权限模型,部分函数可以被授予给非超级用户,工具内部会检查调用者是否对目标表有读权限。这比老版本更安全,也更适合只读副本环境。

第二,某些托管数据库服务可能不开放CREATE EXTENSION的权限,即便你是主账号。这时可以试试直接使用扩展中已内置的函数,不行就得联系服务商开白名单。

第三,pg_read_all_data角色能否直接使用pageinspect?答案是否定的,除非显式授予函数权限。它有读数据的能力,但pageinspect函数默认不受该角色覆盖。我在一个项目里就吃过这个亏,排查账号看着权限很全,一调用get_raw_page就报permission denied。

6.2 只读工具也有它的“软风险”

pageinspect的定位是只读检查,它不会修改页面字节。但“只读”不等于“零副作用”。它读取页面时,需要获取共享缓冲区里的页面内容锁,虽然只是共享锁,但在业务高峰期对一张大表进行全页扫描式排查,会显著放大锁竞争,甚至让某些核心查询产生可感知的延迟。

我踩过的坑是在一个千万级表上循环调用get_raw_page,从第0页扫到最后一页,发现CPU和锁等待都出现了尖峰。后来学乖了:先按pg_relation_size估算总页数,再随机抽取有代表性的页面,比如0号页、中间页、末尾页,以及从FSM信息里看到“接近满”的页。先把问题范围缩小,再决定要不要扩大扫描。

另一个容易忽略的点是:即使你在SQL里对get_raw_page返回的bytea做修改,也不会真正写盘,因为那只是内存副本。它看起来像“能改底层数据”,实际上不会。如果真要修损坏的页面,应该走备份恢复、pg_surgery这种明确用于修复的路径,绝对不要手工拼字节。

6.3 生产环境操作守则

综合几年的使用经验,我给自己定了六条纪律:

  1. 只读原则:pageinspect只用来诊断,不用来修补。
  2. 先在测试库练手:任何新函数,先在和开发环境同构的库上跑一遍,熟悉输出格式再上生产。
  3. 抽样代替全扫:大表先随机抽样页面,定位到可疑页再针对看。
  4. 避开业务高峰:检查类操作放到维护窗口或只读备库上执行。
  5. 保留基线记录:定期记录关键表的页面数、索引level、平均链长,异常时才有参照物。
  6. 对结果保持怀疑:pageinspect输出的是原始页面的快照,如果同时有其他进程在做DDL或vacuum,读到的可能是中间状态,需要结合pg_stat_activity判断。

7. 经验速查:常见报错与函数选择

7.1 常见报错对照表

报错信息可能原因处理思路
permission denied for function get_raw_page当前用户无权限使用超级用户,或显式授予函数权限
relation "xxx" does not exist表名错误,或不是当前schema使用带schema的完整名称
page is not a heap page用heap_page_items检查了索引页换成bt_page_items/bt_page_stats
invalid page in block 0页面本身损坏先确认坏页范围,评估是否从备份恢复
index contains unexpected zero page索引页内容为空用bt_metap确认结构,REINDEX INDEX CONCURRENTLY
fatal error: unable to read block磁盘IO问题或文件缺失检查系统日志,恢复对应表空间

7.2 业务场景与函数选择表

排查目的首选函数辅助手段
判断页面填充度page_header对比pg_relation_size
统计死元组比例heap_page_items结合pgstattuple
追踪HOT链长度heap_page_items结合业务更新频率分析
判断索引树是否健康bt_metap记录基线level和root
定位索引页死项bt_page_stats配合vacuum状态
还原索引键值bt_page_items用于确认索引与数据一致性
查看FSM空闲空间分布fsm_page_contents或pg_freespacemap扩展
查看VM可见性映射vm_page_contents配合vacuum和index-only scan问题

7.3 三条独家心得

第一,pageinspect和pg_freespacemap一起使用,诊断表膨胀的效率会高很多。free space map先告诉你哪些页面几乎空了,pageinspect再去翻那些页面确认里面是不是堆满了死元组,两步就能定位膨胀源头。

第二,排查索引问题时,先bt_metap、再bt_page_stats、最后bt_page_items的顺序不要乱。三个函数看的是不同粒度,跳过中间层直接看明细,容易淹没在大量输出里。

第三,把pageinspect的关键查询做成SQL模板存起来,固定字段、固定格式。遇到问题直接套模板,比现场临场拼SQL省事得多,也更容易对比历史数据。

说到底,pageinspect给我的感觉像是一个“数据页面的案发现场勘查工具”。它不解决所有问题,也没有魔法,但当你面对那些“逻辑层怎么看都正常”的诡异故障时,它是少数几个能让你看到物理真相的入口。我个人实战中的体会是:学会看页面,才算真正开始懂PostgreSQL的存储引擎。这个工具的深度远比表面看起来大,值得每个DBA花一个下午去研究它。

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

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

立即咨询