数据库Grace Hash Join

Hash Join(哈希连接) 是关系型数据库(如 MySQL、PostgreSQL、Oracle 等)在处理表连接操作时非常经典且高效的一种算法。它特别适用于大表之间的等值连接(Equi-join),尤其是在参与连接的列上没有合适的索引时。

简单来说,Hash Join 的核心思想是:利用较小的那张表在内存中构建一个哈希表,然后遍历较大的那张表,通过哈希运算去内存中的哈希表里"探测"匹配项。

以下是 Hash Join 全过程的详细拆解:


第一阶段:构建阶段 (Build Phase)

在这个阶段,数据库引擎会选择参与连接的两张表中较小的一张 (通常称为 Build Table驱动表)。选择小表是为了尽可能让生成的哈希表能够完整地存放在内存中。

  1. 全表扫描:数据库开始顺序扫描这张较小的 Build Table。
  2. 计算哈希值 :对于扫描到的每一行数据,提取出用于连接的列(Join Key),将其输入到一个特定的哈希函数 (Hash Function) 中,计算出一个哈希值。
  3. 存入哈希表 :根据计算出的哈希值,将这一行数据(或所需的列数据)放入内存中哈希表对应的"桶(Bucket)"里。
    • 注意:如果不同的连接键计算出了相同的哈希值(哈希冲突 / Hash Collision),它们会被放入同一个桶中,通常以链表的形式连接起来。

第二阶段:探测阶段 (Probe Phase)

构建阶段完成后,内存中就准备好了一个供快速查找的哈希表。接下来处理较大的一张表 (通常称为 Probe Table被驱动表)。

  1. 全表扫描:数据库开始顺序扫描这张较大的 Probe Table。
  2. 计算哈希值 :对于扫描到的每一行,提取出连接列(Join Key),使用与第一阶段完全相同的哈希函数来计算哈希值。
  3. 探测哈希表:拿着计算出的哈希值,去第一阶段在内存中构建好的哈希表中寻找对应的"桶(Bucket)"。
  4. 精确匹配与输出
    • 如果桶是空的,说明没有匹配项,直接丢弃该行。
    • 如果桶里有数据,数据库会遍历该桶中的链表,将其与当前 Probe Table 的连接键进行精确比较(这一步是为了排除哈希冲突导致的误判)。
    • 如果确认为等值匹配,则将这两行数据组合在一起,作为连接结果输出。

如果内存放不下怎么办?(Grace Hash Join)

经典的 In-Memory Hash Join 要求小表必须能全部塞进可用内存(即 Work Memory)。但如果两张表都非常巨大,导致最小的表也远远超出了内存限制,数据库就会采用它的变种:Grace Hash Join(由于数据需要落盘,性能会有所下降)。

整个过程会多出一个**分区(Partitioning)**的步骤:

  1. 切分两张表:使用一个哈希函数处理两张表的连接键,将 Build Table 和 Probe Table 拆分成多个较小的、一一对应的"分区文件(Partitions)"并写入磁盘。
  2. 分批处理 :因为使用的是同一个哈希函数,所有潜在匹配的数据一定会被分发到编号相同的分区中
  3. 按区连接:此时,数据库只需要将 Build Table 的第 1 个分区读入内存构建哈希表,然后用 Probe Table 的第 1 个分区去探测。完成后清空内存,接着处理第 2 个分区......以此类推,直到所有分区处理完毕。

Hash Join 的优缺点总结

优点:

  • 极高的吞吐量:在处理缺乏索引的大批量数据等值连接时,性能通常碾压 Nested Loop Join(嵌套循环连接)。
  • 扫描次数少:只需要对两张表各进行一次全表扫描(在内存充足的情况下)。

缺点:

  • 仅限等值连接 :只能用于基于 = 的连接操作(例如 ON a.id = b.id)。对于大于、小于(><)等非等值连接完全无能为力。
  • 内存消耗大:需要占用大量内存来构建哈希表。如果内存不足导致频繁读写临时磁盘文件(发生 Spill to disk),性能会大幅下降。
  • 启动延迟高:必须等 Build Phase 完全结束后,才能开始返回第一条连接结果,不适合需要极低响应延迟的场景。

Partition

在 Hash Join 中,Partition(分区/分片) 是数据库为了应对**"内存不足"** 而采用的一种**"分而治之(Divide and Conquer)"**的策略。

​ 最理想的 Hash Join 是把较小的表(Build Table)全部塞进内存里。但现实中,如果两张表都非常大,数据库分配给这个查询的内存(Work Memory)根本装不下哪怕是较小的那张表时,强行塞入会导致严重的内存溢出(OOM)。

这时候,Partition 机制 就登场了(这也就是著名的 Grace Hash Join 算法的核心)。它的详细原理如下:

1. 为什么要切分成 Partition?

如果内存装不下,数据库只能把数据写到磁盘上(Spill to disk)。如果直接在磁盘上维护一个巨大的哈希表,会产生海量的随机 I/O(Random I/O),导致查询慢得像蜗牛。

将数据切分成多个小的 Partition,可以保证每一个小 Partition 都能完整地放进内存中,从而把低效的磁盘随机 I/O 转化为高效的顺序 I/O


2. Partition 的完整工作流程

引入 Partition 后,Hash Join 的过程就从两步(构建、探测)变成了三步:分片、分批构建、分批探测

第一步:对两张表进行分片 (Partitioning Phase)

