当前位置: 首页 > news >正文

PostgreSQL Join 执行策略(Nested Loop、Hash Join、Merge Join)与 NOT EXISTS 优化

以集成数据压缩 SQL 优化为例,用大白话讲清楚 Nested Loop、Hash Join、Merge Join 三种执行策略。


一、背景:一条慢 SQL 引发的思考

在对上游下发数据做压缩时,有这样一条 UPDATE SQL:

-- ❌ 原始写法UPDATEmagellan_nk_order_inboundSETversion_number=CONCAT(version_number,'_zip')WHEREversion_number='20260521172049432'ANDorde_idIN(SELECTa.orde_idFROMmagellan_nk_order_inbound aLEFTJOINmagellan_nk_order_inbound bONb.version_number='20260522000707011'ANDa.orde_id=b.orde_idWHEREa.version_number='20260521172049432'ANDb.orde_idISNULL);

这张表有几千万行数据,跑起来非常慢。问题根源就在于 PostgreSQL 对IN (子查询)选择了Nested Loop执行策略。


二、先搞清楚 N 和 M 是什么

后面讲复杂度时会用到 N 和 M,先把它们定义清楚:

变量含义举例
NJoin 左表(外表)过滤后的行数旧批次数据:500 万行
MJoin 右表(内表)过滤后的行数新批次数据:500 万行

三、三种 Join 执行策略

3.1 Nested Loop(嵌套循环)

原理

就是两层 for 循环,外表每扫一行,都去完整扫描一遍内表:

for 外表每一行 a: ← 循环 N 次 for 内表每一行 b: ← 循环 M 次 if a.id == b.id: 匹配!
时间复杂度
O(N × M)

举例:N = 500万,M = 500万

500万 × 500万 = 25,000,000,000,000 次(25万亿次比较)💀

即使内表有索引,内层变成二分查找,也是O(N × logM)

500万 × log(500万) ≈ 500万 × 23 ≈ 1.15亿次

比无索引好得多,但数据量越大越吃力。

适用场景
  • 外表结果集非常小(几十行、几百行)
  • 内表在 Join Key 上有索引
一句话总结

数据量小时很快,数据量大时是灾难。


3.2 Hash Join(哈希连接)

原理

分两个阶段,可以理解为"先建字典,再查字典":

第一阶段 —— Build(建字典):
小表的 Join Key 全部装进一个 HashMap(哈希表):

HashMap = { id_001: true, id_002: true, id_003: true, ... }

这一步扫描内表一次,共M 次操作

第二阶段 —— Probe(查字典):
遍历外表每一行,拿 Join Key 去 HashMap 里查,HashMap 查询是 O(1):

for 外表每一行 a: if a.id 在 HashMap 里? ← O(1) 查询 匹配!

这一步扫描外表一次,共N 次操作

时间复杂度
O(N + M)

举例:N = 500万,M = 500万

500万 + 500万 = 1000万次操作 ✅

与 Nested Loop 的25万亿次相比,快了约 250万倍

适用场景
  • 两张表数据量都较大
  • Join Key 上没有合适索引
  • 内存足够装下小表的 HashMap
一句话总结

大表 Join 的首选策略,用内存换时间。


3.3 Merge Join(归并连接)

原理

和归并排序思路一样,要求两张表都按 Join Key事先排好序,然后像拉链一样合并:

表A(已排序): 1, 3, 5, 7, 9 表B(已排序): 2, 3, 6, 7, 8 双指针同时移动,遇到相同值就匹配: A=1, B=2 → A前进 A=3, B=3 → 匹配!双方前进 A=5, B=6 → A前进 ...
时间复杂度
O(N + M) (不含排序开销) 如果需要临时排序:O(N·logN + M·logM)
适用场景
  • 两张表在 Join Key 上都有排序索引(B-Tree 索引本身就是有序的)
  • 大范围扫描场景
一句话总结

有序数据的最优解,依赖索引排序。


