数据库算子

数据库算子

回表

1. 为什么会发生回表?

想象你在图书馆查书:

  1. 索引(Index) :就像图书馆的索引卡片。卡片上写着:书名《数据库原理》,存放位置:3排-B架-12号。
  2. 表(Table/Heap) :就像书架上的实物书。书里才有具体的内容(作者、出版社、正文)。

如果你只想知道书名,看卡片(索引)就够了。但如果你想看"作者是谁"或者"具体内容",你就必须拿着卡片上的位置,走到书架前把那本书抽出来翻开。这个"从索引到书架取书"的过程,就是回表。


2. 回表的过程(以 PostgreSQL/MySQL 为例)

假设你有一张表 users,在 username 字段上有索引:

sql 复制代码
-- 查询语句
SELECT id, username, age FROM users WHERE username = 'a';
  1. 第一步:查索引。 数据库在 username 索引树中快速定位到 'a'
  2. 第二步:拿指针。 索引节点里存储着该行数据的物理地址(在 MySQL 中是主键 ID,在 PostgreSQL 中是 CTID/TID)。
  3. 第三步:回表。 索引里没有 age 字段 。数据库根据物理地址,回到原始数据表(Heap)中找到这一行,把 age 的值读出来。

3. 回表有什么代价?

回表是数据库性能优化的头号公敌,主要原因有两个:

  • 随机 I/O 增加:索引通常是顺序排列的,但数据行在磁盘上的分布是零散的。每回表一次,可能都要进行一次磁盘随机读,这比顺序读慢得多。
  • 性能损耗:如果查询结果有 10,000 行,数据库就要执行 10,000 次"回表"动作。

4. 如何避免回表?

最有效的方案是:覆盖索引(Covering Index)

如果你经常需要根据 usernameage,你可以建立一个包含这两个字段的索引:

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)

当查询条件命中索引时,数据库会先去查索引。

  • 工作原理:
    1. 在 B-Tree 索引中找到匹配条件的 Entry(条目)。
    2. 根据 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 太多"的问题。

  • 工作原理(分两步):
    1. Bitmap Index Scan: 扫描索引,但不立刻回表。它在内存中创建一个"位图",标记哪些数据页包含符合条件的行。
    2. 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)
  • 工作流程:

    1. 优化器选择一张表作为驱动表(Outer Table),通常是过滤后结果集较小的那张表。
    2. 逐行读取驱动表的数据。
    3. 拿着这一行数据的关联键,去**被驱动表(Inner Table)**中查找匹配的行。
  • 性能关键点:

    • 被驱动表必须有索引! 如果内层循环每次都要全表扫描,性能就是灾难级的 O(N*M)。
    • 如果有索引,复杂度降为 O(N*log M)。
  • 适用场景:

    • "小表驱动大表":驱动表只有几百行,被驱动表有几亿行但有索引。
    • 首行快速响应:因为它不需要预处理,找到第一行匹配就能立即返回,适合分页查询的第一页。

2. 哈希连接 (Hash Join)

这是处理大数据量连接的神器,也是现代数据库最常用的算法之一。

  • 工作流程(分两个阶段):
    1. 构建阶段 (Build Phase): 选择较小的那张表,在内存 中建立一张哈希表(Hash Table)。键是连接字段,值是行数据。
    2. 探测阶段 (Probe Phase): 扫描较大的那张表,对每一行计算连接字段的哈希值,去内存的哈希表中查找是否存在。
  • 性能关键点:
    • 内存(work_mem): 哈希表必须能装入内存。如果内存不够,数据库会把哈希表切分写入磁盘(临时文件),性能会急剧下降(你会看到 Disk: xxx kB)。
    • 只支持等值连接: 只能用于 ON a.id = b.id,不支持 ><
  • 适用场景:
    • 两张表都很大,且被驱动表上没有合适的索引。
    • 查询结果集很大,索引扫描产生的随机 I/O 代价太高。

3. 归并连接 (Merge Join / Sort Merge Join)

这是一种优雅的算法,前提是数据已经排好序

  • 工作流程:
    1. 如果不有序,先对两张表分别进行排序 (Sort)
    2. 使用双指针算法,同时遍历两张表。
    3. 如果 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 中看到以下情况时,可以尝试优化:

  1. 看到 Nested Loop 但很慢:
    • 检查被驱动表(Inner Table)的连接字段是否有索引。如果没有,数据库在疯狂做全表扫描。
  2. 看到 Hash Join 且带有 BatchesDisk
    • 说明内存不够用了,数据溢出到了磁盘。尝试调大 work_mem 参数,或者优化 WHERE 条件减少参与连接的数据量。
  3. 看到 Merge Join 之前有一个巨大的 Sort
    • 排序非常消耗 CPU 和内存。如果在连接字段建立索引,可以消除这个排序步骤,直接利用 Index Scan 进行 Merge Join。

