- Published on
深入 PostgreSQL Merge Join 执行器
- Authors

- Name
- Tails Azimuth
为什么需要 Merge Join
当两张表都已经在连接键上有序时,PostgreSQL 会优先选择 Merge Join(合并连接):它不需要把任何一张表完整地搬进内存,而是像合并两个有序链表一样,同时向前扫描两个输入流,按排序键对齐元组。
这让它特别适合两类场景:
- 输入本身有 B-tree 索引,天然有序;
- 大数据集的等值连接,且排序代价可以摊薄。
代价是:输入必须有序。如果规划器拿不到有序输入,就会在下面挂一个 Sort 节点——这个排序的开销有时会让 Merge Join 反而不如 Hash Join。
代码位置
| 内容 | 文件 |
|---|---|
| 算子实现 | src/backend/executor/nodeMergejoin.c |
| 头文件 | src/include/executor/nodeMergejoin.h |
| 执行器节点 | src/include/nodes/execnodes.h |
核心数据结构:MergeJoinState
执行时,每个 Merge Join 节点持有一个 MergeJoinState,其中最关键的几个字段:
typedef struct MergeJoinState {
JoinState js; /* 继承自 JoinState */
int mj_JoinState; /* 当前状态机状态 */
bool mj_FillOuter; /* 左/全外连接:填充无匹配的外层元组 */
bool mj_FillInner; /* 右/全外连接:填充无匹配的内层元组 */
bool mj_MatchedOuter;
bool mj_MatchedInner;
TupleTableSlot *mj_OuterTupleSlot;
TupleTableSlot *mj_InnerTupleSlot;
TupleTableSlot *mj_MarkedTupleSlot; /* Mark/Restore 的标记位 */
/* ... merge join 子句、比较函数等 ... */
} MergeJoinState;
mj_OuterTupleSlot/mj_InnerTupleSlot:当前正在比较的外层、内层元组。mj_MarkedTupleSlot:最关键的字段——记住内层流的某个位置,用于处理重复 join key。mj_FillOuter/mj_FillInner:外连接时,无匹配的一侧需要用 NULL 填充后输出。
状态机:11 个状态
Merge Join 的执行主体是一个状态机。每次 ExecMergeJoin() 被调用,根据 mj_JoinState 跳到对应分支,处理完一段逻辑后转移到下一个状态。
| 状态 | 职责 |
|---|---|
EXEC_MJ_INITIALIZE_OUTER | 取第一条外层元组 |
EXEC_MJ_INITIALIZE_INNER | 取第一条内层元组 |
EXEC_MJ_JOINTUPLES | 当前外/内元组匹配,执行连接并输出 |
EXEC_MJ_NEXTOUTER | 前进到下一条外层元组 |
EXEC_MJ_TESTOUTER | 用标记位测试新外层元组 |
EXEC_MJ_NEXTINNER | 前进到下一条内层元组 |
EXEC_MJ_SKIP_TEST | 比较两侧,跳过较小的一侧 |
EXEC_MJ_SKIPOUTER_ADVANCE | 跳过(填充)不匹配的外层元组 |
EXEC_MJ_SKIPINNER_ADVANCE | 跳过(填充)不匹配的内层元组 |
EXEC_MJ_ENDOUTER | 外层流耗尽 |
EXEC_MJ_ENDINNER | 内层流耗尽 |
核心算法
把状态机折叠成伪代码,主体结构是两层循环:
Join {
取初始的外层元组 outer 和内层元组 inner
do forever {
// 第一阶段:对齐——把较小的那一侧向前推进
while (outer != inner) {
if (outer < inner) 前进外层
else 前进内层
}
// 对齐成功,标记当前内层位置
mark = inner
do forever {
// 第二阶段:输出所有 outer == inner 的组合
while (outer == inner) {
输出 (outer, inner) 的连接结果
前进内层
}
前进外层
if (outer == mark) 恢复内层到标记位 // 重复 join key
else break // 进入下一轮对齐
}
}
}
Mark/Restore:为什么要标记内层?
考虑这样两组输入(连接键相等的元组要两两组合):
外层: ... 5 5 5 ...
内层: ... 5 5 ...
外层有 3 个 5,内层有 2 个 5,正确结果是 条记录。
算法的处理方式是:当第一个外层 5 对齐内层时,标记内层当前位置(mark)。把这个 5 和内层所有 5 配对后,外层前进到下一个 5——此时发现新的外层元组仍然等于 mark,就把内层指针恢复回标记位,再配对一遍。
没有 Mark/Restore,内层流已经被消费完,第二个外层 5 就找不到匹配了。这正是 mj_MarkedTupleSlot 存在的意义。
三种 Join 算法对比
| 特性 | Merge Join | Hash Join | Nested Loop |
|---|---|---|---|
| 输入要求 | 两侧都要有序 | 无 | 无 |
| 额外结构 | 无(需 Sort 配合) | 哈希表 | 无 |
| 内存占用 | 低 | 较高(建表侧) | 低 |
| 适合场景 | 大表 + 已有序 | 大表 + 小表 | 小表驱动大表 |
| 流式输出 | 是 | 否(需先建表) | 是 |
| 不等值连接 | 不支持 | 不支持 | 支持 |
一个经验法则:如果两侧都大、且能拿到有序输入,Merge Join 的常数因子和内存占用往往优于 Hash Join;否则 Hash Join 通常胜出。
小结
Merge Join 的精妙之处在于用状态机把"对齐 → 标记 → 配对 → 恢复"这套流程拆成可恢复的步骤,配合 Mark/Restore 机制优雅地处理重复 join key。理解这 11 个状态的转移,也就理解了 PostgreSQL 执行器处理有序连接的核心思路。
下次用 EXPLAIN 看到一个 Merge Join 节点下面挂着 Sort 时,不妨想想:规划器是相信排序的代价能换来流式、低内存的连接,值得吗?