DBMS Implementation 笔记 05: SIMC & CATC 以及 Join 操作的实现


在上一节中,我们已经了解到,基于 Signature 的 Indexing 方法有 2 种,分别为:

  • Superimposed Codewords (SIMC) - 叠加码字
  • Concatenated Codewords (CATC) - 拼接码字

这一节,我们主要围绕这两种方法进行深入了解

Superimposed Codewords (SIMC) - 叠加码字

在正式开始介绍 SIMC 之前,我们需要回顾一下 Signature File 的结构:

在这里插入图片描述
可以看到,Signature File 与 Data File 并行存在,每个 Signature Slot 对应一个 Tuple Slot。如果某个 Tuple Slot 是空的 (闲置/被删除),那么对应的 Signature Slot 就会被置为 0.

在 SIMC Indexing 模式下,每个 Tuple Descriptor (Signature) 由多个 Attribute Codewords 位或 (bitwise OR) 叠加而成,

  • Signature 实质上就是一个 bit-string 它的长度为 m bits,其中 j ≤ nk (≈ n/2) bits 被置为 1.
  • desc(t) = cw(Ai) OR cw(A2) OROR cw(An)
Bits desc = 0
for (i = 1; i <= n; i++) {
	bits cw = codeword(A[i],m,k)
	desc = desc | cw
}

值得注意的是,这里我们使用了所有的 Attribute,但在实际情况中,可能会有一部分 Attribute 基本不会被使用,那么就可以忽略它。下面我们看一个具体的例子:

我们考虑一个如下所示的 Tuple:
在这里插入图片描述
对于其每个 Attribute,有如下的 Codeword:
在这里插入图片描述
这里的 m = 12,k = 2,意味着每个 codeword 的长度是 12 位,其中有 2 位会被置为 1. 这里我们能够看到,通过叠加 (位与) 得到的 Signature 种,恰好有一半的 bits 被置为 1,当然这不是必然的。在本例中,我们有 4 个 Attribute,同时 k = 2,那么最终的 Signature 极有可能有 8 位为 1. 而如果所有 cw(Ai) 都在同样的 2 位上为 1,那么最后的 Signature 也有可能只有 2 位为 1.

下面来看如何在 SIMC 模式下进行查询:

  • 首先,需要将 Query q 中已知 Attribute 的 codeword 进行叠加
  • 之后,在 Signature File 中搜寻所有与 cw(q) 匹配的 Signatures

比如我们的查询为 (Perryridge, ?, ?, ?) 那么就有:

在这里插入图片描述

pagesToCheck = {}
// scan r signatures
for each descriptor D[i] in signature file {
		if (matches(D[i],desc(q))) {
			pid = pageOf(tupleID(i))
			pagesToCheck = pagesToCheck ∪ pid
		}
}
// then scan bsq = bq + δ pages to check for matches

上面的 δ 表示包含所有 False Match 的 Pages。至于具体的匹配函数,可以非常高效的实现,我们只需要进行位与运算即可:

#define matches(sig,qdesc) ((sig & qdesc) == qdesc)

下面,我们仍用上面的例子,来看看 Query Signnatrue 和 Database 中的 Signatures:

在这里插入图片描述
上图中,我们使用绿色表示匹配的对象,红色表示 False Match 的对象。此时正确匹配的 False Macth 的几率为 50%,并不是十分理想的结果。我们用 pF 来表示 False Match 结果出现的几率,我们关系的是,如何降低这个几率:

  1. 对不同的 Attibture 使用不同的 Hash Function
  2. 增大 Signature 尺寸 (m)
  3. 调整 k 的值,使其约为 m 的一半

但是,如果我们增大 m,就意味着 Signature File 会被增大,这就需要读取更多的 Signature Data。对于 k,如果太大,就会有更多的位重叠,如果太小,就会使 Hash Collision (相同的 Hash)出现得更多那么,如何挑选合适的 m 和 k ?

  1. 首先选择一个可以接受的 pF,比如 pF ≤ 10-4 (即 1000 个匹配中,只有一个 False Match)
  2. 之后再选择 m 和 k,确保它们不会使 pF 超过这个预期

使用 pF 来推定 m 和 k 的公式为:

k = 1/loge 2 . loge ( 1/pF )

m = ( 1/loge 2 )2. n . loge ( 1/pF )

Query Cost for SIMC

