☰
PostgreSQL Hybrid Hash Join原理与调优:从内存溢写到EXPLAIN分析
2026/10/21 7:30:34 网站建设 项目流程

1. 从一次慢查询开始:为什么非要弄懂Hybrid Hash Join

PostgreSQL执行计划里的Hash Join见得多了,但把“Hybrid Hash Join”单独拿出来讲清楚的文章,其实少得可怜。很多人第一次意识到这个词,是因为EXPLAIN输出里Hash Join节点下出现了Batches这个字段,显示batch数量大于1,于是临时文件开始出现在磁盘上。那次查询慢得离谱,DBA告诉你说“哈希表放不下了,溢写了”。你点头表示听懂,但心里其实只有一个模糊印象:好像是内存不够的时候会分批次处理?至于什么是hybrid、为什么叫hybrid、批次数怎么算出来的、溢写后性能影响有多大,基本没体系。

这篇就是想把这个缺口补上。我会从数据流和内存策略的角度拆解Hybrid Hash Join,而不是用状态机那种抽象讲法。后者确实严谨,但对排查问题、调参数、做性能分析帮助不大。适合阅读这篇的人:正在被慢JOIN困扰的开发、刚接手PG调优的DBA、以及读源码读得云里雾里但想搞懂执行引擎真面目的同学。

先说结论:PostgreSQL里几乎所有Hash Join都是Hybrid Hash Join的变体。它混合了两套思路——纯内存哈希连接和磁盘分批哈希连接。内存够用,走的是快速路径;内存紧张,就按批次把数据倒腾到磁盘再回读。搞清楚这套混合机制,才是读EXPLAIN、调work_mem、理解临时文件暴涨的第一前提。

2. 先站到高处:三种JOIN算法的取舍是什么

2.1 嵌套循环、排序合并与哈希连接各自的位置

执行引擎面对两个表连接,脑中的第一件事不是“用什么哈希函数”,而是“这条路线的成本模型划不划算”。嵌套循环连接(Nested Loop)在小表驱动大表且内表走索引时非常快,但它本质是双层循环,代价随着外表行数线性放大。排序合并连接(Merge Join)需要两边都排序,排序本身是O(N log N)的开销,如果数据原本有序,它也相当快。但问题在于:排序合并没法利用“等值连接”里的哈希能力,哈希连接可以直接把等值匹配变成一次哈希查找,理想情况下能做到近似O(N)。

所以PostgreSQL的代价模型决定JOIN顺序时,通常逻辑是这样的:能走索引且数据量悬殊,倾向于嵌套循环;两边排序成本可控或输入有序,考虑merge join;其余等值连接,在能承受内存开销时,哈希连接往往是兜底最优解。实际生产环境里Hash Join占据执行计划的大头,一点都不意外。

2.2 内存版与Grace式的本质区别

Hash Join有一个天然矛盾:希望把整张表装进哈希表,但现实里work_mem就那么大。学术上有一个经典方案叫Grace Hash Join,核心思路是把两个表按哈希值分片,每次只处理一个分片,内存压力骤减,代价是每片都可能落盘。还有一个更朴素的方案就是纯内存哈希连接:直接把整张外表扫进哈希表,然后内表一条条探测。

PG的Hybrid Hash Join,名字里的Hybrid就来自对这两者的折中。它不是“要么全内存、要么全落盘”这种非黑即白,而是一开始默认目标是把整个哈希表放进内存,只有当估算出的哈希表大小超过预算阈值时,才启动分批机制。启动分批后,也不是所有数据都立即落盘,它会尽量让当前batch留在内存里优先处理,剩下的往临时文件写。这个“尽可能把一批留在内存,其余外溢”的策略,正是Grace式思想的本土化改良。

2.3 为什么很多资料说不存在单独的“Hybrid节点”

不少人在EXPLAIN里搜索“Hybrid”这个词,搜索半天找不到,于是怀疑自己版本不对。实际上PG的EXPLAIN输出节点名统一显示为Hash Join,无论它内部是否启用了批量溢写。只有在出现Batches字段时,你才能间接推断这是hybrid路径。在没有批次溢写时,节点内部实际上也是同一套代码框架,不会单独挂一个“Hybrid”标签。理解这一点,再看网上各种名词就不会乱。

3. 核心细节:哈希表在内存里到底怎么组织

3.1 哈希表、桶与元组的排布方式

