用生活中最直观的例子,彻底搞懂 Merge Join 是什么、为什么快、什么时候用。
一、先从生活场景开始
场景一:两摞乱序试卷找同学
期末考试,老师手里有两摞试卷:
- A 摞 :数学试卷,500 份,乱序堆放
- B 摞 :语文试卷,500 份,乱序堆放
现在要找出同一个同学的两份试卷配成一对。
笨办法(Nested Loop):
拿起数学试卷第1份(张三)→ 翻遍500份语文找张三 → 找到!
拿起数学试卷第2份(李四)→ 翻遍500份语文找李四 → 找到!
...
总共翻了:500 × 500 = 25万次
聪明办法:先排序,再合并(Merge Join):
第一步:把两摞试卷都按学号排好序(001~500)
第二步:左手拿A摞,右手拿B摞,同时从第一份开始翻:
左手=001号, 右手=001号 → 匹配!配成一对,两手同时往后翻一张
左手=002号, 右手=003号 → 左边小,只翻左手
左手=003号, 右手=003号 → 匹配!两手同时往后翻一张
左手=004号, 右手=005号 → 左边小,只翻左手
...
总共翻了:500 + 500 = 1000次
结论:排完序之后,各翻一遍就结束,两摞试卷绝不回头。
二、Merge Join 核心原理
双指针扫描
Merge Join 本质是对两个有序序列用双指针做合并,规则非常简单:
指针A → [1, 3, 5, 7, 9]
指针B → [2, 3, 6, 7, 8]
规则:
A == B → 匹配,两指针同时右移
A < B → A指针右移
A > B → B指针右移
任意一方到头 → 结束
逐步演示:
步骤1:A=1, B=2 → 1<2,A右移
步骤2:A=3, B=2 → 3>2,B右移
步骤3:A=3, B=3 → 匹配!✅ 双方右移
步骤4:A=5, B=6 → 5<6,A右移
步骤5:A=7, B=6 → 7>6,B右移
步骤6:A=7, B=7 → 匹配!✅ 双方右移
步骤7:A=9, B=8 → 9>8,B右移
步骤8:A=9, B=结束 → 扫描结束
总步骤数:8次(远小于 5×5=25次)
两个序列各走一遍,绝不回头,这就是 O(N+M) 的来源。
三、时间复杂度分析
3.1 有索引时:O(N + M)
B-Tree 索引本身就是按 Key 排好序的有序结构。
索引结构(简化示意):
key=001 → 行指针
key=002 → 行指针
key=003 → 行指针
...(天然有序!)
PostgreSQL 直接沿着两个索引做双指针扫描,无需临时排序,直接 O(N+M)。
3.2 无索引时:O(N·logN + M·logM)
没有索引,PostgreSQL 必须先把数据排序:
第一步:对 N 行数据排序 → O(N·logN)
第二步:对 M 行数据排序 → O(M·logM)
第三步:双指针合并 → O(N+M)
总计:O(N·logN + M·logM)
举例:N = M = 500万
有索引:500万 + 500万 = 1000万次操作
无索引:500万×23 + 500万×23 + 1000万 ≈ 2.4亿次操作
没有索引时代价远超 Hash Join,规划器通常不会选 Merge Join。
四、Merge Join vs Hash Join:选哪个?
这是实际工作中最常见的疑问,一张表说清楚:
| 对比项 | Hash Join | Merge Join |
|---|---|---|
| 需要排序 | ❌ 不需要 | ✅ 必须有序(靠索引) |
| 额外内存 | 需要把小表装进 HashMap | 几乎为零(只有两个指针) |
| 有索引时 | 不一定用索引 | 直接复用索引,O(N+M) |
| 无索引时 | O(N+M),照样快 | 需先排序,代价大 |
| 内存不够时 | 会写临时磁盘,变慢 | 不受内存限制 |
| 规划器偏好 | 通用首选 | 有合适索引时才考虑 |
一句话记忆
没有索引 → Hash Join(建字典,O(N+M))
有排序索引 → Merge Join(双指针,O(N+M),且不占内存)
内存紧张 → 优先 Merge Join(不需要建 HashMap)
五、用 EXPLAIN 看规划器的选择
sql
EXPLAIN ANALYZE
SELECT a.orde_id
FROM orders a
JOIN orders b
ON a.orde_id = b.orde_id
AND b.version_number = '20260522000707011'
WHERE a.version_number = '20260521172049432';
执行计划输出示例:
-- 没有索引时,规划器选 Hash Join:
Hash Join (cost=...)
Hash Cond: (a.orde_id = b.orde_id)
-> Seq Scan on orders a (...) ← 全表扫描
-> Hash
-> Seq Scan on orders b (...) ← 建 HashMap
-- 有 (version_number, orde_id) 联合索引时,规划器可能选 Merge Join:
Merge Join (cost=...)
Merge Cond: (a.orde_id = b.orde_id)
-> Index Scan using idx_a on orders a (...) ← 走索引,已有序
-> Index Scan using idx_b on orders b (...) ← 走索引,已有序
看到 Merge Join + Index Scan 的组合,说明规划器充分利用了索引的有序性。
六、什么情况下 Merge Join 会"失效"
6.1 Join Key 数据类型不一致
sql
-- a.id 是 INT,b.id 是 VARCHAR
ON a.id = b.id -- 需要隐式类型转换,破坏有序性,无法 Merge Join
6.2 索引列被函数包裹
sql
ON LOWER(a.name) = LOWER(b.name) -- 走不了索引,无法 Merge Join
6.3 非等值 Join
sql
ON a.price > b.price -- Merge Join 只支持等值连接(=)
6.4 数据分布极度不均匀
某个 Key 值有几百万重复值(低基数),双指针在这个值上会反复扫描,退化成 O(N×M)。
七、总结
Merge Join 的本质
两个有序序列 + 双指针 = 各走一遍,O(N+M)
什么时候 Merge Join 最好用
✅ Join Key 上双方都有 B-Tree 索引
✅ 内存资源紧张,不想建 HashMap
✅ 大范围顺序扫描场景
什么时候不要指望 Merge Join
❌ 没有索引(需临时排序,代价大)
❌ 非等值 Join
❌ Join Key 被函数处理过
❌ 数据低基数(大量重复值)
三种策略终极对比
| Nested Loop | Hash Join | Merge Join | |
|---|---|---|---|
| 比喻 | 两层 for 循环 | 建字典查字典 | 双指针合并有序列表 |
| 复杂度 | O(N × M) | O(N + M) | O(N + M) |
| 内存 | 低 | 中 | 极低 |
| 依赖索引 | 内表需要 | 不需要 | 必须 |
| 适合场景 | 外表极小 | 大表通用 | 大表+双方有索引 |
核心记忆:Merge Join = 先排好序,再双指针各走一遍。排序靠索引,索引有了就免费,索引没有就代价大。