3. 集合与聚合算子 (Aggregation & Set Nodes)

这类算子负责对数据进行去重、分组计算或合并多个结果集。

  • 聚合类:
    • Aggregate: 实现 COUNT, SUM, AVG 等。
    • GroupAggregate: 针对已排序的数据进行分组聚合。
    • HashAggregate: 针对未排序的数据,利用哈希表在内存中进行分组。
  • 集合类:
    • Unique: 对有序数据进行去重(如 DISTINCT)。
    • HashSetOp: 利用哈希表进行集合操作(如 INTERSECTEXCEPT)。
    • Append: 将多个子查询的结果集(如 UNION ALL)简单堆叠在一起。

在数据库执行计划中,集合与聚合算子(Aggregation & Set Nodes) 负责对扫描或连接后的"原材料"数据进行深加工。

简单来说:

  • 聚合算子是做"数学题"的(求和、计数、平均、分组)。
  • 集合算子是做"拼图"的(合并、交集、去重)。

第一部分:聚合算子 (Aggregation Nodes)

当你使用 GROUP BYCOUNTSUMAVG 等语句时,就会触发此类算子。数据库通常有两种策略来处理聚合:哈希(Hash)排序(Sort/Group)

1. HashAggregate (哈希聚合)

这是处理未排序数据最常用的聚合方式。

  • 工作原理(桶排序思想):
    1. 数据库在内存(work_mem)中创建一个哈希表。
    2. 扫描每一行数据,计算 GROUP BY 字段的哈希值。
    3. 将数据丢进对应的"桶"里,并实时更新聚合状态(例如:如果是 COUNT 就 +1,如果是 SUM 就累加)。
    4. 扫描结束后,遍历哈希表输出结果。
  • 优点: 不需要数据预先排序,速度通常很快。
  • 缺点: 非常吃内存。如果分组的数量太多(比如按 UserID 分组,有 100 万个用户),哈希表会撑爆内存,导致溢出到磁盘(Disk Spill),性能急剧下降。
  • 场景: 数据无序,且分组基数(Cardinality)适中。
2. GroupAggregate (分组聚合)

这是基于有序数据的聚合方式。

  • 工作原理(流水线思想):
    1. 前提: 输入的数据必须已经按 GROUP BY 字段排好序了(通常由下层的 Sort 算子或 Index Scan 保证)。
    2. 数据库逐行读取数据。
    3. 如果当前行的分组键和上一行一样,就累加;如果不一样,说明上一个组结束了,输出结果,开始下一个组。
  • 优点: 内存占用极低(只需要存当前这一组的状态),且可以流式输出(不用等所有数据读完就能出第一行结果)。
  • 缺点: 强依赖数据有序。如果数据本身没序,前面必须加一个昂贵的 Sort 算子。
  • 场景: GROUP BY 字段上有索引,或者数据量大到内存装不下哈希表。
3. Plain Aggregate (普通聚合)
  • 含义: 没有 GROUP BY,只算一个全局的总数。例如 SELECT COUNT(*) FROM table
  • 特点: 最简单,扫一遍全表,维护一个计数器即可。

第二部分:集合与去重算子 (Set & Unique Nodes)

当你使用 UNIONINTERSECTEXCEPTDISTINCT 时,会用到这些算子。

1. Append (追加)
  • 对应 SQL: UNION ALL
  • 工作原理: 极其简单。先把第一个子查询的结果吐出来,再把第二个子查询的结果吐出来。不做去重,不做排序。
  • 性能: 极快 。只要不需要去重,尽量用 UNION ALL 代替 UNION
