lecture 7 - 9
Hash Table
Hash Table这个概念已经讲烂了,不必多说了。在课程当中,非常学术性地对其分了几个类
Bloom filter: 一个名叫 Bloom 的人提出了一种来检索元素是否在给定大集合中的数据结构,这种数据结构是高效且性能很好的,但缺点是具有一定的错误识别率和删除难度。并且,理论情况下,添加到集合中的元素越多,误报的可能性就越大。
布隆过滤器自带一些hash function和一个初始全为0的数组。当元素要加入时,会先过一遍哈希函数得到几个值,然后将这几个值对应的数组位置置1。查询时,会再过一遍哈希函数得到几个值,然后检查这几个值对应的数组位置是否都为1,如果都为1,则该元素很可能存在于集合中。
- Static Hashing Schemes: 静态哈希策略,指的是在创建表的时候就已经确定好了哈希函数,并且哈希函数的选择是固定的。然后扩展的方式类似于vector,每次都要重新创建整个表,并将大小设置为两倍。
- Linear Probe Hashing: 线性探测,当发生冲突时,通过一个固定的步长来探测下一个槽,直到找到一个空槽。
- Cuckoo Hashing: 布谷鸟散列,每个元素都含有多个hash function可供替换。当发生冲突时,将两个元素交换位置,然后再换用不同hash function散列,直到找到一个空槽。
- Dynamic Hashing Schemes: 动态哈希策略,指的是在创建表的时候并不确定哈希函数,而是在运行时根据需要动态调整。
- Chained Hashing: 链接法,将哈希表的每个槽链接成一个链表,当发生冲突时,将元素插入到链表的尾部。(我们最熟悉的)
- Extendible Hashing: 可扩展哈希
这个语言很难表达,推荐去看图像。它存在着一个global计数位,如果是2,就代表看的是哈希映射后结果的前2位(对应总共有个slot),比如001
和000
的前两位相同,就会被分在同一个bucket中。当试图插入而bucket发生溢出的时候,就将global counter加1,然后重新分配bucket后再进行插入。此外还存在着local计数器,它代表了当前bucket当中元素前缀相同的位数,比如110
和101
这样的local计数器的值就是1,代表了有个slot指向这一个bucket。 - Linear Hashing: 线性哈希。跟Probe不同的是,当溢出时会采用分割并换用不同哈希函数,比如本来是
hash(key) = key % n
可以变成hash(key) = key % 2n
。
Tree Indexes
这个差不多就是一种键值对的形式,由索引得到数据的过程。
B+Tree跟B-Tree大致一样,区别在于后者在每个节点都存储了值,而B+Tree只有在叶子节点才存储值。这就使得B+Tree只在底层存储数据,对于遍历只需要在底层进行遍历即可(若同层节点之间有连接),而不是像普通树一样进行中序遍历这种。
B+Tree的每个节点除了存储键值对外,还存储了指针,指向子节点。这样可以减少磁盘IO,提高查询效率。B+Tree的每个节点都包含一个键/值对数组。
B+Tree中有三种节点,分别为root node, inner node, leaf node,其中inner node有指向下一级node的指针,leaf node指向tuple的指针。
Leaf node的结构如下:
- Record IDs: 记录ID,唯一标识一条记录
- Tuple Data: 记录数据,包含键值对
形式上,B+Tree是一个M向搜索树(其中M表示一个节点可以拥有的最大子节点数),具有以下属性:
- 它是完全平衡的,即每个叶节点有相同的深度
- 根以外的每个内部节点都至少有一半已满,即对于每个节点容量为
M
的树,至少有M/2
个子节点,或者说至少有M/2
个键值对。 - 每个具有
k
个键的内部节点都有k+1
个非空子节点。这个就类似于一根绳子被切了k
刀,那么它会被分成k+1
段。
所以有几个推论:
- 若节点存在空键,空键将聚集在第一个叶节点或最后一个叶节点中,根据索引类型(先为NULL或者后为NULL)。
- 由于每个节点都有指针,所以查询的效率是
O(log n)
,其中n是树的高度。 - 由于每个节点都有指针,所以插入和删除的效率是
O(log n)
。
对B+Tree的操作
-
Insertion: 遍历树并使用内部节点来确定要将键插入的叶节点
- 找到正确的叶子节点L
- 将新entry(即键值对)插入到L当中,如果节点没满直接结束插入
- 如果L满了,则将L均匀分裂成两个节点和,并向上复制中间键,将指向的条目插入原先L的父项
-
Deletion: 在插入中,当树太满时,我们偶尔不得不拆分叶子;如果删除导致树不到半满,我们必须合并(merge)以重新平衡树
- 找到正确的叶子节点L
- 删除entry(即键值对),如果L还是超过一半满的话,结束删除
- 尝试重新分配,向Sibling节点借个键值对过来
- 如果重新分配也失败了,直接合并L和Sibling节点,并必须删除父级中指向L的条目
对于插入相同数值的重复节点:
- append record: 重复的节点多加一个附加的ID标识来区分。采用附加记录ID作为密钥的一部分。由于每个元组的记录ID都是唯一的,这将确保所有键都是可识别的
- overflow node: 多出一条链专门装溢出的节点。允许叶节点溢出到包含重复密钥的溢出节点中。尽管没有存储冗余信息,但这种方法的维护和修改更为复杂。
Index Scan Page Sorting: 索引扫描页面排序,是一种优化查询过程的技术。先获取所有需要查询的索引,然后对它排序,这样就可以使得连续几个排序可能都处于同一页当中,从而最大化地利用cache。它通过对索引扫描结果进行排序,使得查询结果的顺序与索引顺序一致。
Clustered Indexes: 聚簇索引,按照主键(primary key)来排序,如果没有会按照每个数据库自动来创建。
B+Tree的设计选择
又是一个经典的Trade-off…
-
Node Size: B+Tree的高度决定了查询效率,高度越低,节点越大,查询效率越高,但是也越占用空间。所以,我们需要根据实际情况来选择合适的高度。点查询更喜欢尽可能小的页面以减少加载的不必要的额外信息量,而大型顺序扫描可能更喜欢大页面以减少所需的获取次数
-
Merge Threshold: merge显然可以优化,每次删除都合并的话开销太大了,这个就类似于Copy-On-write的思想,拖字诀,等到不得不合并的时候(Threshold决定的)再合并。
-
Variable Length Keys: 用不同的键来存储信息
- Pointers: 用指针来替代key
- Variable Length Nodes: 用变长节点来存储
- Padding: 填充,不论实际内容多长,都填充到对齐长度
- Key Map/Indirection: 将key替换为单独独立出来的dictionary的索引
-
Intra-Node Search: 节点内搜索,也就是说,当我们寻找完某个节点了,那时候我们就停在这个节点,以这个节点为起点,开始寻找下一个节点(而不是重新从Root Node开始)。
- Linear: 线性查找,跟线性探测哈希一样,通过一个固定的步长来探测下一个槽,直到找到目标,时间复杂度
O(n)
- Binary: 类似于二叉查找,跟二叉树一样,通过比较中间键来确定目标,时间复杂度
O(log n)
- Interpolation: 插值查找,通过meta-data来估算目标的位置
- Linear: 线性查找,跟线性探测哈希一样,通过一个固定的步长来探测下一个槽,直到找到目标,时间复杂度
B+Tree的优化
- Pointer Swizzling: 为了跳过从Buffer Pool来fetch指令的流程,来存储原始指针直接指向Page ID,这样可以避免到Page Table的搜索与转换
- Bulk Insert: 批量插入,一次性将多个节点插入到树中,尽可能紧密的插入Leaf Node,减少磁盘IO
- Prefix Compression: 前缀压缩,将相同的前缀的键值对放在同一个节点中,减少磁盘IO
- Deduplication: 去重,将相同的键值对合并到同一个节点中,但依旧需要对它们进行区分,减少磁盘IO
- Suffix Truncation: 后缀截断,因为所有inner node都只是起到一个route路由的作用,所以我们应该尽量减少找leaf node路途当中的冗余inner node
- Write-Optimized B+Tree: Split(for insertion)/Merge(for Deletion)这两个对节点的操作成本高昂。因此,B树的一些变体,如Bε-Tree,会记录内部节点中的更改,并在稍后将更新延迟传播到叶节点。
Concurrency Control
Concurrency Control是DBMS的核心问题之一,它是保证多个用户同时访问数据库的关键。
Latch
- Test-and-Set Spin Latch (TAS): 即原子操作
std::atomic<T>
。一种最简单的并发控制策略,它通过一个原子操作来实现互斥,即将一个变量的值设置为某个值,然后返回之前的值。如果之前的值与期望值相同,则说明没有其他线程在修改这个变量,可以继续执行;否则,说明其他线程正在修改这个变量,需要重新尝试。 - Blocking OS Mutex: 即互斥锁
std::mutex
,它通过系统调用来实现互斥,即当一个线程试图获取一个互斥锁时,如果锁已经被其他线程占用,则该线程会被阻塞,直到锁被释放。 - Reader-Writer Lock: 即读者-写者锁
std::shared_mutex
,它允许多个线程同时读同一个资源,但只允许一个线程写。读者只能读,写者只能写。
HashTable Latching
HashTable的并发控制也是很有必要的,因为它是有方向性的。如果说一个线程要从上往下操作(insert, delete, search),另一个要从下往上操作(merge, split, update),就会出现死锁。此外对于来说,它每个同层的节点之间还拥有Sibling
指针,如果两个线程一个向左一个向右,也会出现死锁。
有两种以latch的粒度(granularity)为区分的解决方案:
- Page Latch: 对于在同一个page当中的读写效率很高,不同的话则效率低下。同时对并行度(prallelism)是降低的,因为同一时间只有一个线程能访问一个page。
- Slot Latch: 增加并行度,但是添加开销。
此外还有CAS(Compare-and-Swap)操作这个概念。CAS操作是一种原子操作,它通过一个比较-替换操作来实现互斥,即当一个线程读一个变量时,它会将这个变量的值与一个期望值进行比较,如果相同,则说明没有其他线程在修改这个变量,可以继续执行;否则,说明其他线程正在修改这个变量,需要重新尝试。CAS操作的优点是它不需要锁的开销,而且在并发度较高的情况下,它的性能要优于锁。
B+Tree Latching
总的来说,我们要防止两类情况的发生:
多个线程同时对同一个节点中的内容进行修改
某个线程在其他进程对节点进行split/merge操作时进行遍历
safe的定义
总结来说,如果某个节点确定为safe就可以被解锁
R锁代表Read读锁,W锁代表Write写锁
由此可以发现对于basic crabbing,每次进行写操作都会使得根节点处于写锁的独占状态,使得其他线程甚至连读都不允许。
-
Basic Latch Crabbing Protocol
- 对于find/search操作: 从上往下(root -> leaf)遍历B+Tree,先是父节点申请一个R锁,然后子节点申请R锁,父节点发现子节点只是read而不进行修改,所以判定为safe,父节点解锁,以此类推直到leaf node。
- 对于insert/delete操作: 从上往下(root -> leaf)遍历B+Tree,先是父节点申请一个W锁,然后子节点申请W锁,子节点判断这次的修改会不会导致父节点、及其祖宗节点的变化(父节点和祖宗节点safe与否),若会则保持父节点及之上的W锁,若不会则把上面全部解锁了
-
Improved Latch Crabbing Protocol
- 对于find/search操作: 是一样的
- 对于insert/delete操作: 将除了leaf node之前的所有路径都设置为R锁,,直到leaf node上设置W锁。如果叶不安全,释放以前的所有锁存器,然后使用Basic的插入/删除协议重新来一遍,即每次都是W锁的形式。
那么上面的这两个Crabbing都是为了解决垂直的竞争,从上到下或者从下往上的冲突,那么同一行的水平竞争该如何解决呢?
这就要提到Leaf Node Scan:
对于find/search操作,同级的叶子节点的扫描读取加R锁是没有问题的,因为多少个人读都不改变内容。但是对于insert/delete操作,同级的叶子节点的扫描读取加W锁是不安全的,因为其他线程可能在修改这些节点。
出于B+Tree无法设置死锁监测,我们只能用编码来实现避免死锁。叶节点同级锁存获取协议必须支持“无等待”模式。