Chapter 02 · 核心观念 ①

查询优化器:计划是被代价估算选出来的

上一章学会了读 EXPLAIN、定位最贵的查询,并把"估算行数 vs 真实行数的偏差"记为头号信号。这一章打开优化器这个黑盒,解释那个偏差为什么能决定查询快慢——以及你能怎么影响它。

本章你将建立的心智模型

  • 核心观念:社区版 PostgreSQL 不接受 query hint,执行计划完全由代价模型自动选出;调优 = 改它的输入,不是命令它
  • 代价从哪来:cost = 预计读的页数 × 单页代价 + 预计处理的行数 × 单行代价,而"预计多少"来自统计信息
  • 四种取数方式(Seq / Index / Index-Only / Bitmap)各自的适用选择率区间
  • 三种 JOIN 算法(Nested Loop / Hash / Merge)各自何时最便宜,以及估错行数如何让它选错
  • 把上一章的"行数偏差"信号补全成一条完整因果链:统计过期 → 估错选择率 → 选错算法 → 慢

2.1核心观念:你喂输入,不指挥优化器

社区版 PostgreSQL 默认不支持 query hint——你无法命令它走某个计划,计划完全由代价模型自动选出。

为什么它是核心

从 MySQL 或 Oracle 过来的工程师,第一反应常是"这查询走错索引了,我加个 FORCE INDEX"。PostgreSQL 社区版没有这个东西(这是项目的明确设计取向)。它的逻辑是:hint 会把人的临时判断固化进代码,而数据分布会变;不如让优化器始终根据当前统计信息自动选。代价是:当它选错时,你不能直接纠正它的输出,只能去修它的输入。理解了这一点,整本教程的结构就清楚了。

优化器(planner / optimizer)对一条查询做的事,可以拆成一条流水线:解析 → 枚举出多个候选执行计划 → 给每个计划估算一个代价(cost)→ 选 cost 最小的 → 交给执行器跑。你在第 01 章看到的 EXPLAIN 输出,就是这条流水线最后选定的那个计划。

SQL 候选计划集 多种取数/连接 估算代价 给每个计划打分 选最便宜 执行 统计信息 行数/选择率从哪估 代价参数 seq/random_page_cost…
图 2.1优化器流水线。注意:红色的"估算代价"这一步吃两个输入——统计信息和代价参数。你能调的几乎所有查询级旋钮,都是在改这两个输入,而不是改"选最便宜"这一步本身。

这张图直接对应调优的三个杠杆,也对应后面三章:

  • 给准统计信息(本章 §2.2):让"估算代价"基于真实的数据分布。统计过期是头号事故源。
  • 提供更便宜的路径(第 03 章索引):往"候选计划集"里塞入一个代价更低的选项,优化器自然会选它。
  • 校准代价参数(第 05 章配置):调 random_page_cost 等,让抽象代价单位贴近你的硬件(SSD vs 机械盘)。

2.2代价从哪来:统计信息 + 代价模型

cost = 预计读取的页数 × 单页代价 + 预计处理的行数 × 单行代价;"预计多少"由统计信息算出。

为什么需要它(比文档深一层)

文档会告诉你"优化器基于代价选计划"。再深一层:代价公式里几乎每一项都依赖一个估算量——这一步会输出多少行(行数估算),要随机读还是顺序读多少页(选择率 × 页数)。行数估算几乎全部来自统计信息。所以"优化器选错了"的根因,十有八九不是代价模型本身的问题,而是它拿到的统计信息和现实不符。

统计信息存在哪、怎么更新

ANALYZE 命令(autovacuum 也会自动触发,见第 04 章)对每张表随机采样若干行,算出每列的分布特征,存进系统目录 pg_statistic(可读视图是 pg_stats)。关键的几个统计量:

表 2.1 · 优化器最依赖的几个列统计量(pg_stats)
统计量含义优化器拿它做什么
n_distinct该列不同值的个数(或比例)估 col = ? 的选择率 ≈ 1 / n_distinct
most_common_vals (MCV)最高频的几个值 + 它们的频率值在 MCV 里时,直接用真实频率(更准)
histogram_bounds把其余值分成等频区间的边界估范围条件 col > ? 的选择率
null_fracNULL 占比估 IS NULL / IS NOT NULL
correlation列物理顺序与值顺序的相关度判断索引扫描的回表是顺序还是随机(影响代价)

看一眼真实的统计行,会让"估算"具体起来:

pg_stats.sql sql
SELECT attname, n_distinct, null_frac,
       most_common_vals
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'customer_id');
pg_stats-out.txt output
   attname    | n_distinct | null_frac |        most_common_vals
--------------+------------+-----------+---------------------------------
 status       |          4 |       0   | {shipped,paid,pending,cancelled}
 customer_id  |     -0.85  |       0   | (空,几乎全是唯一值)
  • status 的 n_distinct=4,且四个值都在 MCV 里。优化器查 status='shipped' 时,直接用 'shipped' 的真实频率(比如 60%)估选择率——很准。低基数列,统计信息几乎不会骗它。
  • customer_id 的 n_distinct=-0.85,负数表示"按行数比例",即不同值约占总行数的 85%(近似唯一)。查 customer_id=42 的选择率 ≈ 1/(0.85 × 行数),极低——优化器会倾向走索引。
陷阱 · 头号事故源

统计信息是采样快照,不实时更新。批量导入一千万行后立刻查询、或一列的数据分布刚剧烈变化,统计还停留在旧状态,优化器就会基于错误前提选计划。表现就是第 01 章那个信号:rows(估算)和 actual rows(真实)差出几个数量级。应急修法一条命令:ANALYZE orders;

想一想

一个高基数列(比如近乎唯一的 email),如果默认采样精度不够、统计把它的 n_distinct 估小了,优化器会怎样误判 WHERE email = ? 的选择率?后果是什么?

展开答案(先想一步再点)

n_distinct 被估小 → 1/n_distinct 偏大 → 优化器以为 email=? 会命中很多行 → 可能放弃索引、改走顺序扫描,或在 JOIN 里选错算法。

修法:提高该列的统计精度。ALTER TABLE t ALTER COLUMN email SET STATISTICS 1000; 然后 ANALYZE。这把采样精度从默认的 default_statistics_target=100 提到 1000,直方图更细、n_distinct 更准。第 05 章会讲这个参数的全局设置。

2.3四种取数方式:按选择率挑

从一张表取数有四种方式,优化器主要按"要取多少行占全表的比例"(选择率)来选。

这是把上一章的 Seq Scan / Index Scan 补全。同一条 WHERE,命中行占比不同,最便宜的取数方式就不同:

表 2.2 · 四种扫描方式
扫描方式怎么取最适用IO 模式
Seq Scan从头到尾读整张表命中大部分行,或表很小顺序读(快)
Index Scan走索引拿 TID,逐行回表取数据命中极少行(几行)随机读(单页贵)
Index-Only Scan只读索引,不回表查询列都在索引里 + 页 all-visible只读索引(最省)
Bitmap Heap Scan先扫索引建 TID 位图,排序后按物理顺序回表命中中等数量行近顺序读 + 可合并多索引

为什么命中行多了反而该用顺序扫描?因为索引扫描的回表是随机读:每拿到一个 TID 就要跳到表的对应页去取行。命中几行时,几次随机读很快;但要命中 30 万行时,30 万次随机跳读,比把整张表顺序读一遍还慢。优化器用 random_page_cost(默认 4.0,即随机读一页约等于顺序读 4 页的代价)来量化这个差别。这正是"顺序扫描有时比索引快"这个反直觉结论的来源。

Index Scan 命中极少行 Bitmap Heap Scan 命中中等数量 Seq Scan 命中大部分 / 小表 0.001% ~1–5% ~10–25% 100% 选择率 = 命中行数 / 全表行数 →
图 2.2选择率决定扫描方式。注意:三个区间的边界(那几个百分比)不是固定阈值,而是优化器用代价公式现算出来的——它取决于表大小、random_page_cost、列的 correlation 等。所以同一查询在不同表上,切换点不同。
洞察 · 选择率是估出来的

