一阶段哈希连接(One-Pass Hash Joins)
散列连接使用预构建的散列表搜索匹配的行。下面是一个使用这种连接的计划的例子:
在第一阶段 ,哈希连接节点1调用哈希节点2,哈希节点2从其子节点提取整个内部行集,并将其放入哈希表中。
哈希表存储哈希键和值对,可以通过键快速访问值 ;搜索时间不依赖于哈希表的大小,因为哈希键或多或少均匀地分布在有限数量的桶之间。一个给定的键所在的桶是由该哈希键的哈希函数决定的;由于桶的数量始终是2的幂,因此取计算值的所需位数就足够了。
就像buffer cache一样,这个实现使用一个动态可扩展的哈希表,通过链接(chaining)来解决哈希冲突。
在连接操作的第一阶段 ,扫描内部集合,并为它的每一行计算散列函数 。在连接条件(Hash Cond)中引用的列用作哈希键,而哈希表本身存储内部集合的所有查询字段。
如果整个哈希表可以容纳在内存中,那么哈希连接是最有效的,因为在这种情况下,执行器将管理一次处理数据。为此目的分配的内存块的大小受到work_mem × hash_mem_multiplier值的限制。
让我们运行EXPLAIN ANALYZE查看一下查询的内存使用统计数据:
嵌套循环连接对内部集和外部集的处理方式不同,而散列连接可以交换它们。较小的集合通常用作内部集合,因为它会产生较小的哈希表。
在本例中,整个表放入分配的缓存中:大约占用143MB (Memory Usage),包含4M =(2的22次方) 个内存桶。因此,连接在一次传递(batch)中执行。
但是如果查询只引用了一列,那么哈希表将填充111MB:
这是避免在查询中引用多余字段的另一个原因(举个例子,如果使用星号,可能会出现这种情况)。
所选择的桶数应该保证当哈希表完全填满数据时,每个桶平均只保存一行。更高的密度会增加哈希冲突率,使搜索效率降低,而不太紧凑的哈希表会占用太多内存。 桶的估计数量增加到最接近的2的幂。
如果估计的哈希表大小超过基于单行平均宽度的内存限制,则将应用两遍散列(two-pass hashing)。
在哈希表完全构建完成之前,哈希连接不能开始返回结果。
在第二阶段 (此时已经构建了哈希表),hash Join节点调用其第二个子节点以获取外部行集。对于扫描的每一行,将在散列表中搜索匹配项。它需要计算连接条件中包含的外部集合的列的散列键。
找到的匹配项返回到父节点。
成本预估
我们已经讨论了基数估计;因为它不依赖于连接方法,所以我现在将重点放在成本估计上。
Hash节点的成本由其子节点的总成本表示。这是一个虚拟数字,只是填补了计划中的空缺。所有实际的估计都包含在Hash Join节点的成本中。
连接的启动成本主要反映了创建哈希表的成本,包括以下部分:
- 获取构建哈希表所需的内部集合的总成本
- 计算连接键中包含的所有列的哈希函数的成本,对于内部集合的每一行(估计为cpu_operator_cost 每个操作)
- 将所有内部行插入哈希表的成本(估计为cpu_tuple_cost每插入一行)
- 获取外部行集的启动成本,这是启动连接操作所必需的
总成本包括启动成本和连接本身的成本,即:
- 对于外部集合的每一行,计算连接键中包含的所有列的哈希函数的成本(cpu_operator_cost)
- 重新检查连接条件的成本,这是解决可能的哈希冲突所必需的(估计为每个检查的操作符的cpu_operator_cost)
- 每个结果行的处理成本(cpu_tuple_cost)
所需复核的次数是最难估计的。它是通过将外部集合的行数乘以内部集合(存储在哈希表中)的某个分数来计算的。为了估计这个分数,计划者必须考虑到数据分布可能不均匀。
因此,我们的查询成本估计如下:
这是依赖关系图:
双阶段哈希连接(Two-Pass Hash Joins)
如果规划器的估计显示哈希表将超过分配的内存,则将内部的行集分成若干批,分别进行处理。批的数量(就像桶的数量)总是2的幂;要使用的批处理由哈希键的相应位数决定。
任意两个匹配的行属于同一个批处理:放置在不同批处理中的行不能具有相同的哈希码。
所有批都持有相同数量的哈希键。如果数据均匀分布,批大小也将大致相同。计划器可以通过选择适当数量的批来控制内存消耗。
在第一阶段 ,执行程序扫描内部行集以构建散列表。如果扫描的行属于第一批,则将其添加到哈希表中并保存在内存中。否则,它将被写入临时文件(每个批处理都有一个单独的文件)。
会话可以存储在磁盘上的临时文件的总量是由temp_file_limit参数限制的(临时表不包括在这个限制中)。一旦会话达到这个值,查询就会终止。
在第二阶段,扫描外部集合。如果该行属于第一批,它将与包含内部集合的第一批行的哈希表进行匹配(无论如何,在其他批中不可能有匹配)。
如果该行属于不同的批处理,则将其存储在临时文件中,该文件将为每个批处理单独创建。因此,N批可以使用2(N−1)个文件(如果某些批为空,则可以使用更少)。
一旦第二阶段完成,为哈希表分配的内存将被释放。此时,我们已经有了其中一个批次的连接结果。
对于保存在磁盘上的每批数据,都要重复这两个阶段:内部数据集的行从临时文件转移到哈希表;然后从另一个临时文件中读取与同一批处理相关的外部集的行,并与此哈希表进行匹配。一旦处理,临时文件将被删除。
与One-pass连接的类似输出不同,two-pass连接的EXPLAIN命令的输出包含多个批处理。如果使用BUFFERS选项,该命令还显示磁盘访问的统计信息:
我已经用增加的work_mem设置展示了上面的查询。默认值4MB对于整个哈希表来说太小了,无法容纳内存;在这个例子中,数据被分成64个批次,哈希表使用64K = (2的16次方)个桶。在构建哈希表(Hash节点)时,数据被写入临时文件(temp written);在连接阶段(Hash Join节点),读取和写入临时文件(temp read,written)。
要收集更多关于临时文件的统计信息,可以将log_temp_files参数设置为零。然后,服务器日志将列出所有临时文件及其大小(在删除时显示的大小)。
动态调整
两个问题可能打乱计划的事件进程:不准确的统计和不均匀的数据分布。
如果连接键列中值的分布不均匀,则不同批次将具有不同的大小。
如果某个批处理(第一个批处理除外)太大,则必须将其所有行都写入磁盘,然后再从磁盘读取。最麻烦的是外部集合,因为它通常更大。因此,如果外部集的mcv上有常规的非多元统计信息(即,外部集由表表示,连接由单列执行),则具有与mcv对应的哈希码的行被认为是第一批的一部分。这种技术(称为倾斜优化)可以在一定程度上减少两次连接的I/O开销。
由于这两个因素,一些(或全部)批的大小可能超过估计。然后,相应的哈希表将不适合分配的内存块,并将超过定义的限制。
因此,如果构建的哈希表太大,批处理的数量就会增加(翻倍)。每个批处理实际上被分成两个新的批处理:大约一半的行(假设分布是均匀的)留在哈希表中,而另一半保存到一个新的临时文件中。
即使最初计划了一次连接,也可能发生这种分离。事实上,一次和两次连接使用由相同代码实现的相同算法;我在这里单独列出它们只是为了更流畅地叙述。
批次数量不能减少。如果计划器高估了数据大小,则不会将批合并在一起。
在不均匀分布的情况下,增加批次数量可能没有帮助。例如,如果键列在其所有行中包含一个相同的值,则它们将被放入同一个批处理中,因为散列函数将一次又一次地返回相同的值。不幸的是,在这种情况下,无论施加了什么限制,哈希表都将继续增长。
为了演示批数量的动态增长,我们首先必须执行一些操作:
结果,我们得到一个名为bookings_copy的新表。它是booking表的精确副本,但是计划器将其中的行数低估了10倍。如果为另一个连接操作生成的一组行生成散列表,则可能出现类似的情况,因此没有可靠的统计信息可用。
这个错误的计算使计划器认为8个桶足够了,但是当执行连接时,这个数字增长到32:
成本预估
我已经使用这个示例来演示单次连接的成本估计,但是现在我要将可用内存的大小减少到最小,因此计划器将不得不使用两个批处理。它增加了连接的成本:
第二次传递的代价是将行溢出到临时文件中并从这些文件中读取它们。
两遍连接的启动成本是基于单遍连接的启动成本,这是由于写入尽可能多的页面以存储内部集所有行的所有必要字段的估计成本而增加的。虽然在构建哈希表时没有将第一批数据写入磁盘,但估计没有考虑到这一点,因此不依赖于批的数量。
反过来,总成本包括一次连接的总成本和读取先前存储在磁盘上的内部集的行以及读取和写入外部集的行的估计成本。
由于假定I/O操作是顺序的,因此写入和读取都以seq_page_cost 每个页面来估计。
在这个特殊的情况下,内部集合所需的页面数估计为7,而外部集合的数据预计适合2309页。将这些估计值添加到上面计算的一次连接成本中,我们得到与查询计划中显示的相同的数字:
因此,如果没有足够的内存,连接将分两次执行,并且效率会降低。因此,重要的是要注意以下几点:
- 查询必须以一种从哈希表中排除冗余字段的方式组成。
- 在构建哈希表时,规划器必须选择两组行中较小的那一组。