Bw-Tree技术解读

Microsoft的DocumentDB,即现在的Azure Cosmos DB背后基于Bw-Tree,笔者有意后面自己实现这个存储引擎,所以这里记录一些之前读这篇paper【1】的想法。

1 动机

这篇paper的目的是为了实现一个高性能的ARSs系统,所谓的ARSs即Atomic Record Stores,提供简单的key-value的原子访问接口,key-value通常提供put/get/scan的访问模型。基于ARSs可以很容易实现一个nosql系统,也可以基于它加上事物机制实现传统数据库。

Bw-Tree主要做了2个优化:

  1. 通过无锁的方式来操作b+tree,提升随机读和范围读的性能。核心的思想是把b+tree的page通过page id(PID)映射map,map的[key, value]变成[PID, page value],把直接对page的修改,变成一个修改的操作记录,加入到“page value”,所以“page value”可能是一个“base page”即page原始的内容,和一串对page修改形成的记录的链表,而在修改记录链表中加入一个修改记录节点可以很容易变成一个无锁的方式来实现。另外就是对btree的split和merge操作也通过类似的原理,把具体的操作细化成好几个原子操作,避免传统的加锁方式。
  2. 把传统checkpoint刷page的变成通过log struct storage方式刷盘,把随机写变成顺序写,提高写的性能。

传统的基于b+tree的存储引擎,例如BerkeleyDB和MySQL的innodb,读的性能很高,随机读和范围读都挺高,但是写性能比较低,因为写的时候,虽然只需要把redo log写入磁盘即可完成,但是为了缩短失效恢复时候的恢复时间,一般都需要频繁的做checkpoint,checkpoint是随机的写,随机写无论是在传统的机械硬盘还是SSD,或者PCID SSD上性能都较低,我们在实际测试MySQL的过程中,使用TPCC,通常能把磁盘写满,导致刷redo log性能降低,从而影响系统整体的吞吐量。

而基于LSM-tree典型的例如leveldb/rocksdb,写入性能较高,但是读性能,特别是范围读能力性能较差,例如读一条数据首先要看memtable中有没有,然后再扫描1级sstable和2级sstable,则需要多次磁盘io才能读到这条数据,特别是1级sstable每个分块的range还可能交叉,需要扫描多个1级sstable文件。另外一个就是在compact的时候会有一个性能的剧烈波动。

从上面看Bw-tree解决了b+tree(以BerkeleyDB,innodb为代表)和LSM-tree(以rocksdb为代表)高性能读和写不能兼得的问题,但是它并没有解决compact的时候带来的性能抖动问题。

最后值得一提的是Bw-Tree的性能,在xbox上测试可以达到千万的OPS。这里必须要放出下面的性能对比测试:

分别在上述3款硬件平台做的测试,其中Bw-Tree比BerkeleyDB高出好几倍。

2 总体架构


系统分成了3层:

B-tree Layer(论文中应该是笔误,写成Bw-tree layer,笔者认为叫做B-tree l)

  1. 维护在内存中的page,并把page组织成一棵树;
  2. 提供对这个树形结构的page的访问借口,search和update。

Cache Layer

  1. 把Bw-tree layer的page的抽象成逻辑页面,逻辑页面通过PID定义,提供给上层的B-tree使用,注意:B-tree节点链接的不是page,而是逻辑页面,即PID
  2. 维护了一个Mapping Table,Mapping Table维护了逻辑页面和物理页面的映射,管理物理页面在内存和磁盘上的换入换出。

Flash Layer

  1. 实现了log-structure storage。存储系统的性能一般受限于磁盘的IO性能,特别是IOPS,Flash Layer实现的LSS实现了写大块数据,避免了磁盘去填充字节对齐,提升磁盘性能。
  2. 实现了磁盘垃圾数据的逻辑回收。

2.1 Mapping Table

Mapping Table保存了从逻辑页面到物理页面的映射,即上文提到的[PID, page value],page value保存的就是物理页面。注意:这个物理页面可能在内存中,也可能在磁盘中。逻辑页面通过PID定义,Mapping Table通过PID可以定位到page所在的位置(内存/磁盘),如果是在内存中,则会指向page所在的地址,如果page在磁盘,则是page所在磁盘的偏移量。