PG的哈希表实现在execNodeHash.c(历史上叫nodeHash.c)和execNodeHashjoin.c里,核心数据结构是HashJoinTable。它不是简单的一个大数组,而是一组分段桶(bucket)加共享哈希表元数据。逻辑上,每个桶对应哈希值的一段范围,哈希函数先算32位hash value,再通过掩码映射到具体桶。桶的数量由nbuckets控制,这个数不是拍脑袋定的,而是根据外表行数和内表行数估算,再向上取整到2的幂。

桶内元组存储不是链表散落,而是按chunk聚合。每个chunk是一个连续内存块,里面密排多个HashJoinTuple。这么设计是为了局部性和减少频繁分配开销。插入时,元组被复制到当前chunk,然后在桶头部串成同hash的链表。以后探测阶段,拿着相同的hash值进桶里顺链表比较,如果hash值相等,再走qual表达式做真正等值判断。

有几个容易被忽略的点需要强调。第一,哈希表里存的是“构建侧表”的全部投影列加上连接key,不是只存key。第二,重复的哈希值会导致同一个桶链表变长,极端情况下,探测阶段比较成本上升,性能从O(1)退化到O(N)。第三,PG的哈希函数选择在规划阶段通过compute_hash_mem、lookup_type_cache等逻辑确定,常见类型走内置的hash_any快速路径,自定义类型则要求有哈希opclass支持。

3.2 batch机制:从内存预算到分批决策

整个Hybrid Hash Join真正精髓的地方,就是批次数nbatches的推导。规划阶段,优化器会估算哈希表需要的内存大小。怎么估?它先估算外表行数outer_rows和平均元组宽度,乘起来得到理想哈希表字节数。然后拿这个字节数跟“允许的哈希内存上限”比较。这个上限并不是work_mem完整数值,而是一套考虑并行度、hash_mem_multiplier、哈希布隆过滤器占用等因素后的综合值。

如果估算大小超过上限,就进入分批路径。这里有一个很关键的参数:每批要容纳多少数据,然后决定nbatches取多大。PG的做法是让当前批次的估算大小尽量不超过内存预算,所以nbatches初始值往往等于估算内存除以预算后向上取整,再调整成2的幂。这个2的幂不是随意的,桶数量nbuckets也会随之改变,掩码运算依赖2的幂做位运算,这也是PG源码和实现层面常用的性能手段。

批次的实际执行不是一次做完的。构建阶段扫描外表时,每一行都算一次hash,然后根据hash值高位取模判断该行属于哪个batch。属于当前batch的,插入内存哈希表;不属于当前batch的,序列化到磁盘临时文件中,按batch编号存放。一批处理完,刷新临时文件,清理哈希表,然后从临时文件回读下一批数据,重建哈希表再探测。整个过程典型特征就是CPU换磁盘I/O,批次数越多,额外I/O越重,查询往往慢一个数量级。

3.3 为什么“无状态机讲解”反而更贴近现场

Hash Join的代码实现里确实存在基于ExecutorState的循环切换,很多课程会把join执行画成一张大状态转移图:构建状态、探测状态、多批次循环状态、结束状态。这种讲法非常严谨,但对刚接触的人来说,很容易陷入细节黑洞。你看完了从ExecHashJoinImpl到ExecHashTableInsert的每条分支,最后遇到真实慢查询还是不知道问题出在哪。

我在实际排查中更愿意把Hybrid Hash Join看成一个“受内存限制的数据搬运过程”:先扫描外表构建内存哈希表,能装就一次性搞定;装不下,就把一部分数据“寄存”到磁盘。内表扫描时,优先探测内存表,然后循环加载后继批次。这样理解问题,一旦看到Batches>1,你不需要在代码状态间跳转,直接想到两件事:一是内存预算没覆盖全量数据,二是硬盘回读占比会显著拉高执行时间。现场定位问题的效率完全不一样。

4. 手动走一遍执行脉络:从构建到探测再到循环

4.1 构建阶段真正做了什么

以外表(通常称为outer relation)为构建侧为例。执行器从上到下开始扫描,每读一行,先映射连接键,算出hash值。哈希函数的结果均匀性直接决定桶分布的均匀程度,PG的hash_any对文本和数值类型的效果在绝大多数场景都不错。接着,根据当前批次数nbatches判断这一行应该走哪个分支。如果nbatches等于1,所有行都是当前批,直接插入内存桶;大于1,则该行可能落在未来批次,需要先写入对应磁盘文件。