把图 2.2 和 §2.2 接起来:优化器选哪种扫描,取决于它估的选择率;而选择率来自统计信息。所以"它该走索引却走了顺序扫描"几乎总是同一个故事——它估错了命中行数。先看 EXPLAIN 里这一步的 rows vs actual rows,而不是急着怀疑索引本身。

关于 Index-Only Scan 的"页 all-visible"条件,牵涉到 MVCC 的可见性映射(visibility map),留到第 04 章讲——那里你会看到,VACUUM 不及时会让本可以 index-only 的扫描退化成要回表。这是优化器章和 MVCC 章的一个连接点。

2.4三种 JOIN 算法:按输入大小和是否有序挑

两表连接有三种算法,优化器按"输入多大、是否已按连接键有序、是否等值连接"来选。

第 01 章那个 Nested Loop + loops=980 的挑战题,现在可以正式拆解了。三种算法:

表 2.3 · 三种 JOIN 算法
算法怎么连最适用内存 / 代价要点
Nested Loop外层每一行,去内层查找匹配外层行数少 + 内层连接列有索引代价 ≈ 外层行数 × 内层单次查找;外层一大就爆炸
Hash Join小的一边建哈希表,大的一边逐行探测大表 + 无序 + 等值连接哈希表要能装进 work_mem,否则落盘变慢(见 05)
Merge Join两边都按连接键排好序,再归并两边已有序(有索引)或数据量极大若需额外排序,排序代价要算进去

三种算法的取舍可以画成一棵决策树。注意这棵树是优化器在走,不是你——它对每种可行算法都估个代价,这里画的是"通常哪个会胜出":

两表 JOIN 两边按连接 键已有序? 是 Merge Join 直接归并 否 一侧很小 & 另侧有索引? 是 Nested Loop 外层小×内层索引 否 Hash Join 大表无序等值连接
图 2.3JOIN 算法选择。注意:这棵树最危险的入口是第二个判断里的"一侧很小"——"小"是优化器估出来的。统计过期导致它把大表的中间结果误估成"很小",就会错误地走进 Nested Loop,然后被几十万次内层查找拖垮。
洞察 · 把第 01 章的 loops 接上来

第 01 章那个 actual time ... loops=980 的内层节点,现在有了名字:它是 Nested Loop 的内层。该节点的真实总耗时 = 单次耗时 × loops,而 loops = 外层实际行数。优化器选 Nested Loop,是因为它估外层只有几行;如果外层实际是 980 行甚至 98 万行,这个选择就是灾难。"loops 很大的 Nested Loop"是 EXPLAIN 里最常见的事故现场。

2.5串起来:一次"选错计划"的完整故事

把本章和第 01 章的所有线索接成一条完整的因果链——这是面试和线上排查都会反复出现的剧本。

场景:一张 orders 表凌晨批量导入了 50 万条新订单,导入后没有跑 ANALYZE。早上一条带 JOIN 的报表查询突然从 200 毫秒涨到 40 秒。

slow-plan.txt output
Nested Loop  (cost=0.42..3201 rows=8 width=120)
             (actual time=0.1..39820.5 rows=503000 loops=1)
  ->  Seq Scan on orders o  (rows=8) (actual rows=503000 loops=1)
        Filter: (created_at >= '2026-06-02')
  ->  Index Scan using pk_customers c  (actual time=0.05..0.07 rows=1 loops=503000)

诊断(用第 01 章的眼睛):顶层 Nested Loop 估算 rows=8,真实 rows=503000——偏差六万倍。根因落在最里层的 Seq Scan on orders:它估 created_at >= '2026-06-02' 只命中 8 行,因为统计信息还是导入前的快照,那时这个日期之后确实几乎没有数据。优化器据此以为外层只有 8 行,放心地选了 Nested Loop。结果内层被驱动了 503000 次(loops=503000),每次 0.07 毫秒,累计约 35 秒。

