Chapter 01

诊断与测量:先知道哪里慢

概念地图把调优分成四类根因。在动其中任何一类之前,这一章解决更靠前的问题:慢在哪、慢多少、谁最该先优化——用数据回答,不用直觉。

本章你将建立的心智模型

  • 调优的工作循环:测量 → 定位 → 改一个变量 → 复测,缺一步都会回到"凭感觉"
  • 三层测量视角:实例级(wait events)、查询级(pg_stat_statements)、单条级(EXPLAIN ANALYZE)各回答什么问题
  • 读懂 EXPLAIN 计划树:cost / rows / actual time / loops / BUFFERS 各自的含义和最常见的三处误读
  • 一个贯穿全书的信号:估算行数与真实行数的偏差——它是后面每一章的起点

1.1调优的起点是测量,不是改参数

调优的第一步永远是定位瓶颈,不是打开 postgresql.conf。

为什么从这里开始

"数据库慢"是一个症状,不是一个问题。同样是慢,根因有缺索引、统计信息过期、表膨胀、内存不足、锁等待五类,对应五种完全不同的修法。不先测量就改参数,等于在黑暗里拧旋钮:偶尔蒙对,但你不知道为什么对,下次换个症状又得重来。

有经验的调优都遵循同一个闭环。任何一次"我把 shared_buffers 调大了,好像快了点"如果跳过了测量和复测,都不算调优,只算碰运气。

① 测量 ② 定位根因 ③ 改一个变量 只改一个 ④ 复测 没达标?带着新数据再来一轮
图 1.1调优工作循环。注意:第 ③ 步"只改一个变量"是红色的——一次改多个参数,即使变快了,你也无法知道是哪个起的作用,等于没学到东西。

这一章覆盖循环的第 ① 和 ② 步(测量与定位)。后面四章是第 ③ 步的弹药库:每一类根因对应哪些可调的变量。第 ④ 步复测,就是把同一个 EXPLAIN ANALYZE 再跑一遍,对比前后。

1.2三层测量视角:从实例到单条查询

测量有三个粒度,自顶向下收窄:实例在等什么 → 哪条查询最贵 → 这条查询慢在哪一步。

新手常常一上来就盯着某条查询的 EXPLAIN,但那条查询未必是真正的瓶颈。正确的顺序是从粗到细,像漏斗一样逐层收窄,避免在无关紧要的查询上花时间。

① 实例级 · 整个库在等什么? pg_stat_activity 的 wait_event · 是 IO / 锁 / CPU? ② 查询级 · 哪条 SQL 最贵? pg_stat_statements 按累计耗时排名 ③ 单条查询 · 慢在哪一步? EXPLAIN (ANALYZE, BUFFERS) 越往下,范围越窄,信息越具体
图 1.2三层诊断漏斗。注意:多数人直接从第 ③ 层开始,容易优化了一条根本不重要的查询。先在第 ① ② 层确认"值得优化谁",再下钻到第 ③ 层。

本章按漏斗的反方向讲解:先讲读者最常用、也最该先掌握的第 ③ 层(EXPLAIN,§1.3),再回到第 ② 层定位最贵的查询(pg_stat_statements,§1.4),最后是第 ① 层的全局视角(wait events,§1.5)。掌握了单条 EXPLAIN 的读法,上面两层的输出才有意义。

1.3EXPLAIN:优化器摊开给你看的执行计划

EXPLAIN 显示优化器为一条查询选定的执行计划和它对代价的估算;加 ANALYZE 则真正执行一遍,给出真实耗时和真实行数。

为什么需要它

同一条 SQL,优化器可以用顺序扫描、索引扫描、不同的 JOIN 算法等多种方式执行,代价差几个数量级。EXPLAIN 是唯一能看到"它到底打算怎么执行"的窗口。没有它,你只能盲猜为什么慢——而第 02 章会告诉你,优化器的这个选择,完全由它对代价的估算驱动。

EXPLAIN 的两种模式

表 1.1 · EXPLAIN 的两种用法
写法是否真正执行查询给出什么什么时候用
EXPLAIN sql否,只规划计划结构 + 估算的 cost / rows查询太慢或会改数据时,先看计划长什么样
EXPLAIN (ANALYZE, BUFFERS) sql是,真跑一遍估算值 + 真实耗时 / 真实行数 / 缓冲区命中定位瓶颈的主力工具
陷阱