2. Unique (去重)
  • 对应 SQL: DISTINCTUNION(不带 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): 处理 LIMITOFFSET,达到行数后立即停止下层算子。
  • 锁定 (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 的堆。这比全排快得多。
  • 性能警报:

    如果在 EXPLAIN 中看到 Sort Method: external merge Disk: 25000kB,说明内存不够用了。

    • 优化: 调大 work_mem,或者建立索引(索引本身就是有序的,可以消除 Sort 算子)。

2. 限制算子 (Limit Node)

它是查询优化的"刹车片"。

  • 功能: 对应 SQL 中的 LIMITOFFSET
  • 工作原理: 它像一个计数器,紧盯着下层算子吐出来的数据。一旦拿到了指定的行数(比如 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 UPDATEFOR 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 的并行模型:

  1. Leader Process (领队进程):
    • 这是你连接数据库的那个主会话进程。
    • 它负责制定计划、分配任务、启动 Worker、收集结果,并把最终结果返回给客户端。
  2. Worker Processes (工人进程):
    • 由 Leader 动态启动。
    • 它们执行计划中标记为 Parallel 的部分(如扫描、聚合、连接)。
    • 它们通过动态共享内存 (Dynamic Shared Memory, DSM) 与 Leader 交换数据。

2. 关键算子详解

在执行计划树中,并行部分通常位于树的下半部分 ,顶部总会有一个 Gather 类的节点作为分界线。

A. Gather (汇总算子)

这是并行执行的总出口,也是 Leader 进程主要工作的地方。

  • 功能:
    1. 启动 N 个 Worker 线程。
    2. 等待所有 Worker 把数据处理完。
    3. 把 Worker 传回来的数据(以及 Leader 自己处理的一部分数据)合并在一起。
    4. 向上层算子输出非并行的结果流。
  • 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:
    1. Shared Hash Table: 所有 Worker 共同在共享内存中构建同一个巨大的哈希表。
    2. 协同探测: 构建完成后,所有 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 ...)

解读(自下而上):

  1. Parallel Seq Scan: 表被分成了很多块,3 个进程(1 个 Leader + 2 个 Workers)同时去抢着扫描。

  2. Partial Aggregate (部分聚合): 每个 Worker 扫完自己那部分数据后,先在本地算一个 count(比如 Worker A 算出 3000 万,Worker B 算出 3500 万)。

  3. Partial Aggregate (部分聚合):工人(Worker)在干活。每个人只算自己手头那一小堆数据的"小账"。

    Aggregate / Finalize Aggregate (最终聚合):老板(Leader)在干活。他把工人们报上来的"小账"加在一起,算出"总账"。

  4. Gather: Leader 进程把这 3 个部分结果收上来。

  5. Finalize Aggregate (最终聚合): Leader 把收上来的 3 个数字加在一起,得到最终的 1 亿,返回给用户。


4. 并行算子的"坑"与调优

虽然并行很快,但它不是银弹,使用时需注意:

  1. 启动成本: 启动 Worker 进程是有开销的。如果查询本身只需 10ms,开启并行可能反而要花 20ms。PostgreSQL 会通过 min_parallel_table_scan_size 参数自动判断表够不够大,小表不会走并行。
  2. 内存倍增风险 (work_mem):
    • 切记: work_mem 是限制每个进程的内存。
    • 如果你设置 work_mem = 1GB,并启动了 4 个 Worker。那么这个查询理论上最高可能消耗 (4 + 1) * 1GB = 5GB 内存。容易导致 OOM(内存溢出)。
  3. 写操作限制: 目前,并行查询主要用于 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 是个有趣的混合体:

  1. 阶段一(阻塞): 构建哈希表(Build Hash Table)。
    • 数据库读取**右表(内表)**的所有数据。此时,查询处于"卡顿"状态,没有任何输出。
  2. 阶段二(非阻塞): 探测(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

在使用之前,你必须知道你手里拿的是哪一把"手术刀"。不同的参数组合,看到的深度完全不同。

  1. Level 1: EXPLAIN SELECT ... (静态推演)
    • 发生了什么: 数据库没有执行 SQL。它只是根据统计信息(Statistics)"脑补"了一个计划。
    • 能看什么: 优化器打算怎么做、预估的成本(Cost)、预估的行数。
    • 缺点: 它是猜的。如果统计信息过期,看到的计划可能完全是错的。
    • 适用场景: SQL 跑得太慢不敢运行,或者涉及 DELETE/UPDATE 不想弄脏数据。
  2. Level 2: EXPLAIN (ANALYZE) SELECT ... (实战复盘)
    • 发生了什么: 数据库真的执行了 SQL(注意:如果是修改语句,数据真的会变!)。
    • 能看什么: 除了预估值,还能看到实际耗时(Actual Time)实际行数(Actual Rows)循环次数(Loops)
    • 核心价值: 对比"预估"和"实际"的差异,这是调优的根基。
  3. Level 3: EXPLAIN (ANALYZE, BUFFERS) SELECT ... (IO 透视) ------ 最推荐!
    • 发生了什么: 在执行的基础上,统计了内存和磁盘的交互
    • 能看什么: 数据是从内存(Shared Buffers)读的,还是从硬盘(Disk)读的。
    • 核心价值: 数据库慢,90% 是因为 IO。不看 Buffers 就无法精准定位 IO 瓶颈。
  4. 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: 最先执行,扫描小表)

真实执行逻辑:

  1. 先扫描 small_table t2
  2. 将 t2 的数据构建成一个内存哈希表(Hash 节点)。
  3. 扫描 large_table t1
  4. 每扫描一行 t1,就去哈希表里比对(Hash Join)。
  5. 比对成功的结果,交给 Sort 节点排序。
  6. 排序完返回给用户。

第三维度:核心参数深度解码

我们来看一行典型的输出,把它像拆炸弹一样拆解开:

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. 预估部分 (括号第一部分)

  1. cost=0.00..188.00 (代价)
    • 单位: 这是一个抽象值,没有单位(通常 1.0 代表读取一个磁盘页的代价)。
    • 0.00 (Startup Cost - 启动代价): 拿到第一行 数据前需要多长时间。
      • Seq Scan 是 0,因为我们要的第一行就在第一页。
      • Sort 节点这里会很大,因为它必须把所有数据排完序才能吐出第一行。
    • 188.00 (Total Cost - 总代价): 拿到所有数据需要的总代价。优化器(Planner)就是凭这个数字选路,它会选 Total Cost 最小的那条路。
  2. rows=1000 (预估行数)
    • 优化器根据统计信息(pg_statistic)猜出来的。
    • 重要性: 它是决定走 Nested Loop 还是 Hash Join 的关键。如果这里猜错了,计划就会选错。
  3. width=45 (行宽度)
    • 平均每一行数据占用 45 字节。
    • 重要性: rows * width = 预估的总数据量。这决定了需不需要把数据写到临时文件(Disk Spill),因为内存(work_mem)是有限的。

B. 实际执行部分 (括号第二部分,仅在 ANALYZE 模式下出现)

  1. actual time=0.006..2.500 (实际时间)

    • 单位: 毫秒 (ms)。
    • 0.006 (Start Time): 拿到第一行花了 0.006ms。
    • 2.500 (Total Time): 平均每次循环拿到所有数据花了 2.5ms。
    • 坑点: 如果 loops > 1,真实总时间 = Total Time * loops
  2. rows=1200 (实际行数)

    • 真实返回了多少行。
  3. 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

这是判断性能瓶颈的金标准:

  1. Shared Hit (内存命中):
    • 数据直接从 PostgreSQL 的共享内存(Shared Buffers)里拿到了。
    • 评价: 很好,极快。我们希望 Hit 越高越好。
  2. Read (磁盘读取):
    • 内存里没有,必须向操作系统申请从磁盘读。
    • 评价: 慢。如果 Read 很高,说明内存不够用,或者索引没建好导致扫描了太多冷数据。
  3. Dirtied (脏页):
    • 查询过程中,发现数据页被修改了(通常是未提交的事务),需要标记为脏页。
    • 评价:SELECT 查询中不应该大量出现。
  4. 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)

