本文摘要:线上建了索引仍 Seq Scan,查询从毫秒级恶化到秒级。EXPLAIN BUFFERS 定位隐式转换等四类根因并量化各节点读取量。
一、问题与结论
一条按user_id精确过滤的查询,线上耗时从 50ms 恶化到 15s。表结构确认user_id上有 btree 索引,EXPLAIN却显示Seq Scan。加了BUFFERS参数后,输出中shared read达到 200 万——约 16GB 数据未命中 shared buffer,被逐块读入内存。
核心结论:索引没生效的原因通常不在索引本身,而在查询写法、数据统计或索引设计三个层面。先用 BUFFERS 定位哪个节点贡献了最多读取,再对照根因逐一排除。
| 现象 | 根因 | 关键线索 |
|---|---|---|
Seq Scan,参数被显式转为text | 隐式类型转换 | Filter出现::text |
| Seq Scan,WHERE 含函数调用 | 函数包裹列 | Filter: (lower(status) = ...) |
| Seq Scan,刚做过 bulk 操作 | 统计信息过期 | ANALYZE后计划切换 |
Index Scan 但shared read高 | 复合索引列顺序或选择率低 | Rows Removed by Filter |
二、排查与选择依据
拿到慢查询后按以下顺序操作:
- 执行
EXPLAIN (ANALYZE, BUFFERS),找到shared read最大的节点。 - 如果是
Seq Scan,检查Filter行:有无类型不匹配或函数包裹。 - 如果
Filter正常,检查pg_stat_user_tables.last_analyze:是否在最近 bulk 操作后刷新过。 - 如果涉及复合索引,确认 WHERE 条件列在索引最左侧(最左前缀原则)。
一个容易忽略的细节:shared read统计的是 8KB 块数,不区分 OS page cache 和物理磁盘。200 万次shared read≈ 16GB 读入 shared buffer,但这 16GB 可能全部来自 OS cache——瓶颈可能是内存带宽而非磁盘 I/O。PostgreSQL 13 起EXPLAIN (ANALYZE, BUFFERS)在各节点附带I/O Timings,可区分 OS cache 读取与实际磁盘读取。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 边界 |
|---|---|---|---|
auto_explain后台自动记录 | 生产环境不方便手动执行 | 需修改shared_preload_libraries并重启 | 默认不带ANALYZE,需显式开启 |
pg_stat_statements定位 top 慢查询 | 尚未锁定具体查询 | 无执行计划和 buffer 明细 | 仅给聚合耗时排名 |
本文方案:手动EXPLAIN ANALYZE BUFFERS | 已定位到具体查询 | ANALYZE真正执行查询 | 不适合写入型语句或超高频调用 |
当查询是 UPDATE/DELETE 且影响范围不确定时,ANALYZE会实际执行修改,不应使用本文方案。调用频率极高的查询也不适合——额外执行一次的开销不可忽略。
三、关键原理
隐式类型转换:当参数以text类型传入时(常见于应用层驱动如 JDBCsetString或 psycopg2),PostgreSQL 对列而非参数做隐式转换以完成类型匹配。user_id是integer,若参数类型为text,planner 生成Filter: ((user_id)::text = $1::text),索引失效。修复方法是确保参数类型与列类型一致。
函数包裹列:WHERE LOWER(status) = 'paid'中status被LOWER()包裹,planner 无法将此条件与status上的 btree 索引匹配。除非建表达式索引CREATE INDEX ON orders (LOWER(status)),否则必然 Seq Scan。
统计信息过期:planner 依赖pg_class.reltuples和pg_stats做代价估算。bulk INSERT/DELETE 后统计未刷新,planner 可能高估结果集大小、错误选择 Seq Scan。执行ANALYZE刷新统计即可恢复正确计划。
复合索引列顺序:btree 索引遵循最左前缀原则。CREATE INDEX ON orders (user_id, status)无法服务WHERE status = 'paid',因为最左列user_id不在条件中。应建(status)或(status, user_id)索引。
四、可运行示例
环境:PostgreSQL 16,shared_buffers默认 128MB,Ubuntu 22.04。
CREATETABLEorders(id bigserialPRIMARYKEY,user_idintegerNOTNULL,statustextNOTNULL,amountnumeric(12,2));INSERTINTOorders(user_id,status,amount)SELECT(random()*999)::int,CASEWHENrandom()>0.5THEN'paid'ELSE'pending'END,round(random()*10000,2)FROMgenerate_series(1,2000000);CREATEINDEXidx_orders_userONorders(user_id);ANALYZEorders;测试 1:隐式类型转换
-- 模拟应用层以 text 类型传入参数EXPLAIN(ANALYZE,BUFFERS)SELECTidFROMordersWHEREuser_id='100'::text;预期输出:Seq Scan on orders,Filter: ((user_id)::text = '100'::text),shared read约数十万。改写为WHERE user_id = 100(整数字面量)后,计划切换为 Index Scan,shared read降至数百。
测试 2:函数包裹列
EXPLAIN(ANALYZE,BUFFERS)SELECTidFROMordersWHERELOWER(status)='paid';预期输出:Seq Scan on orders,Filter: (lower(status) = 'paid'::text)。建表达式索引或改写为精确匹配WHERE status = 'paid'后恢复。
测试 3:统计信息过期
DELETEFROMordersWHEREid>100;-- 不执行 ANALYZEEXPLAIN(ANALYZE,BUFFERS)SELECTidFROMordersWHEREuser_id=100;ANALYZEorders;EXPLAIN(ANALYZE,BUFFERS)SELECTidFROMordersWHEREuser_id=100;预期输出:ANALYZE前 planner 基于过期统计可能走 Seq Scan;ANALYZE后统计刷新,切换为 Index Scan。
测试 4:复合索引列顺序
CREATEINDEXidx_user_statusONorders(user_id,status);EXPLAIN(ANALYZE,BUFFERS)SELECTidFROMordersWHEREstatus='paid';预期输出:Seq Scan on orders——最左列user_id不在 WHERE 中,idx_user_status无法命中。
五、验证结果与边界
预期输出
四个测试的预期输出已在上一节逐项标注。验证要点:对比改写前后的 EXPLAIN 计划,确认Seq Scan切换为Index Scan,且shared read从数十万降至数百。
实际输出
以上示例基于 PostgreSQL 16(200 万行数据)编写。具体shared read数值因shared_buffers和 OS cache 状态而异,但计划类型切换和 BUFFERS 计数趋势在标准配置下稳定可复现。
常见失败
- 改写后仍 Seq Scan → 统计信息过期。修复:执行
ANALYZE。 EXPLAIN (BUFFERS)未输出 buffer 统计 → 未加ANALYZE,查询未真正执行。修复:改为EXPLAIN (ANALYZE, BUFFERS)。- Index Scan 但
shared read仍高 → 索引选择率低,大量回表。修复:建复合索引覆盖过滤列。
边界
索引列数据高度倾斜时(如 90% 行集中在一个值),Index Scan 回表开销可能接近 Seq Scan,应评估部分索引或改写查询逻辑。排序溢出磁盘(temp written高)需调整work_mem,不在本文范围。
参考资料
PostgreSQL 17 官方文档 — auto_explain
PostgreSQL 17 官方文档 — pg_stat_statements