四、三种策略横向对比

对比项Nested LoopHash JoinMerge Join
复杂度O(N × M)O(N + M)O(N + M)
500万+500万25万亿次 💀1000万次 ✅1000万次 ✅
内存需求中(需装小表)
是否需要索引内表最好有索引不需要需要排序索引
最适合外表极小大表无索引 Join大表有排序索引
最不适合两张大表 Join内存不够时无索引时

五、为什么 NOT EXISTS 更容易触发 Hash Anti Join

5.1 什么是 Anti Join(反连接)

普通 Join 是找"匹配的行",Anti Join 是找"没有匹配的行",对应 SQL 里的:

  • NOT IN (子查询)
  • NOT EXISTS (子查询)
  • LEFT JOIN ... WHERE b.id IS NULL

这三种写法逻辑等价,但 PostgreSQL 规划器对它们的理解深度不同。

5.2 IN (子查询) 的问题

WHEREidIN(SELECTidFROM...)

PostgreSQL 在处理IN (子查询)时,早期版本会把它理解为:

"对外表每一行,去子查询里查一遍"

这天然就是 Nested Loop 的思维模式。虽然新版 PostgreSQL 会尝试把它转成 Hash Join,但受统计信息准确性影响,一旦估算行数偏差大,就可能选错策略。

5.3 NOT EXISTS 为什么更好