Mapping Table还会根据需要,决定是否要从磁盘中读取page到内存,以及把内存的数据刷入磁盘。

Bw-tree的节点是逻辑的,在内存和磁盘中没有保存在固定的位置,这点和btree不一样,在内存中它只需要分配一块内存就可以用来保存“page”数据,不像btree那样申请一大块内存用来放page,一般叫buffer pool,然后把buffer pool按固定大小分成切割成page,磁盘中的数据也是一样,保存在磁盘中的文件也是根据page大小切割成不同的小块。在这里,因为page是一个逻辑概念,并通过PID映射,它的大小可以不是固定的,也就是所谓的page的具备弹性(elastic),可以调整page的大小。

2.2 Delta Updating

对page的状态的修改通过创建一个delta record,并把它挂载到对应的page上,这个操作是,通过CAS操作来完成修改的,CAS操作就避免了加锁,如果成功,delta record的地址变成page的新的物理地址,Mapping table中保存的[PID, page value],其中page value具体实现的时候就是一个数据结构的内存地址,如果我们增加了一个delta record,挂载上去的最终结果就是“page value”所执行的内存地址变成了delta record的地址,当然delta record后面还会跟上原来的内容。

当合并page和delta record的时候,会创建一个新的page并应用delta record,可以节省内存的使用,并加快search的速度。新的page也是通过一个CAS操作来完成替换,老的page就可以做GC。

我们做修改的时候,即为page增加delta record的时候,同时允许无锁的读,同时避免了更新page数据的时候使用update-in-place的方式,即替换page部分内存值(这个的后果就是更新这块内存的时候需要加锁保护)。这里的关键就是通过Bw-Tree 的Mapping Table把对node的update只需要局限在这个节点,不会对其他的node有影响。

2.3 Bw-Tree结构的修改

latch不能保护对树的结构的修改,即所谓的SMO操作,例如分裂和合并,这类操作有一个问题,例如树分裂的时候可能修改不止一个page,老的page O,分裂后成为O‘和新page N,N将收到O的部分数据,O剩下另外一半数据,而O原来的父节点 P只指向O,本来应该同时包含O和N的。这样分裂就不能通过一个简单的CAS操作搞定。

为了解决这个问题,这里把SMO操作分成一串操作,并把这一串操作的每个操作都变成原子操作,每个原子操作通过一个CAS来完成,这里使用B-link的数据结构设计来让整个实现更简单。具体就是把一个split分解成2个“half split”原子操作,为了保证没有线程要等待一个部分的SMO完成,线程会在看到这个部分的SMO操作之前,完成自己的操作,这样保证了没有线程需要等SMO操作完成。也就是说如果一个线程要做操作,这个操作本来可能会与SMO冲突,例如修改一个page的值,但是这个page这个时候要做split了,这里就是做了一个处理,让这个操作不应该看到进行到一半的SMO操作,即不能看到进行到一半的split或者merge,要么操作还没开始之前,SMO就完成了,要么就是自己操作完成前,SMO操作还没开始。

2.4 Log Structured Store

这里使用LSS的时候,具备通用的LSS的有点,例如批量写入,很大程度上减少了IO次数。然而因为LSS有垃圾回收的原因,LSS需要额外的写操作,用来重新写入page和log合并后新的page。这里对这种情况做了优化,减少了这种情况的发生。

当刷入一个page的时候,只需要刷入与之前刷入的page的修改部分的数据,即delta数据。这种方式显著的减少了刷盘的数据量,增加了flush buffer中修改的page的个数,这样也可以减少了每个page刷盘的IO次数。另外增加的多个page首先会放到刷盘的flush buffer里面,然后一次刷入,减少刷盘的IO次数。这会给读带来一定的负担,因为page的所有不连续的delta和page数据都需要读入内存,但是这个正好利用SSD和flush的高性能的随机读硬件能力来弥补。

LSS会清理磁盘上老的日志数据,通过刷入delta数据,减少每个page对应的数据的存储数量,侧面减少了LSS的清理的压力。通过这种方式减少了传统的LSS的写放大的问题。

