数据库如何执行 JOIN?Nested Loop、Hash Join、Merge Join 三种算法的原理和选择策略。

建议先阅读:SQL JOIN 执行过程深度拆解

算法快速对比

算法最佳复杂度最坏复杂度适用场景关键条件
Nested LoopO(M × log N)O(M × N)小表 JOIN 大表大表有索引
Hash JoinO(M + N)O(M + N)两个大表内存能容纳小表
Merge JoinO(M + N)O(M log M + N log N)已排序数据有排序索引或预排序

M = 驱动表行数,N = 被驱动表行数

想象你在电影数据库里查询”每部电影有哪些演员”。movies 表 4 行,actors 表 6 行。数据库会如何完成这个 JOIN?答案取决于它选择的算法。

Nested Loop:最朴素的双层循环

这是最容易理解的方式,就像你用笔在纸上对比两张表。

for each row in movies:              # 外层循环(驱动表)
    for each row in actors:          # 内层循环(被驱动表)
        if movies.id == actors.movie_id:
            output (movies.title, actors.name)

取第一部电影 “Inception”,扫描 actors 表全部 6 行,找到 Leonardo DiCaprio 和 Anne Hathaway。再取第二部电影 “The Dark Knight”,又扫描 actors 表全部 6 行,找到 Christian Bale。如此反复。

成本:4 × 6 = 24 次比较

但如果 actors.movie_id 有索引呢?

1. 取 movies[0] (Inception, id=1)
   → 索引查找 actors.movie_id = 1(log(6) ≈ 2-3 次) → 找到 2 个
2. 取 movies[1] (id=2)
   → 索引查找(log(6) ≈ 2-3 次) → 找到 1 个
...

成本降至:4 × log(6) ≈ 10 次比较

索引让 Nested Loop 从灾难变成可用。没有索引时,它是 O(M × N) 的噩梦;有索引时,它变成 O(M × log N) 的优雅方案。

Hash Join:用空间换时间

两张表都很大,内存又足够,数据库会选择另一种策略。

Build Phase:先把小表 movies 全部读入内存,构建一个哈希表:

Hash Table (以 movies.id 为 key):
┌─────┬─────────────────────────────┐
│ Key │ Value                       │
├─────┼─────────────────────────────┤
│ 1   │ { title: 'Inception' }      │
│ 2   │ { title: 'The Dark Knight' }│
│ 3   │ { title: 'Interstellar' }   │
│ 4   │ { title: 'Tenet' }          │
└─────┴─────────────────────────────┘

Probe Phase:遍历 actors 表,每一行直接查哈希表(O(1) 时间):

1. Leonardo DiCaprio (movie_id=1) → hash[1] = 'Inception' ✅
2. Christian Bale (movie_id=2) → hash[2] = 'The Dark Knight' ✅
3. Matthew McConaughey (movie_id=3) → hash[3] = 'Interstellar' ✅
4. Anne Hathaway (movie_id=3) → hash[3] = 'Interstellar' ✅
5. Anne Hathaway (movie_id=1) → hash[1] = 'Inception' ✅
6. Tom Hardy (movie_id=NULL) → hash[NULL] 不存在 ❌

成本:4 次构建 + 6 次探测 = 10 次操作,O(M + N) 线性复杂度

不需要索引,不需要反复扫描,每张表只读一遍。代价是内存:小表必须能完整装入哈希表。

Merge Join:双指针的优雅舞步

如果两张表已经排序(或有排序索引),数据库会选择最省内存的方案。

两张表先按 JOIN 键排序:

movies 按 id 排序:
[1-Inception, 2-The Dark Knight, 3-Interstellar, 4-Tenet]

actors 按 movie_id 排序:
[1-Leonardo, 1-Anne, 2-Christian, 3-Matthew, 3-Anne, NULL-Tom]

然后用两个指针同时往前走:

pointer_m = movies[0]  (id=1, Inception)
pointer_a = actors[0]  (movie_id=1, Leonardo)

迭代 1:movies.id (1) == actors.movie_id (1) ✅
  → 输出 (Inception, Leonardo)
  → pointer_a++

迭代 2:movies.id (1) == actors.movie_id (1) ✅
  → 输出 (Inception, Anne)
  → pointer_a++

迭代 3:movies.id (1) < actors.movie_id (2)
  → pointer_m++
  现在 movies.id=2, actors.movie_id=2

迭代 4:movies.id (2) == actors.movie_id (2) ✅
  → 输出 (The Dark Knight, Christian)
  → pointer_a++

... 继续扫描直到两个指针都到达末尾

成本:4 + 6 = 10 次操作,O(M + N) 线性复杂度

像拉链一样合并两个有序数组,不需要哈希表,内存占用小。但前提是数据已排序,否则排序本身会花费 O(M log M + N log N) 的时间。

数据库如何选择算法

优化器(Optimizer)像一个经验丰富的导演,评估现场条件后选择最佳方案:

条件选择算法关键因素
小表 JOIN 大表 + 有索引Nested LoopO(M × log N),索引是救命稻草
两个大表 + 内存足够Hash JoinO(M + N),用空间换时间
有排序索引Merge JoinO(M + N),最省内存
什么都没有Nested Loop 全表扫描O(M × N),祈祷吧 ☠️

优化器会评估:表大小、是否有索引、是否已排序、可用内存、JOIN 列的数据分布,然后选择预估成本最低的算法。这就是为什么同一条 SQL,数据量变化后可能走不同算法。