ANDNOTEXISTS(SELECT1FROM表BWHEREb.id=a.idANDb.version=#{newerBatch})

NOT EXISTS的语义对规划器来说更直接:

“把表B的数据建成 Hash 表,外表每一行去 Hash 表里查,没查到的就留下”

这正好就是Hash Anti Join的执行模式,规划器几乎不会选错。

5.4 三种等价写法的规划器友好度

-- 写法1:LEFT JOIN + IS NULL(最不友好)FROMaLEFTJOINbONa.id=b.idWHEREb.idISNULL-- 规划器需要推断"IS NULL 意味着没匹配",统计信息偏差时容易走 Nested Loop-- 写法2:NOT IN(较不友好)WHEREa.idNOTIN(SELECTb.idFROMb)-- 还有 NULL 值陷阱:如果子查询结果包含 NULL,整个 NOT IN 结果为空!-- 规划器处理 NULL 语义时额外保守-- 写法3:NOT EXISTS(最友好)✅WHERENOTEXISTS(SELECT1FROMbWHEREb.id=a.id)-- 语义最明确,规划器直接映射为 Hash Anti Join-- 无 NULL 陷阱(子查询返回 NULL 行不影响结果)

六、本次优化对比

优化前

-- countOverlap:LEFT JOIN + IS NOT NULLSELECTCOUNT(a.orde_id)FROMmagellan_nk_order_inbound aLEFTJOINmagellan_nk_order_inbound bONb.version_number=#{newerBatch}ANDa.orde_id=b.orde_idWHEREa.version_number=#{olderBatch}ANDb.orde_idISNOTNULL-- ← 语义绕,规划器理解成本高-- updateZipBatchNumber:IN (子查询 + LEFT JOIN + IS NULL)WHEREversion_number=#{olderBatch}ANDorde_idIN(SELECTa.orde_idFROM...LEFTJOIN...WHEREb.idISNULL-- ← 双重绕弯)

优化后

-- countOverlap:改为 INNER JOIN,语义直接SELECTCOUNT(a.orde_id)FROMmagellan_nk_order_inbound aJOINmagellan_nk_order_inbound bONb.version_number=#{newerBatch}ANDa.orde_id=b.orde_idWHEREa.version_number=#{olderBatch}-- 规划器直接选 Hash Join ✅-- updateZipBatchNumber:改为 NOT EXISTSWHEREversion_number=#{olderBatch}ANDNOTEXISTS(SELECT1FROMmagellan_nk_order_inbound bWHEREb.version_number=#{newerBatch}ANDb.orde_id=magellan_nk_order_inbound.orde_id)-- 规划器直接选 Hash Anti Join ✅

七、额外加速:创建合适的索引

即使规划器选了 Hash Join,扫描version_number时如果没有索引还是要全表扫描。

推荐为压缩相关的表创建联合索引:

-- version_number 用于过滤批次,orde_id 用于 JoinCREATEINDEXidx_st_ver_ridONmagellan_nk_order_inbound(version_number,orde_id);

有了这个索引:

  • 扫描version_number = #{olderBatch}直接走索引,跳过无关数据
  • Hash Join 的 Build 阶段也能走索引快速定位新批次数据

八、总结

知识点结论
Nested Loop 慢在哪两层循环,复杂度 O(N×M),大表灾难
Hash Join 为什么快建字典再查字典,复杂度 O(N+M)
NOT EXISTS vs INNOT EXISTS 语义明确,规划器直接选 Hash Anti Join
NOT EXISTS vs LEFT JOIN IS NULLNOT EXISTS 更简洁,无 NULL 陷阱,规划器更友好
索引的作用减少参与 Join 的行数,从源头降低 N 和 M

核心记忆:大表 Join 看 Hash Join,反连接用 NOT EXISTS,索引让 N 和 M 变小。

http://www.cnnetsun.cn/news/2559659.html

相关文章:

  • flowcontainer实战:加密流量特征工程的高效提取方案
  • 树莓派对接WhatsApp实现双向智能家居控制与监控
  • Playwright登录态管理避坑指南:除了Cookie,你的SessionStorage处理对了吗?
  • springboot提供的机制大全
  • 5分钟快速上手:B站视频解析API完整指南
  • 在 Hermes Agent 中自定义 provider 接入 Taotoken 服务
  • 如何用douyin-downloader轻松实现抖音内容批量下载与整理
  • 2个实测靠谱且有免费体验的AI面试工具,求职模拟必备!
  • 终极指南:用Motrix WebExtension让浏览器下载速度提升300%
  • SingleFile终极指南:一键保存完整网页的免费解决方案
  • Lovable电商网站搭建实战手册:7步完成高转化率前端+稳定后端+合规支付闭环
  • CANN pto-isa:90+ Tile 级虚拟指令速查手册
  • D2DX:让经典《暗黑破坏神2》在现代PC上完美运行的终极解决方案
  • 写给十年后的自己:一个技术人的长期主义宣言
  • Redis 缓存实战:技术资料与最佳实践
  • OFD转PDF深度解析:开源C解决方案Ofd2Pdf专业指南
  • AI算法工程师如何进行数据预处理?这5个步骤让你的数据更优质
  • 解锁你的音乐收藏:浏览器端音频解密完整指南
  • 网络安全基础小知识之常识篇叁
  • 3分钟掌握Windows任务栏美化终极技巧:TranslucentTB完整中文界面设置指南
  • 星露谷物语SMAPI模组加载器:从新手到专家的完整使用指南
  • 如何快速掌握ncmdumpGUI:Windows平台网易云音乐NCM文件转换完整教程
  • LRCGET:一键为本地音乐库下载同步歌词的智能工具
  • CentOS 7上HBase 2.5.6伪分布式搭建保姆级教程(含Hadoop 3.1.4集成与防火墙配置)
  • Elden Ring FPS Unlocker:解锁帧率限制的终极指南
  • 仅限首批200名开发者获取:Lovable v2.4.0未公开的/gateway/debug/integration-trace端点详解(含TraceID全链路染色原理图)
  • VideoDownloadHelper终极指南:解锁浏览器视频下载的完整解决方案
  • 3款Cherry MX键帽3D模型终极指南:解锁个性化机械键盘的完整方案
  • Unlock Music音乐解锁工具:免费解密加密音频的终极解决方案
  • DeepSeek技术方案生成全流程拆解(企业级交付标准白皮书首次公开)