DBMS Implementation 笔记 03
Projection Operation
在之前一节介绍的排序 (Sorting) 操作并不完全是 SQL 中的关系逻辑操作,而是一种相对常见的操作。本节开始,先来介绍第一种比较重要的逻辑关系操作:Projection
首先,在 SQL 中,SELECT 语句就是 Projection 的最简单的形式,比如:
假设 Table Employee 有如下格式:
(94002, John, Sales, Manager, 32)
(95212, Jane, Admin, Manager, 39)
(96341, John, Admin, Secretary, 32)
(91234, Jane, Admin, Secretary, 21)
那么,最后的结果应为:
(Jane, 21) (Jane, 39) (John, 32)
为了实现 Projection,就需要 Tuple projTuple(AttrList, Tuple)
这个函数。==该函数以一个元组和一个目标属性列表作为输入,将结果放在一个新的元组输出。==比如:
projTuple([id], (1234,'John',3778))
returns (id=1234)
projTuple([name,degree]), (1234,'John',3778))
returns (name='John',degree=3778)
Without DISTINCT
在我们不添加 DISTINCT 这个指令时,Projection 的运作非常直观:
// attrs = [attr1 ,attr2 ,...]
bR = nPages(Rel)
for i in 0 .. bR-1 {
P = read page i
for j in 0 .. nTuples(P)-1 {
T = getTuple(P,j)
T' = projTuple(attrs, T)
if (outBuf is full) write and clear
append T' to outBuf
}
}
if (nTuples(outBuf) > 0) write
即遍历一个 Table 的所有 Page,并且遍历该 Page 中的所有 Tuple,然后对每个 Tuple 执行 projTuple()
即可。一般来说,作为结果输出的 Tuple 其尺寸会比原有的 Tuple 小一些
With DISTINCT
当我们加上 DISTINCT 这个指令。此时的 Projection 操作相比之前就多了一个 “去重” 操作,这里的去重一般有两种方法,一是排序 (Sorting),一是哈希 (Hashing)
Sort-based Projection
这里我们可以清楚地看到,相比最初的 File,每个 Page 中的 Tuples 更多了,同时需要的 Pages 更少了,这直接体现了 Tuple 的尺寸变小了。
现在来看看该方法下的代价 (Cost) 是多少。依然根据操作的顺序来看:
- 扫描原本的 Relation Data File 的所有 Page: bR (每个 Page 的容量为 cR)
- 把符合条件的 Tuple 写入一个临时 Relation: bT (此时每个 Page 可容纳的 Tuple 更多 cT > cR)
- 对临时 Relation Data File 中的 Tuples 进行排序(使用外部排序算法): 2*bT *ceil(logn b0 ), b0 = ceil(bT /B),n 为 Buffer 的数量
- 扫描整个排好序的临时 Relation,移除重复 Tuples: bT
- 把结果写入 Result Relation: bOut
所以,总体代价为:bR + bT + 2.bT .ceil(logn b0 ) + bT + bOut
Hash-based Projection
**这里的操作是,首先将原 Relation 的所有 Page 一个一个读入 Buffer,然后根据其中每个 Tuple 的内容,使用 Hash Function 将其放入众多 Output Buffer 中的一个,每当 Output Buffer 被写满,就将其写入 Disk 的一个 Partition,然后重新向该 Buffer 中添加 Tuples。**如此一来,我们就能很直观地知道,每个 Partition 中的 Tuples 都有着相同的 Hash Value,这就意味着它们具备着极高的相似性,换言之,相同的 Tuples 就一定会在同一个 Partition 中。 接下来是第二步:
此时再将每个 Partition (Page) 一个个读入,再次使用一个新的 Hash Function,那么此时,所有相同的 Tuples 又都会被分配到同一个 Output Buffer 中。此时在每个 Output Buffer 中都会进行自我扫描,以去除重复的 Tuples,之后写到 Result Page 中。
现在来看具体的代价:
- 扫描原本的 Relation Data File 的所有 Page: bR (每个 Page 的容量为 cR)
- 将 Tuple 根据哈希值分入不同的 Buffer,当 Buffer 满了之后,写入一个 Partition: bP
- 重新读取这些 Partitions: bP
- 将结果写入 Result Relation: bOut
所以总代价为:bR + 2bP + bOut
需要注意的是,需要保证 Memory Buffer 的数量 n 要大于最大的 Partition,至少需要 sqrt(bR )+1 buffers。同时,最终得到的结果并不会保证是排序的
Projection on Primary Key
如果基于主键进行 Projection,那么就可以不使用上述的任何一种方法,因为主键的原因,已经确保了不会有任何重复:
bR = nPages(Rel)
for i in 0 .. bR-1 {
P = read page i
for j in 0 .. nTuples(P) {
T = getTuple(P,j)
T' = projTuple([pk], T)
if (outBuf is full) write and clear
append T' to outBuf
}
}
if (nTuples(outBuf) > 0) write
Index-only Projection
如果基于索引进行 Projection,会更简单,因为可以完全不需要再访问 Data File。比如将所有的 Relations 进行索引 (A1, A2, …, AN),所有的属性 (Attributes) 以这些 Relation 的索引作为前缀进行索引。假设此时保存所有索引的文件有 bi Pages,那么代价即为:bi read + bout
Selection
在了解了 Projection 操作之后,我们来看其具体的 SQL 操作语句:SELECT。我们在使用 SELECT 时,最主要的目标就是基于一定的条件,过滤一个 Relation,以得到一个相对更小的结果子集。 该语句最大的特点在于,我们可以声明单个或多个维度的条件,除此以外,其另一个最大的特点在于,可以进行相似度匹配 (Similarity Matching),也就是说,得到的不一定是完全匹配结果,在 PostgreSQL 中,相似度匹配的具体形式可由人为定义(就和 Data Type 以及 Operator 一样)。
现在通过图来进行进一步的理解,我们设:
- rq = 匹配查询 q 的 Tuples 的数量
- bq = 包含匹配查询 q 的 Tuples 的 Pages 的数量
在下图中,用红色外框表示包含匹配查询 q 的 Tuples 的 Pages。
在上图中,rq = 8,bq = 5。
Selection 有多种不同的策略
- One Type Query:返回的结果至多只有一个:rq = bq = 1。最简单的例子就是基于主键的条件选择:
select * from R where id = 1234;
- Partial Match Retrieve:基于单个或多个条件返回多个结果 Tuples,0 ≤ rq ≤ r, 0 ≤ bq ≤ b+bov。比如:
select * from R where age=65;
- Range Queries:此时不再基于等性测试条件,0 ≤ rq ≤ r, 0 ≤ bq ≤ b+bov。比如:
select * from R where age≥18 and age≤21
; - Pattern-based Queries: 基于模式的匹配,0 ≤ rq ≤ r, 0 ≤ bq ≤ b+bov。比如:
select * from R where name like '%oo%';
- Similarity Matching:相似度匹配,此时,理论上 rq = r,因为所有的 Tuple 都会和目标 Tuple 有一定的相似度,也就是说所有的 Tuple 都有一个值为 [0, 1] 的 “相似度” 指标。最终需要根据这个相似度来进行排名,选取需要的结果,比如 Top-K
为了更有效率地进行 Select,有几种比较基本的方法:
- 对 Tuples 进行排序 (Sorting) 或者哈希 (Hashing)
- 使用额外的索引结果,比如 Index Files、Signature
File Structure
Heap File
我们这里所说的 “堆文件 (Heap File)” 中的 “堆 (Heap)” 不是数据结构中的堆,仅仅表示一堆 Pages。所以,Heap File 指的就是:
- 包含 Tuples 的一系列 Pages
- Tuples 没有固定的顺序(每次都在下一个固定的 Free Slot 来插入)
- Pages 会有因为删除 Tuples 而遗留下来的空闲空间
- 通常不涉及 Overflow Pages(因为在 Heap File 中我们不需要某些 Tuples 与某个 Page 固定关联)
Selection in Heap File
正因为在 Heap File 中的 Tuples 是无序的,因此唯一可以进行 Selection 操作的方法就是进行线性扫描 (Linear Scanning)。
// select * from R where C
rel = openRelation("R", READ);
for (p = 0; p < nPages(rel); p++) {
get_page(rel, p, buf);
for (i = 0; i < nTuples(buf); i++) {
T = get_tuple(buf, i);
if (T satisfies C)
add tuple T to result set
}
}
因此,此时对于 Partial Match Retrieve 和 Range Queries 来说,都需要扫描所有的 Pages,代价都为:
Costpmr = Costrange = b
而对于 One Type Query 因为最终只有一个匹配 Tuple,所以:
Costone : Best = 1; Average = b/2; Worst = b
Insertion in Heap File
Insertion 操作会把新的 Tuple 添加到 Heap File 的最后一个 Page 中。
rel = openRelation("R", READ|WRITE);
pid = nPages(rel)-1;
get_page(rel, pid, buf);
if (size(newTup) > size(buf))
{ deal with oversize tuple }
else {
if (!hasSpace(buf,newTup))
{ pid++; nPages(rel)++; clear(buf); }
insert_record(buf,newTup);
put_page(rel, pid, buf);
}
即读取最后一个 Page,将 New Tuple 加入后,将新的 Page 写回 Data File。代价为:
Costinsert = 1r + 1w
另一种策略则是从所有 Page 中寻找任意一个有着足够空间的 Page,将 Tuple 添加到其中,如果足够幸运,这样的一个 Page 可能已经在 Memory Buffer 之中,如此一来就可以减少 Read 的代价。
而在 PostgreSQL 中,采用的策略比较接近后一种:
- 使用在 Buffer Pool 中最后一个被更新 (MRU) 的 Relation R 的 Page
- 如果条件不满足,在 Buffer Pool 中寻找一个有足够空间的 Page
- 使用 FSM (Free Space Map) 来寻找这样一个满足条件的 Page
当然,在此过程中,我们也不得不考虑 New Tuple 尺寸比较大的情况:
for i in 1 .. nAttr(t) {
if (t[i] not oversized) continue
off = appendToFile(ovf, t[i])
t[i] = (OVERSIZE, off)
}
insert into buf as before
这个时候我们需要一个独立于 Data File 的额外 File(比如 PostgreSQL 的 Toasting)。比如在上图中,Tuple t 的第二个属性的值无法存放在 Tuple 中,因此,我们会将该属性的值存放在一个额外的 File 中,而在 Tuple 中的对应位置留下一个标记 (Marker) ,这个标记会告诉系统该属性值存放在额外文件中的哪个位置。
现在再来整体看一个 PostgreSQL 中 Insertion 操作的处理,主要表现为一个函数 :
heap_insert(Relation relation, // relation desc
HeapTuple newtup, // new tuple data
CommandId cid, ...) // SQL statement
需要注意的是该函数除了必要的 New Tuple 以及插入目标的 Reltion,还需要一个 SQL 指令ID。该函数
- 寻找一个空间足够存放 New Tuple 的 Page(优先在 Buffer Pool 中寻找)
- 找到对应的 Page 之后,将其加载到 Buffer Pool 并且锁定 (Lock)
- 之后,将 Tuple 数据复制到 Page 中,设置 xmins(xmins表示该 Insertion Transaction 的开始时间,这对于 MVCC 来说是非常必要的)
- 将正在使用的 Buffer 标记为 “使用中”
- 把本次 Insertion 操作的细节写入 Transaction 日志中(有助于系统崩溃后进行恢复操作)
- 返回 New Tuple 的 OID
Deletion in Heap File
Deletion 操作一般需要设置一些条件:
delete from R where Condition;
所以从某种程度来说,它和 Select 操作很相似
rel = openRelation("R",READ|WRITE);
for (p = 0; p < nPages(rel); p++) {
get_page(rel, p, buf);
ndels = 0;
for (i = 0; i < nTuples(buf); i++) {
tup = get_tuple(buf,i);
if (tup satisfies Condition)
{ ndels++; delete_record(buf,i); }
}
if (ndels > 0) put_page(rel, p, buf);
if (ndels > 0 && unique) break;
}
PostgreSQL 中的 Deletion 同样需要使用一个函数:
heap_delete(Relation relation, // relation desc
ItemPointer tid, ..., // tupleID
CommandId cid, ...) // SQL statement
- 找到包含 tid 的 page,将其载入 Buffer Pool 并锁定 (Lock)
- 在 Tuple 中设置 flags,Command ID,xmins,标记 Buffer
- 将本次删除指示写入 Transaction 日志
Updates in Heap File
Update 操作与 Deletion 类似:update R set F = val where Condition; 同样是扫描所有的 Page,找到所有要更新的 Tuple,替换这些 Tuple,然后将 Page 重新写回 disk。
Costupdate = br + bw
这里需要注意的一种情况在于更新后的 Tuple 尺寸不再适合原有的 Page。此时就要先去重新排布 Page 中的 Free Space,以求能有充足的空间,如果仍然不够,此时就需要寻找一个新的 Page,注意,我们无需保证更新后的 Tuple 仍与原本的 Tuple 在同一个 Page 中。
PostgreSQL 中的
heap_update(Relation relation, // relation desc
ItemPointer otid, // old tupleID
HeapTuple newtup, ..., // new tuple data
CommandId cid, ...) // SQL statement
实际上就是先进行 Deletion,再进行 Insertion
Heaps in PostgreSQL
默认情况下,PostgreSQL 将所有的 Relations 都存储在 Heap File 中。如果有需要使用其他形式(比如 Sorted, Hashed)的文件,PostgreSQL 可以生成一个符合形式的副本
Sorted File
所谓的 Sorted File 很好理解,就是 File 中的 Record 按照一定的顺序进行排布。排序会使得搜索 (Search) 更加高效,但是插入 (Insertion) 会比较不便
在上图中,我们想要插入 Tuple g,此时就需要顺延其后的 Tuple 以保证顺序不被打破。
为了减轻插入 (Insertion) 的代价,就需要使用 Overflow Page
也就是说,当某个 Page 满了之后,我们直接为该 Page 创建一个 Overflow Page,而不是将之后的 Tuples 顺延。我们设 Overflow Page 的总数为 bOV,那么 Overflow Chain 的平均长度为 bOV/b。我们称一个 Page 和它的 Overflow Chain 为一个 Bucket。
Selection in Sorted Files
对于 One Type Query,因为当前已经有顺序,因此可以使用二分法搜索 (Binary Search)
// select * from R where k = val (sorted on R.k)
lo = 0; hi = nPages(rel)-1
while (lo <= hi) {
mid = (lo+hi) / 2; // int division with truncation
(tup,loVal,hiVal) = searchBucket(rel,mid,x,val);
if (tup != NULL) return tup;
else if (val < loVal) hi = mid - 1;
else if (val > hiVal) lo = mid + 1;
else return NOT_FOUND;
}
return NOT_FOUND;
rel
表示 Relation
mid, lo, hi
表示 Page Index
k
表示条件属性 (The Sort Key)
val, loval, hival
表示 k 的值
需要注意的是这里的二分法搜索先是以 Page 为单位的,首先找到 Middle Page,在该 Page 的 Bucket 中寻找有无目标 Tuple,searchBucket()
就是进行该任务的,如果能知道满足条件的 Tuple 就直接返回,否则返回 NULL,同时还会返回该 Bucket 中,最小和最大的 Key Value,如果没有找到目标 Tuple,我们需要这两个值来向前或者向后进行后续的搜索。
接下来看 searchBucket()
的具体运作,实际上就是获取当前 Bucket 中的所有 Page,然后找到属性 k 为 val 的 Tuple。
searchBucket(rel,p,k,val)
{
get_page(rel,p,buf);
(tup,min,max) = searchPage(buf,k,val,+INF,-INF)
if (tup != NULL) return(tup,min,max);
ovf = openOvFile(f);
ovp = ovflow(buf);
while (tup == NULL && ovp != NO_PAGE) {
get_page(ovf,ovp,buf);
(tup,min,max) = searchPage(buf,k,val,min,max)
ovp = ovflow(buf);
}
return (tup,min,max);
}
而 searchPage()
为:
searchPage(buf,k,val,min,max)
{
res = NULL;
for (i = 0; i < nTuples(buf); i++) {
tup = get_tuple(buf, i);
if (tup.k == val) res = tup;
if (tup.k < min) min = tup.k;
if (tup.k > max) max = tup.k;
}
return (res,min,max);
}
上述的搜索方法实质上将每个 Bucket 看作一个大的 Page。此时
最好的情况就是目标 Tuple 就在第一个 Page 中:Costone = 1
最坏的情况就是扫描了所有的 Page 但未找到结果:Costone = log2b (Binary Search Pages) + bOV
平均情况需要根据数据分布来个别讨论
对于 Partial Match Retrieve ,由于目标属性 k 的取值会有重复 (比如 age),因此有可能具备相同值的 Tuple 分布在多个不同的 Page 中 (k = 2):
此时当我们使用二分法搜索时,会首先找到 Page[2],但此时它前后的 Page 都可能有符合条件的 Tuple。此时代价为:Costpmr = Costone + (bq - 1) (bOV + 1)
对于 Range Query ,我们首先来看目标属性 k 的取值不会有重复时的情况。 此时基本和 PMR 一致 (k >= 5 and k <= 13):
首先使用二分法搜索找到 k = 5 的 Tuple 所在的 Page,此时无需再往前搜索,只需要向后顺序搜索找到上界即可。代价为:Costrange = Costone + (bq - 1) (bOV + 1)
对于 Range Query ,现在来看目标属性 k 的取值会有重复的情况。 (k >= 2 and k <= 6):
同样先试用二分法搜索寻找下界,我们首先会找到 Page[2],因为此时 k 的取值会有重复,因此还需要向前搜索,找到第一个下界,然后再向后搜索,去找到上界。代价为:Costrange = Costone + (bq - 1) (bOV + 1)
目前为止,我们考虑的所有情况都给予属性 k 为 Sort Key,那么,如果条件属性是 j 而不是 k 呢?此时,Files 同样按照 j 进行排序的概率基本为 0,此时的情况就会和 Heap File 中进行搜索一样。
Insertion in Sorted Files
在 Sorted File 中进行插入操作的具体做法我们之前其实基本已经说过了:
- 使用二分法找到对应的 Page
- 如果 Page 未满,插入
- 如果 Page 已满,创建一个新的 Overflow Page
此时的代价为:Costinsert = Costone + δw (where δw = 1 or 2)
Deletion in Sorted Files
基本操作依旧与 Select 一直,先找到对应的 Page,然后找到对应的 Tuple,将其标记为 Deleted。它的代价同样和 bq 相关,如果 k 取值没有重复,则 bq = 1,如果有重复,bq > 1。代价为:Costdelete = Costselect + bqw
Hash File
Hash File 最基本的思想就在于哈希 (Hashing),即将一个 Key Value 哈希为一个 Tuple 所在 Page 的地址:
比如在上图中,Key 值为 v 的 Tuple 被存储在 Page i 中。如此一来,当新的查询到来,我们只需要使用同样的 Hash Function,就能够找到满足条件的目标 Tuple 所在的 Page,然后在这个 Page 中寻找匹配的 Tuple。因此,我们就需要一个 Hash Function h(v) 来进行一个从 KeyDomian 到 [0, b-1] 的映射,简单来说就是将一个 Key 映射到一个 Page ID。下面是一个简单的 Hash Fcuntion:
Datum hash_any(unsigned char *k, int keylen)
{
uint32 a, b, c, len, *ka = (uint32 *)k;
/* Set up the internal state */
len = keylen;
a = b = c = 0x9e3779b9+len+3923095;
/* handle most of the key */
while (len >= 12) {
a += ka[0]; b += ka[1]; c += ka[2];
mix(a, b, c);
ka += 3; len -= 12;
}
... collect data from remaining bytes into a,b,c ...
mix(a, b, c);
return UInt32GetDatum(c);
}
该 Hash Function 会将任意一种类型的值映射为一个 32 bit 的整数值。这是一个 Raw Hash Value,接下来需要将这个 Raw Hash Value 映射为一个 Page address,这里有两种方案:
- 如果 b = 2k ,那么可以进行 “位与” 操作:
uint32 hashToPageNum(uint32 hval) {
`uint32 mask = 0xFFFFFFFF;
`return (hval & (mask >> (32-k)));
}
- 使用取余运算得到一个 [0, b-1] 内的值(相比前一种方法,取余运算开销更大)
uint32 hashToPageNum(uint32 hval) {
return (hval % b);
}
这么做有 2 个目的。第一,可以将 Tuples 均匀地分布在各个 Bucket 中;第二,会使得每个 Bucket 尽可能存满,不浪费空间。所以此时:
最好的情况:每个 Bucket 有几乎数量一致的 Tuples
最坏的情况:所有的 Tuple 都被映射到同一个 Bucket 中
通常情况:一部分 Bucket 有着更多的 Tuples
对于 Hash File,有 2 个重要的指标:
-
Load Factor: L = r/bc
r 是 Tuples 的总数;b 是 Page 的总数;c 是每个 Page 的容量。所以 bc 就是理论最多能容纳的 Tuple 的数量。我们用 L 来表示每个 Page 被填充的程度,最理想的就是 r = bc,即 L=1,此时存储的 Tuple 刚好为理论最大,每个 Page 存储的 Tuple 数量相同。
-
Average overflow chain length: Ov = bov / b
对于 Tuples 在 Hash File 中的分布,有三种可能的情况:
通常情况下,我们期望保持 0.75 < L < 0.9
Selection in Hash File
依旧首先来看 One Type Query。 此时的 Selection 操作就会显得非常简单。我们只需要对 Key Value 使用 Hash Function,就可以得到目标 Page P,接着在该 Page 及其 Overflow Page 中搜索即可:
// select * from R where k = val
getPageViaHash(R, val, P)
for each tuple t in page P {
if (t.k == val) return t
}
for each overflow page Q of P {
for each tuple t in page Q {
if (t.k == val) return t
} }
最好的情况下,Costone = 1
最坏的情况下,Costone = 1 + MAX(Ov)
一般情况下,Costone = 1 + (Ov/2)
因为寻找到对应 Page 是通过 Hash 实现的,时间复杂度就是 O(1),所以只要针对不同情况考虑 Overflow Chain 的规模即可。
现在来看 Partial Match Retrieve 。基本的操作和之前类似,只是现在因为结果不唯一,所以要找到所有的结果即可。代价 Costpmr = 1 + Ov。
对于 Range Query ,情况会比较糟糕,因为 Hash File 是完全无序的,所以这个时候,就需要扫描所有的 Page 去找到符合条件的 Tuples。因此代价 Costrange = b + bOv
而对于那些不是 Key 的 Attribute 的搜索,则和 Heap File 完全一样:Costone , Costrange , Costpmr = b + bov
Insertion in Hash File
Insertion 操作和 One Type Query 基本一致:
// insert tuple t with key=val into rel R
getPageViaHash(R, val, P)
if room in page P {
insert t into P; return
}
for each overflow page Q of P {
if room in page Q {
insert t into Q; return
} }
add new overflow page Q
link Q to previous page
insert t into Q
最好的情况就是在找到的 Page 中就有足够的 Free Space 以插入:Costinsertion = 1r + 1w
最坏的情况是寻遍该 Page 的所有 Overflow Page 都没有空间,需要再创建一个 Overflow Page,此时代价为:Costinsertion = (1 + max(OvLen))r + 2w
Deletion in Hash File
与目标属性取值有重复的 Selection 类似:
// delete from R where k = val
// f = data file ... ovf = ovflow file
getPageViaHash(R, val, P)
ndel = delTuples(P,k,val)
if (ndel > 0) put_page(dataFile(R),P.pid,P)
for each overflow page Q of P {
ndel = delTuples(Q,k,val)
if (ndel > 0) put_page(ovFile(R),Q.pid,Q)
}
区别仅在与最后将修改过的 Page 写回 Disk 的开销
Problem with Hashing
目前所说的 Hashing 方法实际上存在一个问题,那就是 File 的规模。我们可以想象一下,如果我们的原文件尺寸固定,经过 Hash Function 之后,我们得到 [0, 7] 共 8 个 Page,此时如果某个 Page 装满了,就会为其创建一个 Overflow Page,而随着 Overflow Chain 的不断增长,Selection 操作的效率会越来越低。当然,我们也可以使得 Hash Function 给出的哈希值非常多,这就会有更多的 Page,这种策略可以减少 Page 被装满的风险,从而减少 Overflow Page 出现的可能性,但是也会造成空间的浪费。这还只是针对尺寸固定的 File,如果 File 的尺寸不固定呢?
如果 File 的尺寸不定,此时,Hash Function 实际上也在不断变化,不断地重新将 Tuple 分配到不同的 Page 中。对于如何 Hashing 这种尺寸在变化的 File,有 2 种方法:
- 可扩展哈希,动态哈希 (Extendible hashing, Dynamic hashing):这种方法需要 Directory,且 Directory 会随着 File 尺寸的增长不断增长,但是不需要 Overflow Page
- 线性哈希 (Linear Hashing):系统性地扩展 File,不需要 Directory,但是需要 Overflow Page。这也是更推荐的方法。
所有的这些可变 Hashing 方法,都将哈希值 (Hash) 作为一个 32 位的位字符串。Hash Function 变化时,实际上就是使用更多/少的 bits。换言之,首先我们使用 Hash Function 将数值转换为一个 32 位的哈希值:
uint32 hash(unsigned char *val)
然后,使用一个函数从其中提取 d 位的子串:
unit32 bits(int d, uint32 val)
使用这个子串来表示 Page 的地址。
可变 Hashing 的一个重要概念是 “分割 (Splitting)”。这里的 “分割” 指的是基于一个已有的 Page,创建一个新的 Page,这两个 Page 之间存在某种关系,可以用一位数字来区分,比如原有 Page 为 101,那么在创建了新 Page 之后,原 Page 为 0101,新 Page 为 1101,将 101 中的 Tuple 进行 Shuffle,一部分仍留在 0101 中,一部分放入新的 Page 1101 中:
Linear Hashing
想要了解 Linear Hashing,我们先要了解它的 3 个必要部分:
- Primary Data Pages
- Overflow Data Pages
- 被称为 Split Pointer (sp) 和 Depth (d) 的 Register
先来解释一下什么是 sp 和 d:
所谓的 d (Depth) 会告诉我们使用 Hash Value 中的多少位来计算 Page Index,比如上图中 d=3,表明我们使用后 3 位来计算 Page Index。sp (Split Pointer) 会告诉在文件扩展阶段我们走了多远。
我们之前已经介绍过,Linear Hashing 使用一种系统性地方法来扩展 Data File。它每次添加一个新的 Page,同时会通过调整 sp 和 d 为某些 Tuples 修改 Hash Function,因为会将原本 Overflow Page 中的 Tuple 移动到新的 Page 中,因此变相控制了 Overflow Chain 的长度。其优点我们也在之前说过,不需要借助 Directory 来辅助存储。
现在来看看 Linear Hashing 中文件扩展的几个阶段,我们认为文件为线性扩展,即每次只增加一个 Page:
可以看到,在最初的阶段,只有原文件,此时共有 b=2d Pages,当不断添加 Page 时,sp 会不断增大,增大的具体数值对应新加入 Page 的尺寸,当 sp = 2d 时,此时整个文件的尺寸翻倍,又将 sp 重新置于开头。
Selection with Linear Hashing
==若 b = 2d ,那么此时的 Selection 行为基本等同标准 Hashing。==我们有一个 32 位的位字符串,然后指定使用其中的多少位作为 Page Index 的表示:
// select * from R where k = val
h = hash(val);
P = bits(d,h); // lower-order bits
for each tuple t in page P
and its overflow pages {
if (t.k == val) return t;
}
代价为:Costone = 1 + Ov
若 b != 2d ,我们之前已经说过,此时 sp 一定不会在整个 File 的开头。 此时我们需要对 File 的不同部分进行不同的处理:
此时的 Part A 和 Part C 被当做一个规模为 2d+1 的 File 的部分(Part A 和 Part C 的地址哈希值只差了一位,比如一个是 0101,一个是 1101)
Part B 被当做一个规模为 2d 的 File 的部分
换言之,若我们通过 Hash Function 得到的地址哈希值小于 sp,那么,我们就需要通过再多确认一位,去确认该 Tuple 究竟在 Part A 还是 Part C 之中。而 Part B 目前并不存在关联 Page,因此可以直接根据地址哈希来判断 Tuple 是否在其中。
Part D 目前还不存在,但随着不断增加 Page,直到整体规模为 2k 时,就形成了。
此时,我们的搜索算法就发生了变化:
// select * from R where k = val
h = hash(val);
pid = bits(d,h);
if (pid < sp) { pid = bits(d+1,h); }
P = getPage(f, pid)
for each tuple t in page P
and its overflow pages {
if (t.k == val) return R;
}
这里最关键的就是判断地址哈希是否小于 sp,如果小于,那么就要多读一位。下图是一个更为具体的过程:
Insertion with Linear Hashing
Insertion 操作和一般的 Hash File 一样,只是现在我们需要多检查一位哈希值,同时,还需要进行 Splitting:
pid = bits(d,hash(val));
if (pid < sp) pid = bits(d+1,hash(val));
// bucket P = page P + its overflow pages
P = getPage(f,pid)
for each page Q in bucket P {
if (space in Q) {
insert tuple into Q
break
}
}
if (no insertion) {
add new ovflow page to bucket P
insert tuple into new page
}
if (need to split) {
partition tuples from bucket sp
into buckets sp and sp+2^d
sp++;
if (sp == 2^d) { d++; sp = 0; }
}
现在一个很关键的问题在于,我们该如何确定什么时候去进行 Splitting。有 2 中方法去触发 Splitting:
- 每当有一个 Tuple 插入到一个已满的 Page
- 当载入因子 (Load Factor) 达到一定的阈值,比如每进行 k 次插入
需要格外注意的是,Splitting 总是发生在 sp 当前指向的 Page,即使该 Page 未满或者当前插入的 Page 不是该 Page !!
采用 Splitting 可以有效控制 Overflow Chain 的长度。下面依然用图来理解:
Splitting 算法具体为:
// partition tuples between two buckets
newp = sp + 2^d; oldp = sp;
for all tuples t in P[oldp] and its overflows {
p = bits(d+1,hash(t.k));
if (p == newp)
add tuple t to bucket[newp]
else
add tuple t to bucket[oldp]
}
sp++;
if (sp == 2^d) { d++; sp = 0; }
现在来看具体的代价:
如果不需要使用 Splitting,代价会和普通的 Hashing 一样:
最好的情况 Costinsertion = 1r + 1w
最坏的情况 Costinsertion = (1 + max(OvLen))r + 2w
一般情况 Costinsertion = (1 + Ov)r + 1w
如果进行了 Splitting,就需要在此基础上加上 Splitting 的代价:
- 去读 sp 指向的 Page(及其 Overflow Page)
- 覆写 sp 指向的 Page (及其 Overflow Page)
- 写入新的 sp + 2d Page (及其 Overflow Page)
一般来说,代价为 Costsplitting = (1 + Ov)r + (2 + Ov)w
Deletion with Linear Hashing
Deletion 操作和一般静态哈希文件一样。但是,当删除足够的元组时,可能希望收缩文件。因为当 r 缩小,而 b 保持较大就浪费了空间。 此时相当于进行 Splitting 的反操作,将最后一个 Bucket 移除,将一个 Bucket 中的 Tuples 和其相关 Bucket 中的 Tuples 合并,放入一个 Bucket
Hashing in PostgreSQL
PostgreSQL 可以使用以下语句对 Table 使用 Linear Hashing:
PostgreSQL 中使用不同的文件组织:
- 一个 File 包含了 Header (Metadata),Main (Regular Data Pages), Overflow Pages
- Main Pages 分为多个组,每个组的尺寸为 2n
- 在 Header 中保存了指向这多个组的 Pointers
- 每个 Pointer 指向每个组的开头
这里我们可以注意到,物理索引和 Bucket 索引实际上是不同的。我们需要一个函数将 Bucket Index 转换为物理索引。
// which page is primary page of bucket`
uint bucket_to_page(headerp, B) {
uint *splits = headerp->hashm_spares;
uint chunk, base, offset, lg2(uint);
chunk = (B<2) ? 0 : lg2(B+1)-1;
base = splits[chunk];
offset = (B<2) ? B : B-(1<<chunk);
return (base + offset);
}
// returns ceil(log_2(n))
int lg2(uint n) {
int i, v;
for (i = 0, v = 1; v < n; v <<= 1) i++;
return i;
}