Skip to content

元数据卡

维度
难度(黑魔法)
前置第1章 SQL、第2章 关系代数、第4章 B+树
关键词Query Plan、Join 算法、Cost Model、PostgreSQL Optimizer、EXPLAIN
代码语言SQL(主)/ Pseudocode(算法)

你的进度

老陈的书房里坐着一位军师。你每次说'我要这个那个'(写一条 SQL),军师就开始画地图——走哪条路搬货?先过什么过滤器?用什么方法拼两张表?他手里握着一本厚厚的成本手册,上面记着每一条路线的代价。这就是查询优化器。


破局 · 溯源

1. 查询处理管道

你的 SQL 进了堡垒大门后,不会直接被丢给工人干活。它先走过四站:

SQL 文本


 Parser → 生成抽象语法树(AST)


 Rewriter → 语义分析、视图展开、子查询扁平化


 Optimizer → 生成逻辑计划 → 枚举物理计划 → 选代价最低的


 Executor → 按计划逐算子执行,返回结果集

Parser 是堡垒里的文书——他只关心语法对不对。SELECT * FORM adventurers 这种拼写错误,文书一眼就扔回来了。但 SELECT * FROM adventurers WHERE 1=1SELECT * FROM adventurers——对文书来说一样“对”。

Rewriter 是军师手下的参谋,专做"人有我优"的事:

  • 视图展开:如果你查 SELECT * FROM high_value_quests_view,它把这个视图的定义 SQL 展开进来
  • 子查询扁平化:把 WHERE id IN (SELECT id FROM ...) 改写为连接
  • 谓词推导:WHERE a > 10 AND a > 20WHERE a > 20

Optimizer 就是老陈那位军师。他接到的是一张逻辑路线图(关系代数树),画出去的是实地考察过的物理施工图(带算法选择和代价估算)。

Executor 是账房里的跑腿——军师怎么画,他就怎么走,不关心为什么。

这条管道不是 PostgreSQL 独有的。MySQL、SQLite、Oracle、SQL Server 走的都是同一套流程——区别在于军师的智慧程度。


2. 扫描方式:我怎么从表里拿数据

你告诉军师:

sql
SELECT * FROM adventurers WHERE level > 30;

没有 JOIN,没有子查询。但光这个查询,就有至少三种物理扫描方式。

全表扫描(Seq Scan)

如果 adventurers 表上没有 level 列的索引,优化器只能走全表扫描:

PostgreSQL Seq Scan on adventurers (cost=0.00..35.00 rows=500 width=36)
 Filter: (level > 30)

引擎从第一页读到最后一页,逐行检查 Filter。这是最笨的方法,也是最保险的方法——不管你有没有索引、不管你写的条件多怪,全表扫描永远不会出错。只是慢。

但有时候它反而是最快的——什么情况?

  • 表中大部分行都满足条件(过滤掉的比例很低)
  • 表非常小(几十个页面)
  • 索引所在页面都在磁盘,但全表的数据已经在 Buffer Pool 里

索引扫描(Index Scan)

如果军师手里有一份索引,事情就不一样了:

PostgreSQL Index Scan on idx_adventurers_level (cost=0.28..12.37 rows=100 width=36)
 Index Cond: (level > 30)

过程:走 B+树找到第一个 level > 30 的叶子条目 → 记下页面号和偏移 → 去堆表(heap)取整行 → 重复。

注意:这里有两笔 I/O——索引页 + 数据页。如果你的表列很多,索引里只存了几个键和指针,大段的宽列还在堆表里躺着。

索引全覆盖扫描(Index-Only Scan)

如果查询只需要 level 列(而不需要 nameclass 这些),并且索引已经包含了 level,数据库可以根本不碰堆表:

PostgreSQL Index Only Scan on idx_adventurers_level (cost=0.28..6.37 rows=100 width=4)
 Index Cond: (level > 30)

代价比 Index Scan 低了一截——省掉堆表的随机 I/O。PostgreSQL 靠可见性映射(Visibility Map)来判断行是否对所有人可见;如果有不可见的行,它还是得回表。

Bitmap Scan

当多个条件组合在一起时,比如:

sql
SELECT * FROM quests WHERE status = 'completed' AND reward > 1000;

如果 statusreward 上各有独立索引,军师会这样操作:

  1. idx_quests_status 找到所有 status='completed' 的行——得到一个位图(哪些页面有哪些行满足条件)
  2. idx_quests_reward 找到所有 reward>1000 的行——得到另一个位图
  3. 两个位图做按位与(AND)
  4. 按结果位图去堆表取数据