数据库会先暂停真正的"连接"动作,而是先把两张大表"切碎"。

  1. 数据库选取一个哈希函数(我们称之为 Hash 1)
  2. 扫描驱动表(Build Table),对每个连接键使用 Hash 1 计算出哈希值,根据哈希值将数据分发并写入到磁盘上的多个小文件里,这些小文件就是 Build Partitions(例如 B1, B2, B3...)
  3. 接着,扫描被驱动表(Probe Table),使用完全相同的 Hash 1 计算连接键,将其分发并写入到磁盘上的多个小文件里,产生 Probe Partitions(例如 P1, P2, P3...)

第二步:配对处理 (Processing Phase)

现在,两张大表都被切成了大小合适的一对对小切片。数据库开始进行"分而治之":

  1. 加载 B1 :把磁盘上的 B1 分区读取到内存中,用**另一个不同的哈希函数(Hash 2)**在内存中构建哈希表。(使用 Hash 2 是为了避免同一个分区内的数据再次发生严重的哈希冲突)。
  2. 探测 P1:把对应的 P1 分区从磁盘流式读取出来,去内存里探测 B1 的哈希表,输出匹配结果。
  3. 清空内存,循环继续:B1 和 P1 处理完后,清空内存。接着把 B2 读入内存建表,用 P2 去探测......直到所有的分区(Bn 和 Pn)都处理完毕。

3. 核心精髓:为什么分片后不会漏掉匹配的数据?

你可能会问:把数据打散到不同的文件里,万一表 A 的某一行和表 B 的某一行本来应该匹配,结果被分到了不同的 Partition 里怎么办?

答案是:绝对不会。

这是 Partition 机制最巧妙的地方:因为在切分两张表时,使用的是完全相同的哈希函数(Hash 1)

数学上,如果两个值相等(Key_A = Key_B),那么它们的哈希值必定相等(Hash(Key_A) = Hash(Key_B))。因此,它们必然会被分发到编号完全相同的 Partition 中

换句话说:

  • 表 A 中属于 B1 分区的数据,它的命中对象只可能存在于表 B 的 P1 分区中。
  • B1 绝不需要去和 P2, P3 比较。这样就把一个巨大的全量交叉比对,安全地降维成了局部比对。

除了Grace Hash Join之外还有哪些

在数据库的发展历程中,为了应对不同的硬件条件和数据规模,Hash Join 衍生出了多种变体。


第一阵营:单机环境下的 Hash Join 演进

1. Simple Hash Join (经典内存哈希连接)

这是最原始、最基础的版本(我们在第一个回答中提到的"第一阶段+第二阶段"就是它)。

  • 原理 :假设较小的表(Build Table)能**100%**放入内存。直接在内存中建哈希表,然后用大表去探测。
  • 局限:一旦内存装不下,整个过程就会崩溃(OOM),所以它缺乏处理超大表的容错能力。

2. Hybrid Hash Join (混合哈希连接) ------ 现代数据库的主力

Grace Hash Join 虽然解决了内存不足的问题,但它过于"死板":哪怕内存其实还能装下 80% 的数据,它也会把 100% 的数据先写到磁盘上再读出来,白白浪费了大量 I/O。Hybrid Hash Join 就是为了榨干每一滴内存而生的。 它是目前 PostgreSQL、Oracle 等大多数主流数据库的默认实现方案。

  • 核心思想 :将 Simple Hash Join 和 Grace Hash Join 结合起来。能放内存的放内存,放不下的再落盘。
  • 工作流
    1. 数据库评估可用内存。假设内存能装下 30% 的 Build Table。
    2. 切分 Partition 时,数据库会将第 1 个分区(我们叫它 Partition 0直接留在内存中构建哈希表,而将剩下的 70% 数据(Partition 1, 2, 3...)写到磁盘上。
    3. 扫描大表(Probe Table)时:
      • 如果大表的数据哈希后属于 Partition 0,直接在内存中完成探测并输出结果(零磁盘 I/O!)。
      • 如果属于 Partition 1, 2, 3...,则将大表数据也写到对应的磁盘文件里。
    4. 最后,再像 Grace Hash Join 那样,分批把磁盘上的 Partition 1, 2, 3... 读进内存进行处理。
  • 优势:极大地减少了不必要的磁盘 I/O(本例中省去了 30% 数据的来回读写)。

第二阵营:分布式/大数据环境下的 Hash Join

在 Hadoop、Spark、Flink 等分布式系统中,数据本身就是分散在多台服务器(Node)上的,这时候 Hash Join 的考量重点从"磁盘 I/O"变成了"网络传输开销"。

3. Broadcast Hash Join (广播哈希连接)

这是分布式系统中最快的一种连接方式。

  • 适用场景:一张表非常小(例如配置表/字典表),另一张表非常大。
  • 原理 :将那张"非常小的表"完整地复制一份,通过网络**广播(Broadcast)**发送到集群中的每一个节点(Worker Node)的内存里。然后,每个节点上的大表数据只需要和自己本地内存里的那份小表做 Simple Hash Join 即可。
  • 优势:完全避免了极其昂贵的大表网络数据重分布(Shuffle),速度极快。

4. Shuffle Hash Join (洗牌哈希连接)

这是 Grace Hash Join 在分布式环境下的"亲戚"。

  • 适用场景:两张表都非常大,任何一个节点的内存都装不下任何一张表。
  • 原理
    1. Shuffle(洗牌):两张大表使用相同的哈希函数计算 Join Key,然后通过网络将哈希值相同的数据发送到同一个计算节点上。(这个过程就是把两张大表在整个集群层面上做了 Partition)。
    2. Local Hash Join:每个节点接收到分配给自己的那部分 Build 数据和 Probe 数据后,在节点本地再执行普通的 Hash Join。
  • 局限:极其依赖网络带宽。如果发生"数据倾斜"(比如某个相同的 Join Key 特别多),会导致某一台服务器被撑爆,而其他服务器闲着。