在做清理的时候,LSS还会对数据做重排,让page和他对应的delta数据排列在一块,加快访问性能。

2.5 管理事务日志

在传统的数据库系统,ARS需要保证对每条记录的修改都已经持久化了,这样才能在crash的时候做失效恢复。

为了在crash的时候恢复,这里使用LSN(log sequence number)来标识每个修改操作日志。LSN为了支持失效的时候通过回放操作日志来恢复数据,LSN保证了回放的时候对每条操作日志,能够按照顺序,每条日志最多执行一次。

像传统的系统一样,当遵循了WAL日志原则的时候,page是flush lazily的,即操作日志是立即刷入磁盘,而修改的page是延后刷入磁盘的。与传统的做法不同的是,在写WAL日志的时候不会阻塞page刷盘。因为page和delta record是分开的,刷page的时候,只需要保证WAL日志还没有持久化的那部分的delta数据不刷盘即可。

3 无锁的page管理

这部分介绍了Bw-tree在内存中的page的结构,以及如何管理。首先会介绍page的结构,后面会讨论怎么通过无锁的方式来修改page的内容,后面会讨论传统的page的consolidation(即page和修改的部分的delta record的合并),这可以让search操作更加高效,然后讨论基于tree的索引的实现支持范围扫描,最后讨论内存的垃圾回收,通过一个基于epoch的安全访问机制实现内存安全的回收。

3.1 虚拟的“page”

保存在Bw-Tree的page的信息,和传统的B+tree是一样的。内部的index node(索引节点)包含一对(key,pointer),叶子节点的node包含(key,record),与传统的不一样的地方是:

  1. 多了一个low key,保存了page里面保存的记录的最小的key;
  2. 多了一个high key,保存了page里面记录的最大的key;
  3. 多了一个side link pointer,指向了自己的右兄弟节点。这个做法和B-link tree类似。

Bw-Tree有2个特性与其他设计不一样的地方:

  1. page是逻辑的。这意味着page不需要是固定大小和占据固定的物理存储位置,在B+tree里面page id由于page是固定大小的,page在磁盘上文件的位置也是固定的。page通过PID来标识,Bw-tree的节点保存的是PID,我们通过PID从Mapping Table找到page的物理地址(内存地址或者磁盘地址)。
  2. page是弹性的。意味page的大小没有限制。page随着往它之前插入delta record来增长,delta record可以是单条记录的修改,也可以系统管理的命令,例如分裂。

Updates

更新page的时候,从来不使用替换(即直接修改内存中的内容)的方式。而是通过创建一条delta record来描述对当前page的修改。delta record允许通过无锁操作增加到page中。

首先创建一条新的delta record D指向page的当前的物理地址P,P的物理地址可以从mapping table里面得到,delta record的内存地址将会作为page的地址,即page的地址后面会指向delta record的内存地址,即D的地址。delta record的地址会作为这个页面的新的内存地址,这个把Mapping Table中page P的地址替换为D的操作是一个CAS,具体就是替换的时候会判断Mapping Table里面这个PID对应的page的地址为是否为P,是则替换成功,否则失败,后面会分析CAS失败的情况下的处理。

上面描述的流程可以参考下图的(a)。因为所有Bw-tree的节点的地址都是通过PID来查找,所以更新一个page只需要更新Mapping Table里面保存的(PID, page)中对应page的地址即可。并且这是我们唯一需要使用无锁操作需要更新的地方。


上图(a)展示了更新一条delta record D到page P的过程,虚线连接的Page P是更新前的page的地址,实线是更新后page P的地址,指向了delta record D。因为更新是一个原子操作,只有一个线程的update可以成功,其他的线程都会失败,失败的线程会重试。

经过多次更新后,delta record会组成一个链表,这里叫delta chain,最后面跟一个最初的page,这个page我们叫他base page,就像上图(b)所示,每个新的update会更新这个delta chain的跟节点,mapping table会指向这个delta chain的根节点,即最后加入的那个delta record。

叶子节点的更新操作