写入磁盘文件时也有讲究。写的是“去掉不需要列后的投影元组”还是全部列?实际上写入临时文件的是构建侧投影元组,尽可能压缩体积,减少回读I/O。这个阶段,临时文件名由 logical_tmp 目录或 base/pgsql_tmp 管理,EXPLAIN里的临时文件统计会反映这部分I/O活动。

代码里的ExecHashTableInsert除了负责放数据,还要维护哈希表统计信息。如果哈希表的skew度太高,也就是某些键特别集中,PG有少数版本会尝试用Bitmap等机制缓解,但PostgreSQL主线对倾斜桶没有做动态优化。也就是说,你遇到一个高度倾斜的key,probe阶段锁死在一个桶链上的情况是可能发生的。

4.2 探测阶段:内表扫描与哈希匹配

构建结束,进入探测阶段。内表(inner relation)逐行读取,对每一行同样计算hash值,直接到对应桶里去比对。如果哈希值相等,就调用ExecQual运行连接条件做最终确认。这一步需要把探测行投影出来后,交给后续算子处理。有一种“early skip”优化:如果探测行某些字段和哈希表完全不匹配,会有fast path跳过传递,但这是在算子接口层面做的,不是hash join内核重点。

等到内表全部扫描结束,如果nbatches等于1,join直接完成。但如果是多批次,你会发现当前内存哈希表只包含第一批数据。内表扫描时,同样需要对内表行算batch归属,只对当前batch的行做探测,其他行也写入内表对应的临时文件。这种方式保证内外表都是按相同bucket分区规则切片的,每批探测才可能保持等值映射的一致性。

4.3 批次数翻倍与性能拐点

批次数是动态增长的。执行过程中,如果第一批的哈希表大小涨得比预期猛,而剩余内存不足以继续容纳新插入的元组,PG会启动“batch翻倍”机制,也叫nbatches倍增。它把现有哈希表重新分区,桶数跟批次数同步扩大,已插入数据重新散列到更细的粒度。代价是当前批次已经处理一半时,原始哈希表数据可能要重新组织,产生额外开销。更麻烦的是,已经写出去的临时文件批次编号全部作废,得重新按新batch划分,这会让I/O量突然翻倍。

所以EXPLAIN里Batches呈现出来的数字,是执行结束后的最终值,而非构建阶段的初始估算值。如果看到Batches: 2048这类夸张数字,意味着查询中途发生过多次批次倍增。这种情况下,哪怕总数据量不大,运行时间也可能从几百毫秒变成几百秒。真实生产案例里,一张几百万行的表join另一张几百万行的表,work_mem设得太小,Batches动不动冲到64以上,现场表现为磁盘I/O飙升、CPU等待时间变长。调work_mem后,同样的查询降到秒级,原因就是批次数从64降到了2甚至1。

5. 并行环境下的Hybrid Hash Join又会怎样

5.1 Shared Hash Table与Parallel Hash Join

PG从某一版本开始支持了Parallel Hash Join。这里的核心变化是哈希表不再属于单个backend进程,而是放到共享内存里,多个worker并行构建和探测。并行Hash Join不是简单地把内存哈希表变大,它由leader进程负责创建共享哈希表元数据,worker进程各自扫描一部分外表数据,算好hash后插入共享桶。插入过程需要加锁或者用原子操作,竞争频率取决于桶数量和worker数量。

并行版本下,work_mem的分配规则变了。每个worker可能在构建侧并行插入同一张共享哈希表,而不是各建各的。如果你的系统是并行度16,work_mem配置是128MB,那么哈希内存上限并不是“128MB*16”,而是由哈希表共享后、再配合hash_mem_multiplier计算的总体预算。很多人在这里栽过坑:以为提高并行度能变相扩大内存池,结果落到临时文件的数量反而更多。

5.2 分区倾斜与并行探测的摩擦

并行Hash Join还面临一个额外问题:如果外表按哈希值切分到多个worker,而某个连接键特别集中,那这个key对应的桶数据会扎堆在一个worker的扫描桶里,其他worker处理完就得等待,整体并行度形同虚设。PG对这类倾斜没有自动做动态分区重平衡,实践中需要靠分桶时业务键具备较好的分散度来规避。如果业务上无法避免,可以考虑加过滤条件、重写查询或换用两阶段聚合思路,让倾斜键单独走一条路径。

