本文作者:徐飞
导读
在数据库管理系统(DBMS)中,连接操作(Join)是查询处理的核心环节之一,其性能直接影响到整个系统的响应速度和效率。随着数据量的不断增长和复杂查询需求的增加,优化连接操作的执行效率成为提升数据库性能的关键。Hash Join 作为一种高效的连接算法,因其在处理大规模数据集时的卓越性能而被广泛应用于现代数据库系统中。
本文深入介绍了 TiDB 中新 Hash Join 的设计与实现,对比了新旧 Hash Join 的性能表现。通过引入并发构建机制、优化的哈希表设计以及针对溢出(Spill)操作的全新算法,新 Hash Join 在多个方面实现了显著的性能提升。文章详细探讨了 Build 阶段和 Probe 阶段的优化策略,并通过实际测试数据展示了新 Hash Join 在处理大规模数据集时的高效表现。
1. 背景
JOIN 是数据库中极为重要的操作之一,其核心功能是将两个表的数据按照指定条件进行合并。例如,以下 SQL 语句:
展示了如何通过 JOIN 将表 a 和表 b 的数据进行连接。接下来,我们先简要介绍 JOIN 的一些基本概念。
在上述 SQL 语句中,表 a 和表 b 是参与 JOIN 操作的表,而 ON 后面的条件(a.id = b.id AND a.value >= b.value)则是 JOIN 条件。下图展示了表 a 和表 b 的数据,以及通过 JOIN 操作后的结果:

这种仅将满足 JOIN 条件的 {a_row, b_row} 组合作为结果的连接方式被称为内连接(Inner Join)。除了 Inner Join 之外,还有其他几种常见的连接类型,例如外连接(Outer Join)、**半连接(Semi Join)**和 反半连接(Anti Semi Join) 等。
最基础的 JOIN 实现方式是嵌套循环连接(Nested Loop Join)。这种实现方式严格遵循 JOIN 的语义定义,没有引入额外的优化手段。以 Inner Join 为例,Nested Loop Join 的具体实现逻辑如下:
假设表 a 的数据量为 m,表 b 的数据量为 n,则 Nested Loop Join 的算法复杂度为 O(m*n)。这种复杂度显然较高,当表 a 和表 b 的数据量较大时,Nested Loop Join 的执行速度会显著变慢。
由于 Nested Loop Join 的性能较差,大多数数据库管理系统都提供了其他更高效的连接算法。其中,Hash Join 是最常见的一种高效连接算法。
在 Hash Join 中,JOIN 条件通常被分为两大类:
- 等值连接条件:连接条件的左右两边分别来自两个表,且为等值比较。例如,在上述例子中,
a.id = b.id即为等值连接条件。 - 非等值连接条件:除了等值连接条件之外的其他条件均属于非等值连接条件。例如,在上述例子中,
a.value >= b.value即为非等值连接条件。
Hash Join 的核心思想是利用哈希表来高效判断等值连接条件。因此,若要使用 Hash Join,连接条件中至少需要包含一个等值连接条件。
继续以上述 SQL 示例和数据为例,Hash Join 的执行过程示意如下:

在 Hash Join 中,表 b 的数据被构建为一个 Hash Table,其中键(Key) 是等值连接条件中表 b 的数据(即 b.id,以下称为连接键(Join Key)),而 值(Value) 是表 b 的原始数据。
当表 a 进行 Hash Join 时,会使用表 a 的 Join Key(即 a.id )在 Hash Table 中查找相关的表 b 数据。Hash Table 查找的结果已经满足了等值连接条件 a.id = b.id。对于没有非等值连接条件的连接操作,Hash Table 查找的结果即为连接的最终结果。而对于包含非等值连接条件的连接操作,可以在 Hash Table 查找结果的基础上,进一步对非等值连接条件进行求值。
在具体实现中,Hash Join 会从参与连接的两个表中选择一个表作为构建端(Build 端),另一个表作为探测端(Probe 端)。Hash Join 的执行分为两个阶段:**Build 阶段(构建阶段)**和 Probe 阶段(探测阶段)。
- Build 阶段:Build 阶段的主要任务是接收 Build 端的数据,并构造相应的 Hash Table。由于 Hash Table 需要保存在内存中,因此 Build 阶段的内存使用量与 Build 端的数据量成正比。通常情况下,选择数据量较小的表作为 Build 端是一个更优的选择。一方面,较小的数据量可以减少内存占用;另一方面,在 Probe 阶段,较小的 Hash Table 能够提供更高的查询性能。
- Probe 阶段:Probe 阶段的主要任务是使用 Probe 端的数据查询 Hash Table,并根据查询结果以及非等值连接条件的结果构造最终的连接结果。与 Build 阶段不同,Probe 阶段不会产生内存积累问题。Probe 端的每一行数据都可以独立地进行 Hash Table 查询,并生成连接结果。
从时间复杂度的角度来看,Hash Join 的性能表现如下:
- Build 阶段:
- Build 阶段的时间复杂度为 ***O(n)***,其中 n 是 Build 端表的大小。这一阶段主要涉及对 Build 端数据的扫描和 Hash Table 的构建。
- **Probe 阶段: **
- Probe 阶段的复杂度与数据分布密切相关。假设 Build 端每个 Join Key 平均有 k 行数据,Probe 端的 Join Key 在 Hash Table 中命中的概率为 x,则 Probe 阶段的复杂度可以表示为 ***O(m*x*k + m*(1-x))***,其中 m 是 Probe 端表的大小。
- 因此,总体而言,Hash Join 的时间复杂度可以表示 ***O(m*x*k+m*(1−x)+n)***。
在大多数实际场景中,k 通常是一个较小的常数(例如 k≤10),因此 Hash Join 的复杂度大致可以简化为 O(m+n) 的量级。这意味着在常见情况下,Hash Join 的性能表现非常高效。
然而,在极端情况下,如果 Build 端和 Probe 端的所有 Join Key 都完全相同(即每个 Join Key 对应大量重复行),Hash Join 的时间复杂度可能会退化到 ***O(m*n)***。在这种情况下,Hash Join 不仅需要执行 Nested Loop Join 类似的操作,还需要额外进行 Hash Table 的构建和探测过程。因此,在这种极端情况下,Hash Join 的性能可能不如 Nested Loop Join。
2. TiDB 中的 hash join
TiDB 自首个版本(v1.0.0)起便支持 Hash Join。从功能角度来看,TiDB 的 Hash Join 已经具备了较为完善的支持:一方面,它支持 TiDB 中几乎所有的连接类型;另一方面,当内存使用量过高时,它还能自动触发 spill 操作。然而,从性能角度来看,TiDB 当前的 Hash Join 实现仍存在诸多已知问题,这也是我们需要在 TiDB 中引入新版本 Hash Join 的原因。
已知的性能问题
Build 阶段
目前,TiDB 的 Hash Join 在 Build 阶段存在以下两个主要问题:
1. 单线程构建问题:Hash Join 的 Build 阶段仅支持单线程操作。尽管 Hash Join 使用的 Hash Table 是并发安全的(concurrent map ( https://github.com/pingcap/tidb/blob/v8.1.0/pkg/executor/concurrent_map.go#L37 )),但在构建哈希表时,只使用了一个 Goroutine 来进行 hash table 的 build ( https://github.com/pingcap/tidb/blob/v8.1.0/pkg/executor/join.go#L1238 )。当构建表的规模较大时,单线程构建会显著拖慢整个连接操作的性能。
2. 哈希表性能瓶颈:Hash Join 中使用的哈希表是 Go 语言自带的 map 类型 ( https://github.com/pingcap/tidb/blob/v8.1.0/pkg/executor/concurrent_map.go#L31 )。虽然这种类型能够保证基本的性能下限,但对于 Hash Join 这种以哈希表为核心数据结构的算法来说,如果能够针对 Hash Join 的特点设计一个专用的哈希表,将能够实现比自带 map 类型更高的性能表现。
Probe 阶段
TiDB 的 Hash Join 在 Probe 阶段虽然支持多线程执行,但仍存在一些性能问题,主要体现在以下几个方面:
- Probe 接口设计不合理:
<!---->
- 哈希表查找与 Probe 分离,且不感知连接类型:在 TiDB 的 Hash Join 实现中哈希表以 Join Key 的哈希值为键,所以查找时需要对所有匹配的哈希表条目再次进行 Join Key 的比较。对于某些特殊连接类型(例如仅包含等值连接条件的 Semi Join),实际上并不需要查找哈希表中所有匹配的 Build 端数据,只需判断哈希表中是否存在匹配的 Build 端数据即可。然而,由于哈希表查找不感知连接类型,这种情况下仍需执行完整的哈希表查找。当 Build 端存在大量重复数据时,连接性能会受到显著影响 ( https://github.com/pingcap/tidb/issues/47424 )。
- Probe 接口以行为单位:目前 TiDB 的 Hash Join 以行为单位 ( https://github.com/pingcap/tidb/blob/v8.1.0/pkg/executor/joiner.go#L62 )进行 Probe 操作。如果连接操作中包含非等值连接条件,这些条件的求值很难实现完全的向量化执行,从而导致连接性能大幅下降。
<!---->
- 哈希表查找中 Join Key 比较存在冗余计算:TiDB 的哈希表以 Join Key 的哈希值作为键进行索引。在哈希表查找过程中,需要对所有匹配的哈希值再次进行 Join Key 的精确匹配。对于复杂的 Join Key(例如带有排序规则(collation)的字符串类型),比较操作需要重新计算字符串列的排序键。在当前的 TiDB Hash Join 实现中,每次 Join Key 比较都需要重新执行这些计算,从而导致大量冗余计算。
- Hash Join 生成结果的效率较低:Hash Join 生成结果的过程本质上是将
{build_row, probe_row}组合成一个新行,并将其插入到 Hash Join 的结果集中。目前,TiDB 的 Hash Join 在这一环节缺乏针对性的优化,导致生成结果的效率较低。
Spill
虽然 TiDB 的 Hash Join 支持在内存使用超出限制时进行 Spill 操作,但当前的 Spill 算法在功能和性能方面仍存在一些问题。
目前,Hash Join 的 Spill 操作完全封装在 hashRowContainer 中。如图所示,hashRowContainer 包含两部分内容:hash Table 以及 Build 端的数据。

当 Hash Join 需要触发 Spill 操作时,hashRowContainer 会将 Build 端的部分或全部数据(chunk 1 - chunk N)Spill 到磁盘,而 hash table 本身不会被 Spill。一个已经触发 Spill 操作的 hashRowContainer 如下图所示。

其中,chunk 2 至 chunk N 已被 Spill 到磁盘,而 chunk 1 仍然保留在内存中。
从功能角度来看,当前的 Spill 算法存在一个显著的缺点:hash table 本身无法被 Spill。然而,哈希表的规模会随着 Build 端数据量的增加而增大。这意味着即使已经触发了 Spill 操作,随着 Build 端数据量的进一步增加,Hash Join 的内存占用量依然会持续上升。
从性能角度来看,当前的 Spill 逻辑完全封装在 hashRowContainer 中,Probe 端无法感知是否发生了 Spill。例如,假设某一行探测数据的 Join Key 为 key1,在探测时,系统会将 {chunk1-row1}、{chunk2-row2}、{chunk2-rowN} 等所有相关数据读取到内存中。这表明,每探测一行数据,都可能引发一次或多次对磁盘的随机访问。众所周知,磁盘的随机访问速度较慢,因此一旦发生 Spill,Hash Join 的性能将显著下降。
其他问题
除了上述问题外,TiDB 的 Hash Join 还有一些其他功能上的缺失:
1. Semi Join使用左表做 build:对于 a SEMI JOIN b,TiDB 目前仅支持使用表 b 作为 Build 端。当表 a 较小而表 b 较大时,这种限制会导致连接操作的整体性能下降。
2. Null-Aware 左外半连接(Null-Aware Left Outer Semi Join)缺失:目前 TiDB 的 Hash Join 尚未支持 Null-Aware 左外半连接。这使得此类连接操作只能退化为使用 Cross Join 来实现,而与 Hash Join 相比,Cross Join 的性能通常会下降数倍甚至数十倍。
性能问题的具体体现
为了直观地说明上述性能问题在实际查询中的具体体现,我们基于 TPC-H 50 数据集构造了一些简单的连接查询(join query)。
简单 join query 测试
以下是测试使用的 join query:
在测试中,orders 表包含 7500 万条数据,而 lineitem 表包含 3 亿条数据。在 TiDB 中,orders 表被选为 Build 端,这已经是当前场景下的最优选择。
然而,即使在这种情况下,上述查询在 TiDB 中的执行时间仍然需要大约 65 秒。

最低0.47元/天 解锁文章
605

被折叠的 条评论
为什么被折叠?