PostgreSQL Bitmap Heap Scan on quests (cost=8.00..12.00 rows=50 width=40)
 Recheck Cond: (status = 'completed'::text AND reward > 1000)
 → BitmapAnd
 → Bitmap Index Scan on idx_quests_status (cost=0.00..4.00 rows=200 width=0)
 Index Cond: (status = 'completed'::text)
 → Bitmap Index Scan on idx_quests_reward (cost=0.00..4.00 rows=100 width=0)
 Index Cond: (reward > 1000)

位图扫描的精髓:把随机 I/O 攒成一捆顺序 I/O。如果不合并,直接拿 idx_status 的结果去堆表逐行读,好比不整理仓库就一头扎进去搬货——每次都要找不同的货架。Bitmap 先记下所有要去哪些页面,然后按货架序号一路搬过去,把乱走变成逛超市。


3. Join 算法:把两张表拼起来

堡垒数据库里没有指针——没有 user.address.city 这种直通车。要把冒险者和他们完成的任务关联起来,必须做 JOIN。

假设军师接到这个查询:

sql
SELECT a.name q.reward
FROM adventurers a
JOIN quests q ON a.adventurer_id = q.adventurer_id
WHERE q.reward > 500;

adventurers 有 1 万行,quests 有 100 万行——怎么拼最快?

Nested Loop Join(NLJ)

最简单的做法:对每一个冒险者,翻遍所有任务找匹配。

pseudocode
for each row a in adventurers: // 外层:10000 次
 for each row q in quests: // 内层:1000000 次
 if a.adventurer_id == q.adventurer_id:
 emit (a.name q.reward)

如果内层是顺序扫描,复杂度是 |adventurers| × |quests| = 1 万 × 100 万 = 100 亿次比较。这么搞,一个简单的登录查询都能卡死堡垒的服务器。

关键优化来了:如果内层表有索引,内层就不再是扫描,而是索引查找。

pseudocode
for each row a in adventurers: // 10000 次
 index_lookup(idx_quests_adventurer_id a.adventurer_id) // log n 查找
 emit matching rows

复杂度降到 |adventurers| × log|quests| ≈ 1 万 × 20 = 20 万次操作——差了 5 万倍。这才是 OLTP 数据库做简单 JOIN 的真实方式。

适用场景: 外层表小(几百到几千行),内层表有索引。 不适合: 外层表很大(几十万行),即使有索引也会有大量随机 I/O。

Hash Join

如果内层表没有索引,或者两张表都很大,NLJ 就是个灾难。军师翻开另一页:Hash Join。

pseudocode
// 构建阶段:扫描小表,建立哈希表
hash_table = {}
for each row a in adventurers: // 10000 行
 hash_table[hash(a.adventurer_id)] = a

// 探测阶段:扫描大表,哈希匹配
for each row q in quests: // 1000000 行
 if a = hash_table.lookup(hash(q.adventurer_id)):
 if a.adventurer_id == q.adventurer_id: // 处理哈希冲突
 emit (a.name q.reward)

复杂度:|adventurers| + |quests| = 10000 + 1000000 ≈ 1010000 次操作。相比 NLJ 的 100 亿,这是近 1000 倍的差距。

优化器会选择较小的表作为构建侧(build side),让哈希表尽可能小,少占内存。如果构建侧太大放不进内存,需要Grace Hash Join——分多次分区写入磁盘再分别处理。

pseudocode
// Grace Hash Join:内存放不下时
// Phase 1: Partition
for each row a in adventurers:
 partition = hash(a.adventurer_id) % N
 write_to_disk(partition a)
for each row q in quests:
 partition = hash(q.adventurer_id) % N
 write_to_disk(partition q)

// Phase 2: Probe (每个分区单独做 Hash Join)
for partition in 0..N-1:
 load partition_adventurers into memory
 probe against partition_quests

哈希冲突的处理不是用 == 就完事的——不同键的哈希值可能相同。所以 lookup 之后必须做一次精确比较。

适用场景: 大表 JOIN 大表、没有索引、等值连接(=)——Hash Join 只支持等值条件。

Sort-Merge Join

军师还有第三招——如果数据恰好是排好序的:

pseudocode
// 如果两张表已经按 adventurer_id 排好序(或者通过排序算子实现)
sort adventurers by adventurer_id
sort quests by adventurer_id

