数据库慢查询定位:从慢日志到 EXPLAIN ANALYZE——PostgreSQL 篇
引言
"PG 有条 SQL 特别慢,但我说不清慢在哪。"这个求助背后通常藏着三个没做对的事:不知道哪条 SQL 最该被优化(凭感觉抓一条,可能优化完也省不出 1% 的负载)、不知道它为什么慢(猜是缺索引,加完索引发现没走)、不知道优化后有没有变好(没有前后数据,全靠"感觉快了")。MySQL 时代大家习惯了 slow log + EXPLAIN 那套流程,但 PG 的参数名、日志格式、EXPLAIN 输出都长着另一副面孔,照搬经验会处处碰壁。
PG 的慢查询定位有一套清晰的漏斗:慢查询日志圈定"慢"的事实 → pg_stat_statements 排序找出"最值得治"的 SQL → EXPLAIN ANALYZE 拆开看它到底慢在哪一步。这三步各解决一个层次的问题,跳过任何一步都容易变成"在错误的 SQL 上使劲"。这篇文章按漏斗顺序走完整个流程,并把重心放在第三步——读懂 PG 的 EXPLAIN 输出:Seq Scan / Index Scan / Bitmap Scan 三种扫描节点的区别与选择逻辑、cost 的真实含义(它不是时间)、以及 rows 估算与实际行数偏差为什么是"优化器选错计划的头号信号"。
一、第一步:慢查询日志——圈定"慢"的事实
1.1 核心参数:log_min_duration_statement
PG 用一个参数控制"执行超过多少毫秒的语句被记录",MySQL 的 long_query_time 对应物:
-- 会话级临时改(验证用)
SET log_min_duration_statement = 500; -- 超过 500ms 记录
-- 实例级修改(需重载配置,不重启)
ALTER SYSTEM SET log_min_duration_statement = '500ms';
SELECT pg_reload_conf();
生产推荐 500ms~1s 起(先抓大放小,阈值太低日志会淹没磁盘);关键前置参数一并联调:
ALTER SYSTEM SET logging_collector = on; -- 日志落文件(很多发行版默认 off)
ALTER SYSTEM SET log_min_duration_statement = '500ms';
ALTER SYSTEM SET log_statement_sample_rate = 0.2; -- PG13+:高 QPS 场景采样 20%,防爆量
ALTER SYSTEM SET log_line_prefix = '%m [%p] %u@%d app=%a '; -- 日志前缀带时间/pid/用户/库名/应用名
SELECT pg_reload_conf();
1.2 慢日志长什么样、怎么读
2026-09-27 14:22:31 CST [8821] bench@shop app=order-service LOG: duration: 1834.292 ms statement: SELECT o.id, u.name FROM t_order o JOIN t_user u ON u.id=o.user_id WHERE o.status=2 AND o.created_at > '2026-09-20' ORDER BY o.created_at DESC LIMIT 50
四个关注点:duration(真实耗时)、statement(被记录的完整 SQL,参数已被内联——注意 prepared statement 场景日志的是实际值)、前缀里的 user/database/app(定位是哪个服务发的)、时间戳(与业务时间线对照,如是否与大促/批量任务重合)。
相关参数辨析(容易配错的一组):
| 参数 | 含义 | 典型用法 |
|---|---|---|
| log_min_duration_statement | 执行完成后记录,超阈值才记 | 主力配置,生产常开 |
| log_statement | 记录所有执行的语句(不看耗时) | 只在排障时临时开,生产别开 |
| log_lock_waits | 锁等待超过 deadlock_timeout 记录 | 建议常开,抓锁问题 |
| log_temp_files | 临时文件超过阈值记录 | 建议设 0 记录所有,排序溢出磁盘的重要信号 |
1.3 慢日志的局限——为什么要进第二步
慢日志只知道"这条 SQL 慢了 1.8 秒",不知道:它一天跑了多少次(一条 2 秒但每天跑 10 次的 SQL,危害远小于一条 300ms 每秒跑 50 次的 SQL);也看不到资源维度(读了多少页、排序溢没溢出)。"总耗时 = 单次耗时 × 频次"才是优化优先级,这就要靠 pg_stat_statements。
二、第二步:pg_stat_statements——按总损耗排序抓元凶
之前《PG 必备工具链》篇装过这个内置扩展,这里聚焦它在慢查询定位流程中的用法。
2.1 确认启用
-- postgresql.conf:shared_preload_libraries = 'pg_stat_statements'(需重启)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT * FROM pg_stat_statements LIMIT 1; -- 验证有数据
2.2 四板斧查询(按不同口径找元凶)
-- ① 总耗时 TOP:对整体负载影响最大的 SQL(优化优先级第一依据)
SELECT round(total_exec_time::numeric/1000, 1) AS total_s,
calls,
round(mean_exec_time::numeric, 1) AS avg_ms,
rows,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 10;
-- ② 平均耗时 TOP:单条最慢(频次低但拖垮关键接口的)
SELECT round(mean_exec_time::numeric,1) AS avg_ms, calls,
left(query,80) AS query
FROM pg_stat_statements
WHERE calls > 100 -- 过滤掉冷门 SQL
ORDER BY mean_exec_time DESC LIMIT 10;
-- ③ 缓存命中率差的:shared_blks_read 大 = 频繁从磁盘读,缺索引或数据量超出缓存
SELECT round(100.0*shared_blks_hit/nullif(shared_blks_hit+shared_blks_read,0),1) AS hit_pct,
calls, shared_blks_read, left(query,80) AS query
FROM pg_stat_statements
WHERE shared_blks_read > 0
ORDER BY shared_blks_read DESC LIMIT 10;
-- ④ 排序/临时文件溢出的:temp_blks_written 大 = work_mem 不够,排序落盘
SELECT calls, temp_blks_written, left(query,80) AS query
FROM pg_stat_statements
ORDER BY temp_blks_written DESC LIMIT 10;
2.3 用"总耗时×频次"定优化对象
假设慢日志抓到三条 SQL:
| SQL | 单次 | 频次 | 总耗时/天 | 结论 |
|---|---|---|---|---|
| A 报表聚合 | 1800ms | 200 次/天 | 6 分钟 | 慢但不频繁,优化收益有限 |
| B 订单列表 | 280ms | 5000 次/天 | 23 分钟 | 头号目标:总损耗最大 |
| C 状态更新 | 90ms | 20000 次/天 | 30 分钟(锁等待为主) | 另一类问题(锁),换战场 |
慢日志会误导你先去搞 A,pg_stat_statements 的 total_exec_time 会告诉你 B 才是该先治的。 定位到 B 之后,进入第三步:EXPLAIN ANALYZE 拆开它。
三、第三步:EXPLAIN ANALYZE——把 SQL 拆开看
3.1 三个使用姿势(含"别在生产高峰裸跑"的警告)
EXPLAIN SELECT ...; -- ① 只看计划,不执行(安全,任何时候可用)
EXPLAIN ANALYZE SELECT ...; -- ② 真实执行并计时(写 SQL 会真改数据!)
EXPLAIN (ANALYZE, BUFFERS) SELECT ...; -- ③ 再加 IO 统计(定位首选)
红线:ANALYZE 会真实执行语句。 UPDATE/DELETE/INSERT 想分析又不能真跑,用事务包裹回滚:
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE t_order SET status=9 WHERE ...;
ROLLBACK; -- 数据没动,计划与耗时真实
超长查询怕拖垮实例,加 max_parallel_workers_per_gather 调整或 timed 变体(pg 16 没有原生 timeout,可用 statement_timeout 会话内设小值兜底)。输出重点读 actual time(真实毫秒)与 planning time(生成计划的耗时,通常忽略,除非 >1ms 提示计划缓存/统计问题)。
3.2 三种扫描节点:Seq Scan / Index Scan / Bitmap Scan
这是 PG EXPLAIN 最核心的判读对象。一句话先立框架:三者的取舍 = 顺序读的便宜 vs 随机回表的成本。
① Seq Scan(顺序扫描):整表从头读到尾。
Seq Scan on t_order (cost=0.00..894351.00 rows=9999 width=40)
Filter: (status = 2)
Rows Removed by Filter: 49980001 ← 关键行:5000 万行里过滤掉 4998 万
Rows Removed by Filter 占比超过几个百分点还走 Seq Scan,基本就是"该有索引没索引"或"统计信息骗了优化器"。
② Index Scan(索引扫描):B-tree 定位 + 逐行回表。
Index Scan using idx_order_user on t_order (cost=0.43..42.18 rows=12)
Index Cond: (user_id = 8821)
适合选择性高的条件(命中几百行以内):索引里按序定位,每行回堆表取整行。随机 IO,但次数少时总成本最低。它还是唯一能提供有序输出的扫描(ORDER BY 走索引序可免排序)。
③ Bitmap Scan(位图扫描):PG 的中间方案,两阶段执行。
Bitmap Heap Scan on t_order (cost=124.55..12008.30 rows=9800 width=40)
Recheck Cond: (status = 2)
Heap Blocks: exact=9210
-> Bitmap Index Scan on idx_order_status (cost=0.00..122.10 rows=9800)
Index Cond: (status = 2)
执行分两步:先 Bitmap Index Scan 把所有命中的行号收集成位图(只在索引层,不回表),再 Bitmap Heap Scan 按物理页顺序一次性回表——把随机回表变成了准顺序读。三个工程含义:
| 维度 | Index Scan | Bitmap Scan | Seq Scan |
|---|---|---|---|
| 回表方式 | 逐行随机 | 收集后按页批量 | 无需回表 |
| 适用命中行数 | 几百行内 | 几百~几十万行 | 大比例全表 |
| 排序能力 | 可保序输出 | 不保序(需额外 Sort) | 不保序 |
| 特殊能力 | - | BitmapAnd/BitmapOr 合并多个索引 | - |
Bitmap 最实用的进阶形态是多索引组合(MySQL 没有的能力):
BitmapAnd:user_id 索引位图 ∩ created_at 索引位图 → 只回表交集
BitmapOr :status=2 位图 ∪ city='BJ' 位图 → OR 条件两个索引都能用
经验法则:EXPLAIN 里看到 Bitmap Heap Scan 的 Recheck/Heap Blocks 很大而最终 rows 很少,说明位图很宽过滤很差;反之 rows 接近 Bitmap Index Scan 估算,就是健康状态。
3.3 cost:它不是时间,是"代价单位的相对值"
cost 的结构 cost=0.43..42.18:startup cost(吐出第一行前的成本).. total cost。它由代价模型算出,单位是抽象的 page fetch 代价(seq_page_cost=1.0、random_page_cost=4.0、cpu_tuple_cost=0.01 默认权重),用来在候选计划之间比较,不能当毫秒读。
两个实用推论:
- 同一台库上,两条 SQL 的 cost 可以比大小(同单位),跨库/跨配置不可比;
- SSD 上 random_page_cost=4.0 系统性低估了随机读的速度,会让优化器过于偏向 Seq Scan——NVMe 环境建议调到 1.1~1.5,让 Index Scan 更容易被选中(这是"配了索引不走"的经典隐性原因):
ALTER SYSTEM SET random_page_cost = 1.1; -- SSD/内存充足环境
SELECT pg_reload_conf();
3.4 rows 估算 vs 实际行数:优化器选错的头号信号
EXPLAIN ANALYZE 每个节点都会给出 rows=估算值 ... (actual ... rows=实际值)。健康状态是两者同一数量级;偏差超过 10 倍,优化器的每个决策都不可信了——join 顺序、扫描方式、内存排序还是外排,全都建立在 rows 估算之上。
-- 坏信号示例
Bitmap Heap Scan on t_order (cost=... rows=12) ← 优化器以为 12 行
Recheck Cond: (status = 2)
(actual ... rows=480000) ← 实际 48 万行,差 4 万倍
这个例子的后果链:估算 12 行 → 优化器选 Index Scan 逐行回表"感觉便宜" → 实际 48 万次随机回表 → 计划灾难。rows 估算偏差的处理顺序:
① 统计信息过期:ANALYZE t_order; 重跑(导入大变更后必做)
② 统计精度不足(数据倾斜/低基数列):提高采样
ALTER TABLE t_order ALTER COLUMN status SET STATISTICS 500;
③ 类型/写法导致估算失准:隐式转换、函数包裹列、跨类型 join
④ 仍不准:pg_stat_reset 后观察;极端情况考虑 plan_cache_mode=force_custom_plan
估算偏差的常见根因还有相关列:WHERE city='北京' AND district='朝阳区' 两个条件单独看选择性 10%/2%,但实际组合后 1.9%——优化器默认列独立,乘出来 0.2%,偏差 10 倍。PG 提供 CREATE STATISTICS 扩展统计对象解决:
CREATE STATISTICS st_city_district (dependencies) ON city, district FROM t_user;
ANALYZE t_user; -- 之后组合条件估算明显变准
3.5 其他高频节点速读
| 节点 | 含义 | 优化方向 |
|---|---|---|
| Sort (top-N heapsort) / Sort Method: external merge | 排序内存内 / 溢出磁盘 | work_mem 加大、索引消排序 |
| HashAggregate / GroupAggregate | 分组聚合两种实现 | 通常无需干预,rows 估算准即可 |
| Nested Loop / Hash Join / Merge Join | 三种连接 | 小表驱动大表 Nested Loop;大批量等值 Hash Join |
| Limit + 排序组合 | top-N 优化 | ORDER BY+LIMIT 走索引序最佳 |
| Materialize / Memoize(PG14+) | 结果缓存复用 | 见到 Memoize 通常是好事(重复 probe 被缓存) |
| Parallel Seq Scan / Gather | 并行扫描 | rows 估算准 + 大表才会并行 |
四、完整案例走查:一条 1.8 秒 SQL 的定位与修复
把三步串起来。慢日志抓到:
SELECT o.id, u.nickname FROM t_order o JOIN t_user u ON u.id = o.user_id
WHERE o.status = 2 AND o.created_at > now() - interval '3 days'
ORDER BY o.created_at DESC LIMIT 50;
-- duration: 1834 ms,每天执行 4000+ 次
EXPLAIN (ANALYZE, BUFFERS) 关键输出:
Limit (actual time=1840.1..1840.2 rows=50)
-> Sort (actual time=1840.1.. rows=50)
Sort Method: top-N heapsort Memory: 40kB
-> Nested Loop (actual rows=50)
-> Bitmap Heap Scan on t_order
(est rows=38000) (actual rows=51000) ← 偏差可接受
Recheck Cond: (status = 2 AND created_at > ...)
Heap Blocks: exact=49000 ← 回表 4.9 万页!
-> Bitmap Index Scan (est rows=38000)
诊断链:Bitmap Scan 命中 5 万行、回表 4.9 万页,全部取出后才 Sort 再 Limit——取了 5 万行只为最后要 50 行。优化方向是让 ORDER BY created_at 走索引序、边扫边凑够 50 行就停:
-- 修复:复合索引让过滤与排序共用一个索引,且把选择性更高的 status 放前面
CREATE INDEX CONCURRENTLY idx_order_status_time
ON t_order (status, created_at DESC);
-- 修复后计划:
-- Limit (actual time=0.198..0.221 rows=50)
-- -> Nested Loop
-- -> Index Scan using idx_order_status_time on t_order
-- Index Cond: (status = 2 AND created_at > ...)
-- (actual rows=50) ← 扫到 50 行立刻停
-- Execution Time: 0.24 ms
1834ms → 0.24ms,快 7600 倍。回查 pg_stat_statements 的 total_exec_time 确认该项从榜首消失——定位闭环。
五、常见问题
5.1 EXPLAIN 里 cost 很大,是不是代表这条 SQL 很慢?
不是。cost 是优化器内部的代价单位(页读+CPU 的加权相对值),只在同一库同一配置下用于计划间比较,换算不出毫秒。真正反映耗时的是 ANALYZE 后的 actual time。反过来才成立:同一实例上,优化器在多个计划里选了 total cost 最小的那个。
5.2 明明建了索引,为什么还是 Seq Scan?
按概率排查:① 表小(几千行),顺序扫全表比索引+回表还便宜,这是优化器正确决策;② 条件对列套了函数/隐式转换,索引用不上——用表达式索引或改写;③ random_page_cost 偏高(SSD 环境 4.0 会低估索引收益),调 1.1;④ 统计信息过期,ANALYZE;⑤ 查询命中行数占比过大(>5~10%),Seq/Bitmap 本来就更优。逐条对照 EXPLAIN 的 rows 估算与实际偏差定位是哪种。
5.3 actual time 里看到节点重复出现/时间对不上总和是怎么回事?
两个正常机制:① 并行计划里 Gather 下的子节点时间是多 worker 合计的,可能大于上层节点时间,别按串行叠加读;② 缓存的子计划(Memoize)、InitPlan(一次性子查询先算)会让节点顺序看着跳跃。读计划的正确方式是自顶向下看数据流:父节点的 actual time 包含其所有子节点,最深叶子节点的耗时才是扫描本身的成本。
5.4 pg_stat_statements 的 query 里全是 $1 占位符,怎么看具体参数?
它按参数化指纹归并同类语句,这是特性(聚合频次与总耗时)。要看某条模板的代表性参数:结合慢日志(参数被内联)按 SQL 文本搜索;或应用侧 APM 抓样本。注意归并会把不同参数值但同构的 SQL 合并统计,极端倾斜数据下(某个参数值命中 0 行、另一个命中百万行)单看平均值会失真,必要时对嫌疑参数单独 EXPLAIN。
5.5 EXPLAIN ANALYZE 在生产跑安全吗?有什么替代?
SELECT 裸跑 ANALYZE 的风险主要是资源(大查询占用 IO/CPU)而非数据;写语句直接跑就是真改数据,必须 BEGIN...ROLLBACK 包裹(注意 ROLLBACK 时 ANALYZE 的计时依然真实)。资源兜底:会话内先 SET statement_timeout = '5s'、SET max_parallel_workers_per_gather = 0 限并行,避开高峰。替代方案:用 pg_stat_statements 的执行统计反推、或在影子库/预发库跑 ANALYZE。
5.6 SQL 优化后如何确认"全局真的变好了"?
三查闭环:① pg_stat_statements 里该指纹的 mean_exec_time 与 total_exec_time 下降(先 pg_stat_statements_reset() 再积累一个观察周期更干净);② 慢日志同文本的出现频次归零;③ 业务指标(接口 P99/DB CPU)改善。警惕"局部快了全局没变化"——说明优化错了对象(这条 SQL 本来就不是总耗时大头),回到第二步重新排序。
六、总结
三步定位速查卡
第一步 慢日志:log_min_duration_statement=500ms 圈定"慢"的事实
配 log_line_prefix/log_temp_files/log_lock_waits
第二步 pg_stat_statements:按 total_exec_time(单次×频次)排序抓元凶
四板斧:总耗时/平均慢/缓存差/临时文件溢出
第三步 EXPLAIN (ANALYZE, BUFFERS):写语句 BEGIN...ROLLBACK 包裹
判读要点:
扫描:Seq Scan 看 Rows Removed by Filter(占比大=没索引/统计失准)
Index Scan 高选择性+可保序;Bitmap Scan 中等行数+多索引 BitmapAnd/Or
健康曲线:几行 Index Scan → 几百行 Bitmap → 大比例 Seq
cost:不是毫秒,同库内比计划用;SSD 建议 random_page_cost=1.1
rows:估算 vs 实际差 10 倍 = 优化器不可信
处理:ANALYZE → SET STATISTICS 提高采样 → CREATE STATISTICS 相关列
闭环:改完回查 pg_stat_statements total_exec_time 下降才算数
一句话
PG 慢查询定位是一个三层漏斗,每层解决一个"别在错误方向上使劲"的问题:慢查询日志用 log_min_duration_statement 圈定"哪些语句真的慢"的事实——它给单次耗时和 SQL 文本,但给不出频次,而一条 2 秒但一天只跑 10 次的 SQL,危害远小于一条 300ms 却每秒跑 50 次的语句,所以第二步必须用 pg_stat_statements 按 total_exec_time(单次耗时 × 执行次数)排序,让"总损耗"而非"单次体感"决定优化优先级,辅以 shared_blks_read 找缺索引和 temp_blks_written 找排序溢盘两条暗线;第三步才是把元凶 SQL 拆开——EXPLAIN ANALYZE 的铁律是写语句必须 BEGIN 包裹 ROLLBACK,读计划的核心功力在三件事上:第一是三种扫描节点的选择逻辑,Seq Scan 看 Rows Removed by Filter 的占比判断该不该有索引,Index Scan 是高选择性加可保序的点查利器,Bitmap Scan 用"先收位图再按页批量回表"把随机 IO 变准顺序读,覆盖几百到几十万行的中间地带,还能用 BitmapAnd/BitmapOr 让 OR 条件和多索引组合生效——三者的健康分界大致是几百行、几十万行;第二是 cost 的真实身份,它是页读与 CPU 加权的相对代价单位,只在同库内比较计划用,不是毫秒,而 SSD 环境把 random_page_cost 从默认 4.0 调到 1.1,是"建了索引不走"最经典的隐性解法;第三是 rows 估算与 actual rows 的偏差——超过 10 倍就意味着优化器脚下的地基是错的,join 顺序、扫描方式、内存还是外排全部不可信,处理顺序固定为 ANALYZE 刷新统计、对倾斜列 SET STATISTICS 提高采样、对相关列建 CREATE STATISTICS 扩展统计。整套流程的验收标准不是"感觉快了",而是回到 pg_stat_statements 确认该指纹的 total_exec_time 从榜首消失——从慢日志的事实、到统计视图的排序、到执行计划的解剖、再到统计视图的闭环,这才是一条慢 SQL 完整的一生。
给团队的建议
| 项 | 建议 |
|---|---|
| 配置基线 | 慢日志 500ms + log_line_prefix + log_temp_files=0 + pg_stat_statements 常驻 |
| 优先级 | 按 total_exec_time 排序优化,拒绝"看见慢就治" |
| 分析纪律 | 写语句 ANALYZE 一律事务包裹回滚;会话内设 statement_timeout |
| 计划判读 | 三看:扫描节点类型 / cost 只比同库 / rows 偏差超 10 倍先修统计 |
| 环境调优 | SSD 环境 random_page_cost=1.1;大变更导入后必 ANALYZE |
| 验收 | 每次优化回查 pg_stat_statements 总耗时,数据闭环 |
互动话题:你在 PG 上遇到过的最诡异的执行计划是什么?rows 估算偏差害你踩过多少坑?评论区聊聊你的定位经历。
标题:数据库慢查询定位:从慢日志到 EXPLAIN ANALYZE——PostgreSQL 篇
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/28/1790514797090.html
公众号:服务端技术精选
- 引言
- 一、第一步:慢查询日志——圈定"慢"的事实
- 1.1 核心参数:log_min_duration_statement
- 1.2 慢日志长什么样、怎么读
- 1.3 慢日志的局限——为什么要进第二步
- 二、第二步:pg_stat_statements——按总损耗排序抓元凶
- 2.1 确认启用
- 2.2 四板斧查询(按不同口径找元凶)
- 2.3 用"总耗时×频次"定优化对象
- 三、第三步:EXPLAIN ANALYZE——把 SQL 拆开看
- 3.1 三个使用姿势(含"别在生产高峰裸跑"的警告)
- 3.2 三种扫描节点:Seq Scan / Index Scan / Bitmap Scan
- 3.3 cost:它不是时间,是"代价单位的相对值"
- 3.4 rows 估算 vs 实际行数:优化器选错的头号信号
- 3.5 其他高频节点速读
- 四、完整案例走查:一条 1.8 秒 SQL 的定位与修复
- 五、常见问题
- 5.1 EXPLAIN 里 cost 很大,是不是代表这条 SQL 很慢?
- 5.2 明明建了索引,为什么还是 Seq Scan?
- 5.3 actual time 里看到节点重复出现/时间对不上总和是怎么回事?
- 5.4 pg_stat_statements 的 query 里全是 $1 占位符,怎么看具体参数?
- 5.5 EXPLAIN ANALYZE 在生产跑安全吗?有什么替代?
- 5.6 SQL 优化后如何确认"全局真的变好了"?
- 六、总结
- 三步定位速查卡
- 一句话
- 给团队的建议
评论