在叶子节点,update(delta record)有下面3种类型,insert,记录了往page中插入一条记录;modify修改了page中的一条记录;delete,删除了page中的一条记录。insert 和modify还包含修改的具体的数据,delete只包含被删除的记录的key。 delta record包含一个处理这个请求的client提供的LSN。我们用LSN来实现事务的失效恢复,同时LSN也是用来实现WAL协议的一个必须的标志。

page 搜索

页面搜索的时候需要遍历delta chain,search会停在delta chain中第一次search到的key地方,然后读取它的delta record,如果delta record包含search key,并且是insert和update类型的,则直接返回结果,如果delta record标识search key删除,则返回搜索失败,如果delta record链表没有包含search key,则再对base page做二进制的查找,这个查找过程就是标准的B+tree叶子节点查找。

3.2 Page consolidation(合并,类似于LSM的compact,后面用consolidation,中文笔者没有找到一个确切的词)

随着delta chain的增长搜索的性能会变低,所以使用consolidation技术来把delta chain和base page合并成一个新的base page。触发这个consolidation操作是通过访问线程在search的时候,检查到delta chain长度已经超过设定阈值。文中所说的“访问线程”即执行读或者写任务的线程。

当做consolidation的时候,线程首先创建一个新的base page(会申请一块新的内存),然后填充base page通过一个排序的向量,这个向量包含了page最近的修改记录,这些记录来自delta chain和老的base page(删除的记录会被忽略),然后线程会把新的base page的地址通过CAS赋值给mapping table对应的(PID,page value),如果成功,会要求对老的base page和delta chain上的delta record做垃圾回收,整个流程如上图的(b)所示。如果失败该线程会抛弃这个操作,销毁申请的新的base page的内存。该线程不会重试,因为失败了说明有另外一个线程已经成功做了一次consolidation了。

3.3 范围扫描

范围扫描由一个key的范围来定义,即(low key,high key)。范围key也可以省略其中一个,省略的key代表了min key或者max key。同时scan还可以定义升序扫描和降序扫描。

扫描的时候维护了一个cursor,标识了当前的search已经到哪里了。对一个新的扫描,cursor就是low key(后面都是针对有low key和high key的升序扫描,其他的类似)。当一个page包含扫描范围的数据,并且在这次扫描第一次被访问到,我们会构建一个vector包含了这个page所有符合条件的record。当scan的过程中,页面如果没有被改变的话,可以让“next-page”操作更加高效。

每一次的“next-record”操作作为一次原子操作,而整个的scan操作不是原子的。事务锁会阻止我们看到已经被其它事务修改的记录(假设是在serializable transaction隔离级别),但是对于那些我们还没有访问的记录就不知道如何做同步控制了,所以在从vector返回一条record之前,我们会检查是否有一个update影响到了我们搜索的子范围。如果已经有update生效了,我们重新构建这个vector。

3.4 垃圾回收

无锁的环境下,不允许以独占的方式访问共享的内存结构,例如Bw-tree的page,这样意味着一个或者多个reader可以同时访问一个page,即使这个page正在做update。我们不希望释放还在被其他线程访问的内存,例如在合并过程中,一个线程使用新的base page 替换老的base page和delta chain,并要求对老的base page 做垃圾回收。但是我们必须小心对待,不要把有其他线程还在访问的老的base page给释放了。类似的场景也出现在从Bw-tree删除一个page的时候。因为删除page的时候,可能还有其他线程正在访问这个page。这个是通过一个线程执行一个的“epoch”来实现的。

epoch是一种为了保护对象避免在使用前被提前释放的机制,具体的实现参考【2】中的Epoch-based reclamation讲的比较清楚。当一个线程想保护一个它正在使用但是将会被回收的对象,例如search的时候,访问了一个page,就把当前线程加入 epoch,当这个依赖完成后,例如search这个page完成了,当前线程就退出epoch。通常一个线程在一个epoch的时间间隔设定为一个操作,例如一次insert,next-record。当线程注册到epoch E的时候,可能会看到将要被释放的老版本的对象。然而,一个线程注册epoch E成功之后,不可能会看到已经在epoch E-1中释放的对象,因为这个对象还没开始它的依赖周期。因此,一旦所有的线程注册到epoch E并完成然后退出这个epoch,回收epoch E中的所有对象是安全的。我们还使用epoch来保护存储和销毁PID。