EXPLAIN ANALYZE 会真正执行查询。对 SELECT 安全,但对 UPDATE / DELETE / INSERT 会真正改数据。要看写操作的计划又不想改数据,把它包在事务里回滚:BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;。

场景走查:一条查询,有索引和没索引

用一张一百万行的订单表 orders,查某个客户某天之后的订单。先看没有合适索引时优化器给出的计划:

seq-scan.sql sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE customer_id = 42 AND created_at >= '2026-01-01';
plan-seqscan.txt output
Seq Scan on orders  (cost=0.00..18334.00 rows=12 width=84)
                    (actual time=0.412..82.318 rows=9 loops=1)
  Filter: ((customer_id = 42) AND (created_at >= '2026-01-01'::date))
  Rows Removed by Filter: 999991
  Buffers: shared hit=64 read=8270
Planning Time: 0.123 ms
Execution Time: 82.401 ms

逐行解读(读的是含义,不是语法)

  • Seq Scan on orders:执行方式是顺序扫描——把整张表从头到尾读一遍。这就是"全表扫描"。
  • cost=0.00..18334.00:优化器的估算代价,两个数是"启动代价..总代价"。单位不是毫秒(见下面的关键澄清),是一个以"顺序读一个页 = 1.0"为基准的抽象单位。
  • rows=12:优化器估算这一步会输出 12 行。
  • actual ... rows=9:真实只输出了 9 行。估算 12、真实 9,很接近——这里统计信息是准的。
  • Rows Removed by Filter: 999991:破案了。为了找出 9 行,它读了一百万行、扔掉 999991 行。这是顺序扫描的代价,也是"该建索引了"的信号。
  • Buffers: shared hit=64 read=8270:64 个页在内存里命中,8270 个页从磁盘读。大量 read 印证了它在啃整张表。
  • Execution Time: 82.401 ms:真实总耗时。

现在为 customer_id 建一个索引,再跑同一条查询:

plan-indexscan.txt output
Index Scan using idx_orders_customer on orders
      (cost=0.42..8.91 rows=12 width=84)
      (actual time=0.028..0.041 rows=9 loops=1)
  Index Cond: (customer_id = 42)
  Filter: (created_at >= '2026-01-01'::date)
  Buffers: shared hit=5
Planning Time: 0.205 ms
Execution Time: 0.062 ms

同一条查询,82.401 ms → 0.062 ms,快了一千多倍。变化的不是查询,是优化器的执行方式:它现在用索引直接定位到 customer_id = 42 的行(Index Cond),只读了 5 个页(Buffers: shared hit=5),不再啃整张表。created_at 这个条件没进索引,所以仍作为 Filter 在取出的行上二次过滤。第 02、03 章会展开:优化器是怎么算出"用索引更便宜"的,以及索引为什么有时反而不被选。

洞察 · 计划树怎么读

EXPLAIN 输出是一棵树,靠缩进表示父子关系。执行顺序是"最深的叶子先执行,结果向上交给父节点"。读复杂计划时,先找最里层缩进的节点,从那里往外读——而不是从上往下按行读。

Index Scan using idx_... 一个计划节点 = 一种操作 cost=0.42..8.91 估算:启动..总(抽象单位) rows=12 vs actual rows=9 这个偏差是头号信号 actual time=0.028..0.041 是"每次循环"的耗时 × loops
图 1.3一个 EXPLAIN 节点上的字段解剖。注意:中间那条——rows(估算)与 actual rows(真实)的偏差,是贯穿全书的头号信号;偏差大,几乎总是指向第 02 章的统计信息问题。

1.4读懂关键数字:三处最常见的误读

cost 不是时间,actual time 是单次循环的耗时要乘 loops,rows 偏差指向统计信息。

图 1.3 标出了节点上的字段。真正容易出错的是它们的含义。三处误读几乎人人踩过:

误读一:把 cost 当成毫秒

cost 是一个抽象单位,基准是"顺序读取一个 8KB 数据页 = 1.0"(由参数 seq_page_cost 定义)。它用来让优化器比较不同计划的相对贵贱,不对应任何真实时间。一个 cost=18334 的计划不是"18334 毫秒",而是"按优化器的模型,比 cost=8.91 的计划贵约两千倍"。要看真实时间,只看 actual time 和 Execution Time。第 05 章会讲,调 random_page_cost 等代价参数,本质就是在校准这套抽象单位,让它更贴近你的硬件。

误读二:把 actual time 当成该节点的总耗时

