关联比赛: 第二届数据库大赛—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空间均按写入线程分组,每个写线程独占一组空间,确保没有资源竞争。
下面详细介绍我们对存储和索引部分的设计。
存储设计
如前文所述,为了方便对AEP空间的管理,我们将AEP空间切分为若干32字节的block,并以block作为空间分配的最小单位。从程序的角度看,AEP空间以mmap的方式映射为一大块内存空间,我们将该空间均分成16组,每个写入线程独占一组空间,以消除线程同步开销。每组空间的管理分为下图所示两部分:
写入AEP的数据记录由键值对本身以及相关元数据组成,元数据包括:
数据写入流程的介绍见后文读写流程部分。
索引设计
系统的索引部分是一个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包含以下部分:
索引重建
在每次系统重启后,我们用多个线程遍历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的性能特征有了更好的了解,如果未来还有类似机会,希望能取得更好的成绩。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。