4 Bw-tree 结构的修改

所有的Bw-tree结构的修改是使用无锁操作,下面首先描述节点分裂接着描述节点合并,然后讨论一个SMO(struct modification operations)在依赖它的操作之前先完成。

4.1 节点分裂(split)

split通过访问线程触发,在访问线程访问page的时候,如果发现一个page的大小已经超过系统设定的阈值,该线程就会在做完自己的原来的操作后,进行page的分裂。

Bw-tree借用了B-link的基于2阶段的原子分裂技术。首先对child节点做分裂,然后更新parent节点,更新parent节点的时候,增加一个新的separator key,并把新分裂出来的page指针添加到父节点,这个对父节点的处理过程可以进行递归操作(也就是如果为父节点添加子节点导致父节点达到分裂的阈值,可以继续对父节点进行分裂)。B-link结构允许我们把分裂操作分成2个原子操作,因为B-link的side link(即每个节点保存了自己的左兄弟节点的指针)提供一个在子节点分裂后,还能通过side link做search操作。

Child Split

如下图所示,为了分裂P,Bw-Tree要求在Mapping Table创建一个新的节点Q(Q是P的新的右节点)。然后我们从P找一个合适的separator key简写为Kp,用来做分裂,分裂后的Q包含从原来的Q来的比Kp大的记录,注意这个过程同时还包含一个consolidation操作,会合并delta chain中key比Kp大的delta。同时Q还包含一个side link用来连接之前的P,因为分裂后原来的P就成了Q的左兄弟节点。然后把Q添加到Mapping Table,这次的装配过程不需要CAS操作,因为Q只对split线程可见(因为这个时候我们还没有把Q加入到BTree中,查找Q范围的值还需要通过原来的P来查找,所以即使我们现在把Q装配到了Mapping Table,其他线程还是不能通过Mapping Table直接访问到Q)。这个时候的场景如下图(a)所示,Q包含P的部分数据,逻辑指向R,其中R是P的右兄弟节点,在这个点上,原来的P还在Mapping Table中,Q对其它的索引还是不可见。


做完上述的操作后,我们通过一个原子操作给P增加一个split delta record来实现对P的分裂,split delta包含2部分信息:

  1. separator key Kp。用来把P里面的记录分开成,Q包含比Kp大的记录。
  2. 一个逻辑的sider link,指向新的右兄弟Q。

上图的(b)描绘了上述的场景,P(PID)会指向delta record,delta record指向Page Q。这个时候,Q索引变成有效,即使它的父节点O没有指向它的指针。所有搜索的key包含在Q的范围的查找首先会去P,当遇到P里面的delta record的时候,当search key比separator key Kp大的时候,会递归的去到Q节点去查找,类似的如果search key小于separator key,只会在P查找。这个时候Q对其它线程就是可见的了,但是这个时候还不是通过Mapping Table的Q的PID来访问,而是通过P的PID来访问P,然后通过P里面我们增加的split delta record里面的sinder link来访问Q。

Parent Update

为了直接搜索Q,这里通过给P和Q的父节点增加一个index term delta record来完成下半部分split,这个步骤所做的实际就是让Q能够直接通过BTree的父节点来直接访问,而不是通过P来间接访问。这个index term delta record包含下面的信息:

  1. Kp,在P和Q中间的seperator key
  2. 指向Q的逻辑指针
  3. Kq,针对Q的seperator key,之前的还需要去P搜索,现在通过Kq可以定向到去Q搜索了。