i = 0 j = 0
while i < len(adventurers) and j < len(quests):
 if adventurers[i].adventurer_id == quests[j].adventurer_id:
 emit (adventurers[i].name quests[j].reward)
 j++ // 继续看下一个 quest
 elif adventurers[i].adventurer_id < quests[j].adventurer_id:
 i++ // adventurer_id 小了,往前走
 else:
 j++ // quest adventurer_id 小了,往前走

就像两路归并排序的合并阶段。不需要哈希表,不需要随机 I/O,只需要两个指针一路走过有序序列。

复杂度: 如果两表都已排序,|adventurers| + |quests|。如果没排序,需要加上排序代价 |adventurers|·log|adventurers| + |quests|·log|quests|

适用场景:

  • 两张表已经按连接键排好序(例如有聚簇索引或有 ORDER BY
  • 非等值连接(><BETWEEN)——Hash Join 处理不了这种,Sort-Merge 可以
  • 结果集需要排序输出

怎么选?

军师怎么选?取决于他对行数的估算(基数估算):


 等值连接?

 Yes / \ No


 小表 × 大表? Sort-Merge
 Join
 Yes/ \No


 Nested Loop Hash Join
 (带索引)

4. 优化器:代价模型

军师的任务:在上千种物理计划里挑代价最低的。他怎么算账?

成本公式

PostgreSQL 的代价模型用四个基本单位:

sql
-- 每个操作的代价 = (磁盘页面数 × seq_page_cost/random_page_cost)
-- + (行数 × cpu_tuple_cost)
-- + (索引条目数 × cpu_index_tuple_cost)
-- + (操作次数 × cpu_operator_cost)

-- 默认值(可调整):
-- seq_page_cost = 1.0 (顺序读一页的成本单位)
-- random_page_cost = 4.0 (随机读一页:HDD 约 4 倍,SSD 应调低到 1.1)
-- cpu_tuple_cost = 0.01 (处理一行)
-- cpu_operator_cost = 0.0025(执行一个条件判断)

一个简单的全表扫描代价估算:

sql
EXPLAIN SELECT * FROM adventurers WHERE level > 30;
-- cost=0.00..35.00 rows=500 width=36

这里的 cost=0.00..35.00 是:

  • 0.00:启动代价(顺序扫描不需要启动开销)
  • 35.00:总代价 ≈ 表占 35 个页面 × 1.0(顺序读)

军师的情报来源

军师不可能把每条路线都走一遍再估算——他靠预先搜集的情报。

PostgreSQL 的统计数据存在 pg_statistic 表中,通过 ANALYZE 命令更新:

sql
-- 查看表的统计信息
SELECT relpages reltuples FROM pg_class WHERE relname = 'adventurers';
-- relpages reltuples
--
-- 35 10000

-- 查看某列的统计详情
-- 最常用值(MCV Most Common Values)
SELECT most_common_vals most_common_freqs
FROM pg_stats WHERE tablename = 'adventurers' AND attname = 'class';

-- 直方图边界
SELECT histogram_bounds
FROM pg_stats WHERE tablename = 'quests' AND attname = 'reward';

直方图——军师手头的地形图,用来估算范围条件的过滤率。假设 reward 的直方图分了 100 个桶:

sql
WHERE reward > 500 AND reward < 1000

优化器找到 500 和 1000 分别在哪个桶,按面积比例估算行数。

**最常见值(MCV)**用于等值条件的估算。如果 class='Warrior' 是一个高频值,MCV 表里直接有它的频率。

军师也会翻车:

sql
-- 优化器以为只有 100 行,实际有 1000000 行
-- 没有及时 ANALYZE,或者数据分布剧烈变化

情报过时了——这是堡垒里查询突然变慢的头号元凶。

动态规划枚举

军师怎么枚举 3 张表的 JOIN 顺序?用动态规划:

JOIN(T1 T2 T3) 的可能顺序:
 (T1 ⋈ T2) ⋈ T3
 (T1 ⋈ T3) ⋈ T2
 (T2 ⋈ T3) ⋈ T1
 T1 ⋈ (T2 ⋈ T3)
 T2 ⋈ (T1 ⋈ T3)
 T3 ⋈ (T1 ⋈ T2)

逻辑上等价,但物理代价差别巨大。军师会逐一枚举,算每一笔账,取最便宜的。

当表数涨到 10 张,枚举量冲到千万级。PostgreSQL 12+ 引入了 GEQO(Genetic Query Optimizer)——做近似搜索,或者直接降级用启发式。军师脑子再灵,也不会穷举 10 张表的所有顺序。

小贴士: 如果你写了个 15 张表的 JOIN,即使军师最终出了计划,也别太高兴——大概率是矮子里拔将军。拆成几段有时更聪明。


5. 执行计划拆解:EXPLAIN ANALYZE

纸上谈兵够了。接上堡垒里的 PostgreSQL,看军师怎么算、实际怎么跑:

sql
-- 准备数据
CREATE TABLE adventurers (
 adventurer_id SERIAL PRIMARY KEY
 name TEXT
 class TEXT
 level INTEGER
);

INSERT INTO adventurers SELECT
 generate_series(1 100000)
 'hero_' || generate_series(1 100000)
 (ARRAY['Warrior' 'Mage' 'Rogue' 'Paladin'])[floor(random()*4+1)::int]
 floor(random()*50 + 1)::int;

ANALYZE adventurers;

-- 查询 1:按职业过滤
EXPLAIN (ANALYZE BUFFERS) SELECT * FROM adventurers WHERE class = 'Warrior';

输出:

Gather (cost=1000.00..1065.35 rows=235 width=13) (actual time=0.33..3.21 rows=24712 loops=1)
 Workers Planned: 2
 Workers Launched: 2
 → Parallel Seq Scan on adventurers (cost=0.00..1000.00 rows=98 width=13)
 (actual time=0.15..2.18 rows=8237 loops=3)
 Filter: (class = 'Warrior'::text)
 Rows Removed by Filter: 24641
 Planning Time: 0.085 ms
 Execution Time: 4.12 ms

拆解:

  • cost 列:优化器估算的启动代价和总代价(单位不是毫秒,是抽象成本)
  • actual time:真实执行时间,第一行输出时间..所有行输出时间
  • rows:估算 vs 实际。这里估算 235,实际 24712——差了 100 倍!直方图没有准确反映数据分布
  • loops:并行 worker 中这个算子跑了 3 次
  • Filter:54321 行被过滤掉了
  • Buffers:如果加了 BUFFERS 会显示读了多少页面

再来看一个 JOIN:

sql
-- 建任务表
CREATE TABLE quests (
 quest_id SERIAL PRIMARY KEY
 adventurer_id INTEGER REFERENCES adventurers(adventurer_id)
 reward NUMERIC(102)
 created_at DATE DEFAULT CURRENT_DATE
);

INSERT INTO quests SELECT
 generate_series(1 500000)
 floor(random()*100000 + 1)::int
 floor(random()*1000 + 10)::numeric
 '2024-01-01'::date + (floor(random()*365)::int);

ANALYZE quests;

-- 不加索引,看 Hash Join
EXPLAIN (ANALYZE BUFFERS TIMING)
SELECT a.name q.reward
FROM adventurers a
JOIN quests q ON a.adventurer_id = q.adventurer_id
WHERE a.class = 'Mage';

输出:

Hash Join (cost=1065.35..10117.85 rows=323500 width=17)
 (actual time=3.84..148.23 rows=321267 loops=1)
 Hash Cond: (q.adventurer_id = a.adventurer_id)
 → Seq Scan on quests q (cost=0.00..5200.00 rows=500000 width=10)
 (actual time=0.02..37.18 rows=500000 loops=1)
 → Hash (cost=1000.00..1000.00 rows=235 width=13)
 (actual time=3.78..3.78 rows=24712 loops=1)
 Buckets: 32768 Batches: 1 Memory Usage: 1337kB
 → Seq Scan on adventurers a (cost=0.00..1000.00 rows=235 width=13)
 (actual time=0.11..2.09 rows=24712 loops=1)
 Filter: (class = 'Mage'::text)
 Rows Removed by Filter: 75288

拆解:

  1. 先过滤 adventurers 表(Seq Scan + Filter),得到 24712 行 Mage 职业的冒险者
  2. 用这 24712 行构建哈希表(Hash 节点),用了 1337KB 内存
  3. 全表扫描 quests 表,对每一行查哈希表匹配
  4. 成功匹配 321267 行

注意几个关键问题:

  • 如果优化器选错了连接顺序——把大表 quests 当 build side,哈希表会占用几十倍的内存,甚至写到磁盘
  • 这里估算 adventurers 过滤后只有 235 行(实际 24712 行),偏差很大但哈希表仍然放得下
  • 如果偏差导致选错了 Join 算法(比如选了 NLJ 而不是 Hash Join),性能就会从毫秒级跌到秒级

6. 查询重写技巧

军师不是万能的。有些写法他就是优化不好。下面几种手段,你可以在 SQL 层面主动帮他一把。

子查询展开(Subquery Unnesting)

军师的第一招:把子查询展开成 JOIN:

sql
-- PostgreSQL 自动展开为 JOIN
SELECT * FROM adventurers
WHERE adventurer_id IN (SELECT adventurer_id FROM quests WHERE reward > 1000);

如果优化器没展开(或者你的数据库不支持),你手动展开:

sql
-- 显式 JOIN 版本
SELECT DISTINCT a.*
FROM adventurers a
JOIN quests q ON a.adventurer_id = q.adventurer_id
WHERE q.reward > 1000;

但小心: IN (SELECT ...)JOIN ... DISTINCT 不一定等价——如果 quests 里有多个匹配行,JOIN 会让 adventurers 重复,IN 不会。改之前必须确认语义。

谓词推导(Predicate Pushdown)

sql
-- 优化器自动做的事情:
SELECT a.* q.*
FROM adventurers a
JOIN quests q ON a.adventurer_id = q.adventurer_id
WHERE a.class = 'Warrior' AND q.reward > 1000;

-- 逻辑上等价于:
-- 1. 先在 adventurers 上过滤 class='Warrior'(减少了 build side 大小)
-- 2. 提前过滤 quests 的 reward > 1000
-- 3. 再 JOIN

军师会自动把 WHERE 条件推到 JOIN 之前执行。但有些情况他也推不动——比如 OR 条件下的谓词:

sql
-- 优化器很难拆分:
SELECT * FROM adventurers a
LEFT JOIN quests q ON a.adventurer_id = q.adventurer_id
WHERE a.class = 'Warrior' OR q.reward > 1000;

这里 OR 条件跨越了两张表,谓词无法分别下推。

连接重排序(Join Reordering)

如果一批表是链式连接的,显式控制顺序有时比军师自己选更稳妥:

sql
-- 在一个明确的顺序中,大表连接小表再连接中表
-- 不要写成一大串无括号的 FROM + JOIN

-- 如果可以控制顺序:
SELECT *
FROM (small_table JOIN large_table ON ...) -- 先连小表减少数据量
JOIN medium_table ON ...;

PostgreSQL 默认会搜遍所有合法顺序。但如果你用了 FROM large_table LEFT JOIN small_table,LEFT JOIN 会锁死排序自由度(必须保持 left table 在 outer side)。

公共表表达式(CTE)的陷阱

sql
-- PostgreSQL 12 之前:CTE 是优化屏障(Optimization Fence)
WITH high_reward_quests AS (
 SELECT * FROM quests WHERE reward > 1000
)
SELECT a.name h.reward
FROM adventurers a
JOIN high_reward_quests h ON a.adventurer_id = h.adventurer_id;

-- PostgreSQL 12+:CTE 默认可以内联,用 MATERIALIZED 强制物化

如果你依赖 CTE 来保证子查询只执行一次(比如排序代价高昂的子查询),显式加上 MATERIALIZED


深入冒险

自适应执行与参数化计划

传统军师走的是"一次优化,一次执行"的路线。但堡垒里的查询往往通过参数化模板反复执行:

sql
SELECT * FROM quests WHERE adventurer_id = $1;

adventurer_id = 1 可能返回一行,最优计划是 Index Scan + Nested Loop。对 adventurer_id = 99999 可能返回 50% 的表,最优计划是 Sequential Scan + Hash Join。

这里军师面临一个两难:不知道参数值,怎么选计划?

几种方案:

  1. Generic Plan:基于平均分布估算,稳定但可能对极端参数不优
  2. Custom Plan:每次重新优化(Prepared Statement 默认行为,前 5 次执行后会尝试生成 generic plan)
  3. Adaptive Plan:部分数据库(如 Oracle)支持运行时根据实际数据量切换计划

PostgreSQL 16+ 加入更多运行时统计反馈来改善参数嗅探——军师也开始学着根据实际数据调整判断。


乐观估算与悲观估算

军师的决策依赖估算。估算不准,选错计划就是家常便饭。为什么常错?

  1. 数据倾斜:直方图只有 100 个桶,无法精确反映异常分布
  2. 相关列假设:优化器假设列之间独立——但实际 WHERE class='Warrior' AND level>30 可能高度相关
  3. 函数不透明WHERE expensive_func(col) > 100——优化器不知道函数的返回值分布

PostgreSQL 提供了扩展统计信息来救场:

sql
CREATE STATISTICS s1 (dependencies) ON class level FROM adventurers;
CREATE STATISTICS s2 (mcv) ON class level FROM adventurers;

多列统计信息告诉优化器"class 和 level 不是独立的"。


常见陷阱

陷阱 1:把外键索引当"不需要"

adventurer_id 是外键——但 PostgreSQL 不会自动为它建索引。没有索引的话,ON DELETE CASCADE 和 JOIN 都会退化成全表扫描。

sql
-- 记得手动建
CREATE INDEX idx_quests_adventurer_id ON quests(adventurer_id);

陷阱 2:EXPLAIN 和 EXPLAIN ANALYZE 的结果不同

EXPLAIN 是估算,EXPLAIN ANALYZE 是实际执行。有时候 EXPLAIN 显示走 Index Scan,你加了 ANALYZE 强行跑了一遍后发现走了 Seq Scan——这事经常发生,因为 ANALYZE 真的执行了查询。

另外注意:EXPLAIN ANALYZE 会实际修改数据!对于 INSERT/UPDATE/DELETE 要用 EXPLAIN (ANALYZE BUFFERS) 但记得包事务:

sql
BEGIN;
EXPLAIN (ANALYZE BUFFERS) DELETE FROM adventurers WHERE adventurer_id > 99000;
ROLLBACK;

陷阱 3:Hash Join 内存不够就死?不,它会优雅降级

如果构建侧太大放不进 work_mem,Hash Join 会分批次处理("Grace Hash Join"的批次数 > 1)。但每次写盘都会以数量级增大延迟:

Hash (cost=1000.00..1000.00 rows=235 width=13)
 (actual time=3.78..3.78 rows=24712 loops=1)
 Buckets: 32768 Batches: 1 Memory Usage: 1337kB

如果这里 Batches: 4,意味着哈希表被分成了 4 份,每份写到磁盘再读回来——代价从毫秒变为秒。

陷阱 4:军师的手伸不到远程表

sql
-- 用 dblink 或 foreign data wrapper 连的远程表
-- PostgreSQL 只能在本地估算,拿不到远程表的精确统计信息

如果远程表变化频繁,物化视图可能更靠谱。


通关挑战

  • 热身:在 PostgreSQL 中执行 EXPLAIN SELECT * FROM pg_class,读出 cost 和 rows 的含义
  • 挑战:建两张 10 万行表,无索引时测 Nested Loop(强制关闭 Hash Join 用 SET enable_hashjoin=off),对比加索引后的性能
  • 观察:执行 EXPLAIN (ANALYZE BUFFERS) 一个多表 JOIN,观察 batches 和 memory usage
  • 排障:有一个查询 EXPLAIN 显示 rows=10,实际返回 100 万行,怎么定位和修复?

验收标准

  • 能画出一条 SQL 从 Parser 到 Executor 的完整路径
  • 能区分 Seq Scan、Index Scan、Index-Only Scan、Bitmap Scan 的适用场景
  • 能解释 Nested Loop、Hash Join、Sort-Merge Join 的核心差异(何时用、什么条件、复杂度)
  • 能阅读 PostgreSQL 的 EXPLAIN 输出并识别潜在问题
  • 知道 ANALYZE 和统计信息对查询性能的影响

常见卡点

卡点原因
EXPLAIN 的 cost 数值比预期大很多统计信息过时,跑一下 ANALYZE
两个小表 JOIN 却走了 Hash Join可能没有合适的索引,令 NLJ 估价过高
EXPLAIN ANALYZE 耗时远超正常查询它在真的执行——如果 Buffer 是冷状态可能包含 I/O 时间
WHERE 条件下降后还是慢可能是 OR 条件阻止了谓词下推
Bitmap Scan 没出现要么条件是单列索引能覆盖的,要么 random_page_cost 设置不合适

现在不需要理解

  • 并行查询的内部实现(并行算子、worker 生命周期)
  • Custom Scan Providers(扩展优化器的方式)
  • 通过 pg_hint_plan 扩展手动设置执行计划(PostgreSQL 的提示机制)

旅人笔记

你丢给堡垒一条 SQL,它先经过文书检查语法、参谋整理逻辑、军师比划代价,最后跑腿的执行计划。军师不会故意选错路——他只会按他知道的情报选"以为最优"的路。情报有多准,军师就有多靠谱。

下一站预告

Built with VitePress | Software Systems Atlas