现在来看 SIMC 下进行查询的代价是多少:

对于 Partial Matching Retrieve,代价 Costpmr = bD + bsq.

  • 从整个 Signature File 中找到匹配的 Signature。(bD
  • 读取 bsq Data Pages,从中寻找匹配结果

设共有 r 个 Signature,Signature File 中每个 Page 的容量为 cD,那么 bD = ceil(r/cD)。

而 cD = floor(B/ceil(m/8))

bsq 包括那些含有 rq 个匹配 Tuple 和 rF False Match Tuples 的 Pages。rF 的期望值为 (r - rq) pF.

最好情况下:bsq = 1

最坏情况下:bsq = rq + rF

一般情况下:bsq = ceil(b (rq + rF)/r)

Page-level SIMC

我们目前所说的 SIMC 是基于 Tuple 的,即一个 Signature Slot 对应一个 Tuple Slot,但是这么做并不高效,所以我们现在来看一个变体,基于 Page 的 SIMC,即一个 Signature 对应一个 Page。这个时候,该 Page 中的所有 Tuple 的所有 Attribute 都是组成该 Signature 的一部分。所以,一个包含 64 个 Tuple 的 Page 的 Signature 会是一个 Tuple Signature 的 64 倍大。

在这里插入图片描述
此时的搜索过程与之前基本一致:

pagesToCheck = {}
// scan b mp -bit page descriptors
for each descriptor D[i] in signature file {
		if (matches(D[i],desc(q))) {
			pid = i
			pagesToCheck = pagesToCheck ∪ pid
		}
}
// read and scan bsq data pages
for each pid in pagesToCheck {
		Buf = getPage(dataFile,pid)
		check tuples in Buf for answers
}

Bit-sliced SIMC

现在我们不再存储 b 个 m 位长度的 Page Signature,而是存储 m 个 b 位长度的 Bit-sliced Signature:

在这里插入图片描述

matches = ~0 //all ones
// scan m r-bit slices
for each bit i set to 1 in desc(q) {
		slice = fetch bit-slice i
		matches = matches & slice
}
for each bit i set to 1 in matches {
		fetch page i
		scan page for matching records
}

起初的 Matches 中所有位都是 1。之后,将 Query Signature 中所有 1 对应的 Slice 与 Match 进行位与运算,根据最后得到的 Matches,遍历所有 1 的 Pages。这一方法的优点在于,Signature 中通常少于一半的位会被置为 1.

Tuple-basedPage-basedBit-sliced
共 r 个 Signature,每个 Siganature 长 m 位,一个 Attribute 占 k 位共 b 个 Signature,每个 Siganature 长 mp 位,一个 Attribute 占 k/c 位共 m 个 Signature,每个 Slice 长 r 位,一个 Attribute 占 k 位
读取 Signature File 中的所有 Pages,之后进行筛选读取 Signature File 中的所有 Pages,之后进行筛选只需读取 Signature File 中少于一半的 Pages

Concatenated Codewords (CATC) - 拼接码字

现在我们来看 CACT。在 CACT 中:

  • 一个 Tuple 的 Signature 由各 Attribute 的 codewords 拼接而成
  • Signature 长度为 m bits,其中有一半被置为 1
  • Attribute i 的 codeword 长度为 ui bits,同样有一半被置为 1
  • 每个 Attribute 的 codeword 长度可以不同,但其之和必须为 m
  • desc(t) = cw(Ai) + cw(A2) ++ cw(An)

下面同样用一个具体的例子来看:

考虑一个如下所示的 Tuple:
在这里插入图片描述
我们可以得到如下的 codewords/descriptors:
在这里插入图片描述
这里的 m=16, ui=4。即每个 Attribute 的 codeword 长度为 4,Tuple Signature 长度为 16.

在 CATC 模式下回答查询,需要:

  1. 首先,通过拼接所有 Attribute 的 codewords 得到 Query q 的 Signature desc(q)
  2. 对于已知的 Attribute 直接使用其 codeword,对于未知的则使用 0

比如我们的查询为 (Perryridge, ?, Hayes, ?). 那么 Signature 为:

在这里插入图片描述
一旦有了 Query q 的 Signature,就可以对 Signature File 继续扫描:

pagesToCheck = {}
// scan r signatures
for each descriptor D[i] in signature file {
		if (matches(D[i],desc(q))) {
			pid = pageOf(tupleID(i))
			pagesToCheck = pagesToCheck ∪ pid
		}
}
// then scan bsq = bq + δ pages to check for matches

这里的匹配函数 matches() 同样可以使用与 SIMC 一样的方法:使用 Query q 的 Signature 与 Database 中的 Tuple Signature 进行位与运算,如果结果仍为 Query q,那么就是候选目标。需要注意的是,仍会有 False Match 结果:

在这里插入图片描述
上图中用绿色表示正确的匹配结果,红色表示 False Match。与之前一样,我们仍使用 pF 来表示 False Match 的概率。但是,在 CATC 中,对于每个 Attribute 采用不同的 Hash Function 来降低该概率的方法可能不会有很好的效果,这是因为在 CATC 中,原本每个 Attribute 的 Hash Function 就是相独立的。不过使 m 增大的方法仍然可行。这里我们还需要注意到一点,因为 ui 相对比较小,所以 Hash Collision 可能会比较严重。所以又到了老生常谈的问题,我们该如何选择合适的 m 和 u ?

对于 m,和 SIMC 基本一致,先选择理想的 pF ,然后据此选择 m,m 的公式为:

m = ( 1/loge 2 )2. n . loge ( 1/pF )

至于 ui 的选择,我们可以:

  • 每个 Attribute Ai 有着相同的 ui
  • 基于每个 Attribute 的取值范围来分配 ui

Query Cost for CATC

在 CATC 下进行查询的代价和 SIMC 基本一致:Costpmr = bD + bsq.

Variations on CATC

和 SIMC 类似,对于 CATC 也可以基于 Page 来构建 Signature。此时一个 Page 的 Signature 的 长度 m:

mp = ( 1/loge 2 )2 c.n . loge ( 1/pF )

c 使一个 page 中 Tuple 的数量,n 是 Attributes 的数量。

同样,对于 CATC 也有着和 SIMC slice-based 类似的方法来构建 Signature。即存储 mp 个长度为 b bits 的 bit-slice。如果 b=2x 那么此时使用的存储空间与 page-level Signature 一样。此时考虑 Query Cost,对于每个已知的 Attribute 需要扫描 ui/2 bit-slice,如果 k 是已知 Attribute 的集合,那么 number of slices = Σ ui / 2 (i ∈ k)。

下面我们基于相同的 m, n 和 pF 对于一下 SIMC 和 CATC:

CATCSIMC
CATC 中,每个 Attribute 的 codeword 长度为 ui,每个约有一半被置为 1. 所有 Attribute 的 codeword 拼接而成的 Signature 长度为 m,同样有一半被置为 1SIMC 中,Signature 的长度为 m,其中有一半被置为 1
CATC 中对于 ui 的要求更灵活,但是会有更多的 Hash CollisionSIMC 有更少的 Hash Collision,但是会有 Unfortunate Overlay 带来的问题

JOIN Implementation

DBMS 是一个用以存储 (Store),结合 (Combine) 和过滤 (Filter) 信息的引擎。Join (⨝) 就是最主要的结合 (Combine) 信息的方法。Join 操作非常关键,但是代价可能会十分高昂。最普遍的 Join 就是等价 Join,比如:R.pk = S.fk。所有 Join 操作的变体(比如 inner, outer, semi, anti, natural 等)都基本一致,下面我们主要来考虑实现 Join 操作的 3 种方式:

  • Nested Loop
  • Sort-merge
  • Hash-based

我们考虑一个如下所示的 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 join Enrolled E on (S.id = E.stude)
order by E.subj, S.name;

为了方便后续的理解,我们在这里先给出一些该数据库的具体数据:

在这里插入图片描述
同时,为了后续代价分析,我们在这里也定义 N = Number of Memory Buffers。

我们想要的结果是:out = Student ⨝ Enrolled

在这里插入图片描述

  • rOUT:这里之所以最后的 Tuple 数量为 80,000,这是因为在 Enrollment Table 中的所有 Tuple 都会与 Student Table 中的 Tuple 有所对应。
  • COUT:因为最后的结果 Tuple 中,只会有 subject 和 name 两个 Attribute,所以每一页中可容纳的 Tuple 数量就增加了

最朴素的 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}
}	 }	 }

