深入数据库执行计划

深入数据库执行计划

•约 13.0 千字

专门写这一篇文章,是因为曾经遇到过有同事不熟悉数据库,甚至没用过 EXPLAIN,当遇到慢 SQL 之后,第一反应往往是问有经验的人。所以,本文不会假定你对数据库有很高的熟悉程度,但是写 SQL、建索引这些还是得会的,部分超出范围的内容也都有讲解。

执行 EXPLAIN,屏幕上很快出现一堆内容:type=range、cost=35.50、Bitmap Heap Scan、USE TEMP B-TREE。如果使用过或者阅读过文档,看到这里可能会觉得问题已经有了答案,但实际上,排查才刚刚开始。

这些节点只记录了优化器的选择,没有直接说明选择的依据。要理解它还需要继续了解:每个条件预计会留下多少行;当前引擎能够使用哪些物理算子;CPU、I/O、内存和并行通信在成本模型中分别占多大权重;查询运行以后,各节点循环了多少次,又读写了多少页面。

看执行计划,应该先找估算行数开始跑偏的地方。这是因为数据库的优化器只有不完整的统计信息,还得在有限时间里选出路径,猜错的情况虽然不常见,但也不罕见。找到了第一处偏差,后面的 Join 选择和内存问题就好解释了。切记的是,不要遇到问题就直接强制索引,这只会把问题埋得更深更久。

一、EXPLAIN 究竟看到了什么

1.1 从字符串到物理计划

我们就从三个不同的数据库开始,数据库不同,但主干流程相近:

SQL 文本词法与语法分析AST / Parse Tree名称绑定与类型解析逻辑重写候选访问路径与连接顺序目录统计信息等价变换规则CBO 代价比较物理计划执行器与 Instrumentation结果与实际指标

解析器先判断 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 会执行查询,再把耗时、行数、循环次数和缓冲区访问量写回对应节点。

阶段PostgreSQLMySQLSQLite
解析/绑定RawStmt → analyzed QueryLEX、Query_block、Item 表达式树AST 与 Expr、Select 等结构
逻辑变换rewriter、subquery_planner()JOIN::optimize() 前后的一系列 rewriteflattening、常量传播、WHERE 优化
路径搜索RelOptInfo、Path 动态规划与 GEQO传统 join optimizer;8.0 另有 Hypergraph 路径NGQP 的 N3 搜索、WhereLoop
物理表示Plan tree → PlanState treeAccessPath → iterator treeVDBE opcode program
运行时观测Instrumentation、BUFFERS、WALEXPLAIN ANALYZE iterator timingsqlite3_stmt_status()、CLI scanstats;无同构的内建 ANALYZE 计划树

三套源码对这些阶段有不同的划分。MySQL 的 sql_optimizer.cc 列出了逻辑变换、表顺序和访问路径的成本选择,以及排序、临时表的设置过程。PostgreSQL 的 optimizer/README 解释了 RelOptInfo、Path 与最终 Plan 的关系。SQLite 主要在 where.c 中选择路径,再由 vdbe.c 执行生成的虚拟机指令。

1.2 rows 是整棵树的乘法器

成本模型可以粗略写成:

text
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 倍误差就这样沿计划树不断放大。

因此要同时关注两条线:

text
数据流:叶子扫描 → Join/Filter → Aggregate/Sort → 根节点
误差流:第一个 estimated rows ≠ actual rows 的节点 → 向父节点传播

根节点汇总了整条查询的耗时,最早的错误却多半藏在叶子附近。顺着数据流向上看,并找到第一处估算行数与实际行数明显分离的节点,排查会快得多。

1.3 火山模型:为什么父节点会反复拉取子节点

经典火山迭代器模型 (Volcano Iterator Model) 为每个算子定义:

text
open()   初始化状态,打开子节点
next()   返回下一条记录;需要输入时递归调用 child.next()
close()  释放资源

以带索引的 Nested Loop 为例:

每返回一条订单用 customer_id 重新定位匹配行Limit.nextNestedLoop.nextIndexRangeScan ordersIndexLookup customers

父节点需要数据时才向子节点索取下一行。拿满十行以后,LIMIT 10 便可以停止继续拉取,整棵执行树也随之结束。启动代价因此很重要:交互式查询常常关心多久能看到第一行,而完整读取结果集的总代价可能并不重要。

另一方面,逐行虚函数调用、分支判断和解释表达式会增加 CPU 开销。向量化执行把接口改成 nextBatch():通过一次性在紧凑的列向量上处理几百或几千个值,可以减少函数调用开销,提高缓存局部性,并使 SIMD 更好地发挥作用。

