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 输出,就是这条流水线最后选定的那个计划。
这张图直接对应调优的三个杠杆,也对应后面三章:
- 给准统计信息(本章 §2.2):让"估算代价"基于真实的数据分布。统计过期是头号事故源。
- 提供更便宜的路径(第 03 章索引):往"候选计划集"里塞入一个代价更低的选项,优化器自然会选它。
- 校准代价参数(第 05 章配置):调
random_page_cost等,让抽象代价单位贴近你的硬件(SSD vs 机械盘)。
2.2代价从哪来:统计信息 + 代价模型
cost = 预计读取的页数 × 单页代价 + 预计处理的行数 × 单行代价;"预计多少"由统计信息算出。
文档会告诉你"优化器基于代价选计划"。再深一层:代价公式里几乎每一项都依赖一个估算量——这一步会输出多少行(行数估算),要随机读还是顺序读多少页(选择率 × 页数)。行数估算几乎全部来自统计信息。所以"优化器选错了"的根因,十有八九不是代价模型本身的问题,而是它拿到的统计信息和现实不符。
统计信息存在哪、怎么更新
ANALYZE 命令(autovacuum 也会自动触发,见第 04 章)对每张表随机采样若干行,算出每列的分布特征,存进系统目录 pg_statistic(可读视图是 pg_stats)。关键的几个统计量:
| 统计量 | 含义 | 优化器拿它做什么 |
|---|---|---|
n_distinct | 该列不同值的个数(或比例) | 估 col = ? 的选择率 ≈ 1 / n_distinct |
most_common_vals (MCV) | 最高频的几个值 + 它们的频率 | 值在 MCV 里时,直接用真实频率(更准) |
histogram_bounds | 把其余值分成等频区间的边界 | 估范围条件 col > ? 的选择率 |
null_frac | NULL 占比 | 估 IS NULL / IS NOT NULL |
correlation | 列物理顺序与值顺序的相关度 | 判断索引扫描的回表是顺序还是随机(影响代价) |
看一眼真实的统计行,会让"估算"具体起来:
SELECT attname, n_distinct, null_frac,
most_common_vals
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'customer_id');
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,命中行占比不同,最便宜的取数方式就不同:
| 扫描方式 | 怎么取 | 最适用 | IO 模式 |
|---|---|---|---|
| Seq Scan | 从头到尾读整张表 | 命中大部分行,或表很小 | 顺序读(快) |
| Index Scan | 走索引拿 TID,逐行回表取数据 | 命中极少行(几行) | 随机读(单页贵) |
| Index-Only Scan | 只读索引,不回表 | 查询列都在索引里 + 页 all-visible | 只读索引(最省) |
| Bitmap Heap Scan | 先扫索引建 TID 位图,排序后按物理顺序回表 | 命中中等数量行 | 近顺序读 + 可合并多索引 |
为什么命中行多了反而该用顺序扫描?因为索引扫描的回表是随机读:每拿到一个 TID 就要跳到表的对应页去取行。命中几行时,几次随机读很快;但要命中 30 万行时,30 万次随机跳读,比把整张表顺序读一遍还慢。优化器用 random_page_cost(默认 4.0,即随机读一页约等于顺序读 4 页的代价)来量化这个差别。这正是"顺序扫描有时比索引快"这个反直觉结论的来源。
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 的挑战题,现在可以正式拆解了。三种算法:
| 算法 | 怎么连 | 最适用 | 内存 / 代价要点 |
|---|---|---|---|
| Nested Loop | 外层每一行,去内层查找匹配 | 外层行数少 + 内层连接列有索引 | 代价 ≈ 外层行数 × 内层单次查找;外层一大就爆炸 |
| Hash Join | 小的一边建哈希表,大的一边逐行探测 | 大表 + 无序 + 等值连接 | 哈希表要能装进 work_mem,否则落盘变慢(见 05) |
| Merge Join | 两边都按连接键排好序,再归并 | 两边已有序(有索引)或数据量极大 | 若需额外排序,排序代价要算进去 |
三种算法的取舍可以画成一棵决策树。注意这棵树是优化器在走,不是你——它对每种可行算法都估个代价,这里画的是"通常哪个会胜出":
第 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 秒。
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、不建新索引——只修输入:
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
先合上教程,把答案写下来再对照。这一章的题大多没有"背一下就行"的答案,需要你把因果链走一遍。
- 为什么社区版 PostgreSQL 不提供
FORCE INDEX这类 hint?它用什么机制替代"指定计划"? - 一条查询命中全表 40% 的行,优化器选了 Seq Scan 而不是你建的索引。这是 bug 吗?用
random_page_cost解释它的合理性。 n_distinct被低估,会让WHERE col = ?的选择率估高还是估低?进而会选错哪种扫描?- 一个
Nested Loop的内层loops=500000。优化器当初为什么会选 Nested Loop?最该怀疑的根因是什么?
答案(先做完再展开)
- 设计取向:hint 把人对当时数据分布的判断固化进代码,而分布会变。PostgreSQL 选择让优化器始终依据当前统计信息 + 代价模型自动选计划。替代"指定计划"的手段是改它的输入:更新统计、建索引、调代价参数。
- 不是 bug。索引扫描的回表是随机读,
random_page_cost=4.0意味着随机读一页约等于顺序读 4 页。命中 40% 行时,海量随机读比把整表顺序读一遍更贵,所以 Seq Scan 反而更便宜。这是合理的代价权衡。 - n_distinct 低估 → 1/n_distinct 偏大 → 选择率估高 → 优化器以为命中很多行 → 可能放弃 Index Scan 改走 Bitmap 甚至 Seq Scan,或在 JOIN 中误选算法。修法:提高该列
STATISTICS再ANALYZE。 - 因为优化器估外层只有很少行(此时 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 行)或覆盖索引(免回表)会更快。这道题的答案一半在本章,一半在下一章。