数据库慢查询定位:从慢日志到 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 报表聚合1800ms200 次/天6 分钟慢但不频繁,优化收益有限
B 订单列表280ms5000 次/天23 分钟头号目标:总损耗最大
C 状态更新90ms20000 次/天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 ScanBitmap ScanSeq 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 默认权重),用来在候选计划之间比较,不能当毫秒读。

两个实用推论:

  1. 同一台库上,两条 SQL 的 cost 可以比大小(同单位),跨库/跨配置不可比;
  2. 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
公众号:服务端技术精选
    评论
    0 评论
avatar

取消