
深入数据库执行计划
专门写这一篇文章,是因为曾经遇到过有同事不熟悉数据库,甚至没用过 EXPLAIN,当遇到慢 SQL 之后,第一反应往往是问有经验的人。所以,本文不会假定你对数据库有很高的熟悉程度,但是写 SQL、建索引这些还是得会的,部分超出范围的内容也都有讲解。
执行 EXPLAIN,屏幕上很快出现一堆内容:type=range、cost=35.50、Bitmap Heap Scan、USE TEMP B-TREE。如果使用过或者阅读过文档,看到这里可能会觉得问题已经有了答案,但实际上,排查才刚刚开始。
这些节点只记录了优化器的选择,没有直接说明选择的依据。要理解它还需要继续了解:每个条件预计会留下多少行;当前引擎能够使用哪些物理算子;CPU、I/O、内存和并行通信在成本模型中分别占多大权重;查询运行以后,各节点循环了多少次,又读写了多少页面。
看执行计划,应该先找估算行数开始跑偏的地方。这是因为数据库的优化器只有不完整的统计信息,还得在有限时间里选出路径,猜错的情况虽然不常见,但也不罕见。找到了第一处偏差,后面的 Join 选择和内存问题就好解释了。切记的是,不要遇到问题就直接强制索引,这只会把问题埋得更深更久。
一、EXPLAIN 究竟看到了什么
1.1 从字符串到物理计划
我们就从三个不同的数据库开始,数据库不同,但主干流程相近:
解析器先判断 SQL 是否符合语法,并生成语法树。随后,名称绑定和语义分析把 orders.customer_id 对应到具体的表和列,同时确定操作符、函数重载、排序规则和隐式类型转换。索引能不能用,很多时候在这里就已经决定了。绑定后的条件如果变成 CAST(indexed_col AS ...),后续的路径生成器就未必能将它匹配到原来的索引键。
接下来是逻辑重写,此时还没有决定读取哪些页面,数据库只在关系代数层面改写查询,例如:
- 常量折叠:
price > 10 * 2变成price > 20; - 谓词下推:把只引用子查询内部列的过滤条件尽量推到更早的位置;
- 外连接消除或转内连接:前提是 null-rejecting 条件保证语义等价;
- 子查询去相关、半连接改写、视图展开;
- 分区裁剪与不必要排序消除。
这类工作常被归入 RBO (Rule-Based Optimization) 基于规则的优化。现代优化器一般会同时使用规则和成本:规则生成等价表达式和候选路径,成本模型再比较物理代价。规则改变搜索范围,成本模型从中选一条。
到了物理优化阶段,Seq Scan、Index Scan、排序、聚合、连接顺序和连接算法才进入候选集合。每条路径都带有预计行数、启动代价、总代价,以及排序键等物理属性。A ⋈ B ⋈ C 虽然只有一种查询语义,却可以写成 (A⋈B)⋈C、(A⋈C)⋈B 或 A⋈(B⋈C);每种顺序下还要比较 Nested Loop、Hash Join、Merge Join 和不同扫描方式。候选数量增长得很快,数据库只能借助动态规划、剪枝和启发式算法控制搜索时间。
选中的物理计划随后会转换为执行状态对象或字节码。普通 EXPLAIN 展示到此为止,因此只有估算数据。EXPLAIN ANALYZE 会执行查询,再把耗时、行数、循环次数和缓冲区访问量写回对应节点。
| 阶段 | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| 解析/绑定 | RawStmt → analyzed Query | LEX、Query_block、Item 表达式树 | AST 与 Expr、Select 等结构 |
| 逻辑变换 | rewriter、subquery_planner() | JOIN::optimize() 前后的一系列 rewrite | flattening、常量传播、WHERE 优化 |
| 路径搜索 | RelOptInfo、Path 动态规划与 GEQO | 传统 join optimizer;8.0 另有 Hypergraph 路径 | NGQP 的 N3 搜索、WhereLoop |
| 物理表示 | Plan tree → PlanState tree | AccessPath → iterator tree | VDBE opcode program |
| 运行时观测 | Instrumentation、BUFFERS、WAL | EXPLAIN ANALYZE iterator timing | sqlite3_stmt_status()、CLI scanstats;无同构的内建 ANALYZE 计划树 |
三套源码对这些阶段有不同的划分。MySQL 的 sql_optimizer.cc 列出了逻辑变换、表顺序和访问路径的成本选择,以及排序、临时表的设置过程。PostgreSQL 的 optimizer/README 解释了 RelOptInfo、Path 与最终 Plan 的关系。SQLite 主要在 where.c 中选择路径,再由 vdbe.c 执行生成的虚拟机指令。
1.2 rows 是整棵树的乘法器
成本模型可以粗略写成:
Cost(path) = StartupCost
+ PageIO(path, cardinality) × IOPrice
+ TuplesProcessed(path, cardinality) × CPUPrice
+ SortOrHash(path, cardinality, width, memory)
+ ParallelOverhead(path)成本计算中,基数 (cardinality) 往往比某一个成本常量更容易造成严重偏差。假设过滤后实际有 100,000 行,优化器却只估了 100 行。它计算 Nested Loop 时只会把内层代价乘 100,排序内存也按 100 行估算,甚至可能为 Hash Join 选错构建端。最初的 1,000 倍误差就这样沿计划树不断放大。
因此要同时关注两条线:
数据流:叶子扫描 → Join/Filter → Aggregate/Sort → 根节点
误差流:第一个 estimated rows ≠ actual rows 的节点 → 向父节点传播根节点汇总了整条查询的耗时,最早的错误却多半藏在叶子附近。顺着数据流向上看,并找到第一处估算行数与实际行数明显分离的节点,排查会快得多。
1.3 火山模型:为什么父节点会反复拉取子节点
经典火山迭代器模型 (Volcano Iterator Model) 为每个算子定义:
open() 初始化状态,打开子节点
next() 返回下一条记录;需要输入时递归调用 child.next()
close() 释放资源以带索引的 Nested Loop 为例:
父节点需要数据时才向子节点索取下一行。拿满十行以后,LIMIT 10 便可以停止继续拉取,整棵执行树也随之结束。启动代价因此很重要:交互式查询常常关心多久能看到第一行,而完整读取结果集的总代价可能并不重要。
另一方面,逐行虚函数调用、分支判断和解释表达式会增加 CPU 开销。向量化执行把接口改成 nextBatch():通过一次性在紧凑的列向量上处理几百或几千个值,可以减少函数调用开销,提高缓存局部性,并使 SIMD 更好地发挥作用。
逐行 pull: tuple → predicate → tuple → predicate → ...
向量 pull: [1024 values] → vector predicate → selection vector改用向量后,EXPLAIN 仍会显示 Scan、Filter 和 Hash Join,只是运行时传递的单位由 tuple 变成 batch;节点之间有时还需要插入行列转换,CPU 成本也要重新计算。MySQL 8.0 的新执行器采用 iterator 接口,PostgreSQL 的 ExecProcNode() 主干同样按需、逐行拉取。SQLite 大不相同,它的 VDBE (Virtual Database Engine) 虚拟数据库引擎是一台寄存器机,由 opcode 和程序计数器推动执行。
二、MySQL
下面使用一组简化表结构贯穿示例:
CREATE TABLE customers (
id BIGINT PRIMARY KEY,
state CHAR(2) NOT NULL,
zip_code CHAR(5) NOT NULL,
name VARCHAR(100) NOT NULL,
KEY idx_state (state),
KEY idx_zip (zip_code)
) ENGINE=InnoDB;
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
created_at DATETIME(6) NOT NULL,
total DECIMAL(12, 2) NOT NULL,
note VARCHAR(500),
KEY idx_customer_created (customer_id, created_at),
KEY idx_status_created (status, created_at)
) ENGINE=InnoDB;2.1 5.7 列表与 8.0 树的差异
MySQL 5.7 主要使用 TRADITIONAL 表格。每个 query block 中的表各占一行,并列出 type、key、rows、filtered 和 Extra。用它查看简单的 Nested Loop 很方便,但当计划中出现 Hash Join、Materialize 或 Sort,二维表格很难表示清楚父子关系。
EXPLAIN
SELECT o.id, c.name
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC
LIMIT 20;id select_type table type key rows filtered Extra
1 SIMPLE o ref idx_status_created 82000 100.00 Using index condition; Backward index scan
1 SIMPLE c eq_ref PRIMARY 1 100.00 NULLo.type=ref:对idx_status_created的首列做等值定位,结果可能包含多条索引记录;rows=82000:单次扫描预计检查 82,000 行,客户端收到的行数还要看后续过滤;c.type=eq_ref:对外层每一行,用唯一/主键完整键值查最多一行;- 表格顺序表达了 Nested Loop 的外层与内层,却不能自然表示所有非表节点。
MySQL 8.0.16 引入 FORMAT=TREE,直接暴露 iterator 层级;8.0.18 引入 EXPLAIN ANALYZE,实际运行语句并记录每个 iterator 的首行时间、末行时间、实际行数和循环数。
EXPLAIN ANALYZE
SELECT o.id, c.name
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC
LIMIT 20;-> Limit: 20 row(s) (cost=18442 rows=20)
(actual time=0.185..0.492 rows=20 loops=1)
-> Nested loop inner join (cost=18442 rows=82000)
(actual time=0.184..0.487 rows=20 loops=1)
-> Index lookup on o using idx_status_created (status='PAID') (reverse)
(cost=9742 rows=82000)
(actual time=0.151..0.236 rows=20 loops=1)
-> Single-row index lookup on c using PRIMARY (id=o.customer_id)
(cost=0.25 rows=1)
(actual time=0.011..0.011 rows=1 loops=20)这一段最该看的是 loops=20,而非孤立的 0.492 ms。内层主键查找平均每轮约 0.011 ms,外层总共调用了它 20 次。外层若返回 80,000 行,同一个节点便要做 80,000 次索引探测。树的缩进把这层乘法关系显示了出来。节点耗时包含子节点的时间,多次循环时显示的又是每轮平均值,所以各行时间不能直接相加。
2.2 type 的本质
官方把 type 称为 join type,它更像访问方式的粗略分类。类型 const > eq_ref > ref > range > index > ALL 不代表整体计划的好坏,需要结合返回行数、索引覆盖、输出顺序、缓存状态和回表次数。
InnoDB 主键索引是聚簇 B+ Tree,叶子保存完整记录,二级索引叶子保存二级键与主键值。于是二级索引的非覆盖查询通常有两次树导航:先在二级树获得主键,再回聚簇树取整行。
ref:定位相同前缀的第一片叶子,然后沿叶子链读取所有相等键
root
/ \
internal internal
|
leaf: PAID,pk=7 → PAID,pk=19 → PAID,pk=31 → ...
range:定位左边界,顺着叶子链前进,遇到右边界停止
ALL:按聚簇索引叶子页顺序检查整表
index:扫描整棵选定索引的叶子;若不覆盖,还可能逐项回表ref 要先定位到第一个匹配键,再沿叶子链读完所有重复值,成本会随着重复值数量增长,并不会在一次 O(log N) 查找后结束。range 也未必快。若 created_at >= '2020-01-01' 覆盖了 90% 的记录,选择 ALL 完全可能更省事。至于 index,它有时只是把全表扫描换成全索引扫描,看到 index 还不能判断计划优劣。
2.3 Using index condition:哪些条件交给存储引擎
考虑索引 (customer_id, created_at):
SELECT id, customer_id, created_at, note
FROM orders
WHERE customer_id BETWEEN 1000 AND 2000
AND created_at >= '2026-09-01'
AND note LIKE '%urgent%';没有 ICP (Index Condition Pushdown) 索引条件下推时,Server 层让 InnoDB 读取范围内每个二级索引项,再逐个用主键回表,最后判断 created_at 等剩余条件。开启 ICP 后,可仅由索引列求值的条件会被交给存储引擎;InnoDB 在二级索引项上先判断 created_at,不满足就省掉聚簇索引查找。note 不在索引中,仍要回表后判断。
Extra: Using index condition 表示存储引擎已经在索引项上检查部分条件。覆盖索引对应的是 Using index,两者含义不同。根据 MySQL 的ICP 文档,InnoDB 的 ICP 用于二级索引。读取聚簇索引时,完整记录已经进入缓冲区,条件下推也就无法再省去一次回表。
判断 ICP 是否有效,要算它省掉了多少次回表。假如原来的范围包含 500 万个索引项,即使 ICP 拦下 99% 的回表,遍历这些索引项仍要消耗 CPU 并读取页面。此时调整复合索引的列序、直接缩小扫描范围,效果可能更好。
2.4 Using filesort:file 不一定就是磁盘
filesort 的准确含义是「不能直接利用访问路径提供所需顺序,需要额外排序」。它可以完全在内存中完成。MySQL 收集通过 WHERE 的记录,将排序键与以下两类 payload 之一放入 sort buffer:
- 排序键 + row ID:排序后再按 row ID 回表取输出列,常被称作双路排序;
- 排序键 + 查询需要的附加字段:排序后直接输出,省一次回表,但单条记录更宽,buffer 能容纳的记录更少,常被称作单路排序。
单路、双路用来解释执行过程,不过用户不能直接指定。实际选择还受字段宽度、max_length_for_sort_data、可打包字段和优化器实现影响。MySQL 8.0.12 起,sort buffer 会按需增长到 sort_buffer_size 上限。数据放不下时,执行器生成多个有序 run,再进行外部归并。
内存足够:scan → sort buffer → quicksort/priority queue → output
内存不足:scan → run 1 ┐
run 2 ├→ multi-way merge → output
run 3 ┘ ↑ 临时文件 I/O排查 Using filesort 时,先确认参与排序的实际行数和行宽,再检查是否发生外部归并,以及索引能否同时满足过滤与排序。只有这些信息都清楚以后,才有理由调整内存。全局增大 sort_buffer_size 会放大并发连接的内存占用,弥补不了索引设计上的问题。
2.5 Using temporary 并不是 MyISAM
GROUP BY、DISTINCT、窗口函数、某些派生表与 ORDER BY/GROUP BY 顺序不一致时,MySQL 可能物化内部临时表。Extra: Using temporary 只说明需要这个中间结构,不说明它已经落盘。
MySQL 8.0 默认使用 TempTable 保存内存内部临时表。它和简单的 MEMORY Hash 表有不同的内存、字段与溢出规则。根据Internal Temporary Table 文档:
- 8.0.28 起,
tmp_table_size限制单个 TempTable 内存临时表,达到上限会转换为磁盘 InnoDB 内部临时表; temptable_max_ram限制 TempTable 全局 RAM 使用量;超过后可使用 memory-mapped 临时文件;temptable_max_mmap限制 mmap 部分,设为0会迫使超限部分使用 InnoDB 磁盘临时表;- MEMORY 模式下才主要受
min(tmp_table_size, max_heap_table_size)约束。
早期版本曾把磁盘内部临时表交给 MyISAM,MySQL 5.7 就已默认是 InnoDB。看到 Using temporary 并不意味着索引设计出现了问题,大规模精确聚合总要在某处保存状态,问题是状态是否被错误的基数估计放大、能否预聚合、能否通过索引顺序消除物化。
2.6 JSON Cost:看累计关系,不要换算毫秒
EXPLAIN FORMAT=JSON
SELECT id, total
FROM orders
WHERE status = 'PAID';{
"query_block": {
"cost_info": { "query_cost": "17342.10" },
"table": {
"table_name": "orders",
"access_type": "ref",
"key": "idx_status_created",
"rows_examined_per_scan": 82000,
"rows_produced_per_join": 82000,
"filtered": "100.00",
"cost_info": {
"read_cost": "9142.10",
"eval_cost": "8200.00",
"prefix_cost": "17342.10",
"data_read_per_join": "48M"
}
}
}
}read_cost:读取表或索引数据的估算成本,受页面、缓存估计与访问方法影响;eval_cost:对候选行计算条件等 CPU 工作的估算成本;默认成本常量下,示例中82,000 × 0.1 = 8,200;prefix_cost:从 join 顺序的第一个表执行到当前表为止的累计成本,其中包含前序节点;query_cost:query block 的总估算,通常与最后一个 join prefix 对应,但不能保证所有 iterator 都已完整纳入旧成本模型。
17342.10 是比较候选计划用的成本,不是毫秒。分析时应关注几个变化:为什么不同路径的 rows_examined_per_scan 相差很大,哪一段以 read_cost 为主,以及加入哪张表以后 prefix_cost 突然上升。
可以通过修改 mysql.server_cost 和 mysql.engine_cost 来改变成本常量,但动它们之前,至少要证明默认模型对一类查询和当前硬件存在稳定偏差。只为一条 SQL 改全局成本,只会把问题转嫁到其他 SQL 上。
三、PostgreSQL
3.1 怎样理解 cost=0.00..35.50
PostgreSQL 每个节点的两个数字是 startup cost 与 total cost。startup cost 是返回第一行前必须付出的代价;total cost 则是总成本。Sort 必须先吃完输入,startup 较高;Index Scan 可以很快吐出第一行,startup 往往很低。
对一个无并行、无额外投影的 Seq Scan,可以用源码 cost_seqscan() 的主干近似为:
disk_run_cost = rel_pages × seq_page_cost
cpu_per_tuple = cpu_tuple_cost + qual_operator_cost
cpu_run_cost = cpu_per_tuple × rel_tuples
total_cost = startup_cost + disk_run_cost + cpu_run_cost + target_list_cost假设表有 20 个 heap page、1,000 行,谓词只有一个普通比较,使用默认的 seq_page_cost=1.0、cpu_tuple_cost=0.01、cpu_operator_cost=0.0025:
页面成本 = 20 × 1.0 = 20.00
元组成本 = 1000 × (0.01 + 0.0025) = 12.50
总成本 ≈ 0.00 + 20.00 + 12.50 = 32.50完整计算还包括 target list、并行除数等细节,上面的式子无法复现为计划,它说明的是成本如何计量:过滤后返回 10 行,并不表示扫描节点只处理了 10 行。Seq Scan 仍按读过的 1,000 行计算 CPU 成本,父节点接收到的 rows 才是过滤后的 10。
random_page_cost 默认高于 seq_page_cost,用来表示离散读取页面一般更加昂贵,同时也把一部分缓存命中考虑在内。计算 Index Scan 成本时,规划器还会调用索引访问方法的 amcostestimate,综合索引页数、选择率、heap 页数,以及索引顺序与 heap 物理顺序之间的相关性。PostgreSQL 用近似缓存模型估计页面的重复访问,不能用「命中行数 × random_page_cost」直接代替。
3.2 三种扫描其实在选择 TID 的消费顺序
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
total numeric(12,2) NOT NULL
);
CREATE INDEX orders_status_idx ON orders (status);
CREATE INDEX orders_created_idx ON orders (created_at);Seq Scan 顺着 heap block 读取,检查每个对当前 MVCC 快照可见的 tuple。它的页面访问连续、预读友好,返回比例高时通常优于大量随机回表。
Index Scan 顺着索引得到 TID,再访问 heap tuple。它启动快、能保留 B-tree 顺序,适合高选择率条件和 ORDER BY ... LIMIT。但 TID 若在 heap 上离散,同一 heap 页可能反复访问。
Bitmap Index Scan + Bitmap Heap Scan 把找 TID 和取 tuple 拆开。一个或多个索引先产生 TID bitmap,BitmapAnd/BitmapOr 在位图上合并,再由 heap 节点按物理 block 顺序读取。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total
FROM orders
WHERE status = 'PAID'
AND created_at >= timestamptz '2026-09-01';Bitmap Heap Scan on orders (cost=812.40..9412.62 rows=18420 width=14)
(actual time=7.884..31.221 rows=19107 loops=1)
Recheck Cond: ((status = 'PAID'::text) AND
(created_at >= '2026-09-01 00:00:00+00'::timestamptz))
Heap Blocks: exact=4218
Buffers: shared hit=3901 read=511
-> BitmapAnd (cost=812.40..812.40 rows=18420 width=0)
(actual time=7.202..7.204 rows=0 loops=1)
Buffers: shared hit=130 read=64
-> Bitmap Index Scan on orders_status_idx
(actual time=4.401..4.401 rows=82011 loops=1)
Index Cond: (status = 'PAID'::text)
-> Bitmap Index Scan on orders_created_idx
(actual time=2.251..2.251 rows=44290 loops=1)
Index Cond: (created_at >= '2026-09-01 00:00:00+00'::timestamptz)
Planning Time: 0.341 ms
Execution Time: 32.014 ms- 两个
Bitmap Index Scan只生成位置集合,不返回业务列,所以父节点处常见actual rows=0;这个零与索引是否命中无关; BitmapAnd对 TID 集合求交,避免先从一个索引回表 8 万次再过滤另一个条件;Bitmap Heap Scan按 block 顺序访问 4,218 个 heap block,把离散随机访问整理成更局部的读取;exact=4218表示位图为这些页面保留了精确 tuple 位;若work_mem不足会出现lossy页面,只记「这个页面可能有」,因此必须用Recheck Cond逐 tuple 复查;shared hit=3901是从 PostgreSQL shared buffer 找到页面,read=511是装入 shared buffer 的块数。它不直接等于物理盘 I/O,因为操作系统页缓存仍可能命中。
位图要先构建完成,既不能自然保留原索引顺序,也很难像普通 Index Scan 那样为一个很小的 LIMIT 提前停止。它适合中等选择率和多个索引之间的集合运算,却不一定比普通索引扫描更好。
3.3 Nested Loop、Hash Join 与 Merge Join 怎样取舍
Nested Loop:快不快取决于内层是否可参数化
for outer_tuple in outer:
for inner_tuple in inner(outer_tuple.join_key):
if join_qual: emit如果外层只有 100 行,内层的连接键又有索引,每轮只需执行一次很快的参数化 Index Scan,复杂度接近 O(N log M)。这种情况下,Nested Loop 往往最合适。反之,内层没有可用索引,并且要被重复扫描 100 万次,工作量才会逼近 O(N×M)。
PostgreSQL 可以在内层插入 Materialize,缓存与外层参数无关的结果,供后续循环反复读取。PostgreSQL 14 起提供的 Memoize 还能按参数值缓存内层结果;外层连接键大量重复时,索引探测次数会明显下降。这两个节点只负责消除重复工作,连接算法仍然是 Nested Loop。
Hash Join:Build、Probe 与 batch
执行等值连接时,Hash Join 先读完构建端,按连接键的哈希值建立桶;随后扫描探测端,计算哈希值、定位桶,并再次检查连接条件。内存充足时,平均工作量接近 O(N+M)。它必须先建好哈希表才能输出第一行,因此启动代价较高,输出也没有固定顺序。
哈希表放不进可用内存时,执行器会利用更多哈希位,把连接两侧划分为多个批次。当前批次留在内存,其余批次写入临时文件,之后再逐批构建和探测。计划中可以看到:
Hash (actual time=... rows=3000000 loops=1)
Buckets: 262144 Batches: 32 Memory Usage: 7168kB
Buffers: shared hit=... read=..., temp written=11842Batches: 32 和 temp read/written 直接说明已经发生溢出。增大 work_mem 可能减少批次数量,但并发查询中的每个哈希节点、每个并行 worker 都可能各自消耗内存,不能只按一条查询计算。
Merge Join:有序流上的双指针
两个输入都按连接键排序后,Merge Join 比较两边的当前键:值较小的一侧继续前进,两边相等时输出匹配组。主体扫描接近 O(N+M),适合处理较大的输入,也支持部分非等值连接。前提是两个输入已经通过索引获得所需顺序,或者愿意先承担 O(N log N) 的排序和可能产生的临时 I/O。遇到重复键时,执行器还可能需要标记并恢复一侧的位置。
A: 1 2 2 5 8
↑
B: 2 2 3 5 9
↑
较小键前进;相等时处理整个 duplicate group,再继续。选择算法不能只按表大小:
| 条件 | 更可能有利的算法 | 决定性成本 |
|---|---|---|
| 小外表 + 内表连接键索引 | Nested Loop | 外层行数 × 内层探测成本 |
| 大表等值连接、无合适索引 | Hash Join | build 内存、batch 与线性扫描 |
| 两侧已有所需顺序 | Merge Join | 无需建 hash,保持顺序 |
| 两侧无序且只做一次等值连接 | Hash Join | 通常免去两次排序 |
| 需要最终按连接键排序 | Merge Join 可能复用顺序 | Sort 成本能否被下游摊销 |
3.4 ANALYZE, BUFFERS:把时间拆成数据流和页面流
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE)
SELECT c.state, sum(o.total)
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.created_at >= current_date - interval '30 days'
GROUP BY c.state;一个代表性的并行计划片段:
Finalize GroupAggregate (cost=48210.11..48218.30 rows=50 width=35)
(actual time=188.202..191.416 rows=50 loops=1)
Buffers: shared hit=12410 read=8821, temp read=1210 written=1228
-> Gather Merge (cost=48210.11..48217.05 rows=100 width=35)
(actual time=188.181..191.342 rows=150 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Sort (actual time=181.004..181.011 rows=50 loops=3)
Sort Key: c.state
Sort Method: quicksort Memory: 31kB
Worker 0: Sort Method: quicksort Memory: 31kB
Worker 1: Sort Method: quicksort Memory: 31kB
-> Partial HashAggregate (... rows=50 loops=3)
-> Parallel Hash Join (... rows=311240 loops=3)
Hash Cond: (o.customer_id = c.id)
-> Parallel Seq Scan on orders o (... loops=3)
-> Parallel Hash (... loops=3)这份计划中,loops=3 通常对应 leader 和两个 worker。节点执行多轮时,actual rows 显示每轮平均值;判断总输出量要连同 loops 一起计算。若 Workers Launched 少于 Workers Planned,说明执行时没有获得预期数量的 worker,leader 可能需要承担更多工作。
Gather 不保证 worker 结果顺序;Gather Merge 要求各 worker 输出有序流并归并,因而能保留排序。Parallel Hash Join 可让参与进程协作构建共享 hash table,而普通 Hash Join 放在并行子树中时,各 worker 可能各建一份。
| 指标 | 含义 | 常见误读 |
|---|---|---|
shared hit | 在 PostgreSQL shared buffer 中命中 | 仍然有内存访问和锁管理成本 |
shared read | 从 shared buffer 外读入块 | 不必然是物理盘,OS cache 可能命中 |
shared dirtied | 本语句把此前干净的块弄脏 | 尚未说明页面已经写盘 |
shared written | 本语句执行期间写出 shared block | 不能涵盖全部 WAL 和最终持久化成本 |
temp read/written | Sort、Hash、Materialize 等临时块 I/O | 是工作内存不足或中间结果过大的强信号 |
开启 track_io_timing 后,I/O Timings 才会进一步给出读写等待时间;这项计时本身也有与平台相关的开销。EXPLAIN ANALYZE 会执行语句,并为各节点采集指标。分析写语句时,可以用 BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; 回滚普通数据修改,但序列值、外部副作用、锁和触发器未必都能恢复原状,因此最好不要在生产环境上运行。
四、SQLite
4.1 两种 EXPLAIN 回答不同层次的问题
SQLite 没有单独的服务端执行器,SQL 会被编译成一组 VDBE 指令,再由 sqlite3_step() 推动虚拟机运行。
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
total NUMERIC NOT NULL
);
CREATE INDEX orders_status_created_idx
ON orders(status, created_at);
EXPLAIN QUERY PLAN
SELECT id, total
FROM orders
WHERE status = 'PAID'
AND created_at >= '2026-09-01';QUERY PLAN
`--SEARCH orders USING INDEX orders_status_created_idx
(status=? AND created_at>?)EQP (EXPLAIN QUERY PLAN) 展示高层策略树,适合判断 loop 顺序、索引与临时 B-tree。在同一 SQL 前使用 EXPLAIN,得到的则是 VDBE 程序:
addr opcode p1 p2 p3 p4 comment
0 Init 0 15 0
1 OpenRead 0 2 0 5 -- table cursor
2 OpenRead 1 3 0 k(3,,,) -- index cursor
3 String8 0 1 0 PAID
4 String8 0 2 0 2026-09-01
5 SeekGE 1 14 1 2 -- seek lower bound
6 IdxGT 1 14 1 1 -- stop after status range
7 DeferredSeek 1 0 0 -- delay table lookup
8 IdxRowid 1 3 0
9 Column 0 4 4 -- total from table
10 ResultRow 3 2 0
11 Next 1 6 0 -- advance B-tree cursor
14 Halt 0 0 0这段输出经过裁剪,仍能看出 EQP 隐去的执行过程。OpenRead 打开表和索引的 B-tree 游标,SeekGE 定位索引下界,IdxGT 检查是否越过上界,Next 沿叶子记录前进。DeferredSeek 会推迟表 B-tree 的定位,直到查询确实需要读取表中字段。查询所需的列如果全在索引里,EQP 会显示 USING COVERING INDEX,表游标和相应的 seek 也可以省去。
4.2 SCAN 与 SEARCH 描述的是访问范围
SQLite EQP 对每一层 loop 输出一条 SCAN 或 SEARCH:
SEARCH:只访问由约束确定的一个子集,例如a=? AND b>?;SCAN:访问全部记录,即使它为了顺序而遍历某个索引,也仍然叫SCAN ... USING INDEX;USING COVERING INDEX:需要的列都能从索引取到;USING AUTOMATIC INDEX:SQLite 估计构建一次查询期临时索引比反复扫描代价更低。
所有 SQLite join 都由嵌套扫描实现,EQP 中从上到下就是 outer 到 inner:
EXPLAIN QUERY PLAN
SELECT o.id, c.name
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'PAID';QUERY PLAN
|--SEARCH o USING INDEX orders_status_created_idx (status=?)
`--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)执行时,外层每产生一条 o,内层便按 c.rowid 做一次 B-tree seek。调换 SQL 中的 FROM 顺序,不一定会改变两层 loop 的顺序,因为普通 inner join 可以由优化器重排。SQLite 的 CROSS JOIN 能阻止这种重排,但只有在统计信息和索引都无法纠正计划时才值得使用。
SQLite 也会计算成本,NGQP (Next Generation Query Planner) 通过对数形式的 LogEst 估计准备成本、单轮成本和输出行数,再用 N3 算法保留若干条较好的部分路径。按照官方的 NGQP 说明,规划时主要决定嵌套顺序以及每一层使用的索引。与 PostgreSQL 相比,它的模型更紧凑,算子和统计维度也少得多,而且没有 seq_page_cost、cpu_tuple_cost 这类供 DBA 校准硬件差异的参数。
4.3 没有统计信息时,它只能依赖默认猜测
ANALYZE 会写入 sqlite_stat1;若编译时启用 SQLITE_ENABLE_STAT4,还可生成带索引键样本的 sqlite_stat4,帮助估计非最左列、范围与数据偏斜。没有这些信息时,优化器依据索引形态、约束类型和内建默认选择率猜测,相关列和热点值很容易误导它。
当前 SQLite 官方更推荐应用在连接即将关闭时定期运行:
PRAGMA optimize;它会按需要选择 ANALYZE 的范围,省去每次扫描整个数据库的开销。希望部署后计划保持稳定的客户端应用,还可以固定 sqlite_stat1,再配合 QPSG (Query Planner Stability Guarantee) 查询计划稳定性保证。这样做以后,生产数据分布发生变化,计划也不会自动调整。
SQLite 还实现了子查询 flattening、co-routine、automatic index、skip-scan、OR-by-union 和部分排序,远不止挑一个索引。不过,它没有并行执行,也没有 Hash Join 和 Merge Join。一旦某一层 loop 找不到有效索引,内外两层的工作量仍会相乘。
五、三种优化器的能力边界
5.1 先修正几个流传很广的旧结论
| 流传说法 | 更准确的版本化结论 |
|---|---|
| MySQL 只能 Nested Loop | MySQL 8.0.18 引入 Hash Join,8.0.20 起在适用场景取代旧 Block Nested-Loop 实现;但没有 Merge Join |
| MySQL 磁盘临时表就是 MyISAM | 这是旧版本经验;MySQL 8.0 的磁盘内部临时表走 InnoDB,默认内存引擎是 TempTable |
| SQLite 没有 CBO | 它有成本估算与 join-order 搜索,只是统计、成本参数和物理算子库比服务器数据库小得多 |
| SQLite 不支持部分/表达式索引 | 两者都支持;限制主要在谓词蕴含判断和表达式文本匹配能力 |
| PostgreSQL 的 cost 就是毫秒 | cost 是相对单位,默认以顺序页读取成本为基准 |
5.2 优缺点切片
| 维度 | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Join 算子 | Nested Loop、Hash、Merge;Memoize;Parallel Hash | Nested Loop、BKA、Hash;无 Merge Join | Nested Loop;可建 automatic index |
| 多列统计 | dependencies、MCV、ndistinct、表达式统计 | 单列 histogram;索引统计;无 PostgreSQL 同等级跨列扩展统计 | stat1,可选 stat4 索引样本 |
| 多索引组合 | BitmapAnd/BitmapOr → Bitmap Heap | Index Merge 的 intersect/union/sort-union | OR-by-union;通常不对 AND 做通用多索引求交 |
| 并行查询 | Scan、Join、Aggregate、Append 等成熟框架 | 单条普通查询的 CPU 并行能力相对有限;部分存储/DDL 路径另论 | 无查询内并行 |
| Hint | 核心不提供通用 SQL Hint;可用 GUC、显式语法或扩展 | Index、join order、optimizer hint 丰富 | INDEXED BY、CROSS JOIN 等有限控制 |
| 规划成本 | 搜索空间大,复杂 SQL 规划时间可能明显 | 简单 OLTP 规划快;新旧优化器路径并存 | 编译开销和内存极小,适合嵌入式 |
| 主要风险 | 复杂 join 的搜索与估算仍会失败;扩展统计不用于 join selectivity | 相关列估算和多表 fanout 容易累积误差;算子种类少于 PostgreSQL | 一层坏 loop 就可能放大;统计和观测能力有限 |
PostgreSQL 的长处来自整套规划体系,而非某个单独节点。路径表示能够携带排序等物理属性,统计框架能够补充多列关系,执行器又提供多种连接和并行方式。这套体系扩大了可选范围,也增加了规划成本。参与连接的表一多,组合数量便迅速增长;超过 geqo_threshold 后,规划器可能改用 GEQO 遗传查询优化器,以搜索精度换规划时间。表达式、分区和参数化路径很多时,应把 planning time 单独纳入监控。PostgreSQL 核心没有提供通用 SQL Hint,有些团队会使用 pg_hint_plan,随之而来的则是扩展兼容和升级维护。
MySQL 很适合常见的 OLTP 查询:参与表不多,条件选择率高,并且有合适的 InnoDB 索引。此类计划生成很快,聚簇索引的访问路径也容易判断。Index Hint、STRAIGHT_JOIN 和 optimizer hint 还能用于故障时止血。到了多表连接,相关性误差容易随 fanout 放大;算子方面虽已加入 Hash Join,却仍没有 Merge Join,查询内并行和运行时观测也与 PostgreSQL 有差距。FORCE INDEX 固定了人的判断,同时也限制了优化器适应日后的数据分布,因此使用时应写清触发条件和撤销条件。
SQLite 面对的是另一种运行环境:进程内嵌入、内存很小、不需要运维配置,并且希望计划生成得足够快。它只提供 Nested Loop,也不做查询内并行,这正是控制代码体积和运行时复杂度所付出的代价。用分析型服务器的算子数量衡量 SQLite 并不公平;反过来,如果负载已经变成多路大表分析,继续坚持使用嵌入式引擎同样不合适。
5.3 场景一:大表等值 Join,谁会退化成 N×M
SELECT count(*)
FROM clickstream AS e
JOIN users AS u ON u.external_id = e.external_id;假设两边各数千万行,external_id 都没有索引。
PostgreSQL 通常可以选择 Hash Join:顺序扫描一侧建立哈希表,再顺序扫描另一侧进行探测。两侧已有排序路径,或者下游仍要使用这一顺序时,Merge Join 也会进入候选。内存不足会增加 batch 和临时 I/O,主体工作仍是分区后的线性处理,可以避开完整的笛卡尔循环。
MySQL 8.0.18 以后也能选择 Hash Join。按照官方的 Hash Join 文档,它可以处理没有可用索引的等值连接;从 8.0.20 开始,适用条件进一步扩展,并取代了旧的 BNL。
两者仍有差别,MySQL 没有 Merge Join,普通查询的并行执行能力也不同,统计误差还可能使它选错哈希表构建端或连接顺序。
SQLite 的连接算法只有 Nested Loop,不过它可能为内层建立 automatic index。建立临时 B-tree 需要约 O(M log M) 的工作,之后的 N 次探测各需约 O(log M),因而可以避开直接的 N×M 扫描。如果 automatic index 没有触发、被禁用,或者连接条件无法索引,内层才会对每个外层行做一次全扫描。此时先在 EQP 中寻找 SEARCH ... USING AUTOMATIC INDEX;若没有,建立显式索引通常最直接。
所以,大表连接怎么样,不能只根据数据库下结论。先检查引擎是否提供适合该谓词的算子,再看基数估算有没有使它进入最终计划,最后检查内存溢出、并行度和临时 I/O。代码中实现了某种算法,这次查询未必会选;计划中选了它,运行时也未必有足够内存维持理想复杂度。
5.4 场景二:state 与 zip_code 存在相关性
SELECT *
FROM customers
WHERE state = 'NY'
AND zip_code = '10001';假设单列统计认为 state='NY' 占 5%,zip_code='10001' 占 0.01%。在 independence assumption 下:
P(NY AND 10001) = P(NY) × P(10001)
= 0.05 × 0.0001
= 0.0000051000 万行会被估成 50 行。但邮编几乎决定州,两个条件不是独立过滤;若真实有 1,000 行,误差就是 20 倍。更复杂的三四列谓词会继续相乘,最终改变 join order 与算法。
PostgreSQL 可以显式收集扩展统计:
CREATE STATISTICS customers_geo_stats
(dependencies, mcv, ndistinct)
ON state, zip_code
FROM customers;
ANALYZE customers;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM customers
WHERE state = 'NY' AND zip_code = '10001';dependencies 描述函数依赖强度,避免把近乎冗余的条件机械相乘;多列 MCV (Most Common Values) 记录常见组合及频率;ndistinct 改善多列 distinct/group 估算。官方 CREATE STATISTICS 同时给出了边界:这些扩展统计目前只处理同一 relation 上的相关谓词,不参与表间连接选择率的估算。
MySQL 8.0 的 histogram 是单列对象,能改善热点值与非均匀分布的估算,却无法直接表达 (state, zip_code) 的联合频率。复合索引统计能提供前缀基数。建立 (state, zip_code) 后,查询访问会变快,优化器也会得到更好的前缀信息;但这些信息仍少于 PostgreSQL 的 dependencies 和 multivariate MCV。
SQLite 的 stat4 会为多列索引保存样本,从而改善索引前缀的选择率估算。对这个例子来说,建立 (state, zip_code) 联合索引并运行 ANALYZE 十分重要。没有相应的联合索引时,SQLite 也没有 PostgreSQL 那种可独立定义的多列扩展统计对象。
5.5 场景三:多个离散索引怎样变成页面友好的读取
SELECT id, total
FROM orders
WHERE status = 'PAID'
AND created_at >= '2026-09-01';PostgreSQL 的 BitmapAnd 先在内存中组合 TID,再让 Bitmap Heap Scan 按 heap block 读取。它把逻辑集合运算与物理页面调度放在同一路径中;位图内存不足时可以退化为 lossy page bitmap,以复查 CPU 换内存上界。
MySQL 的 Index Merge 提供 intersect、union 和 sort_union,同样能够组合多个索引。InnoDB 的二级索引项指向主键,因此这些算法合并的是行标识或主键序列;后续访问聚簇索引时,并不具备 PostgreSQL TID bitmap 直接按 heap block 归组的语义。MRR (Multi-Range Read) 可以把二级索引得到的主键重新排序,再批量回表,但能否采用还受成本模型、索引覆盖情况和配置影响。条件结构、可用索引和搜索范围也会限制 Index Merge;范围过宽时,维护多个索引流甚至可能比扫描一次表代价更高。
SQLite 官方 EQP 能显示 MULTI-INDEX OR,即对 a=1 OR b=2 分别搜索索引再合并 rowid。遇到 a=1 AND b=2,它一般会选一个索引后过滤,没有通用的 BitmapAnd。加入 bitmap 算子还要补上内存管理、成本估算、rowid/without-rowid 表适配和执行器支持,不只是增加一次位运算。SQLite 更倾向于让用户建立 (a,b) 联合索引,以控制运行时规模。
Bitmap 路径本身也有成本。两个低选择率索引如果各自返回大半张表,构建位图、求交和复查的工作可能比 Seq Scan 更多。看到计划中出现 Bitmap 还不能说明优化成功,至少还要检查实际访问的 heap blocks 和行数。
5.6 场景四:部分索引与表达式索引的匹配范围
PostgreSQL 可以建立:
CREATE INDEX orders_active_customer_idx
ON orders (customer_id, created_at DESC)
WHERE status = 'ACTIVE';
CREATE INDEX users_lower_email_idx
ON users (lower(email));当查询谓词能被规划器证明蕴含 status='ACTIVE' 时,部分索引只维护活跃行,体积和写放大都较小。表达式索引则把 lower(email) 的结果作为键。计划可能显示:
Index Scan using orders_active_customer_idx on orders
(cost=0.42..24.61 rows=20 width=...)
Index Cond: (customer_id = 42)status='ACTIVE' 可能不会出现在 Index Cond 中,因为索引谓词已经保证了这个条件。PostgreSQL 的谓词推理有明确边界:规划参数化条件 status = $1 时,它无法保证参数始终是 'ACTIVE';写成规划器无法识别的等价形式,同样可能匹配失败。
MySQL 8.0 支持 functional key part,例如 INDEX ((lower(email))),内部借助隐藏生成列实现;但没有原生 CREATE INDEX ... WHERE ... 部分索引。常见替代是把不需要的行映射为 NULL 的生成列再索引,语义、唯一性和维护方式都更绕,也不具备 PostgreSQL 通用 predicate implication 框架。
CREATE INDEX orders_active_customer_idx
ON orders(customer_id, created_at)
WHERE status = 'ACTIVE';
CREATE INDEX users_lower_email_idx
ON users(lower(email));SQLite 的匹配规则相对简单。查询表达式要与索引表达式保持基本一致,x+y 不会利用代数交换律去匹配 y+x;部分索引也只用有限规则判断查询谓词 W 是否蕴含索引谓词 X,并不进行通用的定理证明。PostgreSQL 在表达式规范化、谓词推理、统计信息和路径成本之间结合得更深。两者的差别在匹配范围,并非一方支持、另一方完全不支持。
六、三个复杂死角的实战诊断
6.1 隐式类型转换与字符集碰撞:先看函数落在哪一侧
准备一个字符型业务键:
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
account_no VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
balance DECIMAL(18,2) NOT NULL,
UNIQUE KEY uk_tenant_account (tenant_id, account_no)
);下面的参数若被驱动绑定为数值,比较语义可能迫使数据库把字符串列转换成数值:
EXPLAIN FORMAT=TREE
SELECT * FROM accounts
WHERE tenant_id = 42 AND account_no = 100001;代表性的坏计划:
-> Filter: (cast(accounts.account_no as double) = 100001)
(cost=18240 rows=9980)
-> Index lookup on accounts using uk_tenant_account (tenant_id=42)
(cost=18240 rows=100000)复合索引只用到 tenant_id。因为实际条件作用于 CAST(account_no AS ...),原 B+ Tree 按字符串排序,不能直接把数值比较映射成连续字符串键范围,执行器扫描该租户的 10 万个索引项再过滤。
这类问题通常应从参数类型入手:
SELECT * FROM accounts
WHERE tenant_id = 42 AND account_no = CAST(? AS CHAR CHARACTER SET utf8mb4);更好的是让驱动按字符串绑定,避免 SQL 内额外 CAST。修复后应看到两个 used_key_parts,TREE 中 lookup 同时包含 tenant_id 与 account_no,实际扫描接近 1 行。
字符集冲突更难察觉。两个表使用不同的字符集或排序规则连接时,MySQL 可能在其中一侧插入 CONVERT()。转换如果作用在被探测的索引列上,ref 便可能退化为 ALL 或全索引扫描。可以先用 SHOW CREATE TABLE 对照两边的列定义,再查看 EXPLAIN FORMAT=JSON 中的 attached_condition,或通过 SHOW WARNINGS 检查重写后的表达式。长期修复时,统一列类型、长度、字符集和排序规则,比在查询中保留 CONVERT() 更稳妥。
PostgreSQL 的同类信号是计划中的 cast 或表达式不匹配:
Filter: ((account_no)::numeric = '100001'::numeric)
Rows Removed by Filter: 99999SQLite 使用动态类型,同样会受这类问题影响。列 affinity、collation 和表达式形式共同决定比较规则以及索引能否使用;给索引列套上 CAST(),也可能导致普通索引失配。排查三种数据库时都可以问同一个问题:比较时保留了索引键原有的可排序形式,还是必须先对每一行计算函数?
6.2 深度分页:OFFSET 丢掉了哪些工作
SELECT id, customer_id, created_at, total, note
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC, id DESC
LIMIT 100000, 10;即使 (status, created_at, id) 能提供顺序,执行器也要从范围开头拉取 100,010 条索引记录,再丢掉前 100,000 条。查询列没有被索引覆盖时,还可能发生 100,010 次回表,复杂度为 O(offset + limit)。
-> Limit/Offset: 10/100000 row(s)
(actual time=... rows=10 loops=1)
-> Index scan on orders using idx_status_created_id
(actual time=... rows=100010 loops=1)延迟关联 (Deferred Join) 先用窄覆盖索引定位十个主键,再回表十次:
SELECT o.id, o.customer_id, o.created_at, o.total, o.note
FROM orders AS o
JOIN (
SELECT id
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC, id DESC
LIMIT 100000, 10
) AS page ON page.id = o.id
ORDER BY o.created_at DESC, o.id DESC;它没有消灭扫描 100,010 个索引项,只是把 100,010 次宽行回表降成约 10 次。若索引叶子远小于聚簇记录、note 很宽,差异会很明显;若原查询本就完全覆盖,收益有限。
若希望把复杂度降到接近 O(log N + limit),需要改用 keyset 或 seek pagination:
SELECT id, customer_id, created_at, total, note
FROM orders
WHERE status = 'PAID'
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 10;客户端把上一页最后一行的 (created_at, id) 传回来。id 用作稳定的次级排序键,以免相同时间戳造成重复或漏行。这样做无法随意跳到第 10,001 页;发生并发插入或删除时,分页含义也从「固定偏移」变成了「从游标之后继续」。
在 PostgreSQL 中,同样比较 Limit 子节点的 actual rows 与根节点 rows;在 SQLite 中,VDBE 会用计数寄存器和 OffsetLimit/IfPos 一类 opcode 丢弃前序结果,底层仍需移动 B-tree cursor。三种引擎都不会因为写了 OFFSET 100000 就瞬移到「结果集数组」的第 100001 项。
6.3 基数估计失真:沿树找到第一个谎言
假设 PostgreSQL 出现:
Nested Loop (cost=0.85..921.22 rows=120 width=...)
(actual time=0.071..8412.550 rows=382140 loops=1)
-> Bitmap Heap Scan on customers
(cost=... rows=12 ...)
(actual ... rows=38190 loops=1)
-> Index Scan on orders_customer_idx
(cost=... rows=10 ...)
(actual ... rows=10 loops=38190)根节点慢是结果,第一次数量级分叉在 customers:预计 12,实际 38,190。优化器以为只会做约 12 次内层探测,实际做了 38,190 轮。
诊断按下面顺序走:
- 检查
ANALYZE是否及时,表是否刚批量导入、分区是否有独立统计、临时表是否从未分析; - 查单列 MCV、直方图边界、
n_distinct与 null fraction,确认热点值是否被样本捕获; - 若单列分布偏斜,只提高目标列的统计样本;
- 若多个条件相关,创建 dependencies/MCV/ndistinct 扩展统计并再次
ANALYZE; - 若误差来自参数,检查 prepared statement 的 generic plan 与 custom plan 差异;
- 修复后重新比较第一个误差节点,确认 join order/算法是否随之变化。
PostgreSQL 实战:
ALTER TABLE customers
ALTER COLUMN zip_code SET STATISTICS 1000;
CREATE STATISTICS customers_geo_stats (dependencies, mcv)
ON state, zip_code FROM customers;
ANALYZE customers;
SELECT statistics_name, attnames, kinds
FROM pg_stats_ext
WHERE tablename = 'customers';MySQL 实战:
ANALYZE TABLE customers
UPDATE HISTOGRAM ON state, zip_code
WITH 128 BUCKETS;
SELECT COLUMN_NAME, HISTOGRAM
FROM information_schema.COLUMN_STATISTICS
WHERE SCHEMA_NAME = DATABASE()
AND TABLE_NAME = 'customers';MySQL histogram 可让单列热点值不再按均匀分布估算,但两个 histogram 仍不等于多列联合分布。若查询长期固定为 state + zip_code,联合索引同时改善访问与前缀统计,通常更实在。
SQLite 实战:
CREATE INDEX customers_state_zip_idx
ON customers(state, zip_code);
ANALYZE customers;
PRAGMA optimize;还要确认构建是否启用了 STAT4:
PRAGMA compile_options;若没有 ENABLE_STAT4,不要假设部署环境拥有样本级估算能力。嵌入式应用尤其容易出现「开发机自带 SQLite」与「App 静态链接 SQLite」编译选项不同,计划差异必须连同 SQLite 版本、schema 和 sqlite_stat* 一起记录。
七、慢 SQL 的 EXPLAIN 决策流程
下面按实际排查顺序整理了一张表。前一步没有得到证据时,不急着跳到后一步;尤其不要一看到全表扫描就立刻添加索引。
| 步骤 | 要问的问题 | 看什么证据 | 常见结论 | 下一步 |
|---|---|---|---|---|
| 0. 建立边界 | SQL 是否真的慢,慢在执行还是等待? | 端到端延迟、锁等待、CPU、I/O、网络、返回行数 | 瓶颈可能在计划之外 | 先排锁、资源与客户端消费 |
| 1. 获取真实计划 | 只有估算,还是已实际执行? | MySQL/PostgreSQL EXPLAIN ANALYZE;生产写语句风险 | 普通 EXPLAIN 不能证明运行时事实 | 在安全环境采 actual rows/time |
| 2. 从叶子找误差 | 第一个 estimated rows 与 actual rows × loops 数量级分叉在哪? | rows、actual rows、loops、filtered | 统计过期、相关性、参数或表达式失配 | 更新/增强统计,检查参数计划 |
| 3. 检查访问范围 | 是定位少量键,还是扫完整索引/表? | range/ref/ALL、Index/Seq/Bitmap、SEARCH/SCAN | 有索引不等于扫描少 | 修 SARG、列类型、复合索引顺序 |
| 4. 检查回表 | 候选位置是否造成大量随机 heap/clustered lookup? | covering、heap fetch/block、ICP、Rows Removed | 索引命中但回表占主导 | 覆盖索引、ICP、Bitmap/MRR、缩小范围 |
| 5. 检查乘法 | 哪个内层节点被调用了多少次? | 缩进层级与 loops | Nested Loop 内层代价被放大 | 修外层基数、内层索引或换 Hash/Merge |
| 6. 检查阻塞算子 | 是否在吐第一行前必须完成 Sort/Hash/Aggregate? | startup cost、首行时间 | LIMIT 也救不了阻塞节点 | 利用索引顺序、预聚合、改算法 |
| 7. 检查溢出 | 中间状态是否超过内存? | PostgreSQL temp、Sort Method、Hash Batches;MySQL merge passes/disk tmp | 随机 I/O 或多轮归并 | 先缩数据,再局部调内存 |
| 8. 检查并行 | 计划是否并行,worker 是否按计划启动且负载均衡? | Gather、Workers Planned/Launched、每 worker 指标 | 并行开销或倾斜可能抵消收益 | 调整粒度、统计、并行阈值 |
| 9. 检查分页/输出 | 是否扫描大量行只为丢弃或传输? | child rows 对比 root rows、OFFSET、宽行 | 数据库在做无价值工作 | keyset、延迟关联、缩小 projection |
| 10. 验证改动 | 改动改善了计划还是碰巧热缓存? | 同数据、同参数、冷热缓存、P95/P99、写放大 | 单次快不等于稳态快 | 做 A/B 与回归,保留回滚条件 |
现场排查时,可以把顺序压缩成下面几行:
先证明确实在执行上慢
→ 找第一处基数误差
→ 看访问了多少行和页面
→ 把 loops 乘回去
→ 找排序/哈希/临时表溢出
→ 最后才考虑 Hint 与成本常量八、结语:回到行数和页面
MySQL、PostgreSQL 和 SQLite 给出的计划格式相差很大,运行时终究要落到实处:读取了多少页面,处理了多少行,内层节点循环了几次,中间结果是否溢出,以及排序要求有没有阻塞数据流。成本模型所做的只是在执行前猜出这些数字。
PostgreSQL 准备了更丰富地统计、路径和算子,复杂查询可选的方案更多,规划时间和模型参数也随之增加。MySQL 擅长由索引驱动的 OLTP 查询;8.0 加入 iterator、TREE、Hash Join 和 EXPLAIN ANALYZE 后,许多关于旧版本的判断已经过时,不过多列相关性、Merge Join 和查询内并行仍受限。SQLite 则始终服从嵌入式场景的约束,它有部分索引、表达式索引、STAT4 和 automatic index,但会控制算子、统计信息和运行时的规模,也无意成为并行分析引擎。
我通常先把 estimated rows 和 actual rows 沿执行树对一遍,再计算 rows × loops,同时查看 buffer 和临时 I/O。到这一步,连接顺序为什么会变、内存为什么会溢出,往往已经能解释清楚。剩下的工作是修改统计、索引或 SQL,然后用同样的指标复查。字段名称没必要背,重点在于前后两份计划能不能对上。
