执行计划里出现的Nested Loop和Hash Join是PostgreSQL处理多表连接最常用的两种物理算子。不少慢SQL的根因并不在索引缺失,而是优化器对连接方式的选择和实际情况不匹配。本文将从执行原理、代价模型和实际案例三个方面,详细剖析这两种连接方式的差异与选择依据。

一、Nested Loop的执行原理与适用场景
Nested Loop(嵌套循环)是最朴素的连接算法:对外表(驱动表)的每一行,都去内表(被驱动表)中查找匹配行。伪代码可以写成:
FOR each row r1 IN outer_table LOOP
FOR each row r2 IN inner_table LOOP
IF r1.id = r2.id THEN
output (r1, r2);
END IF;
END LOOP;
END LOOP;如果内表没有索引,这个双层循环的代价是O(N*M),当两边都有几十万行数据时,会产生数十亿次比较,查询基本不可用。但PostgreSQL并不会傻傻地全表扫内表,它通常会配合索引扫描(Index Scan)或位图扫描,将内层的查找代价降到每次几页的IO。
因此Nested Loop真正适合的场景是:外表小、内表大且连接列上有高选择性索引。典型例子是根据一批ID关联明细表,驱动表只有几百行,每行通过索引精准命中内表的少量记录,总代价远低于把大表整体读一遍。
另外,Nested Loop是唯一天然支持非等值连接的算子。当连接条件是t1.a > t2.b这类不等式时,Hash Join无法使用,优化器只能选Nested Loop或Merge Join。这一点在写业务SQL时容易被忽略,导致看似简单的关联却跑出了糟糕的计划。
二、Hash Join的执行原理与适用场景
Hash Join分为两个阶段。第一阶段Build:把较小的那个输入表按连接键散列成一张哈希表放在内存中;第二阶段Probe:扫描另一张表,对每一行的连接键计算哈希值,直接到哈希表里找匹配的桶,无需逐行比较。
EXPLAIN ANALYZE SELECT o.order_id, c.customer_name FROM orders o JOIN customers c ON c.customer_id = o.customer_id;
从上面的执行计划输出中,通常会看到类似Hash Join (cost=... rows=...)和Hash (rows=...) -> Seq Scan on customers的结构,说明优化器选择了customers作为build端、orders作为probe端。
Hash Join的优势在于整表只需要各扫一遍,复杂度约等于O(N+M),对大表与大表的等值连接非常友好。它的代价主要花在建哈希表上,所以输入规模越大,优势越明显。但有两个限制需要注意:
- 只支持等值连接条件(
=),不等式连接无法哈希分桶; - 哈希表理想情况下装在内存里,由参数
work_mem控制,装不下时会分批处理(partition),产生额外IO,性能明显下降。
所以在统计信息准确、两表都比较大、连接列分布均匀时,优化器会倾向Hash Join。反之,如果某一侧数据量极小,Nested Loop加索引往往是更便宜的选择。
三、代价模型与优化器如何做选择
PostgreSQL的planner基于代价估算在几种连接算法之间挑选,核心输入是统计信息:pg_class.reltuples估算的表行数、pg_stats中的列分布(n_distinct、MCV、直方图)。评估连接方式时会粗略比较:Nested Loop代价约等于外表行数乘以单次内表索引查找代价;Hash Join代价约等于两边顺序扫描代价加上哈希构建与探测的CPU开销。
当优化器估算出错时,就会出现选错算子的情况。最常见的坑是统计信息过期或缺失:大批量导入数据后没有执行ANALYZE,planner以为表只有一万行,于是选了Nested Loop,实际有一千万行,查询直接跑几十分钟。此时先手动执行ANALYZE,或者检查autovacuum_analyze阈值配置,往往能立刻解决问题。
另一个常见坑是连接列上的数据倾斜或统计信息无法覆盖的复杂条件,例如连接条件套了一层函数ON upper(a.email) = upper(b.email),估算行数严重偏差。可以对比以下手段排查:
- 用
EXPLAIN (ANALYZE, BUFFERS)对比估算行数与实际行数,偏差超过一个数量级基本可以判定统计信息有问题; - 适当提高
default_statistics_target后重新ANALYZE,提升列统计精度; - 必要时使用
pg_hint_plan扩展或在会话级调整参数引导计划,例如临时关闭enable_hashjoin对比Nested Loop的真实代价。
四、实战优化建议与参数调优
第一,合理设置work_mem。Hash Join的哈希表、排序、聚合都吃这块内存。默认值4MB在分析类查询中明显偏小,容易触发磁盘临时文件。查看EXPLAIN ANALYZE输出末尾的Sort Method: external merge或哈希批次拆分的提示,如果出现,说明需要加大work_mem。注意该参数是每个排序、哈希操作各占一份,不是整个查询共享,建议按并发连接数估算总内存,避免OOM。
第二,让Nested Loop有索引可用。如果业务模式是随机取少量主键再关联大表,务必确认内表连接列上有B-tree索引。复合条件时,把连接列放进复合索引,并注意列顺序对可用等值条件的影响。没有索引的Nested Loop几乎等于灾难,这也是很多分页深查询变慢的隐形原因。
第三,控制驱动表大小。对于LIMIT分页加JOIN的查询,如果优化器先做Hash Join再LIMIT,会白白处理整表数据。可以尝试改写为子查询先取到LIMIT后的主键集合,再回表关联,把大连接变成小规模Nested Loop:
SELECT o.*, c.customer_name
FROM (
SELECT order_id, customer_id
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20
) o
JOIN customers c USING (customer_id);这种写法把先分页后关联的意图明确地交给优化器,通常能把执行时间从秒级降到毫秒级。
总结一下:小表驱动大表且有索引,选Nested Loop;大表等值连接,选Hash Join。理解这两种算子的边界,配合EXPLAIN ANALYZE持续验证估算是否准确,是做PostgreSQL SQL调优的基本功。当计划不符合预期时,先怀疑统计信息,再检查索引与内存参数,最后才考虑用hint类工具干预,这样排查路径最短也最不容易引入新问题。
PostgreSQLHash JoinNested Loop修改时间:2026-09-09 08:26:55