关联比赛: 第二届数据库大赛—Tair性能挑战

​赛题分析

赛题要求实现一个基于persistent memory(AEP)的持久化键值存储系统,并要求从数据正确性和系统读写性能两个方面来考虑系统设计。

正确性

数据正确性包括数据写入的持久性和原子性两个方面。

持久性

系统需要保证在写操作成功返回后数据不会丢失。这部分较为简单,我们使用memcpy()向AEP写入键值对,只需要在写入地址调用pmem\_persist()即可保证数据的持久化。在每次系统启动时可以扫描AEP上持久化的键值对建立数据索引,确保数据不会丢失。

原子性

系统需要保证数据的写入要么成功,要么丢弃,不能读到不正确的数据。由于AEP内部仅保证8B大小的原子更新,若系统在向AEP写入键值对的过程中断电,可能存在仅部分数据被写入的情况,因此系统需要在重启时发现并丢弃这部分未写完的数据。

一种保证原子写入的方式是在每个键值对末尾追加一个tag表示写入完成。但是,由于CPU对指令的乱序执行,若将tag和键值对一起写入AEP并持久化,可能出现的一种情况是tag先于键值对落盘,因此需要在持久化键值对后再发起一次独立的写操作来写入tag,这样需要调用两次pmem\_persist()。通过实验我们发现这种方法写入tag的性能开销较大,因此弃用了该方案,转用checksum在重建数据索引时检测数据的正确性。

性能

性能测试分为纯写和读写混合两个阶段,均由16个线程并发发起请求,其中读写混合阶段会存在较多热点访问。

为了有效释放AEP的性能,我们主要围绕着以下两点设计系统:

针对热点访问,我们尝试了使用DRAM做数据缓存,但是我们发现仅L3 cache便能缓存大量热点数据,同时AEP的读延迟与DRAM相比差距也较小,使用DRAM缓存键值对并无明显效果,因此最终仅使用DRAM缓存了部分数据索引信息。

最后,写操作中含有较多数据更新,同时value大小存在变化。由于赛题存在严格的空间限制,如何高效利用存储空间也需要纳入考虑。

系统设计

整体架构

基于上述分析,我们设计了下图所示的系统架构。系统由AEP和DRAM两部分组成,分别存储键值数据和管理索引等元数据信息。AEP在程序中被映射为一大块内存空间,我们以block为单位分配这部分空间,并在DRAM中以Free List结构管理键值对更新后释放的空间。数据索引为Hash Table结构,采用数组+链表结合的设计,该hash table可以无锁进行并发读访问,同时写操作以粒度较小的hash分片(slot)加锁,每个分片还包含bloom filter和hash cache来加速索引数据的写入和查询。

所有在运行时分配的DRAM和AEP空间均按写入线程分组,每个写线程独占一组空间,确保没有资源竞争。

下面详细介绍我们对存储和索引部分的设计。

image.png

存储设计

如前文所述,为了方便对AEP空间的管理,我们将AEP空间切分为若干32字节的block,并以block作为空间分配的最小单位。从程序的角度看,AEP空间以mmap的方式映射为一大块内存空间,我们将该空间均分成16组,每个写入线程独占一组空间,以消除线程同步开销。每组空间的管理分为下图所示两部分:

写入AEP的数据记录由键值对本身以及相关元数据组成,元数据包括:

image.png

数据写入流程的介绍见后文读写流程部分。

索引设计

image.png

系统的索引部分是一个hash table,其中每个hash entry维护key和value在AEP中的地址信息(Meta),包括:

Hash table采用数组加链表的设计。每个Hash bucket是一个hash entry的数组,数组大小可调整,这样能更高效地利用CPU cache。我们在系统启动时直接分配出8GB内存用于存储Hash table,其中4GB根据bucket大小存储连续的2^n个bucket,键值对根据key的hash值低n位决定其所在的bucket。剩余4GB作为spare空间,当某bucket被写满后,从spare空间中分配一个新的bucket,与前面的bucket组成链表。这种按需分配空间的链表结构能有效避免hash不均匀引起的空间浪费。

类似于对AEP空间的管理,我们将spare空间同样均分成16份由写线程独占,消除空间分配的线程同步开销。

