数据库算子
回表
1. 为什么会发生回表?
想象你在图书馆查书:
- 索引(Index) :就像图书馆的索引卡片。卡片上写着:书名《数据库原理》,存放位置:3排-B架-12号。
- 表(Table/Heap) :就像书架上的实物书。书里才有具体的内容(作者、出版社、正文)。
如果你只想知道书名,看卡片(索引)就够了。但如果你想看"作者是谁"或者"具体内容",你就必须拿着卡片上的位置,走到书架前把那本书抽出来翻开。这个"从索引到书架取书"的过程,就是回表。
2. 回表的过程(以 PostgreSQL/MySQL 为例)
假设你有一张表 users,在 username 字段上有索引:
sql
-- 查询语句
SELECT id, username, age FROM users WHERE username = 'a';
- 第一步:查索引。 数据库在
username索引树中快速定位到'a'。 - 第二步:拿指针。 索引节点里存储着该行数据的物理地址(在 MySQL 中是主键 ID,在 PostgreSQL 中是 CTID/TID)。
- 第三步:回表。 索引里没有
age字段 。数据库根据物理地址,回到原始数据表(Heap)中找到这一行,把age的值读出来。
3. 回表有什么代价?
回表是数据库性能优化的头号公敌,主要原因有两个:
- 随机 I/O 增加:索引通常是顺序排列的,但数据行在磁盘上的分布是零散的。每回表一次,可能都要进行一次磁盘随机读,这比顺序读慢得多。
- 性能损耗:如果查询结果有 10,000 行,数据库就要执行 10,000 次"回表"动作。
4. 如何避免回表?
最有效的方案是:覆盖索引(Covering Index)。
如果你经常需要根据 username 查 age,你可以建立一个包含这两个字段的索引:
sql
-- PostgreSQL 语法
CREATE INDEX idx_user_age ON users(username) INCLUDE (age);
-- 或者通用的复合索引
CREATE INDEX idx_user_age_composite ON users(username, age);
发生了什么变化?
现在,age 字段的信息直接存在了索引树里。数据库查到 username 时,顺手就能把旁边的 age 带走,再也不用去翻原始表了。这种算子在执行计划中会显示为 Index Only Scan。
5. 什么时候"回表"比"不回表"还快?
并不是所有时候都要避免回表。
- 如果你的
WHERE条件过滤后,只剩下几行数据,回表的代价微乎其微。 - 如果表很小,数据库可能会直接放弃索引,选择 Seq Scan(全表扫描)。因为它觉得"与其先看卡片再翻书,不如直接把这本薄书从头到尾翻一遍"。
总结
- 回表 = 在索引里没找全,得回原表取。
- 后果 = 产生随机 I/O,变慢。
- 对策 = 覆盖索引(把查的字段都塞进索引里)。
算子
在 PostgreSQL 中,算子(Operators/Nodes)是构成执行计划的最小单元。为了方便记忆,我们可以按照它们在数据处理流中的功能角色 ,将其分为四大类:数据扫描 、连接查询 、集合/聚合操作 、以及辅助控制算子。
1. 数据扫描算子 (Scan Nodes)
这类算子负责从物理存储(磁盘或内存缓冲区)中读取原始数据。
| 算子名称 | 英文名 | 功能描述 |
|---|---|---|
| 全表扫描 | Seq Scan | 顺序读取整个表的所有数据页。 |
| 索引扫描 | Index Scan | 先扫描索引找到位置,再回表读取数据行。 |
| 索引覆盖扫描 | Index Only Scan | 仅通过索引就能拿到所有所需字段,无需回表。 |
| 位图扫描 | Bitmap Heap/Index Scan | 先在内存中构建位图标记匹配行,再批量回表,减少随机 IO。 |
| TID 扫描 | Tid Scan | 通过行标识符(Tuple ID)直接定位数据。 |
在 PostgreSQL 中,**扫描算子(Scan Nodes)**是执行计划的"地基"。所有数据的处理都始于扫描,它的效率直接决定了查询的生死。
PostgreSQL 会根据数据量的大小、过滤条件的筛选率以及索引的分布,从以下几种主要扫描方式中选择最优解。
1. 全表扫描 (Seq Scan)
这是最原始的扫描方式。
- 工作原理: 数据库从磁盘上顺序读取该表的所有数据页(Blocks),并对每一行进行条件检查。
- 适用场景:
- 表非常小(加载索引的代价反而比全表扫描高)。
- 查询条件没有索引。
- 返回的数据占全表比例很高(例如超过 20%-30%),此时顺序 IO 比频繁的随机 IO 更快。
- EXPLAIN 标志:
Seq Scan on table_name。
2. 索引扫描 (Index Scan)
当查询条件命中索引时,数据库会先去查索引。
- 工作原理:
- 在 B-Tree 索引中找到匹配条件的 Entry(条目)。
- 根据 Entry 中的指针(TID),回表(Heap)读取完整的数据行。
- 优缺点: 适合返回少量数据的查询。如果返回行数太多,频繁的"回表"会导致大量随机 IO,性能反而不如 Seq Scan。
- EXPLAIN 标志:
Index Scan using index_name on table_name。
3. 索引覆盖扫描 (Index Only Scan)
这是性能优化的"天花板"。
- 工作原理: 如果你查询的字段全都在索引里(例如
SELECT id FROM users WHERE id < 10),数据库直接从索引树拿到结果就返回,完全不需要回表。 - 关键点: 受 Visibility Map 的影响。如果某些数据页刚被更新过,数据库还是得回表确认数据的可见性(MVCC)。
- EXPLAIN 标志:
Index Only Scan using index_name on table_name。
4. 位图扫描 (Bitmap Scan)
这是 PostgreSQL 的一大特色,专门解决"索引扫描导致随机 IO 太多"的问题。
- 工作原理(分两步):
- Bitmap Index Scan: 扫描索引,但不立刻回表。它在内存中创建一个"位图",标记哪些数据页包含符合条件的行。
- Bitmap Heap Scan: 根据位图,按磁盘顺序访问数据页。
- 意义: 它将随机 IO 转换成了局部顺序 IO。它比 Index Scan 更适合查询"中等规模"的数据(既不太多也不太少)。
- EXPLAIN 标志: 往往成对出现,先有
Bitmap Index Scan,后有Bitmap Heap Scan。
5. 常见扫描算子对比表
为了直观理解,我们假设有一张 100 万行的表:
| 扫描类型 | 动作比喻 | 适用情况 | 性能瓶颈 |
|---|---|---|---|
| Seq Scan | 翻完整本书找一个词 | 查大量数据/小表 | 磁盘总 IO 量 |
| Index Scan | 看目录,查到一个页码翻一下书 | 查极少量数据 | 随机读取延迟 |
| Index Only Scan | 只看目录就找到了答案 | 覆盖索引查询 | 索引页的大小 |
| Bitmap Scan | 看目录,把所有页码记在纸上,按页码从小到大翻书 | 查中等量数据 | 内存 (Work_mem) |
2. 连接算子 (Join Nodes)
连接算子(Join Nodes)是数据库执行计划中最核心的部分之一。当你的 SQL 查询涉及两张或更多的表时,数据库必须决定**"用什么算法把这两堆数据拼在一起"**。PostgreSQL(以及大多数关系型数据库)主要支持三种连接算法。理解它们的区别,是优化多表查询的关键。
| 算子名称 | 英文名 | 功能描述 | 适用场景 |
|---|---|---|---|
| 嵌套循环连接 | Nested Loop | 对外表的每一行,去内表中查找匹配行。 | 小数据集连接,或内表有索引。 |
| 哈希连接 | Hash Join | 为内表在内存建立哈希表,扫描外表进行匹配。 | 大表关联,且无索引支持。 |
| 归并连接 | Merge Join | 将两个有序集合像拉链一样合并。 | 两表在关联键上均已有序(如已有索引)。 |
1. 嵌套循环连接 (Nested Loop Join)
这是最简单、最直观,但在特定场景下也是最快的一种方式。
-
直观理解: 就像写代码时的"双层
for循环"。Python# 伪代码逻辑 for outer_row in Outer_Table: # 外层循环(驱动表) for inner_row in Inner_Table: # 内层循环(被驱动表) if outer_row.id == inner_row.id: yield (outer_row, inner_row) -
工作流程:
- 优化器选择一张表作为驱动表(Outer Table),通常是过滤后结果集较小的那张表。
- 逐行读取驱动表的数据。
- 拿着这一行数据的关联键,去**被驱动表(Inner Table)**中查找匹配的行。
-
性能关键点:
- 被驱动表必须有索引! 如果内层循环每次都要全表扫描,性能就是灾难级的 O(N*M)。
- 如果有索引,复杂度降为 O(N*log M)。
-
适用场景:
- "小表驱动大表":驱动表只有几百行,被驱动表有几亿行但有索引。
- 首行快速响应:因为它不需要预处理,找到第一行匹配就能立即返回,适合分页查询的第一页。
2. 哈希连接 (Hash Join)
这是处理大数据量连接的神器,也是现代数据库最常用的算法之一。
- 工作流程(分两个阶段):
- 构建阶段 (Build Phase): 选择较小的那张表,在内存 中建立一张哈希表(Hash Table)。键是连接字段,值是行数据。
- 探测阶段 (Probe Phase): 扫描较大的那张表,对每一行计算连接字段的哈希值,去内存的哈希表中查找是否存在。
- 性能关键点:
- 内存(work_mem): 哈希表必须能装入内存。如果内存不够,数据库会把哈希表切分写入磁盘(临时文件),性能会急剧下降(你会看到
Disk: xxx kB)。 - 只支持等值连接: 只能用于
ON a.id = b.id,不支持>或<。
- 内存(work_mem): 哈希表必须能装入内存。如果内存不够,数据库会把哈希表切分写入磁盘(临时文件),性能会急剧下降(你会看到
- 适用场景:
- 两张表都很大,且被驱动表上没有合适的索引。
- 查询结果集很大,索引扫描产生的随机 I/O 代价太高。
3. 归并连接 (Merge Join / Sort Merge Join)
这是一种优雅的算法,前提是数据已经排好序。
- 工作流程:
- 如果不有序,先对两张表分别进行排序 (Sort)。
- 使用双指针算法,同时遍历两张表。
- 如果
A.id < B.id,A 的指针往下移;如果A.id > B.id,B 的指针往下移;如果相等,输出结果。
- 性能关键点:
- 排序成本: 如果数据本身没排序,排序的代价非常高。
- 索引优势: 如果连接字段上本来就有 B-Tree 索引(索引本质就是有序的),那么可以直接跳过排序步骤,性能极快。
- 适用场景:
- 连接字段上有索引(天然有序)。
- SQL 中包含
ORDER BY,正好利用连接后的有序结果。 - 非等值连接 中的范围连接(如
BETWEEN)。
4. 三种算子对比总结表
| 特性 | Nested Loop (嵌套循环) | Hash Join (哈希连接) | Merge Join (归并连接) |
|---|---|---|---|
| 核心逻辑 | 双层循环 | 内存哈希表匹配 | 排序后拉链式合并 |
| 适用数据量 | 小数据量 (小表驱动大表) | 大数据量 (两表均大) | 大数据量 (且有序) |
| 索引依赖 | 强依赖 (被驱动表需索引) | 不依赖 | 最好有 (可免去排序) |
| 内存消耗 | 极低 | 高 (需构建哈希表) | 中 (若需排序则高) |
| 支持条件 | 任何 (等值/不等值/范围) | 仅限等值 (=) |
等值或范围 |
| 启动速度 | 快 (立即返回首行) | 慢 (需先构建哈希表) | 慢 (需先排序) |
5. 如何根据算子优化 SQL?
当你在 EXPLAIN 中看到以下情况时,可以尝试优化:
- 看到
Nested Loop但很慢:- 检查被驱动表(Inner Table)的连接字段是否有索引。如果没有,数据库在疯狂做全表扫描。
- 看到
Hash Join且带有Batches或Disk:- 说明内存不够用了,数据溢出到了磁盘。尝试调大
work_mem参数,或者优化 WHERE 条件减少参与连接的数据量。
- 说明内存不够用了,数据溢出到了磁盘。尝试调大
- 看到
Merge Join之前有一个巨大的Sort:- 排序非常消耗 CPU 和内存。如果在连接字段建立索引,可以消除这个排序步骤,直接利用 Index Scan 进行 Merge Join。
3. 集合与聚合算子 (Aggregation & Set Nodes)
这类算子负责对数据进行去重、分组计算或合并多个结果集。
- 聚合类:
- Aggregate: 实现
COUNT,SUM,AVG等。 - GroupAggregate: 针对已排序的数据进行分组聚合。
- HashAggregate: 针对未排序的数据,利用哈希表在内存中进行分组。
- Aggregate: 实现
- 集合类:
- Unique: 对有序数据进行去重(如
DISTINCT)。 - HashSetOp: 利用哈希表进行集合操作(如
INTERSECT或EXCEPT)。 - Append: 将多个子查询的结果集(如
UNION ALL)简单堆叠在一起。
- Unique: 对有序数据进行去重(如
在数据库执行计划中,集合与聚合算子(Aggregation & Set Nodes) 负责对扫描或连接后的"原材料"数据进行深加工。
简单来说:
- 聚合算子是做"数学题"的(求和、计数、平均、分组)。
- 集合算子是做"拼图"的(合并、交集、去重)。
第一部分:聚合算子 (Aggregation Nodes)
当你使用 GROUP BY、COUNT、SUM、AVG 等语句时,就会触发此类算子。数据库通常有两种策略来处理聚合:哈希(Hash) 和 排序(Sort/Group)。
1. HashAggregate (哈希聚合)
这是处理未排序数据最常用的聚合方式。
- 工作原理(桶排序思想):
- 数据库在内存(
work_mem)中创建一个哈希表。 - 扫描每一行数据,计算
GROUP BY字段的哈希值。 - 将数据丢进对应的"桶"里,并实时更新聚合状态(例如:如果是
COUNT就 +1,如果是SUM就累加)。 - 扫描结束后,遍历哈希表输出结果。
- 数据库在内存(
- 优点: 不需要数据预先排序,速度通常很快。
- 缺点: 非常吃内存。如果分组的数量太多(比如按 UserID 分组,有 100 万个用户),哈希表会撑爆内存,导致溢出到磁盘(Disk Spill),性能急剧下降。
- 场景: 数据无序,且分组基数(Cardinality)适中。
2. GroupAggregate (分组聚合)
这是基于有序数据的聚合方式。
- 工作原理(流水线思想):
- 前提: 输入的数据必须已经按
GROUP BY字段排好序了(通常由下层的Sort算子或Index Scan保证)。 - 数据库逐行读取数据。
- 如果当前行的分组键和上一行一样,就累加;如果不一样,说明上一个组结束了,输出结果,开始下一个组。
- 前提: 输入的数据必须已经按
- 优点: 内存占用极低(只需要存当前这一组的状态),且可以流式输出(不用等所有数据读完就能出第一行结果)。
- 缺点: 强依赖数据有序。如果数据本身没序,前面必须加一个昂贵的
Sort算子。 - 场景:
GROUP BY字段上有索引,或者数据量大到内存装不下哈希表。
3. Plain Aggregate (普通聚合)
- 含义: 没有
GROUP BY,只算一个全局的总数。例如SELECT COUNT(*) FROM table。 - 特点: 最简单,扫一遍全表,维护一个计数器即可。
第二部分:集合与去重算子 (Set & Unique Nodes)
当你使用 UNION、INTERSECT、EXCEPT 或 DISTINCT 时,会用到这些算子。
1. Append (追加)
- 对应 SQL:
UNION ALL - 工作原理: 极其简单。先把第一个子查询的结果吐出来,再把第二个子查询的结果吐出来。不做去重,不做排序。
- 性能: 极快 。只要不需要去重,尽量用
UNION ALL代替UNION。
2. Unique (去重)
- 对应 SQL:
DISTINCT或UNION(不带 ALL)。 - 工作原理: 类似于
GroupAggregate,它要求输入数据是有序的。它对比当前行和上一行,如果相同就丢弃,不同就输出。 - 注意: 如果数据没序,通常会先看到
Sort,再看到Unique。
3. HashSetOp / SortSetOp (集合操作)
- 对应 SQL:
INTERSECT(交集) 或EXCEPT(差集)。 - HashSetOp: 用哈希表来判断元素是否存在于两个集合中。
- SortSetOp: 先把两个集合排序,然后用双指针算法比对。
第三部分:实战中的性能博弈
在看执行计划时,你需要关注以下几点:
1. 内存 vs 排序 (HashAgg vs GroupAgg)
- 如果你的 SQL 跑得很慢,且看到 HashAggregate 下方有
Disk: xxx kB,说明内存不够了。- 优化: 调大
work_mem参数,让哈希表能装入内存。
- 优化: 调大
- 如果你看到 GroupAggregate 下方有一个巨大的 Sort ,且
Sort Method: external merge。- 优化: 尝试在
GROUP BY字段上加索引。有了索引,数据天然有序,Sort算子就会消失,直接进行GroupAggregate,性能起飞。
- 优化: 尝试在
2. DISTINCT 的代价
DISTINCT 本质上就是一次聚合或排序。
SELECT DISTINCT user_id ...等价于SELECT user_id ... GROUP BY user_id。- 千万不要滥用
DISTINCT。如果你写了DISTINCT,数据库就必须把所有数据拿来进行一次昂贵的去重计算。
总结对比表
| 算子名称 | 功能 | 依赖条件 | 内存消耗 | 适用场景 |
|---|---|---|---|---|
| HashAggregate | 分组/去重 | 无 | 高 (受基数影响) | 无索引,分组数量适中 |
| GroupAggregate | 分组/去重 | 数据必须有序 | 低 | 有索引,或数据量极大 |
| Append | 合并结果 | 无 | 极低 | UNION ALL |
| Unique | 排序去重 | 数据必须有序 | 低 | DISTINCT (配合排序) |
4. 辅助与控制算子 (Materialize & Control Nodes)
这类算子不直接产生新数据,而是为了满足特定语法(排序、分页)或优化执行效率。
- 排序 (Sort): 执行
ORDER BY,如果内存装不下会触发磁盘排序。 - 物化 (Materialize): 将下层算子的结果缓存到内存中,供上层算子重复读取(常见于 Nested Loop)。
- 限制 (Limit): 处理
LIMIT和OFFSET,达到行数后立即停止下层算子。 - 锁定 (LockRows): 处理
SELECT FOR UPDATE等锁定行操作。 - 结果 (Result): 处理不涉及表的计算(如
SELECT 1+1)。
在 PostgreSQL 的执行计划中,辅助与控制算子(Auxiliary & Control Nodes) 虽然不直接负责"找数据"或"拼数据",但它们是整个流水线的调度员 和加工厂。它们决定了数据如何排序、何时停止、如何并行处理以及如何锁定。
以下是这类核心算子的详细讲解:
1. 排序算子 (Sort Node)
这是最常见,也是最容易成为性能瓶颈的辅助算子。
-
功能: 对下层算子返回的数据集进行排序(响应
ORDER BY,或者为Merge Join做准备)。 -
关键算法与内存机制:
- Quicksort (内存排序): 当数据量小于
work_mem参数时,PostgreSQL 会在内存中完成快速排序。这是最快的。 - External Merge Sort (磁盘排序): 当数据量超过
work_mem时,数据库被迫把数据写到临时文件(Disk),排好序后再合并。这会产生大量磁盘 I/O,非常慢。 - Top-N Heapsort: 当 SQL 包含
ORDER BY ... LIMIT n时,数据库不需要全排,只需要维护一个大小为 N 的堆。这比全排快得多。
- Quicksort (内存排序): 当数据量小于
-
性能警报:
如果在 EXPLAIN 中看到
Sort Method: external merge Disk: 25000kB,说明内存不够用了。- 优化: 调大
work_mem,或者建立索引(索引本身就是有序的,可以消除 Sort 算子)。
- 优化: 调大
2. 限制算子 (Limit Node)
它是查询优化的"刹车片"。
- 功能: 对应 SQL 中的
LIMIT和OFFSET。 - 工作原理: 它像一个计数器,紧盯着下层算子吐出来的数据。一旦拿到了指定的行数(比如 10 行),它会立即切断下层算子的执行,不再让它们继续工作。
- 性能意义:
- 这是一个"逻辑算子",本身消耗极小。
- 它的价值在于**"短路效应"** 。比如
SELECT * FROM billion_table LIMIT 1,Limit 算子会让 Seq Scan 在读到第一行时就停止,而不是扫完十亿行。
3. 物化算子 (Materialize Node)
注意,这跟"物化视图"是两码事。这里的 Materialize 是执行计划中的一个临时缓存机制。
- 功能: 将下层算子的结果完整地读取并暂存 (在内存或磁盘中),以便上层算子可以反复读取这些数据。
- 典型场景:
- 出现在 Nested Loop Join(嵌套循环连接)中。
- 如果内表(被驱动表)是一个复杂的子查询或计算结果,数据库不希望每处理外表的一行,内表就重新计算一次。
- 优化逻辑: 内表计算一次 -> Materialize 存起来 -> 外表每行去 Materialize 里查。
- EXPLAIN 特征: 通常夹在 Nested Loop 和内层扫描之间。
4. 并行控制算子 (Parallel Nodes)
当 PostgreSQL 决定动用多个 CPU 核心来加速查询时,就会出现这些算子。它们负责协调"领队"和"工人"之间的关系。
- Gather (收集):
- 角色: 它是"工头"(Leader Process)。
- 功能: 启动多个并行工作线程(Workers),等待它们干完活,把结果汇总到这里,再发给上层。
- Gather Merge (有序收集):
- 功能: 类似于 Gather,但它要求所有 Worker 返回的数据是有序的,并且它在汇总时会保持这种顺序(类似于归并排序的最后一步)。
- 场景: 并行查询且带有
ORDER BY时。
- Parallel Seq Scan / Parallel Hash Join:
- 这些带有
Parallel前缀的算子,说明它们是在 Worker 线程内部执行的。
- 这些带有
5. 结果算子 (Result Node)
这是最简单的算子,通常处理不涉及表扫描的计算。
- 功能: 计算并返回一个常量或表达式。
- 场景:
SELECT 1;SELECT version();- 某些复杂的
CASE WHEN逻辑,如果优化器认为不需要查表,也会用 Result。 - One-Time Filter: 如果
WHERE条件是常量且为假(如WHERE 1=2),Result 算子会直接返回空,整个查询瞬间结束。
6. 锁定算子 (LockRows Node)
这是为了并发控制而存在的。
- 功能: 对应 SQL 中的
SELECT ... FOR UPDATE或FOR SHARE。 - 工作原理:
- 它会去访问数据行,并尝试在行头(Tuple Header)打上锁标记。
- 如果有其他事务锁住了这行,它会在这里等待(Blocked),直到锁释放或超时。
- 位置: 通常位于执行计划的最顶层附近,确保数据在返回给用户前已经被锁住。
总结:如何通过这些算子诊断问题?
| 算子 | 看到的现象 | 潜在问题 | 解决方案 |
|---|---|---|---|
| Sort | Disk: xxx kB |
内存溢出,磁盘排序 | 调大 work_mem 或加索引 |
| Materialize | 耗时很久 | 内层子查询太重 | 优化子查询,或改写 JOIN 逻辑 |
| Gather | Workers Planned: 2, Launched: 0 |
并行未生效 | 检查服务器负载或并行参数配置 |
| LockRows | 查询卡死不返回 | 锁竞争 | 检查是否有长事务未提交 |
5.并行算子 (Parallel Nodes)
在开启并行查询时,你会看到带 Gather 前缀的特殊算子:
- Gather: 汇总节点。收集所有并行工作线程(Workers)的结果。
- Gather Merge: 收集结果的同时保持数据的有序性。
- Parallel Seq Scan: 多个线程同时分段扫描一张表。
在 PostgreSQL 9.6 之前,无论服务器有多少个 CPU 核心,一条 SQL 查询只能使用一个 CPU 核 (单线程)。这就像让你一个人搬一万块砖,哪怕旁边站着 10 个人也没用。并行算子 (Parallel Nodes) 的引入彻底改变了这一点。它允许数据库启动多个后台工作线程(Background Workers),大家一起干活,最后由"包工头"汇总结果。以下是并行查询的核心架构与关键算子详解:
1. 核心架构:领队与工人 (Leader & Workers)
理解并行算子,首先要理解 PostgreSQL 的并行模型:
- Leader Process (领队进程):
- 这是你连接数据库的那个主会话进程。
- 它负责制定计划、分配任务、启动 Worker、收集结果,并把最终结果返回给客户端。
- Worker Processes (工人进程):
- 由 Leader 动态启动。
- 它们执行计划中标记为
Parallel的部分(如扫描、聚合、连接)。 - 它们通过动态共享内存 (Dynamic Shared Memory, DSM) 与 Leader 交换数据。
2. 关键算子详解
在执行计划树中,并行部分通常位于树的下半部分 ,顶部总会有一个 Gather 类的节点作为分界线。
A. Gather (汇总算子)
这是并行执行的总出口,也是 Leader 进程主要工作的地方。
- 功能:
- 启动 N 个 Worker 线程。
- 等待所有 Worker 把数据处理完。
- 把 Worker 传回来的数据(以及 Leader 自己处理的一部分数据)合并在一起。
- 向上层算子输出非并行的结果流。
- EXPLAIN 关键信息:
Workers Planned: 2: 计划启动 2 个工人。Workers Launched: 2: 实际启动了 2 个。如果系统负载太高,Launched 可能小于 Planned,甚至为 0(降级为单线程)。
B. Gather Merge (有序汇总算子)
这是 Gather 的升级版,用于需要保留顺序的场景。
- 功能:
- 假设下层的每个 Worker 返回的数据都已经局部排好序了。
Gather Merge就像归并排序的最后一步,它读取所有 Worker 的输出,通过比较,按顺序把数据吐给上层。
- 适用场景: SQL 中有
ORDER BY,且下层走了Parallel Index Scan或做过并行排序。
C. Parallel Seq Scan (并行全表扫描)
这是 Worker 们最常干的活。
- 工作原理:
- 并不是把表切成 N 份固定分配给 N 个 Worker。
- 而是采用**"抢任务"模式(Block-by-Block)**。
- 所有 Worker(加上 Leader)共享一个扫描游标。谁扫完一个数据块(Block),就向系统申请下一个块。这样能防止有的 Worker 扫到了空闲页很快干完,有的 Worker 扫到了大对象页累死。
- 优势: 极大地提高了 IO吞吐量(如果磁盘撑得住)和 CPU 过滤速度。
D. Parallel Hash / Parallel Hash Join (并行哈希连接)
这是 PostgreSQL 11+ 引入的重磅功能。
- 传统 Hash Join: 一个进程构建哈希表,构建完再探测。
- 并行 Hash Join:
- Shared Hash Table: 所有 Worker 共同在共享内存中构建同一个巨大的哈希表。
- 协同探测: 构建完成后,所有 Worker 再并行去扫描外表,利用这个共享哈希表进行探测。
- 注意: 这非常消耗内存!
3. 一个典型的并行执行计划
假设我们要统计一张 1 亿行大表 big_table 的行数:
SQL
sql
EXPLAIN SELECT count(*) FROM big_table;
执行计划可能长这样:
sql
Finalize Aggregate (cost=... rows=1 ...)
-> Gather (cost=... rows=3 ...)
Workers Planned: 2
-> Partial Aggregate (cost=... rows=1 ...)
-> Parallel Seq Scan on big_table (cost=... rows=41666666 ...)
解读(自下而上):
-
Parallel Seq Scan: 表被分成了很多块,3 个进程(1 个 Leader + 2 个 Workers)同时去抢着扫描。
-
Partial Aggregate (部分聚合): 每个 Worker 扫完自己那部分数据后,先在本地算一个
count(比如 Worker A 算出 3000 万,Worker B 算出 3500 万)。 -
Partial Aggregate (部分聚合): 是工人(Worker)在干活。每个人只算自己手头那一小堆数据的"小账"。
Aggregate / Finalize Aggregate (最终聚合): 是老板(Leader)在干活。他把工人们报上来的"小账"加在一起,算出"总账"。
-
Gather: Leader 进程把这 3 个部分结果收上来。
-
Finalize Aggregate (最终聚合): Leader 把收上来的 3 个数字加在一起,得到最终的 1 亿,返回给用户。
4. 并行算子的"坑"与调优
虽然并行很快,但它不是银弹,使用时需注意:
- 启动成本: 启动 Worker 进程是有开销的。如果查询本身只需 10ms,开启并行可能反而要花 20ms。PostgreSQL 会通过
min_parallel_table_scan_size参数自动判断表够不够大,小表不会走并行。 - 内存倍增风险 (work_mem):
- 切记:
work_mem是限制每个进程的内存。 - 如果你设置
work_mem = 1GB,并启动了 4 个 Worker。那么这个查询理论上最高可能消耗(4 + 1) * 1GB = 5GB内存。容易导致 OOM(内存溢出)。
- 切记:
- 写操作限制: 目前,并行查询主要用于
SELECT。对于UPDATE/DELETE,只有在RETURNING子句后的部分或者子查询中才能利用并行,修改数据本身的操作通常是单线程的。
总结
- Gather = 包工头(汇总)。
- Parallel Scan = 工人(干苦力)。
- 核心优势 = OLAP 类查询(大表扫描、聚合、大连接)速度成倍提升。
- 代价 = CPU 飙升,内存消耗翻倍。
阻塞算子和非阻塞算子
这是一个非常关键的概念,它直接决定了你的 SQL 查询是**"马上就有结果蹦出来"** ,还是**"等了半天没反应,然后哗啦一下全出来了"**。
在数据库执行计划中,算子根据处理数据的方式,分为阻塞(Blocking) 和**非阻塞(Non-Blocking / Pipelined)**两大类。
我们可以用**"自来水管"** 和**"蓄水池"**来比喻。
1. 非阻塞算子 (Non-Blocking Operators) ------ 自来水管
特点:
- 即时性: 只要从下层拿到了一行数据,处理完马上就吐给上层(或者返回给用户)。
- 流式处理 (Pipelined): 数据像水流一样,源源不断地穿过算子。
- 低延迟: 用户能很快看到第一条结果(First Row Time 极短)。
典型算子:
- Seq Scan / Index Scan: 读到一行,就给一行。
- Nested Loop Join: 只要外表找到一行,去内表匹配到了,就立马返回这一行结果。
- Limit: 拿到一行算一行,数够了就关门。
- Append: (
UNION ALL) 读完这表读那表,中间不停顿。
场景举例:
sql
SELECT * FROM users WHERE age > 20 LIMIT 10;
数据库扫到第一条 age > 20 的人,你马上就能在屏幕上看到。不需要等全表扫完。
2. 阻塞算子 (Blocking Operators) ------ 蓄水池
特点:
- 全量等待: 必须把下层传上来的所有数据 都读完、存下来(通常在内存或磁盘),处理完毕后,才能吐出第一行结果。
- 物化 (Materialization): 数据在这个算子这里"停滞"了,被堆积成了临时结果集。
- 高延迟: 在处理完最后一行数据之前,用户什么都看不到。
典型算子:
- Sort (排序): 这是最典型的阻塞算子。你想输出"最贵"的商品,必须把所有商品都看一遍并排好序,才能知道谁是第一名。
- Aggregate (聚合): 比如
COUNT,SUM,AVG。你必须数完所有豆子,才能告诉我总共有多少颗。 - Hash Join (Build Phase): 哈希连接的第一步是构建哈希表。它必须把一张表完全读入内存构建好 Hash Map,才能开始探测第二张表。
- Unique / DISTINCT: 为了去重,通常需要把数据全看一遍(除非基于有序索引)。
场景举例:
sql
SELECT * FROM users ORDER BY age;
哪怕你只看前 10 行,数据库也得把 100 万行用户全读出来,排好序,才能给你最年轻的那 10 个。
3. 半阻塞算子 (Hash Join 的特殊性)
Hash Join 是个有趣的混合体:
- 阶段一(阻塞): 构建哈希表(Build Hash Table)。
- 数据库读取**右表(内表)**的所有数据。此时,查询处于"卡顿"状态,没有任何输出。
- 阶段二(非阻塞): 探测(Probe)。
- 哈希表建好后,数据库开始扫描左表(外表) 。每读一行左表,去哈希表里查一下。查到了,立刻输出。
所以,Hash Join 的启动速度取决于右表的大小。这也解释了为什么优化器总是喜欢用小表来做 Hash Join 的构建表。
4. 为什么要区分这个?
理解阻塞与非阻塞,对性能优化有两大指导意义:
A. 响应时间 vs. 总时间 (Response Time vs. Total Time)
- OLTP 系统(Web 应用): 用户希望点开页面立马看到内容。
- 目标: 尽量使用非阻塞算子。
- 策略: 利用索引消除
Sort,利用 Nested Loop 代替 Hash Join(在数据量小时)。
- OLAP 系统(报表分析): 用户可以等 10 秒,但必须跑完几亿行数据。
- 目标: 吞吐量优先。
- 策略: 阻塞算子(如 Hash Join, Sort Merge Join)通常在处理大数据量时效率更高。
B. 内存消耗 (work_mem)
- 非阻塞算子通常不怎么吃内存,因为它们只处理当前这一行。
- 阻塞算子 是内存杀手 。
Sort需要内存排序,Hash Agg需要内存存哈希表。如果内存 (work_mem) 不够,它们就会把数据写到磁盘临时文件,导致性能从"内存级"跌落到"磁盘级"(慢 1000 倍)。
5. 如何在 EXPLAIN 中看出来?
看 cost 的两个数字:
cost=启动代价..总代价
- 非阻塞算子: 启动代价(第一个数字)通常很小,接近 0.00 。
Seq Scan (cost=0.00..145.00)-> 马上开始。
- 阻塞算子: 启动代价通常很大 ,接近总代价。
Sort (cost=1000.00..1050.00)-> 前 1000 的代价都在等它排序,排完才开始输出。
总结图表
| 特性 | 非阻塞算子 (Pipeline) | 阻塞算子 (Blocking) |
|---|---|---|
| 比喻 | 水管流过 | 蓄水池蓄满 |
| 首行输出 | 极快 (0ms 级别) | 慢 (需处理完所有数据) |
| 内存消耗 | 低 (处理完即丢) | 高 (需缓存所有数据) |
| 典型代表 | Nested Loop, Seq/Index Scan | Sort, Hash Agg, Hash Join(构建端) |
| 优化方向 | 适合分页、快速响应 | 适合全量统计、大数据吞吐 |
执行计划分析
第一维度:EXPLAIN
在使用之前,你必须知道你手里拿的是哪一把"手术刀"。不同的参数组合,看到的深度完全不同。
- Level 1:
EXPLAIN SELECT ...(静态推演)- 发生了什么: 数据库没有执行 SQL。它只是根据统计信息(Statistics)"脑补"了一个计划。
- 能看什么: 优化器打算怎么做、预估的成本(Cost)、预估的行数。
- 缺点: 它是猜的。如果统计信息过期,看到的计划可能完全是错的。
- 适用场景: SQL 跑得太慢不敢运行,或者涉及
DELETE/UPDATE不想弄脏数据。
- Level 2:
EXPLAIN (ANALYZE) SELECT ...(实战复盘)- 发生了什么: 数据库真的执行了 SQL(注意:如果是修改语句,数据真的会变!)。
- 能看什么: 除了预估值,还能看到实际耗时(Actual Time) 、实际行数(Actual Rows) 、循环次数(Loops)。
- 核心价值: 对比"预估"和"实际"的差异,这是调优的根基。
- Level 3:
EXPLAIN (ANALYZE, BUFFERS) SELECT ...(IO 透视) ------ 最推荐!- 发生了什么: 在执行的基础上,统计了内存和磁盘的交互。
- 能看什么: 数据是从内存(Shared Buffers)读的,还是从硬盘(Disk)读的。
- 核心价值: 数据库慢,90% 是因为 IO。不看 Buffers 就无法精准定位 IO 瓶颈。
- Level 4:
EXPLAIN (ANALYZE, VERBOSE, SETTINGS) SELECT ...(全息视图)- VERBOSE: 显示每个算子具体输出了哪些列(Output List),有助于分析是否查了不该查的字段。
- SETTINGS: 显示哪些非默认参数影响了这次计划(比如你临时把
enable_seqscan关了)。
第二维度:解构树状执行流
执行计划是一个嵌套的树状结构。理解它的阅读顺序至关重要。
1. 阅读法则:由内向外,自下而上
- 缩进最深的节点,通常是"叶子节点",最先开始工作(通常是扫描表)。
- 缩进相同的节点,通常按顺序执行(对于 Hash Join,上面的分支是 Build,下面的分支是 Probe)。
- 父节点依赖子节点的输出。
2. 箭头 -> 的含义
它代表数据的流动方向。子节点把处理好的数据"喂"给父节点。
3. 示例结构解析
Plaintext
sql
-> Sort (Level 1: 最后执行,等待 Hash Join 的结果)
-> Hash Join (Level 2: 它是 Sort 的孩子,等待 Hash 和 Seq Scan 的结果)
Hash Cond: (t1.id = t2.uid)
-> Seq Scan on large_table t1 (Level 3: 和下面的 Hash 处于同一级)
-> Hash (Level 3: 它是 Hash Join 的内表构建过程)
-> Seq Scan on small_table t2 (Level 4: 最先执行,扫描小表)
真实执行逻辑:
- 先扫描
small_table t2。 - 将 t2 的数据构建成一个内存哈希表(Hash 节点)。
- 扫描
large_table t1。 - 每扫描一行 t1,就去哈希表里比对(Hash Join)。
- 比对成功的结果,交给
Sort节点排序。 - 排序完返回给用户。
第三维度:核心参数深度解码
我们来看一行典型的输出,把它像拆炸弹一样拆解开:
sql
-> Seq Scan on orders (cost=0.00..188.00 rows=1000 width=45) (actual time=0.006..2.500 rows=1200 loops=1)
A. 预估部分 (括号第一部分)
cost=0.00..188.00(代价)- 单位: 这是一个抽象值,没有单位(通常 1.0 代表读取一个磁盘页的代价)。
- 0.00 (Startup Cost - 启动代价): 拿到第一行 数据前需要多长时间。
Seq Scan是 0,因为我们要的第一行就在第一页。Sort节点这里会很大,因为它必须把所有数据排完序才能吐出第一行。
- 188.00 (Total Cost - 总代价): 拿到所有数据需要的总代价。优化器(Planner)就是凭这个数字选路,它会选 Total Cost 最小的那条路。
rows=1000(预估行数)- 优化器根据统计信息(pg_statistic)猜出来的。
- 重要性: 它是决定走 Nested Loop 还是 Hash Join 的关键。如果这里猜错了,计划就会选错。
width=45(行宽度)- 平均每一行数据占用 45 字节。
- 重要性:
rows * width= 预估的总数据量。这决定了需不需要把数据写到临时文件(Disk Spill),因为内存(work_mem)是有限的。
B. 实际执行部分 (括号第二部分,仅在 ANALYZE 模式下出现)
-
actual time=0.006..2.500(实际时间)- 单位: 毫秒 (ms)。
- 0.006 (Start Time): 拿到第一行花了 0.006ms。
- 2.500 (Total Time): 平均每次循环拿到所有数据花了 2.5ms。
- 坑点: 如果
loops > 1,真实总时间 =Total Time * loops。
-
rows=1200(实际行数)- 真实返回了多少行。
-
loops=1(循环次数)-
这个算子被执行了几次。
-
Nested Loop 中的大坑:
sql-> Index Scan on child_table ... (actual time=0.005..0.010 rows=1 loops=10000)乍一看只花了 0.01ms?错! 真实的耗时是
0.010 * 10000 = 100ms。一定要乘 Loops!
-
第四维度:Buffers ------ IO 的秘密
加上 BUFFERS 选项后,你会看到类似这样的输出:
Buffers: shared hit=5 read=10 dirtied=2 written=1
这是判断性能瓶颈的金标准:
- Shared Hit (内存命中):
- 数据直接从 PostgreSQL 的共享内存(Shared Buffers)里拿到了。
- 评价: 很好,极快。我们希望 Hit 越高越好。
- Read (磁盘读取):
- 内存里没有,必须向操作系统申请从磁盘读。
- 评价: 慢。如果 Read 很高,说明内存不够用,或者索引没建好导致扫描了太多冷数据。
- Dirtied (脏页):
- 查询过程中,发现数据页被修改了(通常是未提交的事务),需要标记为脏页。
- 评价: 在
SELECT查询中不应该大量出现。
- Temp Read / Written (临时文件读写):
- 评价: 灾难级。
- 说明
work_mem太小,排序(Sort)或哈希表(Hash)在内存装不下,被迫把数据写到硬盘上再读回来。这会让查询慢几个数量级。
第五维度:警示信号 ------ 看到这些要报警
在审视长篇大论的计划时,请带上"找茬"的眼镜,寻找以下红线:
1. 估算与实际的巨大偏差 (Estimation Skew)
- 现象:
rows=1,但actual rows=1000000。 - 后果: 优化器以为数据很少,选了 Nested Loop,结果被海量数据教做人。
- 对策:
ANALYZE table_name;更新统计信息。
2. 高 Filter 移除率 (High Filter Ratio)
- 现象:
Rows Removed by Filter: 99999(Seq Scan 返回了 10 万行,过滤丢掉了 9 万 9 千行)。 - 含义: 数据库做了大量无用功。它像个笨拙的图书管理员,把整架书搬下来,一本本看,最后只给你一本。
- 对策: 针对 Filter 的条件建立索引。
3. 临时文件溢出 (Disk Spill)
- 现象:
Disk: 10240kB(出现在 Sort 或 Hash 节点)。 - 对策: 调大
work_mem参数,或者优化 SQL 减少排序/聚合的数据量。
4. 错误的连接方式
- 现象: 两个大表连接(比如各 100 万行),却用了
Nested Loop。 - 原因: 通常是因为上述的"估算偏差"导致的。
第六维度:实战逐行拆解案例
让我们来看一个包含"坑"的真实复杂案例。
sql
SELECT * FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.age > 20 AND o.status = 'paid'
ORDER BY o.create_time LIMIT 10;
Execution Plan:
sql
Limit (cost=100.50..100.60 rows=10) (actual time=55.000..55.010 rows=10 loops=1)
-> Sort (cost=100.50..105.00 rows=500) (actual time=55.000..55.005 rows=10 loops=1)
Sort Key: o.create_time
Sort Method: external merge Disk: 800kB <-- 警报 1
-> Hash Join (cost=20.00..80.00 rows=500) (actual time=10.000..45.000 rows=10000 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..50.00 rows=2000) (actual time=0.005..15.000 rows=50000 loops=1) <-- 警报 2
Filter: (status = 'paid')
Rows Removed by Filter: 100000 <-- 警报 3
-> Hash (cost=15.00..15.00 rows=100) (actual time=5.000..5.000 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on users u (cost=0.00..15.00 rows=100) (actual time=0.004..3.000 rows=100 loops=1)
Filter: (age > 20)
逐行侦探分析:
- 看最下面 (users):
Seq Scan on users。扫了 users 表,过滤age > 20,剩 100 行。速度挺快 (3ms),没大毛病。 - 看中间 (Hash): 把这 100 个 user 放入内存哈希表。内存只用了 9kB,很健康。
- 看下面 (orders) - [警报 2 & 3]:
Seq Scan on orders。Filter: status='paid'。Rows Removed by Filter: 100000。- 解读: 为了找 'paid' 的订单,全表扫描并扔掉了 10 万行废数据!
- 优化: 应该在
orders(status)上加索引。
- 看 Join (Hash Join):
actual rows=10000。Join 完有 1 万行数据。
- 看排序 (Sort) - [警报 1]:
Sort Method: external merge Disk: 800kB。- 解读: 这里的 1 万行数据要排序,但是内存不够用了,数据溢出到了磁盘(Disk)。这严重拖慢了速度。
- 优化: 调大
work_mem,或者给orders(user_id, status, create_time)加复合索引,可能直接消除排序。
- 看顶层 (Limit): 取了前 10 条。
总结
看执行计划,本质上是在回答三个问题:
- 数据怎么找的? (Scan: 是傻傻的扫全表,还是聪明的查索引?)
- 数据怎么连的? (Join: 是双层循环,还是哈希匹配?)
- 资源够不够? (Buffer/Disk: 内存命中率高吗?有没有溢出到磁盘?)
只要抓住了 Actual Time(真实耗时)、Rows Removed(过滤浪费)和 Disk/Buffers(资源瓶颈),你就掌握了性能优化的钥匙。
查询优化器
简单来说,数据库做决定的过程,就像是你在用地图软件导航。
- 起点是数据现在的状态。
- 终点是你想要的查询结果。
- 路径就是各种"执行计划"。
- 优化器 就是那个算法,它需要在成千上万条可能的路线中,算出一条**"代价(Cost)最低"**的路。
这个过程被称为 CBO (Cost-Based Optimization,基于代价的优化)。以下是它做决定的完整逻辑链条:
第一阶段:预处理与逻辑优化 (Query Rewriting)
在计算代价之前,优化器会先对你的 SQL 做"整形手术",把它改写成逻辑上等价但更容易优化的形式。这叫逻辑优化。
- 去除多余条件: 比如
WHERE 1=1会被删掉。 - 常量折叠:
WHERE id = 1 + 2会变成WHERE id = 3。 - 视图合并 (View Merging): 如果查询里用了视图,优化器会试图把视图拆开,把里面的表直接拿出来和外面的表连接,扩大选择空间。
- 子查询扁平化 (Subquery Unnesting): 尽量把子查询改写成 JOIN,因为数据库处理 JOIN 的算法比处理子查询丰富得多。
第二阶段:枚举候选计划 (Plan Enumeration)
这是最耗时的步骤。优化器会像变魔术一样,排列组合出各种可能的执行路径。
如果不加限制,一个涉及 5 张表的查询,可能的连接顺序就有 5! = 120 种,再乘以每两张表可能有 3 种连接方式(Hash/Nested/Merge),再乘以每张表可能有 2-3 种扫描方式(Seq/Index/Bitmap)......搜索空间是指数级爆炸的。
优化器主要考虑三个维度的组合:
- 访问路径 (Access Path):
- 表 A:是用全表扫描,还是用索引 X,还是用索引 Y?
- 连接顺序 (Join Order):
- 是先 A Join B,结果再 Join C?
- 还是先 B Join C,结果再 Join A?
- 连接算法 (Join Method):
- 是用 Nested Loop,还是 Hash Join,还是 Merge Join?
第三阶段:代价估算 (Cost Estimation) ------ 核心中的核心
面对生成的一堆候选计划,优化器怎么知道哪个好?它需要给每个计划算一个分(Cost)。
公式大致如下(简化版):
Cost = (IO代价 \times IO权重) + (CPU代价 \times CPU权重)
为了算出这个公式,优化器必须依赖统计信息 (Statistics)。如果统计信息不准,优化器就会变成"盲人"。
1. 统计信息 (The Fuel)
数据库会定期(通过 ANALYZE 命令)收集每张表的情报,存放在系统表里(如 PG 的 pg_statistic):
- 行数 (Tuples): 表里大概有多少行?
- 页面数 (Pages): 表占了多少磁盘块?
- 唯一值个数 (n_distinct): 某一列有多少个不同的值?(决定了过滤性)
- 高频值 (MCV - Most Common Values): 哪几个值出现得最多?(比如"状态"字段,90% 都是 'success')
- 直方图 (Histogram): 数据的分布情况是怎样的?(用于范围查询估算)
2. 推算过程 (The Calculation)
优化器利用统计信息进行基数估算 (Cardinality Estimation),也就是猜每一各步骤会返回多少行。
- 例子:
SELECT * FROM users WHERE age > 20 AND city = 'Beijing'- 统计信息说:
age > 20的大概占 50%,city = 'Beijing'的大概占 10%。 - 优化器假设两列不相关,估算出符合条件的行数 = 总行数 \\times 50% \\times 10%。
- 如果算出来只有 10 行,它可能选 Index Scan。
- 如果算出来有 100 万行,它可能选 Seq Scan。
- 统计信息说:
第四阶段:搜索算法 (Search Algorithms)
因为候选计划太多了,不可能真的把每一个都算一遍(那光是生成计划就要几分钟)。数据库使用高效的算法来"剪枝"和搜索。
1. 动态规划 (Dynamic Programming) ------ System R 风格
这是大多数数据库(包括 PostgreSQL 和 Oracle)处理中小型查询(通常 < 12 张表)的标准算法。
- 思路: 自底向上。
- 先算出访问单表 A、B、C 的最优路径。
- 再基于此,算出 {A, B} 连接的最优路径,和 {B, C} 连接的最优路径。
- 再算出 {A, B, C} 的最优路径。
- 优势: 能保证找到全局最优解。
2. 遗传算法 (Genetic Algorithm / GEQO)
当表非常多(比如 PostgreSQL 默认超过 12 张表 JOIN)时,动态规划太慢了。优化器会切换到遗传算法。
- 思路: 随机生成几个计划(种群),让它们"变异"和"杂交"(交换连接顺序),保留 Cost 低的,淘汰高的,迭代几轮后,选一个"足够好"的(但不一定是最优的)。
- 优势: 速度快,避免优化过程本身把数据库搞死。
第五阶段:最终决策 (Plan Selection)
经过上述步骤,优化器手里捏着几个经过筛选的"决赛圈"计划。它会简单粗暴地比较它们的 Total Cost:
- 计划 A (Hash Join): Cost = 500
- 计划 B (Nested Loop): Cost = 2000
- 计划 C (Merge Join): Cost = 480
决定: 选用计划 C。
总结:为什么优化器有时候会"犯傻"?
了解了原理,你就知道为什么数据库有时候会选错计划(比如选了全表扫描而不走索引):
- 统计信息过期: 数据变了,但没运行
ANALYZE,优化器以为表是空的,结果表里有 1 亿行。它会错误地选择 Nested Loop,导致系统卡死。 - 数据相关性 (Correlation): 优化器默认假设列与列之间是独立的。
- 比如查询"省份=湖北 AND 城市=武汉"。
- 优化器觉得这两个概率要相乘(0.03 * 0.01 = 0.0003),认为结果极少。
- 实际上这两个条件是强相关的,结果很多。估算错误导致选错索引。
- 代价模型偏差: 传统的 Cost 模型主要看 IO。现在的 SSD 很快,有时候随机读(Index Scan)比顺序读(Seq Scan)快,但旧的代价参数可能还觉得机械硬盘的随机读很慢,从而不敢用索引。
一图胜千言
可以将整个过程想象成一个漏斗:
- SQL 文本 (输入)
- Parser (语法树)
- Rewriter (逻辑优化后的树)
- Planner (生成 1000 个路径 -> 估算 Cost -> 动态规划剪枝 -> 剩 1 个路径)
- Executor (执行选定的那个计划)
Hash Join的Buckets和Batches
在 PostgreSQL 的 EXPLAIN (ANALYZE) 输出中,Buckets 和 Batches 是 Hash Join(哈希连接)算子下两个至关重要的参数。它们直接揭示了你的查询是**"在内存里飞"** ,还是**"在硬盘里爬"**。
简单总结:
- Buckets (桶): 是哈希表的**"房间数"**。它决定了 CPU 查找的效率(解决哈希冲突)。
- Batches (批次): 是哈希表的**"分身数"** 。它决定了内存够不够用(解决内存溢出)。这是性能杀手。
1. Buckets (桶) ------ 内存里的"门牌号"
Hash Join 的第一步是构建哈希表(Build Phase)。数据库会申请一块内存,把这块内存划分为 N 个槽位,每个槽位就是一个 Bucket。
-
工作原理:
- 数据库读取内表(Build Table)的一行数据。
- 对连接键(Join Key)算出一个哈希值。
- 用
哈希值 % Bucket总数算出这行数据该进哪个桶。 - 把数据挂在这个桶的链表后面。
-
理想情况:
每个桶里只有 1 行数据。这样探测(Probe)的时候,算一次哈希就能直接定位到数据,复杂度是 O(1)。
-
糟糕情况(哈希冲突):
如果 Buckets 太少,或者哈希函数不好,导致几千行数据都挤在一个桶里(冲突)。那在这个桶内部查找就变成了线性扫描,CPU 消耗剧增。
-
EXPLAIN 中的表现:
PostgreSQL 通常会自动调整 Buckets 的数量以保持较高的效率(通常是 2 的幂次方)。
Buckets: 1024 Memory Usage: 50kB- 这说明申请了 1024 个槽位,内存用了 50kB。这通常不是瓶颈。
2. Batches (批次) ------ 内存不够时的"分身术"
这是最需要警惕的指标。
Hash Join 必须把整个哈希表建在内存里。但是,如果你的 work_mem(工作内存)只有 4MB,而内表有 100MB,怎么办?内存装不下!
这时候,PostgreSQL 就会启动 Batching(分批)机制 ,也就是把大象切块装进冰箱。
- 工作原理(分治法):
- 切分: 数据库根据哈希值,将内表(Build Table)和外表(Probe Table)切分成 N 个 Batches。
- 驻留与落盘:
- Batch 0:留在内存里,立刻开始做连接。
- Batch 1 ~ N :内存放不下了,写到磁盘临时文件(Temp Files)里。
- 轮询: 等 Batch 0 处理完,清空内存,把 Batch 1 从磁盘读回内存,构建哈希表,再处理......直到所有 Batch 处理完。
- Batches = 1 (完美状态):
- 说明所有数据都能装进内存 (
work_mem)。 - 没有磁盘 I/O,速度最快。
- EXPLAIN:
Batches: 1 Memory Usage: ...
- 说明所有数据都能装进内存 (
- Batches > 1 (溢出状态):
- 说明内存不够,触发了磁盘读写。
- EXPLAIN:
Batches: 4 Disk: 2500kB。 - 这意味着你的查询正在经历"写盘 -> 读盘"的折磨,性能会下降几个数量级。
3. 实战案例:如何通过这两个参数调优?
让我们看一个发生"内存溢出"的 Hash Join 执行计划:
Plaintext
sql
-> Hash Join (cost=...) (actual time=...)
Hash Cond: (outer.id = inner.id)
-> Seq Scan on outer_table ...
-> Hash (cost=...) (actual time=...)
Buckets: 65536 Batches: 8 Memory Usage: 4096kB <-- 关键点在这里!
Disk: 12000kB <-- 证据确凿
-> Seq Scan on inner_table ...
分析:
- Buckets: 65536
- 桶很多,说明数据量不小,PostgreSQL 试图分散哈希冲突。
- Batches: 8
- 报警! 这意味着数据被分成了 8 份。
- 第 1 份在内存处理了,剩下 7 份被写到了磁盘上。
- Disk: 12000kB
- 为了做这个 Join,数据库临时写了 12MB 的数据到硬盘。
- Memory Usage: 4096kB
- 当前的
work_mem设置限制了它只能用 4MB 内存。因为装不下 16MB(12MB+4MB)的数据,所以被迫分批。
- 当前的
优化方案:
为了让 Batches 变回 1,我们需要增加 work_mem。
sql
-- 临时调大当前会话的内存限制(比如调到 32MB)
SET work_mem = '32MB';
-- 再次运行 EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT ...
优化后的可能结果:
Plaintext
sql
-> Hash (cost=...) (actual time=...)
Buckets: 65536 Batches: 1 Memory Usage: 16100kB <-- 舒服了
Disk: 0kB <-- 磁盘 I/O 消失
- Batches: 1: 所有数据都在内存里。
- Memory Usage: 16100kB: 实际上只用了 16MB 内存,完全在我们的预算(32MB)之内。
- 性能提升: 查询速度通常会提升 2-10 倍。
4. 深度机制:Dynamic Growing (动态增长)
有时候你会看到这样的输出:
sql
Buckets: 1024 Batches: 1 (originally 1) Memory Usage: ...
或者:
sql
Buckets: 1024 Batches: 2 (originally 1) Memory Usage: ...
这是什么意思?
PostgreSQL 的优化器在开始执行前,只是估算需要多少 Buckets 和 Batches。
- 如果执行过程中发现:"哎呀,估算少了,内存快爆了!"
- 它会动态增加 Batches(Doubling strategy,通常翻倍)。
- 它会将当前哈希表里的数据"分裂",把一半的数据赶出内存(写到磁盘的新 Batch 里),从而腾出空间。
这说明: 你的统计信息(Statistics)可能严重过期了,导致优化器低估了行数。
对策: 运行 ANALYZE table_name;。
总结
| 参数 | 含义 | 理想值 | 异常值 | 影响 | 解决方案 |
|---|---|---|---|---|---|
| Buckets | 哈希表槽位数 | 2^N (如 1024) | 无特定异常 | 影响 CPU (哈希冲突) | 通常无需手动干预 |
| Batches | 分批处理数 | 1 | > 1 | 严重影响 IO (写磁盘) | 调大 work_mem |
看到 Batches > 1,就是看到数据库在向你求救:"给我点内存吧!"
Hash Join Hash 冲突
在数据库的 Hash Join 中,Hash 冲突(Hash Collision) 是不可避免的物理现象。
简单回顾一下定义:不同的 Join Key(比如 ID=10 和 ID=100),经过哈希函数计算后,得到了完全相同的哈希值(比如都等于 5),于是它们都要挤进同一个 Bucket(桶)里。
数据库(以 PostgreSQL 为例)解决这个问题的标准方案是:拉链法(Separate Chaining) + 值比较(Recheck)。
1. 核心机制:拉链法 (Separate Chaining)
想象一下,Hash Table 是一个巨大的**"快递柜"** ,每个格子就是一个 Bucket。
如果两个包裹(数据行)的取件码(Hash 值)算出来都是"5号柜",怎么办?
数据库不会把后来的包裹扔掉,也不会覆盖前面的,而是在 5 号柜后面挂一条长长的链子(Linked List)。
- Bucket 的结构: 它不再是一个只能存一行数据的"死格子",而是一个链表头指针。
- 冲突处理:
-
第一个冲突的行,挂在 Bucket 后面。
-
第二个冲突的行,挂在第一个行后面。
-
以此类推......
Bucket 0: [ NULL ]
Bucket 1: [ Row A (id=10) ] -> [ Row B (id=100) ] -> [ Row C (id=999) ] -> NULL
Bucket 2: [ Row D (id=5) ] -> NULL
...
-
在上面的例子中,Row A、Row B、Row C 的 ID 不同,但哈希值都撞到了 Bucket 1。这就是哈希冲突。
2. 探测阶段:必须进行"验身" (Recheck)
解决了"存"的问题,关键在于"取"(Probe 阶段)。
当外表(Outer Table)的一行数据来匹配时,数据库如何区分链表里的 A、B、C 谁才是真正的"亲人"?
步骤如下:
- 算哈希: 拿外表数据的 Join Key(比如 ID=100),算出哈希值 ->
Bucket 1。 - 找桶: CPU 定位到内存中的 Bucket 1。
- 遍历链表(关键步骤):
- 看第一个节点 (Row A): 它的哈希值匹配,但数据库不敢确信 。必须拿出原始值比对:
Outer.ID (100) == Inner.ID (10)吗?不相等。跳过。 - 看第二个节点 (Row B): 拿出原始值比对:
Outer.ID (100) == Inner.ID (100)吗?相等! 匹配成功,返回结果。 - 看第三个节点 (Row C): ...
- 看第一个节点 (Row A): 它的哈希值匹配,但数据库不敢确信 。必须拿出原始值比对:
结论: Hash Join 在匹配时,不仅仅比较哈希值,还必须在内存中比较原始的 Join Key。
3. 性能影响:冲突是 CPU 杀手
理解了上述过程,你就明白了为什么哈希冲突是性能的敌人:
- 理想情况 (无冲突): Bucket 里只有 1 个节点。
- 复杂度:O(1)。算一次哈希,比对一次,搞定。
- 糟糕情况 (严重冲突): Bucket 后面挂了 1000 个节点。
- 复杂度:O(N)。算一次哈希,然后要进行 1000 次 CPU 比较操作(遍历链表)。
- 后果: 这实际上把高效的 Hash Join 退化成了低效的 Nested Loop(在桶内部做循环)。
4. 极端案例:数据倾斜 (Data Skew)
有时候,哈希冲突不是因为运气不好,而是因为数据本身的问题。
场景:
你连接的字段是 status(状态),且 99% 的数据都是 'Success',只有 1% 是 'Failed'。
- Hash('Success') = Bucket 5
- Hash('Failed') = Bucket 8
结果:
- Bucket 5 后面挂了一个长达几百万行的链表(因为 99% 的数据都一样)。
- Bucket 8 只有寥寥几行。
- 其他 Bucket 全是空的。
这是 Hash Join 的噩梦。 在这种情况下,Hash Join 的性能会急剧下降,甚至比 Nested Loop 还要慢(因为还多了解析哈希和维护链表的开销)。
PostgreSQL 的对策:
PostgreSQL 的优化器(Planner)通常会利用高频值统计(MCV - Most Common Values)。如果它发现 Join Key 分布极度不均匀(倾斜严重),它可能会:
- 放弃 Hash Join,改用 Merge Join 或 Nested Loop。
- 或者在 Hash Join 之前,对高频值做特殊处理(但这比较复杂)。
5. 怎么避免冲突?(数据库在做什么)
虽然我们改变不了数据,但数据库内核在努力减少"意外冲突":
- 优秀的哈希算法: PostgreSQL 使用
Jenkins Hash或MurmurHash等算法,确保哈希值像"撒胡椒面"一样均匀分布,避免不同的值算出一样的哈希。 - 动态扩容 (Resizing): 如果发现数据量比预期的多,导致 Bucket 不够用了(比如每个桶都挂了 5 个节点),数据库可能会重新申请更大的内存,把 Bucket 数量翻倍(比如从 1024 扩到 2048),然后把数据重新撒一遍(Rehash)。
- 注:PostgreSQL 的 Hash Join 更多是通过 Batches(分批)来解决内存不足,通过预估足够大的 Buckets 来解决冲突。
总结
Hash Join 遇到冲突时:
- 存: 用链表把冲突的数据串起来。
- 取: 遍历链表,逐个进行原始值比较 (Recheck)。
- 代价: 冲突越多,链表越长,CPU 遍历越慢,性能越差。
Parallel和Partial
"Parallel" (并行)和 "Partial"(部分)经常成对出现,但它们描述的是完全不同的两个维度:
- Parallel (并行): 描述的是**"动作的方式"** ------ 大家一起干。
- Partial (部分): 描述的是**"结果的状态"** ------ 干了一半,还没完。
1. "Parallel":动作(怎么干?)
当你在执行计划中看到 Parallel (如 Parallel Seq Scan),它意味着**"分身术"**。
- 含义: 数据库启动了多个工人(Worker Processes),大家同时去干这一件事。
- 场景:
- Parallel Seq Scan: 3 个工人,每人负责扫 1/3 的表。
- Parallel Hash Join: 3 个工人,每人负责连接 1/3 的数据。
- 关键点: 它的反义词是 "Serial"(串行,单线程)。
2. "Partial":结果(干成啥样了?)
当你在执行计划中看到 Partial (如 Partial Aggregate),它意味着**"半成品"**。
- 含义: 因为数据是大家分开处理的,所以每个人算出来的结果只是局部的,不是最终答案。
- 场景:
- Partial Aggregate: 工人 A 算出他那部分有 100 票,工人 B 算出他那部分有 200 票。
- 注意: 这时候谁都不知道总票数是多少。
- 关键点: 它的反义词是 "Finalize"(最终汇总)。
3. 为什么有时候有 Partial,有时候没有?
这取决于你的 SQL 是否需要"聚合"(Aggregation)。
情况 A:有 Parallel,也有 Partial (聚合查询)
SQL: SELECT count(*) FROM big_table;
你想要一个汇总的数字。
- Parallel Seq Scan: 大家分头去数票(动作是并行的)。
- Partial Aggregate: 每个人先把手里的票数加一加,记在小本本上(结果是局部的)。
- 如果不做这一步,每个人都要把几百万张选票扔给领导,领导会被砸死。
- Gather: 领导把小本本收上来。
- Finalize Aggregate: 领导把小本本上的数字加在一起(最终结果)。
执行计划长这样:
sql
Finalize Aggregate <-- 4. 算出总账
-> Gather <-- 3. 收小本本
-> Partial Aggregate <-- 2. 记小本本 (Partial出现了!)
-> Parallel Seq Scan <-- 1. 分头数票 (Parallel出现了!)
情况 B:有 Parallel,但没有 Partial (明细查询)
SQL: SELECT * FROM big_table WHERE id > 1000;
你只想要原始数据,不需要汇总。
- Parallel Seq Scan: 大家分头去找
id > 1000的行。 - Gather: 找到一行,就直接扔给领导。
- Result: 领导直接发给用户。
执行计划长这样:
sql
Gather <-- 2. 收原始票据
-> Parallel Seq Scan <-- 1. 分头找票 (只有Parallel,没有Partial)
为什么这里没有 Partial?
因为不需要计算 SUM 或 COUNT,不需要"中间状态"。工人找到的就是最终需要的行。
4. 深度对比表
| 关键词 | 词性 | 潜台词 | 典型算子 | 出现位置 |
|---|---|---|---|---|
| Parallel | 副词 (Adverb) | "我不孤单,有兄弟帮我一起做" | Parallel Seq Scan Parallel Index Scan Parallel Hash Join |
树的底层 (干苦力的地方) |
| Partial | 形容词 (Adjective) | "这不是最终答案,还要再加工" | Partial Aggregate Partial HashAggregate |
夹在 Parallel 和 Gather 中间 (为了压缩数据) |
总结
- 看到 Parallel ,说明**"为了快,人多力量大"**。
- 看到 Partial ,说明**"为了省,先在本地算个小账"**。
Partial 是 Parallel 在做聚合运算时的"必经之路"。 如果你只是简单地搬运数据(SELECT *),就不需要 Partial 这个步骤。
数据库缓存和操作系统缓存的关系
简单来说,它们是两层防御体系,既有合作,也有竞争。
我们可以用一个**"图书馆复习"**的例子来打比方:
- 硬盘 (Disk): 图书馆的闭架书库。书很多,但取书非常慢(毫秒级)。
- 操作系统缓存 (OS Page Cache): 图书馆的还书手推车 。
- 虽然书还没归架,但就在手边,拿起来很快。
- 这里书很杂,什么都有,而且管理员(OS)会定期把车清空。
- 数据库缓存 (DB Buffer Pool): 你自己的书桌 。
- 这是你精心挑选的、马上要用的书。
- 你知道哪一页最重要,你会一直把它们摊开放在桌上。
1. 核心架构:数据的流动路径
当数据库想要读取一行数据时,并不是直接从硬盘变到数据库里的。这中间经过了层层关卡。
读取路径 (Read Path):
- App 请求数据: SQL 发给数据库。
- DB Cache (Shared Buffers): 数据库先查自己的内存(书桌)。
- Hit: 直接返回。速度最快(纳秒级)。
- Miss: 需要去读文件。
- System Call (read): 数据库发起系统调用,请求操作系统读文件。
- OS Cache (Page Cache): 操作系统查自己的内存(手推车)。
- Hit: 操作系统发现这页数据刚好在内存里(可能是刚才有人读过,或者刚写进去还没落盘)。直接拷贝给数据库。
- Miss: 操作系统真的去读硬盘。
- Disk I/O: 硬盘旋转,磁头寻道,把数据读入 OS Cache。
- Copy: 操作系统把数据从 OS Cache 复制到 DB Cache。
关键点: 如果数据在 DB Cache 没命中,但在 OS Cache 命中,虽然比直接读内存慢一点(因为有系统调用和内存拷贝的开销),但比读硬盘快 1000 倍。
2. "双重缓存" (Double Buffering) 现象
仔细看上面的第 6 步,你会发现一个尴尬的现象:
同一份数据,在内存里存了两份!
- 一份在 OS Page Cache 里。
- 一份在 DB Buffer Pool 里。
这就叫 Double Buffering。
这种冗余是好是坏?
- 坏处: 浪费内存。如果你的服务器有 64GB 内存,结果 30GB 存的是重复数据,那是极大的浪费。
- 好处: 互补。
- DB Cache 满了要淘汰数据时,被淘汰的数据只是从"书桌"移回了"手推车"(OS Cache)。
- 下次如果又突然需要这页数据,虽然书桌上没有,但手推车里有,还是很快。
3. PostgreSQL 的策略:依赖与合作
PostgreSQL 在这方面非常特别。它默认不使用 Direct I/O(即不绕过 OS Cache),而是选择利用它。
- 配置策略:
- 这就是为什么 PostgreSQL 官方建议
shared_buffers(DB Cache)只设置为总内存的 25% - 40%。 - 剩下的内存去哪了? 留给操作系统!PostgreSQL 指望操作系统用剩下的内存做 Page Cache,来做它的"二级缓存"。
- 这就是为什么 PostgreSQL 官方建议
- 优点: 代码简单,利用了操作系统成熟的预读(Prefetching)算法。
- 缺点: 存在双重缓存带来的内存浪费和 CPU 拷贝开销。
4. 其他数据库的策略:Direct I/O (绕过)
很多其他数据库(如 Oracle, MySQL InnoDB)倾向于一种更霸道的做法:O_DIRECT。
- Direct I/O: 数据库告诉操作系统:"我要读写文件,但你别管闲事,别给我做缓存,直接把硬盘数据读到我的内存里。"
- 效果:
- 消除了双重缓存: 数据只存在于 DB Buffer Pool 中。
- 内存利用率高: 数据库可以把 80% 甚至 90% 的物理内存都据为己有。
- 代价: 数据库必须自己实现极其复杂的缓存管理、预读算法和刷盘策略。因为一旦离开了 OS 的保护,自己必须全权负责。
5. 写入与刷盘 (Write & Fsync)
在写入数据时,两者的关系更加微妙,关乎数据安全。
- DB Write: 数据库修改了 Buffer Pool 里的数据(变成了脏页 Dirty Page)。此时数据还在用户空间内存。
- OS Write: 数据库调用
write()。操作系统把数据从 DB Cache 拷贝到 OS Cache。注意:此时数据还没到硬盘! 如果这时候拔电源,数据就丢了。 - Fsync (刷盘): 为了保证 ACID 中的 D(持久性),数据库必须显式调用
fsync()。- 这就像是对操作系统大喊:"别只放在手推车里,现在、立刻、马上把它放回闭架书库(硬盘)去!"
- 只有
fsync返回成功,数据库才敢告诉用户"事务提交成功"。
WAL (预写日志) 的角色:
因为 fsync 随机写数据文件太慢了,数据库会先顺序写 WAL 日志,并频繁 fsync WAL 文件。至于真正的数据页(Data Page),可以在 OS Cache 里多待一会儿,由操作系统的 pdflush 线程或者数据库的 Checkpointer 慢慢刷盘。
总结:谁更聪明?
| 特性 | OS Cache (傻快) | DB Cache (精明) |
|---|---|---|
| 置换算法 | LRU (最近最少使用) 的通用变种。比较简单粗暴。 | Clock-Sweep / ARC。数据库知道哪是索引,哪是全表扫描。它会保护索引页,快速淘汰全表扫描的页。 |
| 预读能力 | 基于文件偏移量的简单预读(往下读 128KB)。 | 理解 B+ 树结构,知道下一个叶子节点在磁盘的什么位置,预读更精准。 |
| 内存控制 | 动态抢占,应用内存不够时会压缩 Cache。 | 大小固定(通常启动时分配)。 |
| 适用场景 | 文件服务器、通用计算。 | 复杂的查询处理。 |