数据库内核:PostgreSQL Join
Join 实现
Join 简介
数据库管理系统是一个用以存储,结合(Combine)和过滤(Filter)信息的引擎。Join(
- nested loop:简单、应用广泛、由于没有使用缓冲区,所以效率不高
- sort-merge:如果 Join 的属性在表中是排序了的,那么效率是最高的
- hash-based:需要一个好的哈希函数和充足的缓冲区
Join 例子
现在有一个 university
数据库,它有以下几张表:
create table Student(
id integer primary key,
name text,
...
);
create table Enrolled(
stude integer references Student(id),
subj text references Subject(code),
...
);
create table Subject(
code text primary key,
title text,
...
);
目标是“列出所有学科 Subject
的学生姓名,按学科 Subject
排列”,其相对应的 SQL 如下:
select E.subj, S.name
from Student S, Enrolled E
where S.id = E.stude
order by E.subj, S.name;
其相应的关系代数表示是
为了方便后面的成本分析,
:之所以最后的元组数量为 80,000,是因为在 Enrollment 表中的所有元组都会与 Student 表中的元组有所对应。 :因为最后的结果元组中,只会有subject
和name
两个属性,所以每一页中可容纳的元组数量就增加了。
最朴素的 Join 方法就如下所示:
for each tuple TS in Students {
for each tuple TE in Enrolled {
if (testJoinCondition(C,TS ,TE )) {
T1 = concat(TS ,TE )
T2 = project([subj,name],T1)
ResultSet = ResultSet ∪ {T2}
} } }
循环遍历两个关系中的所有元组,只要满足 Join 条件就进行拼接,从拼接的结果中提取需要的属性,存入结果集。在这个过程中:
- 一共进行了
次testJoinCondition()
- 一共扫描了
个元组
当然我们也可以选择将对 Enrolled 关系的遍历放在外循环,但这个时候,扫描的元组数量就更多了。
Nested Loop Join
基本策略(
)
Result = {}
for each page i in R {
pageR = getPage(R,i)
for each page j in S {
pageS = getPage(S,j)
for each pair of tuples tR ,tS from pageR,pageS {
if (tR .a == tS .b)
`Result = Result ∪ (tR :tS )
} } }
需要 R 和 S 的输入缓冲区和 “joined” 元组的输出缓冲区。上面代码中,我们认为 R 是外关系,S 是内关系。其成本为
Block Nested Loop Join
当内存中有
对每个关系 S 的页面,检查缓冲区所有
成本分析:
- 最好的情况:当
时,可以将关系 R 的所有页面都读入到缓冲区中,此时 Join 的成本为 。 - 一般的情况:当
时,此时需要读 个页面 Chunk,对于每一个 Chunk,都需要从关系 S 中读取 个页面,因此 Join 的成本为
不管哪种情况,都需要进行
Block Nested Loop Join 在实际中更加实用是因为很多查询如下:
select * from R,S where r.i=s.j and r.x=K;
这类查询等价于:
Tmp = Sel[x=K](R)
Res = Join[i=j](Tmp, S)
如果 Tmp
非常小,那么就能全部放入内存中,所以 Block Nested Loop Join 会更加实用。
练习 1
对于
- 当
时:- 关系 S 作为内关系,关系 E 作为外关系时:此时
,因此其成本为 - 关系 E 作为内关系,关系 S 作为外关系时:此时
,因此其成本为
- 关系 S 作为内关系,关系 E 作为外关系时:此时
- 当
时:- 关系 S 作为内关系,关系 E 作为外关系时:此时
,因此其成本为 - 关系 E 作为内关系,关系 S 作为外关系时:此时
,因此其成本为
- 关系 S 作为内关系,关系 E 作为外关系时:此时
- 当
时:- 关系 S 作为内关系,关系 E 作为外关系时:此时
,因此其成本为 - 关系 E 作为内关系,关系 S 作为外关系时:此时
,因此其成本为
- 关系 S 作为内关系,关系 E 作为外关系时:此时
练习 2
如果练习 1 示例中的查询是:
select j.code, j.title, s.name
from Student s
join Enrolled e on (s.id=e.student)
join Subject j on (e.subj=j.code);
- 之前的分析需要进行哪些改变?
- 有哪些 Join 组合?
- 假设有 2000 subjects 元组,
,中间元组有多大? - 计算
的成本(页面获取与写入的数量)
由于是三个表进行 Join,因此需要先对其中两个进行 Join,然后产生的中间结果写入到临时的磁盘页面中,再接着将中间结果与剩下那个表进行 Join。因此,成本还需要加入将中间结果写出的成本,其余成本与上面差不多。
按照连接的先后顺序,有
由于缺少信息,后两问无法做。
Index Nested Loop Join
Block Nested Loop 也是存在问题的,那就是需要反复读取内关系中的页面。如果内关系有一个索引,那么就可以很好地解决重复读的问题。以下是
for each tuple r in relation R {
use index to select tuples from S where s.j = r.i
for each selected tuple s from S {
add (r,s) to result
}}
成本分析:
- 对于关系 R 进行一个扫描,仅需要一个缓冲区,因为每次只需要使用一个元组
- 对于关系 R 中的每个 元组,使用索引在关系 S 中寻找一个匹配的元组,成本取决于索引的结构以及结果的数量
其成本为
练习 3
现在执行
是主键,关系 T 中有 的索引- 关系 T 按
排序,关系 S 的每一个元组都有两个 T 的元组符合 Join - 数据库管理系统有
个缓冲区
计算分别使用 block nested loop join 和 index nested loop join 两种方法的成本,
- block nested loop join:其成本为
, - index nested loop join:其成本为
, 。
Sort-Merge Join
Sort-Merge Join 简介
它的基本方法为:
- 基于 Join 属性给两个关系进行排序
- 同时扫描两个关系,使用 Merge 去组成结果元组
优点:
- 不需要对每个关系 R 的元组处理整个关系 S(关系 S 为内关系,关系 R 为外关系)
- 只需要处理关系 R 和关系 S 匹配的元组数量的轮次
缺点:
- 为两个关系进行排序的成本高
- 如果 Join 属性的取值有重复 (non-key attribute),此时需要 Rescanning
标准 Merge 需要两个光标,其伪代码如下:
while (r != eof && s != eof) {
if (r.val ≤ s.val) { output(r.val); next(r); }
else { output(s.val); next(s); }
}
while (r != eof) { output(r.val); next(r); }
while (s != eof) { output(s.val); next(s); }
而在这里,对于 Join 的 Merge 需要 3 个光标:
- r:关系 R 中当前的元组
- s:关系 S 中当前的元组
- ss:关系 S 中当前 一轮的起始位置
之所以需要第三个光标是因为 Join 的属性可能不是 Key 属性。比如在上图中,关系 R 和关系 S 中都有一系列 Join 属性为 4 的元组,这些元组都需要 Join。因此,在关系 R 中,我们先读到第一个 4,此时需要将其与关系 S 中的所有 4 进行 Join,然后关系 R 中下一个值仍为 4,此时就需要在 S 中重新读取这轮的 4。其伪代码如下:
Query ri, si; Tuple r,s;
ri = startScan("SortedR");
si = startScan("SortedS");
while ((r = nextTuple(ri)) != NULL && (s = nextTuple(si)) != NULL) {
while (r != NULL && r.i < s.j) // 当关系R每读完,且关系R当前元组小于关系S当前元组
r = nextTuple(ri); // 读关系R的下一个元组
if (r == NULL) break; // 关系R遍历完了
while (s != NULL && r.i > s.j)
s = nextTuple(si);
if (s == NULL) break;
TupleID startRun = scanCurrent(si) // 记下关系S当前的元组为ss
while (r != NULL && r.i == s.j) {
while (s != NULL and s.j == r.i) {
addTuple(outbuf, combine(r,s)); // 符合Join
if (isFull(outbuf)) {
writePage(outf, outp++, outbuf);
clearBuf(outbuf);
}
s = nextTuple(si);
}
r = nextTuple(ri);
setScan(si, startRun);
}
}
缓冲区的需求:
- 对于排序阶段:缓冲区越多越好,因为排序的代价为
,这里的 就是缓冲区的数量。如果缓冲区不够,那么排序所用的开销会十分巨大,基本占据了整个过程的主导地位。 - 对于合并阶段:一个输出缓冲区用来存储结果,一个输入缓冲区读取 关系 R,一个足够放下关系 S 中最大 Run 的输入缓冲区。
成本分析:
- 对于排序阶段:
,这里的 就是缓冲区的数量。 - 对于合并阶段:如果关系 S 中的每个 Run 都能够存入缓冲区中,那么
。如果关系 S 中有一些 Run 长度大于缓冲区,需要根据关系 R 中的值重复扫描 Run。
例子与练习
考虑一个如下的 SQL 查询:
select E.subj, S.name
from Student S join Enrolled E on (S.id = E.stude)
order by E.subj;
数据库管理系统的参数:
例子 1:
例子 1:
在上述条件下,不需要任何 rescaning。因此,其成本为
Hash Join
Hash Join 简介
基本想法:使用哈希对关系进行分区,从而避免考虑所有的元组对。因此,这需要充足的内存缓冲区来容纳大部分的分区,如果能把外关系的最大分区放入其中,这将是最好的。但是该方法也存在问题,那就是只能对等式 Join 使用,而之前两种 Join 方法应用的范围更广泛。而且哈希容易出现数据偏斜。Hash Join 主要有以下 3 种变式:
- Simple Hash Join
- Grace Hash Join
- Hybrid Hash Join
Simple Hash Join
一个页面一个页面地读取外关系 R,并对当前页面中的元组进行哈希,将哈希后的元组存入内存缓冲区,这些缓冲区也称为哈希缓冲区(所有哈希缓冲区可以看作是一个哈希表,那么每一个哈希缓冲区就是一个哈希桶)。一旦有一个哈希缓冲区满了,就开始扫描内关系 S。扫描内关系 S 时,使用相同的哈希函数。如果
- 一个输出缓冲区
- 一个关系 R 的输入缓冲区
- 一个 关系 S 的输入缓冲区
- 剩余所有缓冲区当作哈希缓冲区
其伪代码如下:
for each tuple r in relation R {
if (buffer[h(R.i)] is full) {
for each tuple s in relation S {
for each tuple rr in buffer[h(S.j)] {
if ((rr,s) satisfies join condition) {
add (rr,s) to result
} } }
clear all hash table buffers
}
insert r into buffer[h(R.i)]
}
Simple Hash Join 在页面的读取数量上面和 Nested Loop 基本相同,但是进行 Join 检查的次数要少于 Nested Loop。
Simple Hash Join 中 Join 检查的次数小于等于
成本分析:
- 最好的情况:关系 R 的所有元组都可以放到哈希表中,此时两个关系的页面都只需要读一遍,
。 - 比较好的情况:再填充哈希表 m 次。 对于外关系 R,我们是分为几个 chunks 来读取,当读取某个 chunk,使得有一个哈希缓冲区被填满了,就开始读取内关系 S,当操作完关系 S 中的元组之后,就会把所有哈希缓冲区清空,继续从关系 R 中读入,重新填充这些哈希缓冲区。所以,这里的
。此时 。此时虽然读取页面的数量比 Nested Loop 多,但是 Join 检查的次数仍然更少。 - 最糟糕的情况:所有的元组都被哈希到同一个页面。 此时
。
练习:Simple Hash Join 的成本
现在执行
是主键,关系 R 的每一个元组都有 2 个匹配的 S 元组- 数据库管理系统有
个缓冲区 - 数据和哈希都符合均匀分布
使用 simple hash join,假设哈希表中每个分区的装填因子为 0.75。计算页面读写次数和 Join 检查次数。
一共 43 个缓冲区,去掉输入输出的 3 个缓冲区后,还剩余 40 个,由于装填因子为 0.75,所以 40 个缓冲区可以装
Grace Hash Join
基本思路:
- 使用哈希函数
基于 Join 属性对两个关系进行分区 - 对关系 R 的分区使用另一个哈希函数
将其一个一个载入 个哈希缓冲区中 - 扫描对应的关系 S 的分区去组成结果
- 重复操作直到所有分区都被扫描过
分区阶段:左侧就是关系,一个页面一个页面进行读取,对当前页面中的元组使用哈希函数将其分配到合适地输出缓冲区中,当缓冲区满了之后,写入到磁盘中对应的分区文件中。
Join 阶段:在分区阶段,由于对两个关系都是使用相同的哈希函数,所以两者的分区文件是相互对应的。而每个分区的大小也能直接装入到内存缓冲区中。此时,读入一个关系 R 的分区,并使用一个新的哈希函数
成本分析:
所有分区文件中的页面数量约为原文件页面数量(可能会稍多一些)。所以在进行分区时:
- 关系 R 的分区:
- 关系 S 的分区:
在 Join 阶段中,需要扫描所有的分区文件,
因此:
练习:Grace Hash Join 的成本
现在执行
是主键,关系 R 的每一个元组都有 2 个匹配的 S 元组- 数据库管理系统有
个缓冲区 - 数据和哈希都符合均匀分布
使用 Grace Hash Join,假设关系 R 的分区都没有大于 40 个页面。计算页面读写次数和 Join 检查次数。
读页面次数为
使用 Grace Hash Join,假设关系 R 的分区有一个为 50 个页面,关系 S 对应的分区为 30 个页面,其余都没有大于 40 个页面。计算页面读写次数和 Join 检查次数。
Hybrid Hash Join
Hybrid Hash Join 是 Grace Hash Join 的一个变体。该方法的主要思路是如果有数量很多的缓冲区
此时同样进行分区,得到
第一个阶段:首先对关系 R 进行分区,所有哈希值为 0 的元组都被放入内存分区中,其余存入磁盘。
第二个阶段:对关系 S 进行分区,如果元组的哈希值为 0,那么就在内存分区寻找合适的元组,一旦寻找到匹配的元组,进行 Join,存入输出缓冲区中。在这一阶段中,我们得到了一部分结果:
第三个阶段:对于剩下的这部分分区,使用和 Grace Hash Join 同样的方法进行处理,得到最后的结果。
一些观察:
- 对于
个分区,每个分区的尺寸约为 - 将一个分区存在内存中,需要
个缓冲区 - 对于内存分区空间和分区的数量需要有所权衡
- 如果
,使用 Nested Loop 会更简单, 取决于 ,但是少于 Grace Hash Join
练习
考虑一个如下所示的 SQL 查询:
select E.subj, S.name
from Student S join Enrolled E on (S.id = E.stude)
order by E.subj;
此时数据库的相关数据为:
现在有
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步