循环遍历两个 Relation 中的所有 Tuple,只要满足 Join 条件就进行拼接,从拼接的结果中提取需要的 Attribute,存入结果集。在这个过程中:

  • 一共进行了 rS * rE = 20,000 * 80,000 = 16 * 108testJoinCondition()
  • 一共扫描了 rS + rS * rE = 20,000 + (20,000 * 80,000) 个 Tuples

当然我们也可以选择将对于 Enrolled Relation 的遍历放在外循环,但这个时候,扫描的 Tuples 就更多了 80,000 + (20,000 * 80,000)

总体来说,Nested Loop,Merge-sort,Hash-based 这三种 Join 方式彼此之间没有明显的优劣。对于一个给定的查询,哪种方式更好取决于:

  1. 进行 Join 的 Relation 的规模,Buffer Pool 的规模
  2. 是否有索引/被排序
  3. 用到了哪些 Attributes / Operations
  4. 匹配的 Tuples 的数量
  5. 数据值的分布

给定查询 Q,选择一个合适的 Join 方法是很重要的,代价会有很大的差异

Nested Loop

接下来我们首先来看第一种实现 Join 的方法:Nested Loop。我们仍使用之前的例子来进行理解:

select E.subj, S.name
from Student S join Enrolled E on (S.id = E.stude)
order by E.subj, S.name;