我们记住在tree中走到当前节点的线路(即记录查找到当前节点经过了哪些父节点,记录这些节点的PID,保存在一个列表里面),这样就能够立即找到父节点。有时候我们记录的路线上的点是正确的,但是也有可能记住的父节点可能已经被合并到了其他节点,但是因为我们有epoch机制,所以我们能看到已经删除的状态,我们就能知道之前发生了什么,这样我们保证了我们记录的线路上的父节点的PID不会是一个危险的引用(所谓的危险的引用即该PID已经失效)。所以我们始终能够PID找到该节点的有效的内容,而当我们检测到一个节点被删除了(即通过这个节点的delta record查看),我们会沿着它的祖父节点再做一次对树的遍历,这样就找到还存活的父节点。

Kp和Kq在delta record中的作用是为了优化搜索的速度。因为搜索需要遍历在索引节点的delta chain,从delta chain中查找边界key v,v要比Kp要大,但是小于或者等于Kq,会让我们沿着逻辑指针去到Q。否则我们要到base page搜索去做二进制查找。上图的(c)展示了这个流程。

Consolidations

与创建并装配(指把page挂到mapping table中并生效)一个完整的base page相比,分裂的时候通过增加一条delta record实现可以降低时延。降低时延可以降低split失败的概率。例如我们正在split的时候,有一个update操作突然发生了,这样会导致split失败。当然我们还是需要在某个时间对增加了delta record的分裂节点做consolidation,对于有split delta的page,合并会创建一个新的base page,它包含key小于delta record里面的seperator key的记录。对于有索引delta entry的page,我们合并的时候创建一个新的base page,包含新的separator keys和pointer。

4.2 节点合并

和分裂类似,merge也是被访问线程触发,当访问线程发现node的大小小于一个阈值。merge的流程如下图所示,它比split复杂一些,需要更多的原子操作来完成。

标记删除。节点R将要被合并,也就是说这个节点会被删除,首先会更新这个节点,增加一个remove node delta record,如上图的(1)所示。这使得其它的线程都不能再使用节点R。当一个线程遇到包含remove node delta record的R节点的时候,读写都会通过他的side linker重定向到需要读R的左边的兄弟节点,并把更新写入它的左兄弟节点,因为原来的R节点的内容会被合并到其中。(这个时候读是否还是应该从R节点读,修改和写入添加到左兄弟节点?)

Merging Children。R的左边兄弟节点设为L,会增加一条merge delta,merge delta会保留原来的R的指针。上图(b)显示了在merge过程中的R和L结构之间的关系。其中merge delta意味着R即将被合并到L,现在R逻辑上已经属于L的一部分了。

当我们search L的时候(这个时候它包含了自己L原来的key空间,也包含R的key空间),这样这个search就变成了对L和R组成的一棵树的search。为了达到上面的目的,node merge delta还包含seperator key,让上述的search能够找到正确的节点。

Parent update。R的父节点设为P,现在可以通过删除R关联的索引来更新父节点。如上述(c)图所示。即给P节点增加一个index term delete delta来实现,index term delete delta不仅包含了R将被删除,还包含L将包含R原来的key空间。L新的key空间由L原来的low key和R之前的high key组成,这可以帮助我们重定向search的节点,帮助我们搜索原来R空间的时候,直接从L节点来查找。

当index term delete delta被posted(这个post即把delta数据添加到page的delta chian的根节点,并与Mapping Table中该节点做CAS,替换之前的PID匹配的指针),所有定向到R的路径都失效,这个时候,我们需要回收R的PID,这个通过在当前的活动的epoch中,把PID加入到待删除的PIDs列表。R的PID会等其他线程都从这个epoch退出的时候再删除。

4.3 串行化结构的修改和更新

在Bw-tree实现中是假设数据更新的并发冲突在整个系统中都存在,可能是在把Bw-Tree集成到系统的时候的锁管理,也可能是事务模块。

回到Bw-tree,我们希望SMO修改的数据都能够串行化。所以我们必须构建一个串行化的结构来处理Bw-tree处理过程中的所有的更新。