逐行侦探分析:

  1. 看最下面 (users): Seq Scan on users。扫了 users 表,过滤 age > 20,剩 100 行。速度挺快 (3ms),没大毛病。
  2. 看中间 (Hash): 把这 100 个 user 放入内存哈希表。内存只用了 9kB,很健康。
  3. 看下面 (orders) - [警报 2 & 3]:
    • Seq Scan on orders
    • Filter: status='paid'
    • Rows Removed by Filter: 100000
    • 解读: 为了找 'paid' 的订单,全表扫描并扔掉了 10 万行废数据!
    • 优化: 应该在 orders(status) 上加索引。
  4. 看 Join (Hash Join):
    • actual rows=10000。Join 完有 1 万行数据。
  5. 看排序 (Sort) - [警报 1]:
    • Sort Method: external merge Disk: 800kB
    • 解读: 这里的 1 万行数据要排序,但是内存不够用了,数据溢出到了磁盘(Disk)。这严重拖慢了速度。
    • 优化: 调大 work_mem,或者给 orders(user_id, status, create_time) 加复合索引,可能直接消除排序。
  6. 看顶层 (Limit): 取了前 10 条。

总结

看执行计划,本质上是在回答三个问题:

  1. 数据怎么找的? (Scan: 是傻傻的扫全表,还是聪明的查索引?)
  2. 数据怎么连的? (Join: 是双层循环,还是哈希匹配?)
  3. 资源够不够? (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)......搜索空间是指数级爆炸的

