我们将专注于使用 等值连接(inner equijoin) 等连接算法一次合并两个表。
通常,我们希望较小的表始终是查询计划中的左表(“out table”)。
将外tuple和内tuple中的属性值复制到新的输出tuple中。
只复制join keys和匹配tuple的记录ID。
它被称为延迟物化(late materialization)
只关注I/O的使用次数而不关心实际得到输出结果的开销。
嵌套循环连接(Nested Loop Join)
归并排序连接(Sort-Merge Join)
哈希连接(Hash Join)
# 算法描述的伪代码
foreach tuple r ∈ R:
foreach tuple s ∈ S:
emit, if r and s match
该算法的愚蠢之处在于对于外表R中的每一个tuple,都要去扫描一遍内表S。
Cost: M + m * N
其中M为outer table R中的page数,m是R的tuple数,N是S的page数。
[例]一个数据库:
开销分析
如果用更小的表S作为外表呢?
尽管快了一点,但还是很糟糕。
foreach block B_R ∈ R:
foreach block B_S ∈ S:
foreach tuple r ∈ B_R:
foreach tuple s ∈ B_S:
emit, if r and s match
将R表和S表分别读取到内存的两个block中,对于外表R的block中的每一个tuple,它们全部完成对S表的block中的每一个tuple的匹配、连接后,才重新读取S表的下一个block,当扫描过一遍S表后,重新读取R表的下一块block,重复前面的步骤。
该算法需要更少的磁盘访问:
[例]一个数据库:
开销:
这比之前的算法好很多了,但还是很糟糕。我们可以再进行一些改进,让时间减少到1秒内。
如果我们有B个缓冲块可以用呢?
foreach B-2 blocks b_R ∈ R:
foreach b_S ∈ S:
foreach tuple r ∈ b_R:
foreach tuple s ∈ b_S:
emit, if r and s match
该算法使用B-2个缓冲块来扫描R。
开销:M + (⌈M / (B-2)⌉ * N)
如果外表可以全部放进内存呢(B > M + 2)?
对于外表的每一个tuple,我们必须顺序扫描去检验它和内表的每一个匹配。
我们可以使用索引来查找匹配以避免顺序扫描
foreach tuple r ∈ R:
foreach tuple s ∈ Index(r_i = s_j):
emit, if r and s match
假设对于每一个tuple,每次索引探测的开销是一个常数C。比如,对于哈希表构建的索引,如果使用的是静态哈希表,有可能发生哈希到的slot被其它key占用的情况而必须向后扫描(线性探测),因为哈希表保存的是tuple所在的位置,所以会将page读进来遍历,如果没找到目标tuple,就要将哈希表下一个slot所记载的page读进来。在这里,平均开销为常数C。
开销:M + (m * C)
对于外表里的每一个tuple,经过索引探测后可以知道待连接的目标tuple所在的page,只要将那个page读进内存即可。我们不考虑连续的几个外表tuple是否有待连接的内表tuple在同一个page中的情况,仅仅是重复地对每个外表tuple,将内表tuple所在的page读入内存,即使这个page可能已经在内存当中了。
阶段一:排序
sort R, S on join keys
cursor_R <- R_sorted, cursor_S <- S_sorted
while cursor_R and cursor_S:
if cursor_R > cursor_S:
increment cursor_S
if cursor_R < cursor_S:
increment cursor_R
elif cursor_R and cursor_S match:
emit
increment cursor_S
【例】
下面两张表,按照id进行连接,已排好序
游标开始位置
匹配上了,内表游标下移
再一次匹配上了,继续下移
没匹配上,外表游标下移
匹配上了,内表游标下移
没匹配上,外表游标下移
此时,外表游标指向的id小于内表游标指向的id,但却仍是200,应该与S表中id为200的tuple进行匹配,如果外表游标继续往下走,将会错过这些匹配,内表游标必须回溯到上一个id值的开始位置,因此我们需要维护这个信息
回溯,匹配后,内表游标下移
不匹配,外表游标下移
继续下移
匹配上了,内表游标下移
这里的要点还是在于,后面会不会还有id为500的tuple,如果有的话,内表游标还需要回溯
排序开销(R): 2 M ⋅ ( 1 + ⌈ l o g B − 1 ⌈ M / B ⌉ ⌉ ) 2M\cdot(1+⌈log_{B-1}⌈M/B⌉⌉) 2M⋅(1+⌈logB−1⌈M/B⌉⌉)
排序开销(S): 2 N ⋅ ( 1 + ⌈ l o g B − 1 ⌈ N / B ⌉ ⌉ ) 2N\cdot(1+⌈log_{B-1}⌈N/B⌉⌉) 2N⋅(1+⌈logB−1⌈N/B⌉⌉)
归并开销:(M + N)
总开销:排序 + 归并
[例]一个数据库:
在有100个缓冲页(本文中提到的块、页、block、page含义相同)的情况下,R和S的排序均需要两躺:
合并阶段最坏的情况是两个关系中所有元组的连接属性包含相同的值。比如S表的id全部为1,而我们还去对S表按id排序。
开销:(M * N) + (排序开销)
如果元组r ∈ R和s ∈ S满足连接条件,那么它们含有相同的连接属性值。
如果那个值被散列到一个部分 i,那么R的元组必然在 r i r_i ri 中并且S的元组必然在 s i s_i si 中。
因此,R在 r i r_i ri 中的tuples只需要与S在 s i s_i si 中的tuples比较。
阶段一:构建
阶段二:探测
build hash table HT_R for R
foreach tuple s ∈ S
output, if h_1(s) ∈ HT_R
Key: 查询连接表的属性
Value:因实现而异,取决于查询计划(查询树)中连接运算符上面的运算符期望得到的输入
方法一:完整的Tuple
方法二:Tuple ID
用于集合成员资格查询(set membership query)的概率数据结构(bitmap)。
我们能使用布隆过滤器进行的操作:
Insert(x):
Lookup(x):
如图,为一个布隆过滤器,由8位构成。
插入key,哈希函数数量k = 2
得到哈希值并进行模运算后,将相应位置1
当key可能不在哈希表中时,可以在构建阶段创建布隆过滤器。
在连接操作上执行布隆过滤,内表的元组只有在连接属性经散列后得到的每一位都是1才去哈希表中进行实际的匹配。
布隆过滤器在分布式数据库中十分有用,例如,当A、B分别为两台服务器或数据中心时,要执行连接操作,与其通过发送网络消息来执行探测阶段,不如把大小为几kb的布隆过滤器发送给其他机器,这样一来,在发送网络消息前,可以先在本地进行过滤。
之所以称它为横向消息传递,是因为它把查询操作执行的模型给拆分了,原本每个子节点通过单独的信道向父节点传递数据,不在兄弟节点间传递数据,但现在打破了这个模型。尽管违反了规则,但对我们却大有裨益。
如果没有足够的内存来容纳整个哈希表,会发生什么?
我们不想让缓冲池管理器随机交换哈希表页。
当表不能完全放在内存中时的哈希连接。
在前面的哈希连接中,只对外表构建哈希表,而对内表只进行检索是否有符合连接条件的元组,然后再连接。在Grace哈希连接中,对它们分别构建哈希表,然后对两边表中相应的分区进行嵌套循环连接。
将R散列到不同的桶中(0,1,…,max)
用相同的哈希函数对S进行散列
如果连接的属性重复值太多(很多元组都被散列到同一个分区中),那么桶链会越来越长,没办法都放在内存中时,可以使用递归分区将表拆分为更小的块
这时,可以用另一个哈希函数再分区
当进行连接时,在S表中经h1散列出的分区会和R表
此时,会知道该分区已被拆分,所以进而使用h2进行散列
假设我们有足够的缓冲块:
开销:3(M + N)
分区阶段:
探测阶段:
[例]一个数据库:
开销分析:
如果DBMS知道外表的大小,那么它可以使用静态哈希表。
如果不知道大小,则必须使用动态哈希表或允许溢出页。
算法 | IO开销 | 例子 |
---|---|---|
简单的嵌套循环连接 | M + (m * N) | 1.3 hours |
块嵌套循环连接 | M + (M * N) | 50 seconds |
使用索引的嵌套循环连接 | M + (m * C) | 可变 |
排序合并连接 | M + N + (sort cost) | 0.59 seconds |
哈希连接 | 3(M + N) | 0.45 seconds |
除非数据已经排序,否则哈希几乎总是比排序更好。
注意事项
好的数据库管理系统使用其中一个或两个。