参考论文《A Survey of B-Tree Locking Techniques》

InnoDB 的相关实现参考这篇月报:http://mysql.taobao.org/monthly/2020/06/02/

B-tree 索引的并发控制需要考虑两个方面:

  1. 事务 transaction 并发访问数据内容时的并发控制;

  2. 线程 thread 并发访问内存数据结构时的并发控制;

latch 一般称为闩锁,只作用于内存数据结构,例如,控制多个线程互斥访问内存池中的 B-tree 节点。latch 是低级别、轻量级的锁,线程通常只在临界区内读写共享内存数据结构时持有 latch,因此 latch 的锁定时间一般很短并且频繁使用,latch 的获取和释放需要尽量小的消耗和更好的性能。latch 最简单的形式就是互斥锁(mutex),它不允许任何的并发访问,在数据库系统中通常还会使用共享(shared)和互斥(exclusive)两种类型的 latch。latch 的死锁不能使用复杂的死锁检测或回滚机制,而是需要通过设计编码规范来避免死锁发生,例如多个线程都以规定好的顺序申请 latch。

lock 用于隔离多个事务,锁定的对象是数据库逻辑内容,例如 table、record、B-tree keys、key range 等,通常锁定时间很长,在事务结束时才释放。lock 会参与死锁检测,也支持复杂的调度策略,例如使用队列来排队加锁请求,因此 lock 申请和释放是比较耗时的,通常要上千的 CPU 周期。数据库系统通常会实现 lock table,因为 lock table 是共享的内存数据结构并且会有多个线程并发访问,因此访问 lock table 也就需要 latch 来保护。

latch 可以直接嵌入要保护的数据结构,例如,对于内存池中的磁盘数据页,每个数据页都有一个内存描述符结构记录 page id 等信息,而数据页对应的 latch 就可以嵌入到这个描述符结构中。 lock 用于保护逻辑的数据库内容,被保护的数据可能都不在内存里出现,因此 lock 也就无法像 latch 一样嵌入要保护的对象。

如果要修改 B-tree 的内容或结构,必须先把 B-tree 节点读取到内存池中,修改后再写回磁盘。在多线程场景下,内存池中的一个 B-tree 节点在被一个线程读取时,不能被另一个线程修改,这种场景就是多线程编程中共享数据的临界区问题。数据库中使用 latch 来控制单个 B-tree 节点的访问,从而保持 B-tree 物理结构的一致性,通常在每个节点的描述符中嵌入一个对应的 latch。

当一个线程沿着一个指针从一个节点到另一个节点时,例如从 B-tree 的一个父节点到一个子节点,在这期间不能有其它线程去改变这个指针,例如删除这个子节点等操作。这个时候需要持有父节点的 latch 直到获得了子节点的 latch,这种方法通常称为 ”latch coupling”。

latch coupling 除了上面提到的从父节点到子节点遍历的情况,还有一种是同层相邻节点遍历的场景,例如范围查询时需要沿着叶节点的链表正向或反向遍历,并发查询可能会由于遍历的方向不同导致死锁。为了避免相反方向遍历产生的死锁,一种方法是让 latch 支持立即重试的模式,即当一个 latch 被其它线程占有而获取不到时,立即返回失败而不是继续等待 latch 被其它线程释放。在正向或反向遍历 B-tree 同层节点时,只要遇到 latch 获取失败,就立即释放掉自己占有的 latch,从而让冲突的对方能继续执行下去,而自己进行一次从 root 到 leaf 的重试。这里要考虑的一个问题是,如何避免两个冲突的线程同时重试的情况,因为同时重试后有可能还在相同的地方发生冲突,可以规定一个遍历方向的优先级,这样可以保证冲突时只有一个线程会重试,另一个线程会继续执行。

在向 B-tree 插入记录时可能导致叶节点 overflow,需要将 overflow 的节点分裂成两个,并将新的节点指针插入到父节点,这时父节点可能也会 overflow,因此又会触发插入到祖父节点的操作,最极端的情况,这种递归向上插入会一直执行到 root 节点。除了插入操作,删除或者更新记录操作也可能发生这种从叶节点到根节点的变更,这个过程需要从下到上的加锁顺序,因此可能会与从上到下的遍历操作形成死锁。最简单的解决办法是对整个 B-tree 加一个互斥锁,但是这样太影响多线程并发,最好的方法应该是只对 B-tree 节点加锁,下面总结了几种针对这个问题的策略:

  1. 在从上到下查找目标节点时,就把整个路径节点加互斥锁,这样在从下到上的节点变更时就不再需要加锁。很明显的这种方法每次都会锁住 root 节点,跟锁住整个 B-tree 没有本质区别,严重影响 B-tree 的并发性;

  2. 引入一种共享互斥锁(SX latch),从上到下遍历时给路径上的节点都加 SX latch,这种类型的 latch 可以与共享锁相容,从而不会阻塞其它线程的读请求。但是 SX latch 无法与自身相容,因此对于并发更新来说 B-tree 的根节点依然是一个瓶颈,只允许一个线程进行修改 B-tree 结构的操作。

  3. 为了解决不必要的对节点加互斥锁,可以在第一次从上到下遍历时加共享锁,直到一个节点需要分裂时,重新回到 root 做一次遍历,这次给要分裂的节点加互斥锁,并进行实际的分裂操作。第二次遍历时可以通过检查第一次遍历保存的路径来进行重用,而不必从根节点重新遍历。