一种高性能B+树实现

thire
发布于 2022-9-20 11:30
浏览
0收藏

绪论

在数据库中,索引是非常重要的一部分,可以大幅度提升查询性能,而B+树则是使用最广泛的索引结构,在Oracle,PostgreSQL,MySQL都有采用。在这些传统数据库中,采用B+树主要是为了减少磁盘的随机访问次数,因为磁盘的随机访问非常慢,一般只有几百次/秒。最近几年,随着内存数据库的发展,索引结构可谓百花齐放,如MemSQL使用了Skip List,HyPer使用了Adaptive Radix Tree,SQL Server的内存存储引擎Hekaton使用了Bw-Tree,Kudu使用了B+Tree。其中很多是树形结构,与Hash索引相比,尽管点查询性能要差一些,但是能够支持范围查询,与Skip List相比,有更好的数据局部性,实现好的情况下,性能肯定优于Skip List。

在OceanBase中,数据由内存数据和基线数据组成,两部分数据的索引结构是独立的,当数据量较小时,可以全部放在内存中,为了达到内存数据库的性能,内存索引的性能至关重要,因此我们实现了一个高性能内存B+树,在设计与实现中,参考了一些Masstree[1]和Kudu[2]里的B+Tree的思想,但由于OceanBase支持的字符集和collation非常丰富,实际上的挑战要高于前两者。本文假设读者已经对B+树比较熟悉了,因此仅涉及并发控制和一些优化技巧等内容。

高性能内存B+树

虽然名字都是B+树,但是基于内存的B+树和基于磁盘的B+树有着非常大的区别,基于磁盘的B+树主要是为了减少磁盘的随机访问次数,因此树的高度要低一些,节点的大小最好是文件系统块大小的整数倍,而不是很关心锁的使用,Cachline的大小等。在内存B+树中,为了达到高并发扩展性,锁的使用需要非常小心,CPU的各种特性也需要充分利用起来,虽然都称作B+树,但两者在设计与实现上有着非常大的区别。

数据结构

B+树中的节点类型分为两种,中间节点和叶子节点,叶子节点通过指针连接起来,加快扫描性能

一种高性能B+树实现-鸿蒙开发者社区


struct InternalNode {                   struct LeafNode {
  uint32_t version;                       uint32_t version;
  uint8_t nkeys;                          uint8_t nkeys;
  KeySlice keys[CHILDREN_NUMBER];         KeySlice keys[CHILDREN_NUMBER];
  Node *values[CHILDREN_NUMBER];          Value *values[CHILDREN_NUMBER];
  InternalNode *parent;                   InternalNode *parent;
}                                         LeafNode *next;
                                        }

struct KeySlice{
  char buf[STORAGE_SIZE];
}

B+树中的key类型为KeySlice,value类型为一个指针,指向真实的数据,KeySlice中有一个char数组
,最多可以存储STORAGE_SIZE字节,当key过大时,存储key的前缀和key的指针(6个字节),如下图所示,当STORAGE_SIZE=16,key "oceanbase is great"大小为18字节时,buf存储不下所有的数据,因此存了前缀"oceanbase "和指向真正key的指针。在比较时,如果能通过前缀直接确定大小,就可以节省一次内存的随机访问,只需要一次memcmp,如与"proxy"比较时,可以通过前缀直接确定大小,但是当仅通过前缀不能确定大小时,就需要再比较一次key,如与"oceanbase is good"比较时,两者的前缀相同,因此需要两次memcmp,第一次需要比较前缀,第二次需要比较除前缀以外的其它部分。当key有多列连续的int主键时,多个int都可以编码进buf,这种情况下可以极大地提高性能,建议业务尽量使用bigint, int等类型作为主键。

一种高性能B+树实现-鸿蒙开发者社区


int转为char数组