优化器主要考虑三个维度的组合:

  1. 访问路径 (Access Path):
    • 表 A:是用全表扫描,还是用索引 X,还是用索引 Y?
  2. 连接顺序 (Join Order):
    • 是先 A Join B,结果再 Join C?
    • 还是先 B Join C,结果再 Join A?
  3. 连接算法 (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。


总结:为什么优化器有时候会"犯傻"?

了解了原理,你就知道为什么数据库有时候会选错计划(比如选了全表扫描而不走索引):

  1. 统计信息过期: 数据变了,但没运行 ANALYZE,优化器以为表是空的,结果表里有 1 亿行。它会错误地选择 Nested Loop,导致系统卡死。
  2. 数据相关性 (Correlation): 优化器默认假设列与列之间是独立的。
    • 比如查询"省份=湖北 AND 城市=武汉"。
    • 优化器觉得这两个概率要相乘(0.03 * 0.01 = 0.0003),认为结果极少。
    • 实际上这两个条件是强相关的,结果很多。估算错误导致选错索引。
  3. 代价模型偏差: 传统的 Cost 模型主要看 IO。现在的 SSD 很快,有时候随机读(Index Scan)比顺序读(Seq Scan)快,但旧的代价参数可能还觉得机械硬盘的随机读很慢,从而不敢用索引。

一图胜千言

可以将整个过程想象成一个漏斗:

  1. SQL 文本 (输入)
  2. Parser (语法树)
  3. Rewriter (逻辑优化后的树)
  4. Planner (生成 1000 个路径 -> 估算 Cost -> 动态规划剪枝 -> 剩 1 个路径)
  5. Executor (执行选定的那个计划)

Hash Join的Buckets和Batches

在 PostgreSQL 的 EXPLAIN (ANALYZE) 输出中,BucketsBatches 是 Hash Join(哈希连接)算子下两个至关重要的参数。它们直接揭示了你的查询是**"在内存里飞"** ,还是**"在硬盘里爬"**。

简单总结:

  • Buckets (桶): 是哈希表的**"房间数"**。它决定了 CPU 查找的效率(解决哈希冲突)。
  • Batches (批次): 是哈希表的**"分身数"** 。它决定了内存够不够用(解决内存溢出)。这是性能杀手。

1. Buckets (桶) ------ 内存里的"门牌号"

Hash Join 的第一步是构建哈希表(Build Phase)。数据库会申请一块内存,把这块内存划分为 N 个槽位,每个槽位就是一个 Bucket

  • 工作原理:

    1. 数据库读取内表(Build Table)的一行数据。
    2. 对连接键(Join Key)算出一个哈希值。
    3. 哈希值 % Bucket总数 算出这行数据该进哪个桶。
    4. 把数据挂在这个桶的链表后面。
  • 理想情况:

    每个桶里只有 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(分批)机制 ,也就是把大象切块装进冰箱

  • 工作原理(分治法):
    1. 切分: 数据库根据哈希值,将内表(Build Table)和外表(Probe Table)切分成 N 个 Batches
    2. 驻留与落盘:
      • Batch 0:留在内存里,立刻开始做连接。
      • Batch 1 ~ N :内存放不下了,写到磁盘临时文件(Temp Files)里
    3. 轮询: 等 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 ...

分析:

  1. Buckets: 65536
    • 桶很多,说明数据量不小,PostgreSQL 试图分散哈希冲突。
  2. Batches: 8
    • 报警! 这意味着数据被分成了 8 份。
    • 第 1 份在内存处理了,剩下 7 份被写到了磁盘上。
  3. Disk: 12000kB
    • 为了做这个 Join,数据库临时写了 12MB 的数据到硬盘。
  4. 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 谁才是真正的"亲人"?

步骤如下:

  1. 算哈希: 拿外表数据的 Join Key(比如 ID=100),算出哈希值 -> Bucket 1
  2. 找桶: CPU 定位到内存中的 Bucket 1。
  3. 遍历链表(关键步骤):
    • 看第一个节点 (Row A): 它的哈希值匹配,但数据库不敢确信 。必须拿出原始值比对:Outer.ID (100) == Inner.ID (10) 吗?不相等。跳过。
    • 看第二个节点 (Row B): 拿出原始值比对:Outer.ID (100) == Inner.ID (100) 吗?相等! 匹配成功,返回结果。
    • 看第三个节点 (Row C): ...

结论: 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 分布极度不均匀(倾斜严重),它可能会:

  1. 放弃 Hash Join,改用 Merge Join 或 Nested Loop。
  2. 或者在 Hash Join 之前,对高频值做特殊处理(但这比较复杂)。

5. 怎么避免冲突?(数据库在做什么)

虽然我们改变不了数据,但数据库内核在努力减少"意外冲突":

  1. 优秀的哈希算法: PostgreSQL 使用 Jenkins HashMurmurHash 等算法,确保哈希值像"撒胡椒面"一样均匀分布,避免不同的值算出一样的哈希。
  2. 动态扩容 (Resizing): 如果发现数据量比预期的多,导致 Bucket 不够用了(比如每个桶都挂了 5 个节点),数据库可能会重新申请更大的内存,把 Bucket 数量翻倍(比如从 1024 扩到 2048),然后把数据重新撒一遍(Rehash)。
    • 注:PostgreSQL 的 Hash Join 更多是通过 Batches(分批)来解决内存不足,通过预估足够大的 Buckets 来解决冲突。

总结

Hash Join 遇到冲突时:

  1. 存:链表把冲突的数据串起来。
  2. 取: 遍历链表,逐个进行原始值比较 (Recheck)
  3. 代价: 冲突越多,链表越长,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;

你想要一个汇总的数字

  1. Parallel Seq Scan: 大家分头去数票(动作是并行的)。
  2. Partial Aggregate: 每个人先把手里的票数加一加,记在小本本上(结果是局部的)。
    • 如果不做这一步,每个人都要把几百万张选票扔给领导,领导会被砸死。
  3. Gather: 领导把小本本收上来。
  4. 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;

你只想要原始数据,不需要汇总。

  1. Parallel Seq Scan: 大家分头去找 id > 1000 的行。
  2. Gather: 找到一行,就直接扔给领导。
  3. Result: 领导直接发给用户。

执行计划长这样:

sql 复制代码
Gather                      <-- 2. 收原始票据
  -> Parallel Seq Scan      <-- 1. 分头找票 (只有Parallel,没有Partial)

为什么这里没有 Partial?

因为不需要计算 SUMCOUNT,不需要"中间状态"。工人找到的就是最终需要的行。


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

  1. App 请求数据: SQL 发给数据库。
  2. DB Cache (Shared Buffers): 数据库先查自己的内存(书桌)。
    • Hit: 直接返回。速度最快(纳秒级)。
    • Miss: 需要去读文件。
  3. System Call (read): 数据库发起系统调用,请求操作系统读文件。
  4. OS Cache (Page Cache): 操作系统查自己的内存(手推车)。
    • Hit: 操作系统发现这页数据刚好在内存里(可能是刚才有人读过,或者刚写进去还没落盘)。直接拷贝给数据库。
    • Miss: 操作系统真的去读硬盘。
  5. Disk I/O: 硬盘旋转,磁头寻道,把数据读入 OS Cache。
  6. 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,来做它的"二级缓存"。
  • 优点: 代码简单,利用了操作系统成熟的预读(Prefetching)算法。
  • 缺点: 存在双重缓存带来的内存浪费和 CPU 拷贝开销。

4. 其他数据库的策略:Direct I/O (绕过)

很多其他数据库(如 Oracle, MySQL InnoDB)倾向于一种更霸道的做法:O_DIRECT

  • Direct I/O: 数据库告诉操作系统:"我要读写文件,但你别管闲事,别给我做缓存,直接把硬盘数据读到我的内存里。"
  • 效果:
    • 消除了双重缓存: 数据只存在于 DB Buffer Pool 中。
    • 内存利用率高: 数据库可以把 80% 甚至 90% 的物理内存都据为己有。
  • 代价: 数据库必须自己实现极其复杂的缓存管理、预读算法和刷盘策略。因为一旦离开了 OS 的保护,自己必须全权负责。

5. 写入与刷盘 (Write & Fsync)

在写入数据时,两者的关系更加微妙,关乎数据安全

  1. DB Write: 数据库修改了 Buffer Pool 里的数据(变成了脏页 Dirty Page)。此时数据还在用户空间内存。
  2. OS Write: 数据库调用 write()。操作系统把数据从 DB Cache 拷贝到 OS Cache。注意:此时数据还没到硬盘! 如果这时候拔电源,数据就丢了。
  3. 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。 大小固定(通常启动时分配)。
适用场景 文件服务器、通用计算。 复杂的查询处理。