不过绝大多数等值join场景,并行Hash Join仍然是非常可靠的默认选择。它牺牲了一部分构建阶段的串行效率,换来了更均匀的并行扫描和探测。当你看到EXPLAIN里出现“Partial Hash Join”或“Parallel Hash Join”字样,要意识到这个计划已经不再是传统单进程哈希表模型,对应的等待事件如ParallelHashJoinAllocate、ParallelHashJoinProbe等,排查时不能用单进程思维去套。

6. 调参现场:看懂EXPLAIN与用好内存控制

6.1 work_mem、hash_mem_multiplier与哈希内存上限的关系

关于work_mem,网上最容易看到的误区是“work_mem就是哈希表内存上限”。实际上,如果一个查询包含多个哈希算子,比如三个join、两个聚合,每个算子在单进程内都可能单独申请一块work_mem。也就是说work_mem是按算子个数累加的,而不是全局共享。这也是为什么明明work_mem设了1GB,系统里临时文件还是频繁产生——因为每个算子分到的内存,都比你以为的少。

hash_mem_multiplier是另一个不容忽视的参数。默认值通常大于1,意味着哈希算子可以突破纯work_mem限制,最多用到work_mem乘以上限系数。这个参数的存在是为了应对“排序算子和哈希算子都很吃内存”的场景:排序严格受work_mem约束,而哈希算子稍微放宽,避免一个复杂查询因为两个算子互相挤兑而过度落盘。调优时如果把hash_mem_multiplier调得过高,要小心单查询内存峰值失控;在容器化环境或内存受限的云实例上尤其容易触发OOM。

我自己日常给生产系统调参的路径是这样的:先看当前慢查询的EXPLAIN,确认Hash Join的Batches是否大于1。如果是,说明哈希内存不足。先用默认参数之外的值做一次对比:把work_mem临时调到256MB或512MB,重新执行EXPLAIN ANALYZE,看批次数下降对执行时间的影响。如果影响显著,再回全局配置文件里做合理提升。切忌一次调到2GB,因为多个并发会话叠加,内存很容易被打爆。

6.2 从EXPLAIN ANALYZE现场读关键指标

来看一个真实的简化版EXPLAIN片段:

Hash Join (cost=... rows=... width=...) Hash Cond: (a.id = b.id) -> Seq Scan on a ... -> Hash Buckets: 65536 Batches: 4 Memory Usage: 126761 kB

Buckets表示最终桶数量,Batches表示最终批次数,Memory Usage表示当前批次哈希表使用的内存量。这里的重点不是Buckets越大越好,而是看Batches。Batches等于1且Memory Usage远小于预期内存上限时,一切正常。Batches大于1时,先看Memory Usage:如果这个值已经接近你的hash内存上限,说明调大内存压力就能缓解;如果Memory Usage只有十几MB而Batches照样是8,可能问题不单纯是内存预算,而是估算行数偏差或批次数翻倍时机有问题。

读这个输出另一个容易被忽视的地方是读取“actual time”和“loops”字段。如果用EXPLAIN ANALYZE的话,你还能看到每个节点实际返回行数和执行时间。当Hash Join节点本身时间高,但下方两个子节点都不高时,问题集中在hash join内部的重复批次循环或哈希冲突上;如果其中一侧Seq Scan节点时间就很高,则要先解决扫描问题,再来看hash join参数。

6.3 enable_hashjoin与stats偏差的影响

enable_hashjoin并不是非黑即白的开关。把它关掉,优化器会优先走Nested Loop或Merge Join,但代价模型通常仍然会把hash join作为替补路径。我们曾经在处理一个等值join时发现,关掉hash join后执行计划选择merge join,运行时间反而下降,原因是两边表都自带有序索引,merge join省去了构建哈希表的O(N)开销。但这也是个例,大多数情况下,hash join仍是默认最优解。

真正影响计划选择的关键是统计信息。pg_class.reltuples和pg_statistic里的直方图、频率统计决定了优化器对行数的估计。如果行数估计严重失准,比如实际1000万行估算成10万行,优化器可能错误地认为哈希内存足够,选择了Batches=1的计划,实际执行后却因为批次数翻倍而慢得吓人。所以遇到“计划看起来很好、跑起来要命”的情况,第一个动作往往是ANALYZE两张表,再回看EXPLAIN的行数估算是否贴近现实。

7. 现场实录:那些最常踩的坑

7.1 临时文件暴涨是灾难还是正常

