【CMU 15-445/645 Database Systems】09 index concurrency

399 阅读5分钟

这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战

前面讨论的数据结构都是在单线程中的情况。但是实际场景中,并发和多线程操作是一定存在的,所以必须要确保DBMS在多线程操作的情况下仍然可以安全的访问数据,尽可能的利用现在越来越多的cpu核心,减少磁盘IO。

并发控制,确保在并发操作中对共享资源的正确访问。

  • 逻辑正确:线程能够读取到它应该被允许读到的数据
  • 物理正确:确保数据结构中没有乱七八糟的指针导致线程在找数据的时候访问到无效的内存地址

1.Latch

前面已经提过lock和latch的区别,这里再回顾一下。

  • lock是事务级别上的概念,在整个事务周期内有效,锁的是数据表中具体的数据。比如我们常说的读写锁(互斥锁、共享锁);是lock manager进行维护的;
  • latch是线程级别的概念,管理的是内存中数据结构的并发安全。包括读写两种模式,类似读写者问题,读之间是兼容的,读和写、写和写之间是互斥的。

2.Implementation

blocking OS MUTEX

使用操作系统的互斥量,例如std::mutex,然后调用lock和unlock方法;

太慢,每一次lock/unlock的调用需要约25ns,所以不能大规模扩展。

TAS(test and set spin latch)

自旋锁。忙式等待。自旋,顾名思义,就是一直在原地旋转。持续等待。互斥锁,没有申请到资源的进程会休眠等待。

高效;不可扩展,not cache friendly。

例如std::atomic

reader-writer latch

允许并发读;

需要管理R/W队列来避免饿死;

可以在自旋锁之上实现。

3.Hash table latch

将hash表上锁。有整个bucket粒度的(每次锁住一个桶),也有slot粒度的(每次锁住一个格子)。

在resize整张表的时候,给整个表上一个global latch。

  • Page latches
  • Slot latches

Page latch

每个page有自己的读写latch,来保护它整页的内容;线程访问某页之前先申请read or write latch;

例如,find(key0)命中了page1,那么就申请page1的读latch;如果此时有另一个线程期望在page1中插入,那么需要等待page1的读latch释放再申请page1的写latch。如果发生了冲突,在page1中没有找到key0,需要线性向后查看page2,那么可以安全的释放page1的读latch;

Slot latch

每个slot有自己的latch。

例如,find(key0)命中了page1,那么就遍历page1中的slot,每次在查看某个slot的时候只申请当前slot的读latch;跳到下一个的时候,申请下一个slot的latch并释放前一个latch。如果此时有另一个线程在写page1中另一个slot,那么显然是不受影响的。

4.b+ tree 的并发控制

期望允许多线程同时read和update b+树。这样,就需要考虑两方面的安全问题:

  • 多个线程同时修改某个node的内容怎么办?
  • 某个线程在遍历树的时候,其他线程在进行split、merge操作怎么办?

Latch crabbing/coupling

基本思路:

  • 获取目标node的parent的latch

  • 获取目标node的child的latch

  • 如果parent是一个安全节点(safe node),可以释放parent的latch

    • Safe node:之后不会split或merge的节点(在插入操作中,是一个非满的节点;在删除操作中,是一个非half-full的节点。可以参考b+树的rebalance操作的定义)。

find操作:从root开始查找,重复:

  • 获取当前节点的child节点的read latch
  • Unlatch parent

insert/delete操作:从root开始查找,找到目标节点的过程中获取路径上每个节点的write latch。当某个节点被锁住后(latched),检查,若安全,则释放全部祖先节点的latch。

例如下面的例子,在查找38所在节点(node D)的过程中,依次给node A、node B加上了write latch。在D的时候,我们确认它是安全的(不会merge C),所以可以释放D的全部祖先(A、B)。然后再继续去找叶子结点中的38,将node H加锁并直接删除即可。

关于这块,原slide上还有若干个例子,可以直接查看

Better latching alg

可以注意到,所有update操作的第一步,都是给root节点增加write latch。这意味着在释放之前,其他所有的update操作都是要被阻塞的。所以这个会成为瓶颈。

瓶颈的本质是,write latch是互斥的。

那么能不能改成read latch呢?

之前的方法中,write latch在确认某个节点安全后,会释放该节点的所有祖先(当然也包括root),直到找到目标叶子节点位置。那么我们可以先假设目标叶子节点是安全的,然后按照前面查找操作的方式(依次加read latch)访问到目标叶子节点。如果该节点确实安全,那么没有问题;否则,按照之间加write latch的方法重新执行一遍。

即:

  • search:与之前相同。
  • insert/delete:首先按照search的方式进行操作;如果leaf node安全,则只给该节点加writh latch即可。

Leaf node scan

前面提到的都是top-down的一个申请latch的流程。有些时候,在谓词查找的是某个范围的数据时(例如key < 10),会需要在找到key=10这个叶子节点后,在叶子节点这一level上进行同级扫描(b+树的叶子节点之间有指针互相连接)。这时候,优先获取下一个节点的latch,然后释放当前节点的latch。

问题是,如果下一个节点已经被另一个线程申请了write latch,那么当前线程就无法申请read latch,且不知道另一个线程何时释放。

latch没有死锁检测或者避免的机制。我们只能在代码层面避免产生死锁。

  • 在无法获得兄弟节点的latch时,需要有处理方法
  • 需要支持一个no-wait的模式

Delayed parent updates

当某个叶子节点overflow的时候,需要更新至少三个节点:

  • 当前节点split
  • 新增一个节点
  • 父亲节点

这个优化方案是,在insert操作导致overflow的时候,只首先拆分当前节点,以及新增节点,然后记录一下父亲节点需要修改的部分,但是先不改(例如记一条日志)。等到下一次某个线程持有这个父亲节点的write latch的时候,再去更新。

5.总结

这一节讲了一些并发控制相关的问题。

首先是并发加锁的两种类型,lock和latch。lock是指数据库执行事务等时候给数据表加的各种锁,latch指的是直接对硬件资源上锁(例如锁住b+树中的某个节点,哈希表中的某个slot等)。

然后就要看看如何实现latch。基本上是可以借用操作系统的一些逻辑,例如直接用mutex互斥量,使用自旋锁,以及由于数据库访问多为读写操作,可以用读写锁的逻辑来进行一定的优化。

具体到应用场景中,hash表可以对整个page上锁或对具体的slot上锁;b+树中对各个节点的访问也是按照读写锁分类来提高效率。由于写锁是互斥锁,而b+树作为树形结构,访问都是从同一个root节点开始的,这就很容易造成瓶颈。所以有一个改进方案,就是对update类操作(insert和delete)也优先增加读锁,后续再根据具体情况进行修改。

We are finally going to discuss how to execute some queries.