索引并发控制:到目前为止,我们都假设讨论的数据结构是单线程的。然而,大多数DBMS需要允许多线程安全地访问数据结构,以利用额外的CPU核心并掩盖磁盘I/O带来的停顿。
虽然有些系统采用单线程模型,或者可以通过简单地给单线程数据结构加一把读写锁来将其转换为多线程版本,但这通常是低效的。
并发控制协议是DBMS用来确保对共享对象进行并发操作时产生正确结果地方法。
协议的正确性标准有:
-
逻辑正确性:指线程能够读到它期望读取的值。例如:一个线程应该能读回它之前写入的值。
-
物理正确性:指对象的内部表现形式是完好的。例如:数据结构中不存在会导致线程读取无效内存地址的指针(即不存在悬空指针或破坏的结构)。
锁和闩锁的区别
锁:锁是一种高层级,逻辑性的原语,用于保护数据库内容(例如元组、表、数据库)免受其他事务干扰。
-
持有时间:事务会在其整个生命周期内持有锁。
-
可见性:数据库系统可以将查询运行时所持有的锁暴露给用户。
-
死锁处理:应该有一套更高层级的机制来检测死锁并回滚更改。
闩锁:闩锁是底层、物理性的保护原语,用于保护 DBMS 内部数据结构的临界区(例如具体的数据结构、内存区域)免受其他线程的干扰。
-
持有时间:闩锁仅在数据库系统执行简单操作时短时间持有(例如:页闩锁 Page Latch)。
-
两种模式:
-
READ(读模式):允许多个线程同时读取同一个项目。即便已有其他线程持有了读模式的闩锁,新线程仍可获取读模式闩锁。
-
WRITE(写模式):仅允许一个线程访问该项目。如果已有任何线程以任何模式持有该闩锁,则新线程无法获取写模式闩锁。同时,持有写模式闩锁的线程也会阻止其他线程获取读模式闩锁。
-
闩锁的实现:闩锁的实现应当具备较小的内存占用,并且在没有竞争的情况下,应当有一条快速路径来实现闩锁。实现闩锁的底层原语是现代CPU提供的原子指令。通过这些指令,线程可以检查某个内存地址的内容,判断其是否为特定值。DBMS 中有几种实现闩锁的方法,每种方法在工程复杂度和运行性能之间都有不同的权衡。这些"测试并设置(Test-and-Set)"的步骤都是原子化执行的(即没有其他线程可以在测试和设置步骤之间更新该值)。
1.测试并设置 自旋闩锁:自旋闩锁是比操作系统互斥锁更高效的选择,因为它受 DBMS 控制。自旋闩锁本质上是内存中的一个位置,线程尝试更新它(例如将布尔值设为 true)。线程执行 CAS (Compare-and-Swap) 操作尝试更新该内存位置。如果获取失败,DBMS 可以控制后续行为:是继续尝试(例如使用 while 循环),还是允许操作系统将其切出。
-
示例:
std::atomic -
优点:加锁/解锁操作非常高效(在 x86 架构上仅需单条指令)。
-
缺点:不可扩展且对缓存不友好。在多线程环境下,CAS 指令会在不同线程中多次执行,这些无意义的指令在高竞争环境下会堆积;对 OS 来说线程看起来很忙,但其实没做有用功。这会导致**缓存一致性(Cache Coherence)**问题,因为线程在不断轮询其他 CPU 的缓存行。
2.阻塞式操作系统互斥锁:一种可能的实现是利用操作系统内置的互斥锁机制。Linux 提供了 futex (Fast User-space Mutex),它由 (1) 用户态的自旋闩锁和 (2) 内核态的互斥锁组成。如果 DBMS 能在用户态获取闩锁,则直接设置成功。如果失败,则进入内核尝试获取更昂贵的互斥锁;如果依然失败,线程会通知 OS 自己被阻塞,随后被挂起。
-
示例:
std::mutex(在 Linux 上通常基于 futex 实现) -
优点:使用简单,无需在 DBMS 中编写额外代码。
-
缺点:开销大且不可扩展(每次加锁/解锁约耗时 25 纳秒),这主要是由于操作系统的调度开销引起的。通常在 DBMS 内部使用 OS Mutex 是个坏主意。
3.读写闩锁:互斥锁和自旋锁不区分读/写(即不支持不同模式)。DBMS 需要一种允许并发读取的方法,这样在读密集型应用中性能更好,因为读线程可以共享资源而无需等待。 读写闩锁允许以读模式或写模式持有。它会跟踪有多少线程持有该闩锁,以及有多少线程在等待获取各模式的闩锁。
-
实现:它使用前两种实现(自旋锁或互斥锁)作为基础,并增加了处理读写队列的额外逻辑。不同的 DBMS 对队列的处理策略不同。
-
等待策略:包括读者优先、写者优先以及公平读写锁。其行为在不同 OS 和 pthread 实现中有所差异。
-
示例:
std::shared_mutex -
优点:允许并发读者,提高吞吐量。
-
缺点:DBMS 必须管理读写队列以避免饥饿问题;由于需要存储额外的元数据,空间开销比自旋锁大。
哈希表闩锁:在静态哈希表中支持并发访问相对容易,因为线程访问数据结构的方式非常有限。例如,所有线程在从一个槽位(Slot)移动到下一个槽位时,移动方向是相同的(即自上而下)。此外,线程每次仅访问单个页面或槽位。因此,在这种情况下不可能发生死锁,因为不会有两个线程互相竞争对方持有的闩锁。当我们需要调整表的大小时,只需对整个表加一把全局闩锁(Global Latch)即可执行操作。
在动态哈希方案中,闩锁机制会更加复杂,因为有更多的共享状态需要更新,但基本思路是一致的。
支持哈希表闩锁有两种主要方法,它们的区别在于闩锁粒度:
- 页面级闩锁 (Page Latches):每个页面都有自己的读写闩锁(Reader-Writer Latch),保护其全部内容。
-
机制:线程在访问页面之前获取读闩锁或写闩锁。
-
缺点:这会降低并行性,因为同一时间可能只有一个线程能访问某个页面。
-
优点:由于一个线程只需获取一次闩锁即可访问页面内的多个槽位,因此对于单个线程来说,页内操作速度很快。
槽位级闩锁:每个槽位都有独立且唯一的闩锁。
-
优点:增加了并行性,因为两个线程可以同时访问同一页面上的不同槽位。
-
缺点:增加了存储和计算开销,因为线程每访问一个槽位都必须获取一把锁,且每个槽位都必须存储闩锁相关的元数据。
-
优化:DBMS 可以使用单模式闩锁(如自旋锁 Spin Latch)来减少元数据和计算开销,但这会牺牲一定的并行性能(不区分读写)。
无锁哈希表:也可以直接利用CAS(Compare-and-Swap)指令创建一个无锁的线性探测哈希表。
-
机制:在插入某个槽位时,尝试用 CAS 指令将一个特殊的空值(Null)替换为我们想要插入的元组。
-
冲突处理:如果 CAS 失败(说明别的线程先抢到了这个位置),我们就探测下一个槽位,重复此过程直到成功。
B+树闩锁:B+树闩锁面临的挑战主要在于防止以下两个问题。
-
多个线程同时试图修改同一个节点的内容。
-
一个线程正在遍历树,而另一个线程同时正在拆分或合并节点。
闩锁螃蟹走位是一种允许其多线程同时访问或修改B+树的协议。其基本思想如下:
-
获取父节点的闩锁。
-
获取子节点的闩锁。
-
如果子节点被认为是安全的,则释放父节点的闩锁。
安全节点的定义是:在更新操作时,该节点不会发生分裂、合并或重新分配。换句话说,一个节点是安全的,如果:
-
对于插入操作:节点未满(还有空位)。
-
对于删除操作:节点超过半满(删除后不会导致下溢)。
-
注意:读闩锁(Read Latches)不需要关心安全条件。
假如向节点L插入时节点L安全的,那么可以释放L的父亲的锁,直接插入。假如不安全,由于在检查插入L过程中始终持有父亲的锁,父亲在这个过程中始终保持安全(或父亲的某个祖先始终保持安全),此时向L中插入后,最远波及到安全的父亲或安全的父亲的某个祖先,由于节点安全,所以影响不会再往上波及。
基本螃蟹走位协议:
-
查找:
- 从根节点开始向下,重复执行:获取子节点的读闩锁 (R),然后释放父节点的闩锁。
-
插入/删除:
-
从根节点开始向下,根据需要获取写闩锁 (X)。
-
一旦锁住了子节点,检查它是否安全。
-
如果子节点是安全的,释放所有祖先节点(即当前节点之上的所有节点)的闩锁。
-
从正确性的角度来看,释放闩锁的顺序并不重要。然而,从性能角度来看,最好先释放树上层的闩锁,因为它们阻碍了对更多叶子节点的访问(锁住根节点等于锁住了全世界)。
改进的螃蟹走位协议:基本螃蟹走位协议的问题在于:对于每次插入/删除操作,事务总是需要在根节点上获取独占闩锁。这极大地限制了并行性(把并发变成了串行)。哪怕一个线程在检查完根节点的子节点后,很快地释放根节点的锁,在高并发场景下,根节点仍然会成为单点瓶颈。
相反,我们可以假设调整结构(即分裂/合并节点)是罕见的。因此:
-
事务可以先一路获取共享闩锁 (Shared Latches / Read Latches) 直到叶子节点。
-
每个事务乐观地假设通往目标叶子节点的路径是安全的,并使用读闩锁和螃蟹走位到达底层进行验证。
-
如果发现叶子节点不安全,则中止操作,并回退到上面的"基本算法"(即重新从根节点开始获取写闩锁)。
具体流程:
-
查找:与之前算法相同(全程读锁)。
-
插入/删除:
-
像查找一样设置读闩锁 (READ),一路向下到达叶子节点。
-
在叶子节点上设置写闩锁 (WRITE)。
-
如果叶子节点不安全(需要分裂/合并),释放之前持有的所有闩锁,并使用之前的"基本插入/删除协议"(从根节点开始加写锁)重启事务。
-
叶子节点扫描:上述协议中的线程都是自顶向下的方式获取闩锁,这意味着线程只能获取当前节点下方节点的闩锁。如果所需的闩锁不可用,线程必须等待。基于这种固定的加锁顺序,永远不会发生死锁。
然而,叶子节点扫描容易出现死锁,因为现在我们有线程视图在两个不同的方向上同时获取独占锁。例如,线程1试图删除某个叶子节点,而线程2正在进行叶子节点的范围扫描。索引闩锁不支持死锁检测或避免机制。
注:删除/合并操作有时需要向左看,找前驱兄弟借数据或者合并。
程序员处理此问题的唯一方法是通过编码规范:
-
叶子节点的兄弟闩锁获取协议必须支持不等待模式。
-
也就是说,B+ 树的代码必须能处理闩锁获取失败的情况。
-
由于闩锁原本就设计为(相对)短时间持有,如果一个线程尝试获取叶子节点的闩锁但失败了,它应该迅速中止操作(释放它持有的所有闩锁),然后重启该操作。
project1:Hash Index
之前我们已经学过两种主要的索引数据结构,分别是哈希和B+树。project1中,我们选择使用哈希表的变体------可扩展哈希的变体来实现。
下图中,header根据哈希值的前两位分发到不同的directoty中,directoty根据哈希值的最后两位分发到不同的桶中。directory中的条目(00,1)和(10,1)表示00和10这两个槽位共用局部深度为1的一个桶。directory(0/2)表示当前directory的全局深度为0,但是已经预留了全局深度为2的槽位(4个槽位)。

