Author: yuming
本文介绍 B-tree 物理结构的并发控制,主要讨论设计 B-tree 加锁规则时需要解决几个问题。
参考论文《A Survey of B-Tree Locking Techniques》
InnoDB 的相关实现参考这篇月报:http://mysql.taobao.org/monthly/2020/06/02/
B-tree 索引的并发控制需要考虑两个方面:
事务 transaction 并发访问数据内容时的并发控制;
线程 thread 并发访问内存数据结构时的并发控制;
为了更容易区分这两种情况,我们举一个更清楚的例子:一个事务被分配给多个线程并发地执行时,当一个线程在分裂一个 B-tree 节点时,其他线程不能去访问这种中间状态的数据结构;相反的,当单一线程去服务多个事务时,同样需要考虑数据内容在多个事务间的一致性。在数据库系统中,这两个目标通常用两个不同的机制 lock 和 latch 来实现,而操作系统中常用的 lock 这个词,在数据库中其实指的是 latch。
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 去请求子节点 latch 时,如果子节点没有在内存池中,就需要进行一次磁盘 IO 来获取子节点,因为 latch 的持有时间必须很短,等待读取子节点时,不应该长时间占据父节点的 latch,而是应该释放父节点的 latch。在获取子节点的 IO 操作结束之后,需要重新进行一次从 root 到 leaf 的遍历来获得父节点的 latch,这样可以避免释放父节点的期间 B-tree 的结构改变导致的不一致问题。这种重新遍历的操作代价并不大,因为可以通过检查上一次遍历时保存的路径是否还有效,来重用之前的路径,当需要的节点已经从磁盘读取到内存池中时,它的祖先节点可能还没有被其它线程修改过。
latch coupling 除了上面提到的从父节点到子节点遍历的情况,还有一种是同层相邻节点遍历的场景,例如范围查询时需要沿着叶节点的链表正向或反向遍历,并发查询可能会由于遍历的方向不同导致死锁。为了避免相反方向遍历产生的死锁,一种方法是让 latch 支持立即重试的模式,即当一个 latch 被其它线程占有而获取不到时,立即返回失败而不是继续等待 latch 被其它线程释放。在正向或反向遍历 B-tree 同层节点时,只要遇到 latch 获取失败,就立即释放掉自己占有的 latch,从而让冲突的对方能继续执行下去,而自己进行一次从 root 到 leaf 的重试。这里要考虑的一个问题是,如何避免两个冲突的线程同时重试的情况,因为同时重试后有可能还在相同的地方发生冲突,可以规定一个遍历方向的优先级,这样可以保证冲突时只有一个线程会重试,另一个线程会继续执行。
在向 B-tree 插入记录时可能导致叶节点 overflow,需要将 overflow 的节点分裂成两个,并将新的节点指针插入到父节点,这时父节点可能也会 overflow,因此又会触发插入到祖父节点的操作,最极端的情况,这种递归向上插入会一直执行到 root 节点。除了插入操作,删除或者更新记录操作也可能发生这种从叶节点到根节点的变更,这个过程需要从下到上的加锁顺序,因此可能会与从上到下的遍历操作形成死锁。最简单的解决办法是对整个 B-tree 加一个互斥锁,但是这样太影响多线程并发,最好的方法应该是只对 B-tree 节点加锁,下面总结了几种针对这个问题的策略:
在从上到下查找目标节点时,就把整个路径节点加互斥锁,这样在从下到上的节点变更时就不再需要加锁。很明显的这种方法每次都会锁住 root 节点,跟锁住整个 B-tree 没有本质区别,严重影响 B-tree 的并发性;
在从上到下遍历时给查找路径加共享锁,在必要时再将共享锁升级成互斥锁,升级过程中需要检查死锁的风险。由于这种方法是可能失败的,因此需要有额外的备选方案,这就增加了逻辑的复杂度。
引入一种共享互斥锁(SX latch),从上到下遍历时给路径上的节点都加 SX latch,这种类型的 latch 可以与共享锁相容,从而不会阻塞其它线程的读请求。但是 SX latch 无法与自身相容,因此对于并发更新来说 B-tree 的根节点依然是一个瓶颈,只允许一个线程进行修改 B-tree 结构的操作。
上面三种方法都需要一直持有遍历路径上节点的 latch,直到一个节点不再会触发向上更新才释放路径上的所有 latch。实际情况是大多数节点都不是满的,因此大多数插入操作都不会触发节点分裂并向上变更,锁住整个路径的节点是没有必要的。如果在插入操作的从上到下遍历时主动进行节点分裂,就能避免了根节点的瓶颈问题,也没有升级 latch 时失败的问题。缺点是需要在实际分裂之前预先分配空间,造成一定的空间浪费,并且在可变长记录更新时无法准确地判断是否需要预先分裂。
为了解决不必要的对节点加互斥锁,可以在第一次从上到下遍历时加共享锁,直到一个节点需要分裂时,重新回到 root 做一次遍历,这次给要分裂的节点加互斥锁,并进行实际的分裂操作。第二次遍历时可以通过检查第一次遍历保存的路径来进行重用,而不必从根节点重新遍历。