元数据卡
维度 值 难度 (黑魔法) 前置 第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=1 和 SELECT * FROM adventurers——对文书来说一样“对”。
Rewriter 是军师手下的参谋,专做"人有我优"的事:
- 视图展开:如果你查
SELECT * FROM high_value_quests_view,它把这个视图的定义 SQL 展开进来 - 子查询扁平化:把
WHERE id IN (SELECT id FROM ...)改写为连接 - 谓词推导:
WHERE a > 10 AND a > 20→WHERE a > 20
Optimizer 就是老陈那位军师。他接到的是一张逻辑路线图(关系代数树),画出去的是实地考察过的物理施工图(带算法选择和代价估算)。
Executor 是账房里的跑腿——军师怎么画,他就怎么走,不关心为什么。
这条管道不是 PostgreSQL 独有的。MySQL、SQLite、Oracle、SQL Server 走的都是同一套流程——区别在于军师的智慧程度。
2. 扫描方式:我怎么从表里拿数据
你告诉军师:
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 列(而不需要 name、class 这些),并且索引已经包含了 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
当多个条件组合在一起时,比如:
SELECT * FROM quests WHERE status = 'completed' AND reward > 1000;如果 status 和 reward 上各有独立索引,军师会这样操作:
- 走
idx_quests_status找到所有status='completed'的行——得到一个位图(哪些页面有哪些行满足条件) - 走
idx_quests_reward找到所有reward>1000的行——得到另一个位图 - 两个位图做按位与(AND)
- 按结果位图去堆表取数据
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。
假设军师接到这个查询:
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)
最简单的做法:对每一个冒险者,翻遍所有任务找匹配。
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 亿次比较。这么搞,一个简单的登录查询都能卡死堡垒的服务器。
关键优化来了:如果内层表有索引,内层就不再是扫描,而是索引查找。
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。
// 构建阶段:扫描小表,建立哈希表
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——分多次分区写入磁盘再分别处理。
// 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
军师还有第三招——如果数据恰好是排好序的:
// 如果两张表已经按 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 的代价模型用四个基本单位:
-- 每个操作的代价 = (磁盘页面数 × 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(执行一个条件判断)一个简单的全表扫描代价估算:
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 命令更新:
-- 查看表的统计信息
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 个桶:
WHERE reward > 500 AND reward < 1000优化器找到 500 和 1000 分别在哪个桶,按面积比例估算行数。
**最常见值(MCV)**用于等值条件的估算。如果 class='Warrior' 是一个高频值,MCV 表里直接有它的频率。
军师也会翻车:
-- 优化器以为只有 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,看军师怎么算、实际怎么跑:
-- 准备数据
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:
-- 建任务表
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拆解:
- 先过滤
adventurers表(Seq Scan + Filter),得到 24712 行 Mage 职业的冒险者 - 用这 24712 行构建哈希表(Hash 节点),用了 1337KB 内存
- 全表扫描
quests表,对每一行查哈希表匹配 - 成功匹配 321267 行
注意几个关键问题:
- 如果优化器选错了连接顺序——把大表
quests当 build side,哈希表会占用几十倍的内存,甚至写到磁盘 - 这里估算
adventurers过滤后只有 235 行(实际 24712 行),偏差很大但哈希表仍然放得下 - 如果偏差导致选错了 Join 算法(比如选了 NLJ 而不是 Hash Join),性能就会从毫秒级跌到秒级
6. 查询重写技巧
军师不是万能的。有些写法他就是优化不好。下面几种手段,你可以在 SQL 层面主动帮他一把。
子查询展开(Subquery Unnesting)
军师的第一招:把子查询展开成 JOIN:
-- PostgreSQL 自动展开为 JOIN
SELECT * FROM adventurers
WHERE adventurer_id IN (SELECT adventurer_id FROM quests WHERE reward > 1000);如果优化器没展开(或者你的数据库不支持),你手动展开:
-- 显式 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)
-- 优化器自动做的事情:
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 条件下的谓词:
-- 优化器很难拆分:
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)
如果一批表是链式连接的,显式控制顺序有时比军师自己选更稳妥:
-- 在一个明确的顺序中,大表连接小表再连接中表
-- 不要写成一大串无括号的 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)的陷阱
-- 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。
深入冒险
自适应执行与参数化计划
传统军师走的是"一次优化,一次执行"的路线。但堡垒里的查询往往通过参数化模板反复执行:
SELECT * FROM quests WHERE adventurer_id = $1;对 adventurer_id = 1 可能返回一行,最优计划是 Index Scan + Nested Loop。对 adventurer_id = 99999 可能返回 50% 的表,最优计划是 Sequential Scan + Hash Join。
这里军师面临一个两难:不知道参数值,怎么选计划?
几种方案:
- Generic Plan:基于平均分布估算,稳定但可能对极端参数不优
- Custom Plan:每次重新优化(Prepared Statement 默认行为,前 5 次执行后会尝试生成 generic plan)
- Adaptive Plan:部分数据库(如 Oracle)支持运行时根据实际数据量切换计划
PostgreSQL 16+ 加入更多运行时统计反馈来改善参数嗅探——军师也开始学着根据实际数据调整判断。
乐观估算与悲观估算
军师的决策依赖估算。估算不准,选错计划就是家常便饭。为什么常错?
- 数据倾斜:直方图只有 100 个桶,无法精确反映异常分布
- 相关列假设:优化器假设列之间独立——但实际
WHERE class='Warrior' AND level>30可能高度相关 - 函数不透明:
WHERE expensive_func(col) > 100——优化器不知道函数的返回值分布
PostgreSQL 提供了扩展统计信息来救场:
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 都会退化成全表扫描。
-- 记得手动建
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) 但记得包事务:
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:军师的手伸不到远程表
-- 用 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,它先经过文书检查语法、参谋整理逻辑、军师比划代价,最后跑腿的执行计划。军师不会故意选错路——他只会按他知道的情报选"以为最优"的路。情报有多准,军师就有多靠谱。