需要注意的是,第一级索引只起到一个粗略筛选的作用,它只作用于哈希值的前两位且是静态的。所以它没有全局深度,局部深度之说,它的分辨率是不变的。
项目中的笔记:
auto header_page = guard.template As<ExtendibleHTableHeaderPage>(); 要求必须加上template以显式说明这是一个模板函数。
在Header和Directory的Init函数中,在初始化时应该将页号数组初始化为INVALID_PAGE_ID(Header必须,Directory建议,因为目录页和桶页可以通过Size来判断,而Header只能通过判断值是否为INVALID_PAGE_ID来判断对应的目录页是否已经存在)。
所以,在ExtendibleHTableHeaderPage中判断directory_page_id是否有效,判断它是否等于INVALID_PAGE_ID。若有效,直接深入到下一层,若无效,需要初始化对应directory_idx的目录页。
在ExtendibleHTableDirectoryPage中判断bucket_page_id是否有效,判断bucket_page_id是否等于INVALID_PAGE_ID。若有效,直接深入到桶中,若无效,需要初始化对应bucket_idx的桶页。
在ExtendibleHTableBucketPage中判断键是否存在直接调用API即可。
要求实现螃蟹走位,在Task3中先简单处理,只读函数获取ReadPageGuard,写函数获取WritePageGuard,且都始终不主动调用Drop。
由于桶页是模板类,在类外使用其成员函数时必须提供模板参数。
auto bucket_page = bucket_guard.template As<ExtendibleHTableBucketPage<K, V, KC>>();
在我的实现中,PageGuard只负责在析构时如果page_不为空则释放锁,PageGuard本身并不负责获取锁。而是依赖FetchPageRead和FetchPageWrite来获取锁。
FetchPageRead和Write根据官方代码接口要求返回的是PageGuardRead和Write,但是FetchPage本身可能会返回nullptr,也就是说可能返回一个包裹nullptr的PageGuard,我们需要在AsMut。不用担心获取空页时PageGuard会释放空页的锁,因为PageGuard在Drop时会检查是否持有空页再决定是否Drop。
注意,每次调用FetchPageRead或Write之后都需要判断条件。
对于UpdateDirectoryMapping,我的UpdateDirectoryMapping语义为指定一个满桶或空桶槽位A,之前和该槽位指向相同桶(满桶或空桶)的槽位,现在应该根据局部深度变化------仍应和槽位A指向相同桶的槽位对其指向桶,还有局部深度进行修改;现在不应该和槽位A指向相同桶的槽位,只修改局部深度,不修改指向的桶。
对于Task4,如果只实现基本螃蟹走位的话,优化是在有限,因为可扩展哈希层数是在太少。而插入和删除时,不论是遇到满桶,还是空桶,由于可能扩展或收缩,都必须始终持有目录页的写锁。
所以更好的实现是上面提到的改进螃蟹走位协议,在插入或删除时,先不着急获取目录页的写锁,而是获取其读锁,假设对应桶页安全。当发现对应桶页为空或满时,再回退到一开始获取目录页的写锁。
注:对于头页,由于头页的槽位数量始终不变,获取directory_guard之后(即获取目录页的写锁或读锁后)马上就可以释放头页的锁,即调用Drop。但是也有特殊情况,即对应目录页尚未创建时,需要创建对应目录页之后更新头页的相关记录。由于InsertToNewDirectory和InsertToNewBucket都不传递guard,而是传递page类型,所以这里难以释放头页的锁。