我们把SMO分解的单个操作(一个SMO可以由多个原子操作组成)作为原子操作,并且使用无锁的方法,但是这里隐藏了一个问题,就是一个SMO可能是由多个原子操作组成的。针对这种只做了一半的SMO,我们可以把它作为一个未提交的状态,使用无锁的方法,这种情况是无法避免的。当一个线程遇到一个中间状态的SMO的时候,我们必须保证让线程在继续自己的update或者继续自己的SMO的时候,看到一个已经完成并且提交的SMO,即避免看到一个包含多个原子操作的SMO的中间状态。对于节点分裂,意味着它必须完成分裂,即 SMO通过post新的index term delta到他的父节点,当一个update或者SMO能够通过side pointer达到正确的page,它必须完成split SMO通过post 新的index term delta给父节点。只有这有它才能继续自己的活动,这样它可以强制一个未完成的SMO变成已提交的。

split和merge都是使用上述相同的机制,当删除一个节点R,我们通过访问他的左兄弟节点L的来装配merge delta,如果这个时候发现L即将被删除,我们会看到一个还没有完成的正在处理过程中的事务,我们需要删除L之前删除R来保证串行化,即我们在删除L之前,要保证之前merge的那个未完成的事务先做完,也就是先删除R。所有的SMO操作都是按照同样的方式实现串行化。为了达成这种先后删除的目的,我们就需要有一个SMO处理栈,把这些操作都压入栈中,保证先后执行,这样效率可能会比较低,但是由于这种竞争的情况不是经常发生,所以可以通过递归的方式来处理。

5 Cache管理

cache层负责在内存和flash之间实现读,写以及page的交换。它维护了Mapping Table并提供Bw-tree page的抽象。当Bw-tree层要求使用PID来关联一个page,cache层返回Mapping Table中它在内存中的指针。否则,如果这个地址是flash中的偏移量,即page没有在内存中,它从LSS中读取page到内存之后再返回内存地址。所有对page的更新,包括page的管理操作例如split和merge以及page的flush操作以及相关的在mapping table上的CAS操作都是通过PID来索引。

在内存中的page偶尔会被持久化到存储中,例如cache层会写入更新,当Bw-tree作为事务引擎的一部分做checkpoint的时候。page也会有通过内存和flash的交换来达到减少内存使用。当有多个线程把多个page刷入磁盘,需要有一个正确的顺序来保证写入的正确性。下面描述了page做split的时候的场景。

为了跟踪那个版本的page在持久化的存储,我们使用flush delta record,这个flush delta record使用CAS安装到mappting table的对应的page上,flush delta record同时记录了page上哪些被改变的已经flush到磁盘上,这样随后的flush只需要把增量的修改的内存刷入磁盘即可。当刷入一个page成功,flush delta会包含新的在磁盘上的offset和一些fields,用来描述刷入的page的状态。

剩下的章节描述cache层如何和LSS层配合。首先描述如何刷入LSS来配合一个独立的事务机制的要求。然后描述flush是如何执行的。

5.1. Write Ahead Log Protocol和LSN

Bw-tree是一个ARS,可以包含一个事务引擎。当包含事务的时候,就对事务处理的方面做加强。 Deuteronomy[2]架构让这些方面的加强可以基于一个在事务和数据组件中间的协议来实现。即通过这种协议使得事务逻辑TC(transaction component)和数据管理DC(data component)所以解耦,所以这里使用Deuteronomy的架构来说明。其他的事务引擎也是类似的。

LSN。通过一个Log Sequence Number来记录了插入和更新delta。最大的LSN和flushed是用来描述磁盘输入。LSN是由TC产生,并由它的事务日志使用。

Transaction Log Coordination。TC在写入事务日志到磁盘的时候,需要更新End of stable Log(ESL) LSN的值。ELS LSN也是一个LSN,比它小的说明都已经持久化到磁盘了。它定期的发送ESL至到DC。它强迫DC不要使用大于最后的那个ELS做重复的操作。这样抱着DC是在持久化日志之后再执行对应的操作。为了保证这个规则,page上大于ESL的记录在刷入LSS的时候是不会包含在内的。

DC的page刷入磁盘是TC推进RSSP(Redo-Scan-Start-Point)的时候触发的,当TC想推进RSSP,它向DC发起一个RSSP的提议。这个的意图是允许TC删除比RSSP小的事务日志。TC将会等DC的确认,这个确认意味着DC已经把所有LSN小于RSSP变化的数据都已经持久化了。因为操作的结果已经持久化了,所以TC不需要在recovery的时候再发送这些操作给DC。对于DC,它在回应TC之前,需要把每个page上LSN小于RSSP的都持久化。这些flush是无阻塞式的,因为cache 管理会自己跳过那些LSN大于ESL的记录,即读的时候自动跳过还没有commit的记录。