actual time=0.028..0.041 的两个数是"返回第一行的时间..返回最后一行的时间",而且是单次循环(per loop)的值。当节点的 loops > 1 时,该节点的真实总耗时约等于 (第二个数) × loops。这是嵌套循环里最坑人的地方:

想一想

下面这个内层节点:Index Scan ... (actual time=0.004..0.011 rows=5 loops=1000)。它看起来每次只花 0.011 毫秒,但它对整条查询贡献的真实耗时大约是多少?

展开答案(先估一个数再点)

约 0.011 × 1000 = 11 毫秒。单看 actual time=...0.011 会以为这个节点微不足道,但它被外层循环驱动执行了 1000 次。

这指向一个真实的优化方向:如果外层能少产出几行(减少 loops),或内层每次更快,整体才会快。嵌套循环的性能 = 外层行数 × 内层单次代价——第 02 章会正式展开。

误读三:忽略 rows 估算与真实的偏差

这是最该养成的习惯:每看一个计划,先扫一眼哪个节点的 rows(估算)和 actual rows(真实)差得最远。优化器是根据估算行数选计划的;如果它以为某步出 10 行、实际出 100 万行,它就会基于错误前提选错算法(比如该用 hash join 却选了 nested loop)。偏差大的那个节点,几乎总是问题的源头,且修法通常在第 02 章:更新统计信息、提高统计精度、或重写让优化器能估准。

表 1.2 · 三个数字,各回答什么、别误读成什么
字段它真正的含义常见误读
cost相对贵贱的抽象单位(seq 读一页=1.0)当成毫秒
actual time=a..b单次循环:首行时间 a、末行时间 b当成该节点总耗时(漏乘 loops)
rows vs actual rows估算 vs 真实,偏差=统计信息信号只看真实行数,不看偏差
Buffers: hit / read内存命中页数 / 磁盘读页数忽略它(其实是判断"是否在啃磁盘"的关键)
提示

计划一长就难读。把 EXPLAIN (ANALYZE, BUFFERS) 的输出贴到 explain.depesz.com 或 explain.dalibo.com,它会自动标出耗时占比最高、行数估算偏差最大的节点。线上排查时这一步能省很多时间。

1.5第 ② 层:用 pg_stat_statements 找最贵的查询

pg_stat_statements 把每类查询的执行次数和累计耗时记下来,让你按"总耗时"而不是"单次耗时"排优先级。

为什么需要它

线上慢的往往不是某一条"看起来就慢"的大查询,而是一条单次只要 5 毫秒、却每秒被调用几百次的小查询。单看一次 EXPLAIN 永远发现不了它。pg_stat_statements 把同一形状的查询归并统计,让"总账"浮出水面。

它是官方扩展,需要先在 postgresql.conf 里加载并创建:

enable-pgss.sql sql
-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'  (需重启)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 按累计耗时排名,这是定位瓶颈的主力查询
SELECT
  substring(query, 1, 50) AS query,
  calls,
  round(total_exec_time::numeric, 1) AS total_ms,
  round(mean_exec_time::numeric, 2)  AS mean_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
pgss-output.txt output
             query              | calls |  total_ms  |  mean_ms
--------------------------------+-------+------------+-----------
 SELECT * FROM orders WHERE ... | 48210 |  241050.5  |    5.00
 SELECT count(*) FROM events    |     3 |   45200.1  | 15066.70
 UPDATE inventory SET qty ...   |  9820 |   38110.2  |    3.88
想一想

上面三条,哪一条最该先优化?第二条单次要 15 秒,看起来最吓人;第一条单次只要 5 毫秒。

展开答案(先选一条再点)

第一条。它单次只要 5 毫秒,但被调用了 48210 次,累计 241 秒——是第二条 45 秒的五倍多。把它从 5 毫秒优化到 1 毫秒,省下的总时间(约 193 秒)远超把第二条彻底干掉(45 秒)。

这就是为什么排序键是 total_exec_time 而不是 mean_exec_time:优化要花在累计影响最大的地方。第二条那种"单次 15 秒、一天跑 3 次"的报表查询,可以稍后再管。

提示

定位流程:先用 pg_stat_statements 排名找到 total_ms 最高的查询,复制它的 query 文本,补上真实参数,再对它跑 EXPLAIN (ANALYZE, BUFFERS)。第 ② 层告诉你"优化谁",第 ③ 层告诉你"它慢在哪一步"。

更轻量的补充:把 log_min_duration_statement = 500ms 写进配置,所有超过半秒的查询会自动进日志,适合抓偶发的慢查询。