再来回顾一下最基本的方法:

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 )
}	 } 	}

该方法对于 Relation R 和 S 都需要 Input Buffer,同时还需要一个 Output Buffer 来存储结果。此时 R 为 Outer Relation,S 为 Inner Relation。此时 Cost = bR * bS

现在我们考虑一种情况,就是一共有 N 个 Buffers,这个时候,就可以使用一个 Buffer 来读取 Relation S,一个作为 Output Buffer 来存储结果,剩下的 N-2 个 Buffers 用来读取 Relation R 的 Page Chunk

在这里插入图片描述
对于每个 S Page,检查 Buffer 中所有 (tR, tS) 对的 Join 条件。这个时候,我们再来分析 Cost。

最好的情况无疑是 bR ≤ N-2,那么就可以直接将整个 R 读入 Buffer 中。此时 Cost = bR + bS,即这两个 Relation 中的 Page 都只需要各读一次。

一般情况下,bR > N-2,这就需要从 R 中读取 ceil(bR / N-2) chunks,对于每个 chunk,需要从 S 中读取 bS Pages。此时 Cost = bR + bS * (ceil(bR / N-2))。

但无论如何,对于 Tuple 之间的检查总需要 rR * rS

比如:

Outer Relation 为 S,Inner Relation 为 E,N = 12 Buffers

Cost = bS + bE * ceil(bS / N - 2) = 1000 + 2000 * ceil(1000 / 10) = 201000

Outer Relation 为 E,Inner Relation 为 S,N = 12 Buffers

Cost = bE + bS * ceil(bE / N - 2) = 2000 + 1000 * ceil(2000 / 10) = 202000

Outer Relation 为 S,Inner Relation 为 E,N = 102 Buffers

Cost = bS + bE * ceil(bS / N - 2) = 1000 + 2000 * ceil(1000 / 100) = 21000

实际情况中,Nested Loop 往往能收获比较不错的结果,因为大多数的 Join 操作如下所示:

select *
from R join S on (R.i = S.j)
where R.x = K

其操作过程往往会被等效为;

Tmp = Sel [x=K] (R)
Res = Join [i=j] (Tmp, S)

即首先在其中一个 Relation 进行 selection 再 join。如果 Tmp 足够小,恰好能够存入 Buffer,那么就是 Nested Loop 的最佳情况。但是,Nested Loop 也是存在问题的,那就是需要反复读取 Inner Relation 中的 Pages。如果 Inner Realtion 有一个 Index,那么就可以很好地解决这个问题。

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
} 	}

想要使用 Index Nested Loop Join,需要:

  1. 对于 Relation R 进行一个扫描,仅需要一个 Buffer,因为每次只需要使用一个 Tuple
  2. 对于 R 中的每个 Tuple,使用 Index 在 S 中寻找一个匹配的 Tuple
    • cost 取决于 index 的结构以及结果的数量

此时 Cost = bR + rR * SelS

这里的 SelS 就是在 S 中进行 Selection 的代价,如果 Relation S 使用 Hashing 来 Index,那么代价为 1

Sort-merge

现在我们来看第二种实现 Join 的方式,Sort-merge。它的基本方法为:

  1. 基于 Join Attribute 给两个 Relations 排序
  2. 同时扫描两个 Realations,使用 Merge 去组成结果 Tuple (r, s)
