bigywater.tech
PostgreSQL · 10

PostgreSQL Nested Loop、Hash Join、Merge Join 什么时候用哪个:work_mem 分批、外键没索引和 NOT IN 为什么慢

“PostgreSQL 的 Nested Loop、Hash Join、Merge Join 什么时候用哪个、NOT IN 为什么那么慢” 是读执行计划时绕不开的问题。这篇在本机自己起一个 PostgreSQL 16.15,用同一个连接分别强制三种算法比较耗时,再看 work_mem 对 Hash Join 的影响和 NOT IN 的陷阱。

0.04 ms 对 66.16 ms
外表只取 10 行时,计划器选的 Nested Loop 与强制 Hash Join 的耗时
Batches: 8
work_mem 从 64MB 降到 4MB 后 Hash Join 的分批数(1MB 时 32 批)
10 s 超时
NOT IN 的子查询有 100 万行、work_mem 4MB 时被 statement_timeout 取消;NOT EXISTS 只要 94.28 ms

三种算法

实测 · 本机执行输出
         N          计划器的选择     计划器 ms    只用 Nested Loop    只用 Hash Join   只用 Merge Join
        10     Nested Loop       0.04              0.03           66.16            0.03
      1000       Hash Join      72.69              2.85           76.01            2.35
    100000       Hash Join     160.67            550.43          144.82          359.68

小外表加内表索引用 Nested Loop;大批量用 Hash Join;两边有序且只要前一部分时 Merge Join 占便宜。

Hash Join 内存

实测 · 本机执行输出
  work_mem =  64MB:Buckets: 1048576  Batches: 1  Memory Usage: 43349kB;耗时 321.00 ms
  work_mem =   4MB:Buckets: 262144  Batches: 8  Memory Usage: 6446kB;耗时 337.76 ms
  work_mem =   1MB:Buckets: 65536  Batches: 32  Memory Usage: 1615kB;耗时 340.85 ms

哈希表放不进 work_mem 就分批写临时文件,内存用量随 work_mem 变化。

Merge Join

实测 · 本机执行输出
  limit 100000:计划器选 Merge Join,47.30 ms
  强制 Hash Join:Hash Join,357.60 ms(要先把整个结果排序)
  不带 limit(取全部 100 万行):计划器选 Merge Join,449.50 ms;强制 Hash Join 355.35 ms;强制 Merge Join 438.14 ms

ORDER BY 连接键加 limit 时,Merge Join 不必先排序,远快于 Hash Join。

内表索引

实测 · 本机执行输出
  有索引 b_a_id:Nested Loop,0.23 ms
  删掉 b_a_id 之后:Hash Join,64.97 ms

删掉外键列上的索引,Nested Loop 变成 Hash Join,慢两个数量级。

NOT IN 的陷阱

实测 · 本机执行输出
  work_mem =   4MB NOT IN              :结果 8002 行,12.06 ms;计划里:Filter: (NOT (hashed SubPlan 1))
  work_mem =   4MB NOT EXISTS          :结果 8002 行,10.82 ms;计划里:->  Hash Right Anti Join
  work_mem =   4MB LEFT JOIN … IS NULL :结果 8002 行,10.89 ms;计划里:->  Hash Right Anti Join
  work_mem =  64MB NOT IN              :结果 8002 行,11.33 ms;计划里:Filter: (NOT (hashed SubPlan 1))
  work_mem =  64MB NOT EXISTS          :结果 8002 行,10.96 ms;计划里:->  Hash Right Anti Join
  work_mem =  64MB LEFT JOIN … IS NULL :结果 8002 行,10.96 ms;计划里:->  Hash Right Anti Join
  子查询结果更大(b6 有 100 万行)、外表 a6 有 2000 行,每条语句限时 10 秒:
  work_mem =   4MB NOT IN              :10 s 后被 statement_timeout 取消(canceling statement due to statement timeout)
  work_mem =   4MB NOT EXISTS          :结果 800 行,94.28 ms;计划里:->  Hash Right Anti Join
  work_mem =  64MB NOT IN              :结果 800 行,99.00 ms;计划里:Filter: (NOT (hashed SubPlan 1))
  work_mem =  64MB NOT EXISTS          :结果 800 行,85.98 ms;计划里:->  Hash Right Anti Join
  往 b5 里插入一个 a_id = NULL 之后:NOT IN 查到 0 行,NOT EXISTS 查到 8002 行

子查询大到放不进哈希就退化得很慢,并且含 NULL 时一行都查不到;用 NOT EXISTS。

分步演示

JOIN 算法实测
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / PostgreSQL 16.15(Homebrew);每个实验自己 initdb 一个数据目录,端口 9261 起,只监听 127.0.0.1,不碰本机已有的 5432 上的实例;命令行工具统一用 LC_ALL=C 取英文原文,路径换成 ;计时用 time.perf_counter 或 EXPLAIN ANALYZE 的 Execution Time,数字每次运行会有小幅抖动;实验结束后整个数据目录删除。关掉并行查询,数据全在内存里;每条语句 EXPLAIN ANALYZE 取 Execution Time(多次执行取最小);三种算法的强制是关掉另外两种的 enable_* 开关。N = 1000 那一行计划器选了 Hash Join,而同一份实验的另一次运行里它选的是 Nested Loop(那次的输出没有保存)——这类交叉点附近的选择会随 ANALYZE 的随机采样变化
    • 没有验证:真实磁盘上 Hash Join 分批的代价;并行 Hash Join;外连接、半连接在各算法下的行为;多表连接的连接顺序(join_collapse_limit);JIT;PostgreSQL 其他大版本
    京ICP备2026067502号-1京公网安备11010502063487号