1.6第 ① 层:实例在等什么(wait events)

pg_stat_activity 的 wait_event 字段,告诉你此刻活跃后端正卡在哪一类等待上——是磁盘 IO、锁,还是别的。

当问题不是"某条查询慢",而是"整个库都变慢了",就要上升到实例视角。pg_stat_activity 记录每个连接此刻在做什么、在等什么:

wait-events.sql sql
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY wait_event_type, wait_event
ORDER BY count(*) DESC;
wait-output.txt output
 wait_event_type |   wait_event   | count
-----------------+----------------+-------
 IO              | DataFileRead   |    18
 Lock            | transactionid  |     3
                 |                |     5   -- NULL = 正在跑,没在等

把等待按类型归因,直接指向后面对应的章节:

表 1.3 · wait_event_type → 该读哪一章
等待类型(高占比时)含义常见根因 / 去哪章
IO(如 DataFileRead)大量从磁盘读数据页缺索引(03)/ 内存不足致缓存命中低(05)
Lock等行锁/表锁,常因长事务长事务与并发(04 的 MVCC 部分)
LWLock等内部轻量锁(如 WAL、缓冲区)写入/checkpoint 压力(05)
NULL(占比高)没在等,就是 CPU 在算计划低效、缺索引致大量计算(02 / 03)
洞察 · 三层串起来就是一次完整诊断

一次真实的排查通常是:① 看 wait events 发现大量 IO / DataFileRead → ② 用 pg_stat_statements 找出贡献最多磁盘读的那条查询 → ③ 对它 EXPLAIN (ANALYZE, BUFFERS),看到 Seq Scan + 巨大的 Rows Removed by Filter 和 Buffers read → 结论:缺索引。三层各回答一个问题,合起来才是"测量 → 定位",也就是图 1.1 循环的前两步。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 一个计划节点显示 actual time=0.01..0.05 rows=20 loops=500。这个节点对查询贡献的真实总耗时大约是多少?为什么不是 0.05 毫秒?
  2. cost=0.00..18334.00 里的 18334 是 18334 毫秒吗?如果不是,它是什么?
  3. 在一个 Seq Scan 节点里看到 Rows Removed by Filter: 999991,而最终 rows=9。这说明了什么?下一步该怎么做?
  4. 为什么 pg_stat_statements 排名要用 total_exec_time 而不是 mean_exec_time?举一个 mean 低但最该优化的例子。
答案(先做完再展开)
  1. 约 0.05 × 500 = 25 毫秒。actual time 是单次循环的耗时,该节点被外层驱动执行了 500 次,要乘 loops 才是总贡献。
  2. 不是毫秒。它是优化器的抽象代价单位,以"顺序读一个数据页 = 1.0"为基准,只用于比较不同计划的相对贵贱。真实时间看 actual time / Execution Time。
  3. 说明为了拿到 9 行,优化器顺序扫描了约一百万行、过滤掉 999991 行——典型的"缺合适索引"信号。下一步:为过滤条件涉及的列(如 customer_id)建索引,再用同一条 EXPLAIN ANALYZE 复测。
  4. 因为要优化"累计影响"最大的查询。例子:一条单次 5 毫秒、但每天被调用 48 万次的查询,累计 240 秒,远超一条单次 15 秒、每天只跑 3 次(累计 45 秒)的报表查询——前者才是真瓶颈。
进阶挑战 · 刚好够不着

读一个两层计划,指出真正的瓶颈节点

下面是一条 JOIN 查询的(简化)计划。不借助工具,指出:哪个节点贡献的真实耗时最多?哪个节点的行数估算偏差最大、误导了优化器?

challenge-plan.txtoutput
Nested Loop  (cost=0.42..91234 rows=5 width=120)
             (actual time=0.05..1850.2 rows=4900 loops=1)
  ->  Seq Scan on customers c  (cost=0..1894 rows=10 width=40)
                 (actual time=0.02..6.1 rows=980 loops=1)
        Filter: (region = 'APAC')
  ->  Index Scan using idx_orders_cust on orders o
                 (cost=0.42..89 rows=1 width=80)
                 (actual time=0.3..1.86 rows=5 loops=980)
提示(卡住再展开)

先看顶层 Nested Loop 的估算 rows=5 vs 真实 rows=4900——偏差近千倍。再想:内层 Index Scan 的真实耗时该怎么算(别忘了 loops=980)?这个偏差会让优化器误以为外层只产出极少行,从而选择了 Nested Loop 而不是 Hash Join。