参考:
B+树并发控制机制的前世今生
视频:索引并发
B+树的并发控制机制
基础的并发机制:它采用了两种粒度的锁:
(1)index粒度的S/X锁;
(2)page粒度的S/X锁(本文等同于树节点粒度)。
前者被用来控制对树结构访问及修改操作的冲突,后者被用来控制对数据页访问及修改操作的冲突。
index粒度的S/X锁
索引粒度的S/X锁:对整棵树进行加锁。
/* Algorithm1. 读操作 */
1. SL(index)
2. Travel down to the leaf node
3. SL(leaf)
4. SU(index)
5. Read the leaf node
6. SU(leaf)
读操作在访问树结构的过程中对B+树加的是S锁,所以其它读操作可以并行访问树结构,减少了读-读操作之间的并发冲突。
/* Algorithm2. 悲观写操作 */
1. XL(index)
2. Travel down to the leaf node
3. XL(leaf) /* lock prev/curr/next leaves */
4. Modify the tree structure
5. XU(index)
6. Modify the leaf node
7. XU(leaf)
因为写操作可能会修改整个树结构,所以需要避免两个写操作同时访问B+树。悲观写操作通过索引粒度的互斥锁避免这个问题。但悲观写操作在访问树结构的过程中对B+树加的是X锁,所以它会堵塞其它的读/写操作,这在高并发场景下会导致糟糕的多线程扩展性。
/* Algorithm3. 乐观写操作 */
1. SL(index)
2. Travel down to the leaf node
3. XL(leaf)
4. SU(index)
5. Modify the leaf node
6. XU(leaf)
因为每一个树节点页可以容纳大量的键值对信息,所以B+树的写操作在多数情况下并不会触发split/merge等修改树结构的操作。乐观思想假设大部分写操作并不会修改树结构。在访问树结构过程中持有树结构的S锁,从而支持其它读/乐观写操作同时访问树结构,而写操作对叶节点持有X锁。
B+树往往优先执行乐观写操作,只有乐观写操作失败才会执行悲观写操作,从而减少了操作之间的冲突和堵塞。不管是悲观写操作还是乐观写操作,它都通过索引粒度或者页粒度的锁避免相互之间修改相同的数据。
即使其它读写操作访问的是树结构的不同分支,在实际执行过程中不会产生相互间的影响,但是悲观写操作依然会堵塞其它所有读/写操作,直到树结构修改完成,这导致了过高的堵塞开销。
考虑只锁住B+树中被修改的分支,而不是锁住整个树结构?
page粒度的S/X锁
树节点粒度的S/X锁:只对修改的分支加锁。读操作在持有子节点的锁后才释放父节点的锁。
/* Algorithm4. 读操作 */
1. current <= root
2. SL(current)
3. While current is not leaf do {
4. SL(current->son)
5. SU(current)
6. current <= current->son
7. }
8. Read the leaf node
9. SU(current)
读操作先获得子节点的S锁,再释放父节点的S锁,这个过程反复执行直到找到某个叶节点。因为读操作在持有子节点的锁后才释放父节点的锁,所以不会读到一个正在修改的树节点,不会在定位到某个子节点后子节点的键值对被移动到其它节点。
/* Algorithm5. 写操作 */
1. current <= root
2. XL(current) //写操作同样从根节点出发,首先持有根节点的X锁
3. While current is not leaf do {
4. XL(current->son)
5. current <= current->son//写操作先获得子节点的X锁,
6. If current is safe do {//然后判断子节点是否是一个安全节点
7. /* Unlocked ancestors on stack. */
8. XU(locked ancestors)//子节点是安全节点,写操作立即释放祖先节点的X锁
9. }
10. }
11. /* Already lock the modified branch. */
12. Modify the leaf and upper nodes
13. XU(current) and XU(locked ancestors)
写操作判断子节点安全才释放父节点的X锁,反复执行直到找到某个叶节点。当到达了叶节点后,写操作就已经持有了修改分支上所有树节点的X锁,从而避免其它读/写操作访问该分支。
SX锁
写操作在到达被修改分支之前,对途径的树节点加的是X锁,这在一定程度上阻塞了其它操作访问对应的树节点。当这个写操作需要频繁将树节点从磁盘读取到内存产生较高的IO延迟时,这个堵塞开销会更高。
一种位于S锁与X锁之间的SX锁,它可以堵塞其它的SX/X加锁操作(写操作),但可以允许S加锁操作(读操作),并且当它确定要修改该节点时可升级为X锁堵塞其它读写操作。
/* Algorithm6. 写操作 */
1. current <= root
2. SXL(current)
3. While current is not leaf do {
4. SXL(current->son)
5. current <= current->son
6. If current is safe do {
7. /* Unocked ancestors on stack. */
8. SXU(locked ancestors)
9. }
10. }
11. XL(modified nodes) /* SX->X, top-to-down*/
12. /* Already lock the modified branch. */
13. Modify the leaf and upper nodes
13. XU(current) and XU(locked ancestors)
在写操作将影响分支上的锁升级为X锁前,所有读操作都可以访问被这个写操作访问过的非叶节点,从而减少了线程之间的冲突。由于SX锁的存在,不会出现多个写操作修改同一个分支的情况。
减少锁的使用
前文中的并发控制机制在很大程度上减少了线程间的冲突,但是依然存在一个问题:不论读/写操作,它们在访问一个树节点前都需要对树节点加S/SX/X锁。频繁加锁操作在多核处理器上会产生Coherence Cache Miss过高的问题。
前文中所述的并发机制,往往采用自顶向下的加锁策略,在安全地获取到子节点的锁后释放父节点的锁。然而我们很容易发现,这种加锁方式依然是十分悲观的:大部分获取到的锁其实是无意义的,尤其在树的上层,因为离根节点越近的树节点被更新的概率越低。因此,如果存在一种自底向上加锁的策略,只有在树节点分裂或者合并或者删除的情况下向上加锁,只对被修改的树节点加锁,就可以在很大程度上减少加锁的范围和频率,从而提高B+树的多线程扩展性。为了实现这个目标,我们首先需要支持在不持有锁的状态下从根节点访问到叶节点的功能。
Blink树,对后世影响深远的多线程B+树
Blink树假设访问树节点的读写操作是原子性的,读操作不会读到写操作修改到一半的状态,但写操作之间修改同一份数据时会出现冲突。
/* Algorithm7. 读操作 */
1. current <= root
2. While current is not leaf do {
3. current <= scanNode(current, v)
4. current <= current->son
5. }
6. /* Keep move right if necessary. */
7. /* Deal with the leaf node. */
/* scanNode函数 */
8. Func scanNode(node* t, int v) {
9. If t->next->key[0] <= v do
10. t <= scanNode(t->next, v)
11. return t;
12. }
读操作从根节点出发,遍历整个树结构,直到找到某个叶子节点(step1-5),在这个过程中,读操作并不持有锁。特殊之处在于在每到达一个子节点后,它都会调用scanNode函数,这个函数就是Blink树的精髓所在。因为读操作在遍历树结构的过程中不持有锁,这导致它访问的某个树节点可能被其它写操作所分裂或者删除。
Blink树提出为每一个树节点配置一个右指针,这个右指针为读操作提供了另一种方式去访问子节点的右兄弟节点。Blink树规定树的分裂操作顺序必然是从左至右,因此目标键值对只有可能被分裂到子节点的右兄弟节点。
scanNode函数:读操作会判断子节点的右兄弟节点的最小值是否大于它正在查找的目标键值,如果不是说明目标键值对在右兄弟节点或者更右边的节点,指针就会往右走,直到找到某个右兄弟节点的最小值大于目标键值。
当发生删除操作时,它采用index粒度的X锁,堵塞其它读/写操作,避免了dangling pointer错误的发生。
/* Algorithm8. 写操作 */
1. current <= root
2. While current is not leaf do {
3. current <= scannode(current, v)
4. stack <= current
5. current <= current->son
6. }
7. XL(current) /* lock the current leaf */
8. moveRight(current)
9. DoInsertion:
10. If current is safe do
11. insert(current) and XU(current)
12. else {
13. allocate(next)
14. shift(next) + link(next)
15. modify(current)
16. oldnode <= current
17. current <= pop(stack)
18. XL(current)
19. moveRight(current)
20. XU(oldnode)
21. goto DoInsertion;
22. }
写操作使用和读操作类似的方式定位到目标叶节点current并加锁(step1-8)。为了支持自底向上加锁,写操作遍历过程中将访问到的树节点压入栈stack中。如果叶节点是安全节点,直接插入后释放锁就可以了(step10-11)。如果叶节点不是安全节点,就分配一个新的next节点,将叶节点的数据移动到next节点,修改current节点并将右指针指向next节点(step13-15)。然后,写操作从栈中弹出上一层的父节点并加锁(step16-18)。由于父节点也可能被分裂,所以也需要通过moveRight函数移动到正确的上一层节点(step19),然后重复上述的DoInsertion过程。moveRight与scanNode相似,主要的区别在于前者是在加锁状态下向右走,拿到右节点的锁后可释放当前结点的锁。写操作通过树节点粒度的锁,避免了多个写操作同时修改同一个树节点。
虽然Blink树有效减少了加锁频率,但是它依然存在两个问题:1. 不实际的假设:读写树节点的操作是原子性的;2. 删除操作竟然需要锁住整个索引结构,效率太差了。
OLFIT树,版本号你值得拥有
OLFIT树,在Blink树基础上引入了版本号的概念。
/* Algorithm9. 树节点的写操作 */
1. XL(current)
2. Update the node content
3. INCREASE(version)
4. XU(current)
/* Algorithm10. 树节点的读操作 */
1. RECORD(version)
2. Read the node content
3. If node is lock, go to step1
4. If version differs, go to step1
Algorithm 9-10显示版本号相关的具体操作。
Algorithm 9显示写操作在每个树节点上的执行过程:它首先锁住这个节点(step1),接着更新这个节点的内容(step2),然后递增树节点的版本号(step3),最后释放这个节点的锁(step4)。因为读操作在读取某个树节点时树节点可能被修改/分裂/删除,写操作通过锁告知读操作这个树节点正在被修改,通过版本号告知读操作这个树节点已经被修改。
Algorithm 10显示读操作在每个树结点上的执行过程:它首先记录这个树节点的版本号(step1),再读取这个树节点的内容(step2),在读操作结束后再次读取节点的锁和版本号。如果节点的锁或者版本号发生变化,它判断自己读取的树节点可能处于不一致的中间状态,因此从(step1)重新开始执行。
/* Algorithm11. OLFIT树的读操作 */
1. current <= root
2. While current is not leaf do {
3. RECORD(version)
4. next <= scanNode(current, v)
5. If version/lock is not modified do
6. current <= next
7. }
8. /* Keep move right if necessary. */
9. /* Deal with the leaf node. */
Algorithm 11显示了读操作的完整过程。(step1-7)的过程与Blink树相似,区别在于OLFIT树在访问每个节点时根据版本号/锁的状态判断自己是否读到正确的数据,从而避免读到修改到一半的树节点。
对于删除操作,为了避免读操作正在访问的节点被其它写操作删除,OLFIT树可以采用epoch-based reclamation机制。原理简单来说就是将删除操作分为逻辑删除和物理删除两个步骤,只有在确保一个树节点没有被任何操作访问时才可以回收这个树节点的物理空间。
Masstree,B+树优化机制的集大成者
Masstree融合了大量B+树的优化策略,包括单线程场景下和多线程场景下的。