AdvantageDisadvantage
不需要对每个 r tuples 处理整个 Relation S(注:即之前的内外循环)为两个 Relation 进行排序的代价比较高昂
只需要处理 R 和 S 匹配 Tuple 数量的轮次如果 Join Attribute 的取值有重复 (non-key attribute),此时需要 Rescanning

标准的 Merge 操作需要 2 个光标 (Cursor)

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 个光标 (Cursor):

  • r:Relation R 中当前的 record/tuple
  • s:Relation S 中当前的 record/tuple
  • ss:Relation S 中当前 Run 的起始位置

在这里插入图片描述
之所以需要第三个 cursor 正式因为我们之前所说的 Join Attribute 可能不是 Key Attribute。比如在上图中,Relation R 和 Relation S 中都有一系列 Join Attribute 为 4 的 Tuple,这些 Tuple 都需要 Join,因此,在 R 中,我们先读到第一个 4,此时需要将其与 S 中的所有 4 进行 Join,然后 R 中下一个值仍为 4,此时就需要在 S 中重新读取这个 Run of 4.

具体的算法如下所示:

Query ri, si; Tuple r,s;
ri = startScan("SortedR");
si = startScan("SortedS");
while ((r = nextTuple(ri)) != NULL && (s = nextTuple(si)) != NULL) {
		// align cursors to start of next common run
		while (r != NULL && r.i < s.j)
			r = nextTuple(ri);
		if (r == NULL) break;
		while (s != NULL && r.i > s.j)
			s = nextTuple(si);
		if (s == NULL) break;
		// must have (r.i == s.j) here// remember start of current run in S
​		TupleID startRun = scanCurrent(si)// scan common run, generating result tupleswhile (r != NULL && r.i == s.j) {while (s != NULL and s.j == r.i) {addTuple(outbuf, combine(r,s));if (isFull(outbuf)) {writePage(outf, outp++, outbuf);clearBuf(outbuf);}
​				s = nextTuple(si);}
​			r = nextTuple(ri);setScan(si, startRun);}
}

现在来看看在这个过程中,对于 Buffer 的需求。

  • 对于排序 (Sort) 阶段
    • Buffer 越多越好,因为排序的代价为 O(logN)。这里的 N 就是 Buffer 的数量
    • 如果 Buffer 不够,那么排序所用的开销会十分巨大,基本占据了整个过程的主导地位
  • 对于合并 (Merge) 阶段
    • 一个 Output Buffer 用以存储结果
    • 一个 Input Buffer 读取 Relation R
    • 一个足够放下 Relation S 中最大 Run 的 Input Buffer

在了解了整个流程的基础上,我们可以来看看 Sort-merge 的代价为何。仍然分成两个步骤来进行分析

Sort Phase:对两个 Relation 进行排序

Cost = 2.bR (1 + logN-1 (bR /N)) + 2.bS (1 + logN-1 (bS /N))

这里的 N 仍是 Buffer 的数量

Merge Phase:将两个已排序的 Relation 进行合并

  • 如果 S 中的每个 Run 都能够存入 Buffer 中,那么 Cost = bR + bS
  • 如果 S 中有一些 Run 长度大于 Buffer,需要根据 R 中的值重复扫描 Run

最后我们用一个具体的例子来回顾一遍整个过程:

考虑一个如下的 SQL Query:

select E.subj, S.name
from Student S join Enrolled E on (S.id = E.stude)
order by E.subj

在 Database 中:
rs = 20,000, cS = 20, bS = 1000
rE = 80,000, cE = 40, bE = 2000


Case 1: Join[id=stude](Student, Enrolled)
两个 Relation 都没有基于 id 排序,Buffers N = 32,所有 Run 的长度都小于 30
Cost
= sort(S) + sort(E) + bS + bE
= 2bS (1+log31 (bS /32)) + 2bE (1+log31 (bE /32)) + bS + bE
= 2×1000×(1+2) + 2×2000×(1+2) + 1000 + 2000
= 6000 + 12000 + 1000 + 2000
= 21,000


Case 2: Join[id=stude](Student, Enrolled)
两个 Relation 已经基于 id 排序,Buffer N = 4 (S input, 2 E input, output)。E 中 5% 的 runs 会占据 2 Pages,在 S 中没有 Runs,因此 id 是 S 的 Primary Key。
在上述条件下,不需要任何 rescan
Cost = bS + bE = 1000 + 2000 = 3000