为了支持无阻塞式的操作,我们限定page的合并的时候,只合并那些update delta的LSN小于等于ESL的。这样我们就能在flush page的时候排除那些LSN大于ESL的记录。

Bw-tree机构的修改。我们围绕着page通过使用日志封装了系统事务作为bw-tree的SMOs的一部分。这个解决了我们无锁实现带来的一些并发对SMO访问的问题,例如2个线程同时去分裂同一个page。为了保证LSS和内存一致,我们不会commit一个SMO系统事务直到我们直到它的线程已经赢得竞争,安装SMO delta record到合适的page上。因此我们允许SMO并发,但是我们保证最多只有一个可以commit。在非常少的对象刷入LSS中间的开始和结束事务的记录是不会实例化到page。

5.2 Flushing Pages to the LSS

LSS提供一个很大的buffer,cache管理用来管理page,系统事务,bw-tree结构的修改。下面会做一个大概的说明。

Page marshalling。cache管理会把在内存中page的指针指向的内存做编码放入一段buff,er,这样好刷入磁盘,当page有意向要刷入磁盘的时候,page的状态会被capture(capture的意思应该是霸占,独占,或者是被设置成一个特殊的状态,说明page正在flush,不要对他做其他的事情)。这点特别重要,因为page的state capture的晚一点,可能会违反WAL协议,或者page split的时候可能会移除了page中的一些record,但是这些record却是LSS需要被capture的。例如page上的数据已经编码到了flush buffer,这个时候可能发生split和合并,如果有已经被编码到flush buffer的record被删除,因为split的时候把split到另一个page的记录会从老的page删除,这个时候LSS capture的老的版本的page被capture就会找不到被删除的record,在其他的split的page还没有被刷入磁盘的时候crash,这些记录就会丢失。当编码多条记录去做flush的时候,多条delta记录会被合并到page中,这样他们在LSS中会保持记录的连续性。

增量flushing。当flush一个page的时候,cache manager只编码LSN介于之前已经被flush的最大的LSN和当前的ELS的delta records。之前已经flush的最大的LSN的信息会包含在page的最新的flush delta record中。

增量的flush意味着LSS刷盘的时候比整个page刷盘要节省磁盘。这对一个大的存储系统来说非常有价值,1)一次刷盘可以包含比刷整个page更多的page的更新。这样可以增加每个page的写的效率。2)LSS的清理线程可以不用因为磁盘被消耗过快而太繁忙,这样可以减少每个page的执行代价。同样可以减少写放大的问题。

Flush Activity。flush buffer写入LSS依靠一个配置的阈值(当前是配置成1M,也就是buffer的数据超过1M了,就刷一次磁盘)来减少IO负载。它使用了一个double buffer来处理,这样一个buffer在通过异步的方式往LSS flush数据的时候,另一个buffer可以用来接受写入请求。

flush buffer里面的数据完成写入磁盘之后,在mapping table里面的对应page的状态也需要修改过来。这个是通过给page增加一个delta来实现,这个delta描述了page的数据已经flush成功,增加delta也是通过一个CAS操作完成的。如果flush buffer里面持久化的page都已经被通知到了,说明page是“clean”的,“clean”的意思就是page的修改都已经持久化到磁盘了。

cache manager监控Bw-tree使用的内存,当使用的内存超过一个阈值就把内存的数据交换到磁盘。当一个page是“clean”的,它就可以从内存中删除。在内存中删除一个page也是通过基于epoch的内存回收机制。


6 参考资料

【1】Justin J. Levandoski, David B. Lomet, Sudipta Sengupta Microsoft Research The Bw-Tree: A B-tree for New Hardware Platforms

【2】khizmax Lock-free Data Structures. The Inside. Memory Management Schemes

编辑于 2017-10-21

文章被以下专栏收录