很多人一看到临时文件几十GB就慌了,其实要分情况。如果hybrid hash join在Batches为几十的条件下执行,临时文件大小通常接近构建侧表体量的合计,这属于预期内代价。真正的问题是,同样的数据量在合理内存下本可以不放临时文件,却因为work_mem配置不到位或统计信息失准而溢写。这种情况的典型特征是:查询单次跑完,磁盘占用猛增,但随后被清理。排查入口是log_temp_files参数,设置得合理后,日志里能看见具体临时文件大小和对应查询。

另一些项目里,临时文件暴涨是因为长事务持有旧快照,导致临时文件清理延迟。哈希join产生的临时文件在事务结束时才会删除,如果事务非常长,磁盘上可能堆着多个查询的临时文件。没有经验的同事会误以为是泄漏,实际上只要事务提交,空间就会回收。确认这个现象,可以观察一段时间磁盘空间变化趋势,而不是直接杀进程。

7.2 内存OOM的边界到底在哪

PostgreSQL后端进程是按需申请内存的,哈希表构建到一半内存用尽时,正常情况下不会直接崩溃,而是走批次倍增路径,把部分数据落盘。可如果你把work_mem和hash_mem_multiplier调得过高,同时并发会话特别多,系统内存可能被瞬间占满,触发操作系统OOM Killer。这个时候PG进程可能被杀,但更讨厌的是,OOM发生时,共享缓冲区和WAL可能还在写,整个集群的稳定性都要受影响。

所以生产环境里,调work_mem一定要结合max_connections和实际并发数来评估。一个粗略的估算方法是:并发会话数乘上每个查询最大可能的内存占用,再留出操作系统和shared_buffers的空间。我记得有一次客户把work_mem从128MB调到4GB,并发60个会话后,机器直接卡死。最终发现真正需要的只是把work_mem调到512MB并清掉一个统计信息偏差,同样的查询就再也不溢写了。

7.3 倾斜哈希键的实战应对

哈希表桶链过长是最难从EXPLAIN直接看出来的问题之一。EXPLAIN只显示桶数量和批次数,不会告诉你某个桶里塞了多少行。但有几个外部信号可以辅助判断:CPU单核使用率特别高,而其他核心空闲;Hash Join节点时间极高,但下层节点扫描时间都很低;反复调大work_mem,批次数降下来了,执行时间却基本没变。

原因通常是连接key分布极不均匀。比如一张订单表和一个店铺表join,店铺ID高度集中在几个热门店铺,这些店铺的订单行数占了半边天。普通哈希函数没法解决业务上的倾斜,PG又不像部分数据库那样会自动启用skew处理,因此常见做法是把业务查询改写:先按店铺分组统计,或把热点key拆开分别join。另一个偏门但有效的方式是给连接key再加一层冗余条件,让相同hash key的记录分到更大的桶粒度,这需要具体业务配合。最笨但可靠的办法是,确认热点之后,把数据分成“热点集”和“长尾集”两条路径,再用UNION ALL合一,往往能换来数量级提升。

8. 一些观测手段和实用心得

聊完原理和坑,最后分享点我个人一直在用的观测手段。第一,分析慢查询前,先把EXPLAIN里的“actual rows”和优化器估算行数对一遍,偏差超过10倍就先去修统计信息,千万不要先在参数上瞎折腾。第二,买不起昂贵监控工具没关系,PG内置的pg_stat_statements配合log_temp_files、log_min_duration_statement就能覆盖大部分排查场景,重点看临时文件写入量和运行时间的长尾。第三,有条件的话,在测试环境复现时,手动设置session级别的work_mem做AB测试,比直接改全局参数安全得多,也能快速定位是不是内存预算问题。

还有一个很容易被忽略的习惯:当你看到Hash Join的构建侧节点是外表还是内表时,不要直接认为“Hash节点下面一定是小表”。优化器选择构建侧可能受JOIN顺序、过滤条件下推、并行度等因素影响。只要决定构建侧后,哈希表内存估算都要看“实际元组宽度”,很多宽表问题根本不是行数大,而是单行太宽导致哈希表体积爆掉。压缩投影列、提前裁剪字段,往往比调大work_mem更立竿见影。

多次在生产环境处理这类问题之后我的体会是:Hybrid Hash Join从来不是什么晦涩的算法,它就是一个懂“内存不够就别硬扛、分批拿磁盘来换”的务实方案。理解它,不是为了记住名词,而是为了在EXPLAIN里看到一个Batches字段时,能立刻还原出那一整套数据倒腾过程,然后做出正确的参数调整或查询改写。搞懂这一层,再看PG的执行器代码,你会觉得很多东西都顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询