Hash-based

现在来看最后一种实现 Join 的方式:Hash Join。该方法的基本思想在于使用 Hashing 来分割 Relations,从而避免不得不考虑所有的 Tuple Pairs。 该方法需要充足的 Memory Buffers 以容纳大部分的分段,如果能把 Outer Relation 的最大分段放入其中,是最好的。但是该方法也存在问题,那就是只能对等式 Join (equijoin) 使用,比如 R.i = S.j,而前述的 2 种方法应用的范围更广泛。容易出现数据偏斜 (susceptible to data skew)。Hash Join 主要有以下 3 种变式:

  • Simple
  • Grace
  • Hybrid

Simple Hash Join

Simple Hash Join 的方法相对比较直觉:

  1. 一个 Page 一个 Page 地读取 (Read) Outer Relation R,并对当前 Page 中的 Tuples 进行Hashing,将 Hashing 后的 Tuples 存入 Memory Buffers,我们称这些 Buffers 为 Hashing Buffers(而这些 Hash Buffers 可以看作是一个 Hash Table)。一旦有一个 Hash Buffer 满了,就开始扫描 Inter Relation
  2. 扫描 Inter Relation S 时,使用同样的 Hash Function
    • 如果 R.i = S.j,那么 h(R.i) = h(S.j)。即会被 Hashing 到同一个 Buffer
    • 对每一个 Relation S 中的 Tuple,只需要检查一个 Buffer 即可

需要注意的是,在这里我们不允许有 Overflow

在这里插入图片描述
通过上图我们可以看到,在整个过程中,我们有:

  • 一个 Output Buffer
  • 一个 Relation R 的 Input Buffer
  • 一个 Relation S 的 Input Buffer
  • 剩余所有 Buffers 用作 Hash Buffers

具体的算法描述如下所示:

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 在 Page 的读取数量上面和 Nested Loop 基本相同,但是进行 Join Test 的次数要少于 Nested Loop。# join tests ≤ rS * cR(即 S 中 Tuple 的总数乘以一个 Hash Buffer 的 Tuple 总数),而在 Nested Loop 中, # join tests = rS * rR

最后,我们来看 Simple Hash Join 的代价。

最好的情况就是所有 R 中的 Tuples 都能存入所有的 Hash Buffers。 此时两个 Relations 中的 Pages 都只需要读一遍,Cost = bR + bS。重申一遍,虽然读取 Page 的数量与 Nested Loop 一样,但是 Join Test 的次数更少

比较好的情况是再填充 Hash Table m 次。 这里稍微解释一下什么是再填充,我们结合之前的算法代码和图可以看到,对于 Outer Relation R,我们是分为几个 chunks 来读取,当读取某个 chunk,使得有一个 Hash Buffer 被填满了,就开始读取 Relation S,当操作完 S 中的 Tuples 之后,就会把所有 Hash Buffers 清空,继续从 R 中读入,重新填充这些 Hash Buffers。所以,我们这里的 m ≥ ceil(bR / (N - 3))。此时 Cost = bR + m * bS。此时虽然读取 Page 的数量比 Nested Loop 多,但是 Join Test 的次数仍然更少

最糟糕的情况就是所有的 Tuples 都被 Hashing 到同一个 Page。 此时 Cost = bR + bR * bS

Grace Hash Join

接下来,我们来看 Grace Hash Join。该方法的主要思想在于 (R ⨝ S ):

  • 使用 Hash Function h1 基于 Join Attribute 分割两个 Relation R 和 S
  • 对 R 的 Partition 使用另一个 Hash Function h2 将其一个一个载入 (N-3) 个 Hash Buffer 中
  • 扫描对应的 S 的 Partition 去组成结果
  • 重复操作直到所有 Partition 都被扫描过

最好的情况下,Cost = O(bR + bS)。此时需要 √bR 个 Buffers 去存储 Outer Relation 最大的 Partition

如果某些 Partition 的尺寸更大 (Poor Hash Distribution),或是 Buffers 数量少于 √bR . 可能会需要 rescan 部分 S 的 Partition