text
逐行 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

下面使用一组简化表结构贯穿示例:

mysql.sql
sql
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,二维表格很难表示清楚父子关系。

mysql.sql
sql
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;
text
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    NULL
  • o.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 的首行时间、末行时间、实际行数和循环数。

mysql.sql
sql
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;
text
-> 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,叶子保存完整记录,二级索引叶子保存二级键与主键值。于是二级索引的非覆盖查询通常有两次树导航:先在二级树获得主键,再回聚簇树取整行。

text
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):

mysql.sql
sql
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 不在索引中,仍要回表后判断。

否是是否定位 customer_id 范围读取二级索引项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:

  1. 排序键 + row ID:排序后再按 row ID 回表取输出列,常被称作双路排序;
  2. 排序键 + 查询需要的附加字段:排序后直接输出,省一次回表,但单条记录更宽,buffer 能容纳的记录更少,常被称作单路排序。

单路、双路用来解释执行过程,不过用户不能直接指定。实际选择还受字段宽度、max_length_for_sort_data、可打包字段和优化器实现影响。MySQL 8.0.12 起,sort buffer 会按需增长到 sort_buffer_size 上限。数据放不下时,执行器生成多个有序 run,再进行外部归并。

text
内存足够: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:看累计关系,不要换算毫秒