int转为char数组后用memcmp有两个问题,一个是负数的第一位为1,正数的第一位为0,因此用memcmp比较时负数反而大于正数,但是这个转换很简单,只需要把正数的第一位变为1,负数的第一位变为0即可,例如int8_t的1为00000001,-1为11111111,2为00000010,-2为11111110,如果直接memcmp,结果是-1>-2>2>1,转换后1为10000001,-1为01111111,2为10000010,-2为01111110,memcmp结果是2>1>-1>-2。

第二个问题是x86/x64用的Little-Endian,直接用memcpy(buf, value,sizeof(int))将int拷贝进buf时,buf里的字节顺序和value的实际顺序是相反的,如7的二进制表示为00000000,00000111,拷贝进buf后就变成了00000111,00000000,这个转换也很简单,只需用__builtin_bswap64(value)将value的字节翻转即可。

Version变量

Version用来实现并发控制,locked为1时表示node被上了锁,inserting为1时表示节点正在插入,splitting为1表示节点正在分裂,vinsert表示节点已经插入的次数,vsplit表示节点已经分裂的次数,两次读version如果读到了不一样的,说明可能发生了分裂或插入。unused为了避免vinsert溢出,若没有unused,当version的状态为10111…时,此时节点正在分裂,将vsplit加1后,version的状态会变成11000…,状态又变成了在插入。


一种高性能B+树实现-鸿蒙开发者社区


内存分配器

B+树在插入的过程中节点满了之后需要分裂,就需要分配一块新的内存作为新的结点,多线程并发插入的时候,内存分配器的并发性能变得非常重要。

基本操作与乐观并发控制

加锁与解锁

写操作需要写时,用CAS指令将locked设为1,即加锁成功,并根据节点是插入还是分裂,将inserting或splitting设为1,写完成后,将locked,inserting,splitting设为0,并将vinsert或vsplit加1。

读稳定的节点版本

读操作使用乐观并发控制,读的时候不加锁,而是在读之前取得节点的版本v1,获取版本的时候需要获得稳定的版本,也就是没有被上写锁的版本,若节点已被上锁,则进行自旋直到锁被释放后获得版本v1,然后进行相应操作,在中间节点的操作是查找下一级节点,在叶子节点的操作是查找key对应的value,读完成后再取得节点的稳定版本v2,如果v1与v2不相等,说明在读的过程中,节点被更改了,可能读到了脏数据。如果节点是插入了新的数据,则重新读取该节点即可,如果该节点分裂了,则需要从根节点重新读一遍。

读脏数据

尽管在获得稳定版本的时候,可以等待写操作释放锁,但是读操作在节点中搜索key的时候,写操作可能正在写这个节点,这个是不能避免的,否则就完全成了读写都需要加锁的悲观并发控制,而且写操作做memcpy也不是原子的,因此读操作可能会读到脏数据。但这并不会造成任何问题,因为搜索完成之后,会去验证节点是否已经改变,若改变则重新读一遍就好了。但是需要保证的是,读操作不能读到非法的指针,否则会引发segmentation fault,可以通过原子地设置存储指针的那几个字节来避免这个问题。

插入操作

从根节点开始,从上往下查找key应属的叶子节点,查找过程中读节点时,需要使用上面的读稳定节点的方法,找到叶子节点后,给叶子节点加锁,并设置inserting位,插入完成后若叶子节点已满,则需要分裂,设置spliting位,找到父节点并上锁,然后生成一个新的节点,若需要分裂的节点不是最后一个叶子节点,则拷贝一半的key value到新节点,否则不拷贝,保证新节点为空,这是为了应对单线程插入递增主键的场景,可以避免浪费一半的空间。如果父节点也需要分裂,则重复以上过程直到根节点。插入完成后按加锁的反方向依次解锁。

几个关键点

上面的步骤看起来很简单,但实际上暗藏玄机,由于存在并发的情况,很多地方不够严谨都会导致程序不正确

获取根节点

