1 读写提速之道

根据存储介质金字塔结构,内存的读写性能远高于固态硬盘或者磁盘,自然而然地,使用性能更高的介质来提升读写的性能便是最直接了当的方式。在levelDB学习第二篇中,笔者提到过levelDB使用了内存来提升写性能,但是为了保证存储的可靠性,使用了Redo Log,从而一定程度又降低了写性能,但这是无法避免的权衡。除了能提升写性能,内存自然也可以提升读性能,其在于:

  1. 内存本就是性能更优的介质,无论在随机读或是顺序读,均有相当大的优势。
  2. 维护在内存中的数据,不需要经过内存向硬盘的序列化或者反序列化,同样也能提升一部分的效率。

2 跳表

为了能够更好的访问内存中的数据,levelDB在内存中维护了跳表,数据写入时,先写入RLog,再写入跳表,反之数据读入时,则直接从跳表中读取,相比于直接读硬盘效率提升巨大。

鉴于笔者自己实现过一个跳表demo,故关于跳表的具体原理,笔者此处不在阐述。

3 levelDB跳表实现细节

3.1 内存管理Arena

levelDB以4KB为单位的内存块进行维护。当跳表需要申请内存小于1KB时,levelDB会直接申请4KB的内存块,然后从内存块中返回需要的内存;相反,如果申请内存量大于1KB,levelDB会直接申请实际需要的内存量。申请到的内存指针通过其内部的一个vector进行维护。

除此之外levelDB还提供了一个叫做AllocateAligned的接口,其提供了内存对齐的特性。内存对齐的关键在于如下代码

const int align = (sizeof(void*) > 8) ? sizeof(void*) : 8;
size_t current_mod = reinterpret_cast<uintptr_t>(alloc_ptr_) & (align - 1);

第一行代码获取内存对齐单位,第二行代码获取当前应该空多少内存量。
reinterpret_cast<uintptr_t>将指针转换为整数字节量。

注意:由于levelDB选择自行管理内存,所以在创建新的Node时,使用了placement new,即

`Node* node = new(ptr) Node(Key)

3.2 创建Node对象

levelDB的Node定义包含两个成员变量,分别是

  1. Key const key
  2. std::atomic<Node*> next_[1]

next_是一个原子指针数组,Node创建时,默认就包含一个next指针,此时与单链表相似。在实际创建新的Node对象时,levelDB首先通过Arena申请需要的内存,然后通过placement new创建出Node对象,值得注意得是,由于Node默认自带一个next指针,所以该Node next指针所需空间是通过sizeof(std::atomic<Node>)(height-1)计算得到,注意此处的height-1。

3.3 效率更高的读写

为了加快跳表的读写效率,levelDB并没有使用mutex暴力地将Insert操作锁住,而是使用了原子操作。(https://zhuanlan.zhihu.com/p/539229114)。
首先需要说明的一点是,levelDB同一时间只允许一个写入操作+多个读操作,所以如果同一时间有多个写操作,那么需要在跳表的整个外部加锁,而读操作则不需要。

由于只需要考虑一写多读问题,所以问题其实转化为,当写线程写入数据时,如何确保读线程能读到新数据,此时使用原子操作就比较合适。
让我们来考虑跳表的写过程,其可以分为两个大步骤

  1. 从跳表顶层开始向下遍历,获取待插入节点的所有prev节点
  2. 从跳表底层开始向上,在prev节点后插入新节点

而读操作只会经历步骤1,如果没有任何同步操作的情况下,可能会出现写线程执行了Insert操作,但是读线程却没有读到的情况。使用原子操作则可以规避这样的问题,但是也要注意两个问题

  1. 内存序问题,由于跳表实际上是一个多层的链表,在插入新节点时,会在多个层级上插入,所以为了保证遍历顺序的一致性和安全性,一定要保证插入的顺序同时也要保证读的顺序(一定要保证是从上到下或从下向上连续,而不能因为cpu乱序之行导致发生跳跃),所以levelDB的写操作使用了release的内存序,而读操作使用了acquire内存序。
  2. 写操作一定要按照从下向上的顺序。若按照从上向下,则可能导致读线程在某些层级读到了新数据,但是在接下来的层级却读不到的情况。

金金
1 声望0 粉丝

引用和评论

0 条评论