修复:不改查询、不加 hint、不建新索引——只修输入:

fix.sql sql
ANALYZE orders;   -- 重新采样,让统计信息看到那 50 万新行

ANALYZE 后,优化器重新估算:created_at >= '2026-06-02' 现在命中约 50 万行。它立刻改主意——50 万行外层用 Nested Loop 太贵,转而选 Hash Join:把 customers 建成哈希表,用 50 万订单去探测,一次扫描搞定。查询回到几百毫秒。

洞察 · 核心观念在这里落地

整个修复没有命令优化器做任何事。它从 Nested Loop 改到 Hash Join,是它自己基于更新后的统计重新算代价得出的。你做的只是把它的输入校准到现实——这就是 §2.1 那句"你喂输入,不指挥优化器"的具体含义。记住这个剧本:计划突然变慢 + rows 估算严重偏低 + 最近有大批量数据变动 → 先 ANALYZE。

§本章 self-check

先合上教程,把答案写下来再对照。这一章的题大多没有"背一下就行"的答案,需要你把因果链走一遍。

  1. 为什么社区版 PostgreSQL 不提供 FORCE INDEX 这类 hint?它用什么机制替代"指定计划"?
  2. 一条查询命中全表 40% 的行,优化器选了 Seq Scan 而不是你建的索引。这是 bug 吗?用 random_page_cost 解释它的合理性。
  3. n_distinct 被低估,会让 WHERE col = ? 的选择率估高还是估低?进而会选错哪种扫描?
  4. 一个 Nested Loop 的内层 loops=500000。优化器当初为什么会选 Nested Loop?最该怀疑的根因是什么?
答案(先做完再展开)
  1. 设计取向:hint 把人对当时数据分布的判断固化进代码,而分布会变。PostgreSQL 选择让优化器始终依据当前统计信息 + 代价模型自动选计划。替代"指定计划"的手段是改它的输入:更新统计、建索引、调代价参数。
  2. 不是 bug。索引扫描的回表是随机读,random_page_cost=4.0 意味着随机读一页约等于顺序读 4 页。命中 40% 行时,海量随机读比把整表顺序读一遍更贵,所以 Seq Scan 反而更便宜。这是合理的代价权衡。
  3. n_distinct 低估 → 1/n_distinct 偏大 → 选择率估高 → 优化器以为命中很多行 → 可能放弃 Index Scan 改走 Bitmap 甚至 Seq Scan,或在 JOIN 中误选算法。修法:提高该列 STATISTICS 再 ANALYZE。
  4. 因为优化器估外层只有很少行(此时 Nested Loop 最便宜)。真实却有 50 万行,说明估算严重偏低。最可能的根因:统计信息过期(如刚批量导入未 ANALYZE),或该过滤条件的选择率本就难估。先看外层节点的 rows vs actual rows,再 ANALYZE。
进阶挑战 · 刚好够不着

同一查询,两种计划,判断切换点

查询 SELECT * FROM orders WHERE status = ?,status 有索引,四个取值的真实频率是 shipped 60%、paid 25%、pending 12%、cancelled 3%。问:对哪些取值优化器倾向走索引(或 Bitmap)、哪些走 Seq Scan?如果你希望 status='cancelled' 这类查询更快,只靠 status 单列索引够吗?

提示(卡住再展开)

把每个频率对到图 2.2 的轴上:60%、25% 落在 Seq Scan 区(命中太多,顺序扫描更便宜);3% 的 cancelled 落在 Index/Bitmap 区。所以高频值走 Seq、低频值走索引——这正是优化器按选择率分流。对 cancelled,单列索引能让它走 Index Scan,但仍要回表;如果查询只取少数几列,第 03 章的部分索引(只索引 cancelled 行)或覆盖索引(免回表)会更快。这道题的答案一半在本章,一半在下一章。