获得根节点后,读到稳定的节点之前,根结点可能在其它线程中已经分裂,并被换成了新的节点,旧的根结点中的部分数据也已经被转移到了其它结点,因此会出现读不到已经插入的数据的情况,这就需要检查该节点是否有父节点,若没有父节点,可以确定它仍然是父节点,如果已经有了父节点,则读取父节点并做同样的判断,直到父节点为空。

Node *node = root;
//root有可能已经改变
while (true) {
  node->stable_version();
  if (node->parent == nullptr) {
    break;
  } else {
    node=node->parent;
  }
}
获取父节点

如图所示,最初只有1,2,3这几个节点,线程1向节点2中插入数据,线程2向节点3中插入数据,节点2满了需要分裂,将split key插入节点1后,节点1也满了需要分裂,新生成的节点5是节点3新的父节点,需要将节点3的父节点改为节点5,这里就有潜在的风险了,在设置新的父节点之前,线程2完成了节点3的插入,节点3也满了,需要向父节点插入,拿到父节点1,并上锁后,实际上父节点已经变成5了,因此需要判断父节点是否已经改变,如果改变的话需要再重新读取父节点


一种高性能B+树实现-鸿蒙开发者社区


Thread 1                        Thread 2
                                parent = node3.parent    //获得了node1
node3.parent=node5              node1.lock()             //Threa1解锁后才会执行这里
node1.unlcok()                  if (node3.parent == parent)  //如果相等则说明父节点没变

为了提升程序执行效率,编译器和CPU有可能将指令进行重排,就有可能node1.unlock()执行完成,而node3.parent=node5并没有执行,这导致了线程2认为父节点没有变化,向老的父节点插入了,导致了错误的行为。为了避免这个问题,需要保证代码的执行顺序,因此在线程1中需要一个编译器的release barrier,在线程2中需要一个编译器的acquire barrier,这样就能保证线程2在上锁后,能拿到最新的parent。由于X86/X64是强内存模型,每一个store和load都有release和acquire效果,因此不需要考虑CPU指令重排。

性能测试

测试环境:

CPU:2*Intel® Xeon® CPU E5-2682 v4 @ 2.50GHz
编译器:GCC 5.2.0
内存分配器:jemalloc

不同主键类型

当主键类型为int,而不是把int转化为char数组时,性能无疑是最快的,尽管在真实的系统中没办法这么做,但是可以作为一个性能基准

顺序insert性能

该场景下,多线程各自插入连续递增的key,如线程1插入1-1000000,线程2插入1000001-2000000,当数据量小时,多线程之间在插入分裂节点时会有较大冲突,数据量大时,冲突很小,而且每次插入时所走的节点路径都相差不多,因此CPU Cache命中率会非常高,因此该场景性能很好,线程数少时性能基本可以达到线性扩展。该B+树单线程性能也很好,C++ STL里的unordered_map单线程顺序插入时性能为9.26 million/s,B+树为6.91 million/s,非常接近。


一种高性能B+树实现-鸿蒙开发者社区


随机insert性能

相比顺序insert性能,随机insert性能要差很多,主要是由于锁冲突会多一些,并且每次插入过程中访问节点的路径会相差很大,因此CPU Cache命中率差很多

一种高性能B+树实现-鸿蒙开发者社区


当线程数为16时,cache-misses如下图所示,确实相差非常大


一种高性能B+树实现-鸿蒙开发者社区



一种高性能B+树实现-鸿蒙开发者社区


随机insert get混合性能

get性能,insert性能和50% insert,50% get性能相差不大,由此可见,这种乐观并发控制算法对读写性能都很友好

一种高性能B+树实现-鸿蒙开发者社区


参考资料

[1] Mao, Yandong, Eddie Kohler, and Robert Tappan Morris. “Cache craftiness for fast multicore key-value storage.” Proceedings of the 7th ACM european conference on Computer Systems. ACM, 2012.
[2] Lipcon, Todd, et al. “Kudu: Storage for fast analytics on fast data.” (2015).

本文转载自公众号OceanBase

分类
标签
已于2022-9-20 11:30:14修改
收藏
回复
举报
回复
    相关推荐