在这里插入图片描述
Partition Phase: 在上图中,左侧就是 Relation R,一个 Page 一个 Page 地进行读取,对当前 Page 中地 Tuple 使用 Hash Function 将其分配到合适地 Output Buffer,当 Buffer 满了之后,写入到 Dsik 中地一个 Partition File。对于 Relation S 也进行相同的操作。

在这里插入图片描述
Join Phase: 我们在前一阶段已经得到了 Relation R 和 S 的 Partition File。一个很好的地方在于,因为我们使用同样的 Hash Function h1 进行分割,所以,R partition 和 S Partition 之间存在对应关系。 此时,读入一个 R Partition,并使用一个新的 Hash Function h2 来将其中的 Tuples 再分配。接下里,读入一个对应的 S Partition,此时同样对其中的 Tuples 使用 Hash Function h2,就可以知道每个 Tuple 对应哪一个 Hash Buffer,在这个对应的 Hash Buffer 中,寻找符合 Join 条件的 Tuple,组成 Join 之后的结果。当 Output Buffer 满了之后,写入 Disk,清空 Hash Buffers。

现在来看 Cost。首先我们要知道,所有 Partition Files 中的 Pages 数量约为原文件 Pages 数量(可能会稍多一些)。所以在进行 Partition 时:

  • Partition Relation R:Cost = read(bR) + write(≈bR) = 2bR
  • Partition Relation S:Cost = read(bS) + write(≈bS) = 2bS

在 Join Phase 中,需要扫描所有的 Partition Files,Cost = bR + bS. 由于所有的 Hashing 和比较操作都在内存中进行,所以代价忽略不计。因此:

Total Cost = 2bR + 2bS + bR + bS = 3 * (bR + bS)

Hybrid Hash Join

Hybrid Hash Join 是 Grace Hash Join 的一种变式。该方法主要思考如果有数量很多的 Buffer(√bR < N < bR + 2),那么该如何利用这些富余资源。

此时同样进行 Partition,得到 k (<< N) 个 Partitions,只是不再把所有 Partitions 都写入 Disk,而是留一个在 Memory 中,剩余的 k - 1 个写入 Disk。对于 N Buffers,有 1 个 Input Buffer,k-1 个 Output Buffer,p = N - k - 2 个用以存储 In-memory Partition。

当对 Relation S 进行扫描和 Partition 时,所有哈希值为 0 的 Tuple 可以使用内存中的 Partition 来进行解析。其他的 Tuples 会写到 S 的 k 个 Partition 之一。最后的 Join Phase 与 Grace Hash Join 基本一致,但是只有 k - 1 个 Partitions。

Grace Hash JoinHybrid Hash Join
在 Disk 上有 N-1 个 Partitions在 Disk 上有 k-1 个 Partitions,在 Memory 有 1 个

在这里插入图片描述
首先对 R 进行 Partition,所有 Hash Value 为 0 的都被放入 In-memory Partition。剩余凡是 Hash Value 不为 0 的都存入 Disk。

在这里插入图片描述
接下来读取 Relation S,同样是一个 Page 一个 Page 地读取,如果 Hash Value 为 0,那么就在 In-memory Partition 寻找合适的 Tuple,一旦寻找到匹配的 Tuple,进行 Join,存入 Output Buffer。在这一阶段中,我们得到了一部分结果,但是 Disk 中还有剩余的 Partitions

在这里插入图片描述
对于剩下的这部分 Partitions,我们使用和 Grace Hash Join 同样的方法进行处理,得到最后的结果。

在这个过程中,还有一些值得我们注意的点:

  • 对于 k 个 Partitions,每个 Partition 的尺寸约为 ceil(bR/k)
  • 将一个 Partition 存在内存 (Memory) 中,需要 ceil(bR/k) 个 Buffers
  • 对于 In-memory Partition Space 和 Partitions 的数量需要有所权衡
  • 如果 N = bR - 2,使用 Nested Loop 会更简单
  • Cost 取决于 N,但是少于 Grace Hash Join

对于 k 个 Partitions,Cost = (3 - 1/k) * (bR + bS)

最后用一个具体的例子来看一下几种不同方法的代价:

考虑一个如下所示的 SQL Query:

select E.subj, S.name
from Student S join Enrolled E on (S.id = E.stude)
order by E.subj ;

此时数据库的相关数据为:

rS = 20000, cS = 20, bS = 1000

rE = 80000, cE = 40, bE = 2000

现在有 N=103 个 Buffers,我们来考虑 Join 的 代价
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值