该Hash table可以保证读操作的无锁访问,但是写操作仍是互斥的,因此需要以较小的粒度加锁来减少对锁的争用。然而,如果为每个bucket都分配一个独立的锁则内存开销太高。因此,我们将连续的若干bucket组成一个分片(slot),并以slot为粒度加锁。Slot包含以下部分:

索引重建

image.png

在每次系统重启后,我们用多个线程遍历AEP上的键值对来重建索引。由于在AEP上记录有每个键值对实际分配的block数量,因此很容易找到下一个键值对的起始偏移量。对每个键值对,首先重新计算checksum,与AEP上记录的checksum做对比判断其有效性,然后将有效键值对的索引写入Hash table。

由于我们在写操作中会重复利用被释放的空间,因此在遍历时可能存在较新版本的键值对先于旧版本键值对出现,我们在遇到重复键值对时通过对比数据记录与hash entry中的version信息来分辨其版本新旧,决定是否覆盖已写入的索引,并将旧版本键值对占据的空间记录到free list中。

读写流程

下面介绍数据读写流程。

细节优化

以上是对系统主要部分的介绍,除此以外我们还做了一些细节上的优化,包括内存页预读,缓存预取以及快速内存拷贝。

  • 内存页预读。由于PMEM是以内存映射的方式访问的,在系统初始化后写操作会产生较多的page fault,存在一定性能开销。因此,我们在系统初次启动的初始化阶段提前访问整个PMEM空间,避免运行时产生page fault。

for (int i = 0; i < THREAD\_NUM; i++) {

ths\_init.emplace\_back(= {

memset\_movnt\_sse2\_clflushopt(aep\_value\_log\_ + (uint64\_t)i * PMEM\_SIZE / THREAD\_NUM,

0, PMEM\_SIZE / THREAD\_NUM);

}

  • 缓存预取。我们令hash bucket大小与cache line大小对齐,在搜索hash table时使用\_mm\_prefetch()命令提前将下一块bucket预取到cache中,以加速查询速度。

mm\_prefetch(\&hash\_cache[slot], \_MM\_HINT\_T0);

...

\_mm\_prefetch(bucket\_base, \_MM\_HINT\_T0);

\_mm\_prefetch(bucket\_base + 64, \_MM\_HINT\_T0);

...

  • 快速内存操作。系统中大量数据与元数据的写入和比较都是以memcpy/memcmp进行的,为了优化执行速度,我们针对不同大小数据的内存操作做了不同的实现。对于固定大小的小数据如key(16B),使用sse指令优化,并取得了一定效果;对于较大的数据如value,我们尝试了使用avx指令作优化,但是效果并不明显,因此在最终版本中没有采用。

inline int memcmp\_16(const void *a, const void *b) {

register \_\_m128i xmm0, xmm1;

xmm0 = \_mm\_loadu\_si128((\_\_m128i *)(a));

xmm1 = \_mm\_loadu\_si128((\_\_m128i *)(b));

\_\_m128i diff = \_mm\_xor\_si128(xmm0, xmm1);

if (\_mm\_testz\_si128(diff, diff))

return 0; // equal

else

return 1; // non-equal

}

inline void memcpy\_16(void *dst, const void *src) {

\_\_m128i m0 = \_mm\_loadu\_si128(((const \_\_m128i *)src) + 0);

\_mm\_storeu\_si128(((\_\_m128i *)dst) + 0, m0);

}

总结

上述系统最终成绩为38.3秒,其中写阶段为29秒,读写混合阶段为9.3秒。我们介绍了所做的各类优化,但是其中一些优化,比如Bloom filter的引入并没有带来期望的性能提升,还需进一步思考。此外,我们仅利用了CPU的L3 cahe来缓存热点数据,至于在DRAM中缓存数据,我们仅在实现读无锁前进行了测试,得到了对性能没有提升的结论,在引入无锁操作后或许会更加有效,但是由于时间关系最后没有尝试。

通过本次比赛,我们对AEP的性能特征有了更好的了解,如果未来还有类似机会,希望能取得更好的成绩。

查看更多内容,欢迎访问天池技术圈官方地址:tair性能挑战赛攻略心得-Zzzzz\_天池技术圈-阿里云天池


阿里云天池
51 声望14 粉丝