mysql.sql
sql
EXPLAIN FORMAT=JSON
SELECT id, total
FROM orders
WHERE status = 'PAID';
json
{
  "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() 的主干近似为:

text
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:

text
页面成本 = 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 的消费顺序

postgresql.sql
sql
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 顺序读取。

postgresql.sql
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total
FROM orders
WHERE status = 'PAID'
  AND created_at >= timestamptz '2026-09-01';
text
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:快不快取决于内层是否可参数化

text
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

较小输入Build hash tablehash key → bucket → tuplechain较大输入Probe校验 hash/Join Qual 后输出

执行等值连接时,Hash Join 先读完构建端,按连接键的哈希值建立桶;随后扫描探测端,计算哈希值、定位桶,并再次检查连接条件。内存充足时,平均工作量接近 O(N+M)。它必须先建好哈希表才能输出第一行,因此启动代价较高,输出也没有固定顺序。

哈希表放不进可用内存时,执行器会利用更多哈希位,把连接两侧划分为多个批次。当前批次留在内存,其余批次写入临时文件,之后再逐批构建和探测。计划中可以看到:

text
Hash  (actual time=... rows=3000000 loops=1)
  Buckets: 262144  Batches: 32  Memory Usage: 7168kB
  Buffers: shared hit=... read=..., temp written=11842

Batches: 32 和 temp read/written 直接说明已经发生溢出。增大 work_mem 可能减少批次数量,但并发查询中的每个哈希节点、每个并行 worker 都可能各自消耗内存,不能只按一条查询计算。

Merge Join:有序流上的双指针

两个输入都按连接键排序后,Merge Join 比较两边的当前键:值较小的一侧继续前进,两边相等时输出匹配组。主体扫描接近 O(N+M),适合处理较大的输入,也支持部分非等值连接。前提是两个输入已经通过索引获得所需顺序,或者愿意先承担 O(N log N) 的排序和可能产生的临时 I/O。遇到重复键时,执行器还可能需要标记并恢复一侧的位置。

text
A: 1 2 2 5 8
      ↑
B: 2 2 3 5 9
   ↑

较小键前进;相等时处理整个 duplicate group,再继续。

选择算法不能只按表大小:

条件更可能有利的算法决定性成本
小外表 + 内表连接键索引Nested Loop外层行数 × 内层探测成本
大表等值连接、无合适索引Hash Joinbuild 内存、batch 与线性扫描
两侧已有所需顺序Merge Join无需建 hash,保持顺序
两侧无序且只做一次等值连接Hash Join通常免去两次排序
需要最终按连接键排序Merge Join 可能复用顺序Sort 成本能否被下游摊销

3.4 ANALYZE, BUFFERS:把时间拆成数据流和页面流

postgresql.sql
sql
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;

一个代表性的并行计划片段:

text
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/writtenSort、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() 推动虚拟机运行。

sqlite.sql
sql
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';
text
QUERY PLAN
`--SEARCH orders USING INDEX orders_status_created_idx
   (status=? AND created_at>?)

EQP (EXPLAIN QUERY PLAN) 展示高层策略树,适合判断 loop 顺序、索引与临时 B-tree。在同一 SQL 前使用 EXPLAIN,得到的则是 VDBE 程序:

text
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:

sqlite.sql
sql
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';
text
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 官方更推荐应用在连接即将关闭时定期运行:

sqlite.sql
sql
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 LoopMySQL 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 优缺点切片

维度PostgreSQLMySQLSQLite
Join 算子Nested Loop、Hash、Merge;Memoize;Parallel HashNested Loop、BKA、Hash;无 Merge JoinNested Loop;可建 automatic index
多列统计dependencies、MCV、ndistinct、表达式统计单列 histogram;索引统计;无 PostgreSQL 同等级跨列扩展统计stat1,可选 stat4 索引样本
多索引组合BitmapAnd/BitmapOr → Bitmap HeapIndex Merge 的 intersect/union/sort-unionOR-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

sql.sql
sql
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 存在相关性

sql.sql
sql
SELECT *
FROM customers
WHERE state = 'NY'
  AND zip_code = '10001';

假设单列统计认为 state='NY' 占 5%,zip_code='10001' 占 0.01%。在 independence assumption 下:

text
P(NY AND 10001) = P(NY) × P(10001)
                 = 0.05 × 0.0001
                 = 0.000005

1000 万行会被估成 50 行。但邮编几乎决定州,两个条件不是独立过滤;若真实有 1,000 行,误差就是 20 倍。更复杂的三四列谓词会继续相乘,最终改变 join order 与算法。

PostgreSQL 可以显式收集扩展统计:

postgresql.sql
sql
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 场景三:多个离散索引怎样变成页面友好的读取

sql.sql
sql
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 可以建立:

postgresql.sql
sql
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) 的结果作为键。计划可能显示:

text
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 框架。

SQLite 同时支持部分索引与表达式索引:

sqlite.sql
sql
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 隐式类型转换与字符集碰撞:先看函数落在哪一侧

准备一个字符型业务键:

mysql.sql
sql
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)
);

下面的参数若被驱动绑定为数值,比较语义可能迫使数据库把字符串列转换成数值:

mysql.sql
sql
EXPLAIN FORMAT=TREE
SELECT * FROM accounts
WHERE tenant_id = 42 AND account_no = 100001;

代表性的坏计划:

text
-> 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 万个索引项再过滤。

这类问题通常应从参数类型入手:

mysql.sql
sql
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 或表达式不匹配:

text
Filter: ((account_no)::numeric = '100001'::numeric)
Rows Removed by Filter: 99999

SQLite 使用动态类型,同样会受这类问题影响。列 affinity、collation 和表达式形式共同决定比较规则以及索引能否使用;给索引列套上 CAST(),也可能导致普通索引失配。排查三种数据库时都可以问同一个问题:比较时保留了索引键原有的可排序形式,还是必须先对每一行计算函数?

6.2 深度分页:OFFSET 丢掉了哪些工作

mysql.sql
sql
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)。

text
-> 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) 先用窄覆盖索引定位十个主键,再回表十次:

mysql.sql
sql
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:

mysql.sql
sql
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 出现:

text
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 轮。

诊断按下面顺序走:

  1. 检查 ANALYZE 是否及时,表是否刚批量导入、分区是否有独立统计、临时表是否从未分析;
  2. 查单列 MCV、直方图边界、n_distinct 与 null fraction,确认热点值是否被样本捕获;
  3. 若单列分布偏斜,只提高目标列的统计样本;
  4. 若多个条件相关,创建 dependencies/MCV/ndistinct 扩展统计并再次 ANALYZE;
  5. 若误差来自参数,检查 prepared statement 的 generic plan 与 custom plan 差异;
  6. 修复后重新比较第一个误差节点,确认 join order/算法是否随之变化。

PostgreSQL 实战:

postgresql.sql
sql
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 实战:

mysql.sql
sql
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 实战:

sqlite.sql
sql
CREATE INDEX customers_state_zip_idx
ON customers(state, zip_code);

ANALYZE customers;
PRAGMA optimize;

还要确认构建是否启用了 STAT4:

sqlite.sql
sql
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. 检查乘法哪个内层节点被调用了多少次?缩进层级与 loopsNested 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 与回归,保留回滚条件

现场排查时,可以把顺序压缩成下面几行:

text
先证明确实在执行上慢
  → 找第一处基数误差
  → 看访问了多少行和页面
  → 把 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,然后用同样的指标复查。字段名称没必要背,重点在于前后两份计划能不能对上。

作者Aoang
发布于2026-09-24
更新于2026-09-24
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。