这次天池 PolarDB 数据库性能大赛竞争相当激烈,眼睛一闭一睁成绩就会被血洗,最后榜单成绩是第三名,答辩翻车了,最终取得了大赛季军。云计算领域接触的是最前沿的技术,阿里云的 PolarDB 作为云原生数据库里程碑式的革新产品,也为这次比赛提供了最先进的硬件环境。

整个比赛获益良多,体会比较深的两点:

  • 为了充分使用新硬件, 榨干硬件的红利来达到极致的性能,一定要 Benchmark Everything,经验并不一定是对的,实践出真知。
  • 从大的解决方案,到小的细节优化,需要足够的勇气尝试不同的思路,从而不断完善,达到最优解。

比赛背景

  1. 以 Optane SSD 为背景,实现高效的 KV 存储引擎。
  2. 实现 Write、Read 和 Range 接口。
  3. 正确性检测:保证进程意外退出不会造成数据丢失。
  4. 在规定时间内完成随机写、随机读、Range(顺序读)三个性能评测阶段。

赛题剖析

  1. 正确性检测 kill -9 模拟进程意外退出,需要保证数据不丢失。
  2. 只有 2G 物理内存可用。
  3. 64个线程并发顺序读取,每个线程各使用 Range 有序(增序)遍历全量数据 2 次。设计出有利于 Range 并且兼顾读写性能的架构至关重要。
  4. 随机写、随机读、顺序读三个阶段都会重新 Open DB,尽可能挖掘 Open 阶段的细节优化点
  5. Drop Cache 的时间也计入总成绩,尽可能减少 PageCache 的使用

核心设计思想

以 Range 为核心,同时兼顾随机写和随机读的性能。

  1. 划分多个 DB 分片减少锁冲突,提高并发度。
  2. Key/Value 数据分离,可使用块偏移地址表示数据位置。
  3. 充分利用 PageCache 避免进程退出不丢失数据,引入 Mmap 读写数据
  4. 索引全量加载到内存,保证索引分区内和分区之间有序。
  5. Range 阶段需要大块缓存,保证顺序读。
  6. Open DB 并行加载 DB 分片,每个分片的加载过程是独立的。

全局架构

随机写和随机读都会根据 key 定位到具体的数据分片,转变为具体某个 DB 分片的读写操作。Range 查询需要定位到分片的上界和下界,然后依次顺序将分片进行遍历。

DB 划分为多个分片的作用:

  1. 降低锁冲突。
  2. 提高读、写、Open 并发度。
  3. 降低数据定位时间。
  4. 有利于 Range 大块缓存数据。

DB分片需要支持范围查询:DB 分片内和分片之间数据有序。

存储方案

  1. 根据 Key 的高 11 位定位 DB 分片以及分片所对应的文件。
  2. 单个 DB 分片视角:每个 DB 分片主要分为索引文件、MergeFile、数据文件。其中索引文件存储数据的 Key 以及 Offset,MergeFile 的作用用于聚合 IO,将 4k 聚合成 16K 后进行落盘
  3. 数据文件视角:比赛初期数据文件与 DB 分片采用了 1 对 1 的架构设计,后期为了降低随机IO,提高写入速度,数据文件与 DB 分片设计为一对多的方式,每个文件管理多个分片。

关键参数

  1. 64 个数据文件,2048 个 DB 分片,数据文件与 DB 分片是一对多的关系。
  2. 4 个 Value 合并为 16K 落盘,减少磁盘交互次数。
  3. 创建 8 个 DB 分片、1G 的缓存池。
  4. 2 个 Range 预读线程,每个 DB 分片均分成 2 段并发预读。

随机写设计思路

  1. 当需要写入数据时,首先定位到数据分片以及数据文件,进行加锁操作。
  2. 先将数据写入 MergeBuffer,更新索引。
  3. 当下一个数据来时执行同样的操作,当 MergeBuffer 填满 16K,使用 DIO 将 16K 数据批量刷盘。

随机写关键点

  1. Key 转化为 uint64_t,根据 uint64_t 的高 11 位定位 DB 分片以及数据文件。
  2. 索引以及 Megre IO 无法避免 kill -9 检测,采用 Mmap 映射文件读写
  3. 对象复用:每个分片独立一份 16k Buffer 复用,减少资源开销。
  4. 先写数据文件再更新索引,索引直接使用指针地址赋值,避免内存拷贝。
  5. Value 采用 DIO 16K 字节对齐写入

KeyOnly keyOnly;
keyOnly.key = keyLong;
KeyOnly *ptr = reinterpret_cast<KeyOnly *>(mIndexPtr);
pthread_mutex_lock(&mMutex);
memcpy(static_cast<char *>(mSegmentBuffer) + mSegmentBufferIndex * 4096, value.data(), mSegmentBufferIndex++;if (mSegmentBufferIndex == MergeLimit) {pwrite64(mDataDirectFd, mSegmentBuffer, MergeBufferSize, mWritePosition);mWritePosition += MergeBufferSize;mSegmentBufferIndex = 0;
}ptr[mTotalKey] = keyOnly;
mTotalKey++;
pthread_mutex_unlock(&mMutex);复制代码

Open DB 阶段

细节决定成败,因为三个阶段都会重新打开 DB,所以 Open DB 阶段也成为优化的关键一环。

  1. posix_fallocate 预先分配文件空间
  2. 64 个线程并发加载 2048 个 DB 分片,每个分片的加载都是独立的。
  3. 顺序、批量读取索引文件将 KeyOffset 加载到内存 Vector。索引的结构非常简单,只记录key 和 逻辑偏移offset,offset * 4096 计算出数据的物理偏移地址。
  4. 采用快排对 Key 进行从小到大排序,相同 Key 的 Offset 也从小到大排列。方便实现点查询和范围查询。考虑到性能评测阶段基本没有重复的key,所以 Open 阶段去除了索引的去重工作,改为上界查询。

索引结构:

struct KeyOffset {uint64_t key;uint32_t offset;KeyOffset(): key(0),offset(0) {}KeyOffset(uint64_t key, uint32_t offset): key(key),offset(offset) {}
} __attribute__((packed));
复制代码

Drop Cache 优化

Drop Cache 一共包含清理 PageCache、dentries 和 inodes,可根据参数控制。

sysctl -w vm.drop_cache = 1 // 清理 pagecache
sysctl -w vm.drop_cache = 2 // 清理 dentries(目录缓存)和 inodes
sysctl -w vm.drop_cache = 3 // 清理 pagecache、dentries 和 inodes
复制代码

PageCache 是重灾区,尽可能在使用 PageCache 的地方做一些细节优化。

  1. 将每个分片 16K 合并 I/O 的在 close 时强制写入磁盘的数据文件。
  2. 索引加载完成后,调用 POSIX_FADV_DONTNEED 则将指定的磁盘文件中数据从 Page Cache 中换出,稳定提升 20 ~ 30ms。

随机读

随机读核心就是实现点查询 O(logn)

  1. 根据 Key 定位 DB 分片以及数据文件。
  2. 二分查找 DB 分片的索引数据,得到 Key 所对应 Value 数据的 offset 上界。
  3. 根据数据的分区号和偏移地址采用 DIO 读取 Value数据。
uint32_t offset = binarySearch(keyLong);if (unlikely(offset == UINT32_MAX)) {return kNotFound;
}
static __thread void *readBuffer = NULL;
if (unlikely(readBuffer == NULL)) {posix_memalign(&readBuffer, getpagesize(), 4096);
}
if (unlikely(value->size() != 4096)) {value->resize(4096);
}
RetCode ret = readValue(offset - 1, readBuffer);
memcpy(&((*value)[0]), readBuffer, 4096);
return ret;
复制代码

Range

Range 核心设计思想

  • 预读先行,保证预读和 Range 线程可以齐头并进。
  • 预读将整个数据分片加载至缓存,保证 Range 线程完全读取缓存。
  • 尽可能提高预读线程的速度,打满 IO。
  • 建立缓存池循环复用多个缓存片。
  • 典型的生产者 / 消费者模型, 需要控制好缓存片的等待和通知。

Range 架构设计

Range 的架构采用 8 个 DB 分片作为缓存池,从下图可以看出缓存片分为几种状态:

  • 正在被读取
  • 可以被 Range 线程读取
  • Range 线程读完可以被重复利用的
  • 未被使用的

当缓存池 8 个分片全部被填满,将重新从头开始,重复利用已被释放的缓存分片。针对 Range 范围查询采用 2 个预读线程持续读取数据分片到可用的缓存片中,Range 线程顺序从缓存中获取数据进行遍历。整个过程保证预读先行,通过等待/通知控制 Range 线程与预读线程齐头并进。

缓存片的使用注意点:

  1. 将每个 DB 分片分为 2 段并发读取,每段 64m,提高分片预读的速度。
  2. Range 线程需要等待预读线程完成分片的预读之后才可以进行读取。

可以看出这是一个典型的生产者消费者模型,需要做好预读线程和 Range 线程之间的协作:

  1. 每个缓存片持有一把锁和一个条件变量,控制缓存片的等待和通知。
  2. 每个缓存片采用引用计数以及 DB 分片号判断是否可用。
  3. 预读线程将 DB 分片分成 2 个段进行并发读取,每段读完通知 Range 线程。Range 线程收到信号后判断所有段是否都读完,如果都读完则根据索引有序遍历整个缓存片。

缓存片数据结构

class CacheItem {public:CacheItem();~CacheItem();void WaitAllDataSegmentReady(); // Range 线程等待当前缓存片所有段被读完uint32_t GetUnReadDataSegment(); // 预读线程获取还未读完的数据段bool CheckAllSegmentReady(); // 检测所有段是否都读完void SetDataSegmentReady(); // 设置当前数据段预读完成,并向 Range 线程发送通知void ReleaseUsedRef(); // 释放缓存片引用计数uint32_t mDBShardingIndex; // 数据库分片下标void *mCacheDataPtr; // 数据缓存uint32_t mUsedRef; // 缓存片引用计数 uint32_t mDataSegmentCount; // 缓存片划分为若干段uint32_t mFilledSegment; // 正在填充的数据段计数,用于预读线程获取分片时候的条件判断uint32_t mCompletedSegment; // 已经完成的数据段计数pthread_mutex_t mMutex;pthread_cond_t mCondition;
};
复制代码

Range 制胜点

在 Range 阶段如何保证预读线程能够充分利用 CPU 时间片以及打满 IO?采取了以下优化方案:

Busy Waiting 架构

Range 线程和预读线程的逻辑是非常相似的,Range 线程 Busy Waiting 地去获取缓存片,然后等待所有段都预读完成,遍历缓存片,释放缓存片。预读线程也是 Busy Waiting 地去获取缓存片,获取预读缓存片其中一段进行预读,通知 Range 该线程,释放缓存片。两者在获取缓存片唯一的区别就是 Range 线程每次获取不成功会 usleep 让出时间片,而预读线程没有这步操作,尽可能把 CPU 打满。

// 预读线程
CacheItem *item = NULL;
while (true) {item = mCacheManager->GetCacheItem(mShardingItemIndex);if (item != NULL) {break;}
}while (true) {uint32_t segmentNo = item->GetUnReadDataSegment();if (segmentNo == UINT32_MAX) {break;}uint64_t cacheOffset = segmentNo * EachSegmentSize;uint64_t dataOffset = cacheOffset + mStartPosition;pread64(mDataDirectFd, static_cast<char *>(item->mCacheDataPtr) + cacheOffset, EachSegmentSize, dataOffset);item->SetDataSegmentReady();
}
item->ReleaseUsedRef();// Range 线程
CacheItem *item = NULL;
while (true) {item = mCacheManager->GetCacheItem(mShardingItemIndex);if (item != NULL) {break;}usleep(1);
}
item->WaitAllDataSegmentReady();char key[8];
for (auto mKeyOffset = mKeyOffsets.begin(); mKeyOffset != mKeyOffsets.end(); mKeyOffset++) {if (unlikely(mKeyOffset->key == (mKeyOffset + 1)->key)) {continue;}uint32_t offset = mKeyOffset->offset - 1;char *ptr = static_cast<char *>(item->mCacheDataPtr) + offset * 4096;uint64ToString(mKeyOffset->key, key);PolarString str(key, 8);PolarString value(ptr, 4096);visitor.Visit(str, value);
}
item->ReleaseUsedRef();
复制代码

因为比赛环境的硬件配置很高,这里使用忙等去压榨 CPU 资源可以取得很好的效果,实测优于条件变量阻塞等待。然而在实际工程中这种做法是比较奢侈的,更好的做法应该使用无锁的架构并且控制自旋等待的限度,如果自旋超过限定的阈值仍没有成功获得锁,应当使用传统的方式挂起线程。

预读线程绑核

为了让预读线程的性能达到极致,根据 CPU 亲和性的特点将 2 个预读线程进行绑核,减少线程切换开销,保证预读可以打满 CPU 以及 IO。

static bool BindCpuCore(uint32_t id) {cpu_set_t mask;CPU_ZERO(&mask);CPU_SET(id, &mask);int ret = pthread_setaffinity_np(pthread_self(), sizeof(mask), &mask);return ret == 0;
}
复制代码

以上两个优化完成后,Range(顺序读)的成绩相当稳定,不会出现较大幅度的波动。

整体工程优化

  1. Key 转化为 uint64_t 借助 bswap 指令。
  2. 尽可能加上分支预测 unlikely。
  3. 对象复用,减少资源开销。
  4. 位移、& 操作代替除法、取余运算。
  5. 批量读取索引数据,做好边界处理。

memcpy 4k 加速

利用 SSE 指令集 对 memcpy 4k 进行加速

  1. 直接操作汇编,使用 SSE 的 movdqu 指令。
  2. 数据结构需要 8 字节对齐。
  3. 针对 4k 的场景使用 16 个寄存器完成并行运算。
inline void
mov256(uint8_t *dst, const uint8_t *src) {asm volatile ("movdqu (%[src]), %%xmm0\n\t""movdqu 16(%[src]), %%xmm1\n\t""movdqu 32(%[src]), %%xmm2\n\t""movdqu 48(%[src]), %%xmm3\n\t""movdqu 64(%[src]), %%xmm4\n\t""movdqu 80(%[src]), %%xmm5\n\t""movdqu 96(%[src]), %%xmm6\n\t""movdqu 112(%[src]), %%xmm7\n\t""movdqu 128(%[src]), %%xmm8\n\t""movdqu 144(%[src]), %%xmm9\n\t""movdqu 160(%[src]), %%xmm10\n\t""movdqu 176(%[src]), %%xmm11\n\t""movdqu 192(%[src]), %%xmm12\n\t""movdqu 208(%[src]), %%xmm13\n\t""movdqu 224(%[src]), %%xmm14\n\t""movdqu 240(%[src]), %%xmm15\n\t""movdqu %%xmm0, (%[dst])\n\t""movdqu %%xmm1, 16(%[dst])\n\t""movdqu %%xmm2, 32(%[dst])\n\t""movdqu %%xmm3, 48(%[dst])\n\t""movdqu %%xmm4, 64(%[dst])\n\t""movdqu %%xmm5, 80(%[dst])\n\t""movdqu %%xmm6, 96(%[dst])\n\t""movdqu %%xmm7, 112(%[dst])\n\t""movdqu %%xmm8, 128(%[dst])\n\t""movdqu %%xmm9, 144(%[dst])\n\t""movdqu %%xmm10, 160(%[dst])\n\t""movdqu %%xmm11, 176(%[dst])\n\t""movdqu %%xmm12, 192(%[dst])\n\t""movdqu %%xmm13, 208(%[dst])\n\t""movdqu %%xmm14, 224(%[dst])\n\t""movdqu %%xmm15, 240(%[dst])"::[src] "r"(src),[dst] "r"(dst): "xmm0", "xmm1", "xmm2", "xmm3","xmm4", "xmm5", "xmm6", "xmm7","xmm8", "xmm9", "xmm10", "xmm11","xmm12", "xmm13", "xmm14", "xmm15", "memory");
}#define mov512(dst, src) mov256(dst, src); \mov256(dst + 256, src + 256);#define mov1024(dst, src) mov512(dst, src); \mov512(dst + 512, src + 512);#define mov2048(dst, src) mov1024(dst, src); \mov1024(dst + 1024, src + 1024);inline void memcpy_4k(void *dst, const void *src) {for (int i = 0; i < 16; ++i) {mov256((uint8_t *) dst + (i << 8), (uint8_t *) src + (i << 8));}
}
复制代码

String 黑科技

  1. 目标:随机读阶段实现零拷贝。
  2. 原因:由于 String 分内的内存不是 4k 对齐,所以没办法直接用于 DIO 读取,会额外造成一次内存拷贝。
  3. 实现:使用自定义的内存分配器,确保分配出的内存 $string[0] 位置是 4k 对齐的,然后强转为标准的 String 供后续使用。

自定义实现了 STL Allocator 步骤:

  • 申请内存空间
  • 构造函数
  • 析构函数
  • 释放空间
  • 替换 basic_string allocator

为了防止自定义 Allocator 分配的内存被外部接口回收,将分配的 string 保存在 threadlocal 里,确保引用计数不会变0。

template<typename T>
class stl_allocator {public:typedef size_t size_type;typedef std::ptrdiff_t difference_type;typedef T *pointer;typedef const T *const_pointer;typedef T &reference;typedef const T &const_reference;typedef T value_type;stl_allocator() {}~stl_allocator() {}template<class U>struct rebind {typedef stl_allocator<U> other;};template<class U>stl_allocator(const stl_allocator<U> &) {}pointer address(reference x) const { return &x; }const_pointer address(const_reference x) const { return &x; }size_type max_size() const throw() { return size_t(-1) / sizeof(value_type); }pointer allocate(size_type n, typename std::allocator<void>::const_pointer = 0) {void *buffer = NULL;size_t mallocSize = 4096 + 4096 + (n * sizeof(T) / 4096 * 4096);posix_memalign(&buffer, 4096, mallocSize);return reinterpret_cast<pointer>(static_cast<int8_t *>(buffer) + (4096 - 24));}void deallocate(pointer p, size_type n) {free(reinterpret_cast<int8_t *>(p) - (4096 - 24));}void construct(pointer p, const T &val) {new(static_cast<void *>(p)) T(val);}void construct(pointer p) {new(static_cast<void *>(p)) T();}void destroy(pointer p) {p->~T();}inline bool operator==(stl_allocator const &a) const { return this == &a; }inline bool operator!=(stl_allocator const &a) const { return !operator==(a); }
};
typedef std::basic_string<char, std::char_traits<char>, stl_allocator<char>> String4K;}
复制代码

失败的尝试

  1. 随机读建立 4k 缓存池,一次缓存 16k 数据,限制缓存数量。但是实测命中率很低,性能下降。
  2. PageCache 大块预读,通过 posix_fadvise 预读、释放缓存片来提高预读速度。最终结果优于直接使用 PageCache,仍无法超过 DIO。

最佳成绩

整个比赛取得的最佳成绩是 414.27s,每个阶段不可能都达到极限成绩,这里我列出了每个阶段的最佳性能。

思考与展望

这是比赛初期设计的架构,个人认为还是最初实现的一个分片对应单独一组数据文件的架构更好一些,每个分片还是分为索引文件、MergeFile 以及一组数据文件,数据文件采用定长分配,采用链表连接。这样方便扩容、多副本、迁移以及增量备份等等。当数据量太大没办法全量索引时,可以采用稀疏索引、多级索引等等。这个版本的性能评测稳定在 415~416s,也是非常优秀的。

转载请注明出处,欢迎关注我的公众号:亚普的技术轮子

第一届天池 PolarDB 数据库性能大赛相关推荐

  1. 首届POLARDB数据库性能大赛正式收官 云原生数据库爱好者共探索技术边界

    近日,由阿里云发起的首届POLARDB数据库性能大赛正式迎来收官.大赛通过天池平台举行,吸引到来自全球11个国家和地区,1808支队伍,近2000名选手参赛.最终来自众多高等院校.科研单位.互联网企业 ...

  2. 首届POLARDB数据库性能大赛正式收官 云原生数据库爱好者共探索技术边界...

    近日,由阿里云发起的首届POLARDB数据库性能大赛正式迎来收官.大赛通过天池平台举行,吸引到来自全球11个国家和地区,1808支队伍,近2000名选手参赛.最终来自众多高等院校.科研单位.互联网企业 ...

  3. 40万奖金 | 阿里云天池第三届数据库性能大赛,广邀英才挑战创新上云

    简介:日前,"第三届数据库大赛--创新上云性能挑战赛"在阿里云天池官网正式开赛.据悉,本次大赛由阿里云.英特尔联袂主办,聚焦数据库核心技术场景,探索新介质极致性能,挑战海量数据高效 ...

  4. PolarDB数据库性能大赛:95后徐妈的经验分享

    来源:Kirito的技术分享 1 前言 国际惯例,先报成绩,熬了无数个夜晚,最后依旧被绝杀出了第一页,最终排名第 21 名.前十名的成绩分布为 413.69~416.94,我最终的耗时是 422.43 ...

  5. Bio-protocol第一届生物实验短视频大赛集锦(三)——软件使用篇

    点击上方蓝字关注 Subscribe for more 看了去年大赛基础操作和仪器使用类获奖作品的分享集锦,相信大家一定从中得到了不少启发.除此之外,日常科研生活自然也少不了分析软件的应用,在上届比赛 ...

  6. 2021第一届网刃杯网络安全大赛-老练的黑客

    2021第一届网刃杯网络安全大赛-老练的黑客 难度系数:4.0 题目描述:一黑客成功入侵某核电站且获得了操作员站控制权,该操作员站可控制离心机的转速,当离心机的转速持续高于5000时将导致设备损坏,为 ...

  7. 亳州学院计算机设计大赛,亳州学院成功举办2017年(第一届)大学生计算机设计大赛...

    由亳州学院网络信息中心和教务处共同主办的"亳州学院2017年(第一届)大学生计算机设计大赛"于3月31日下午圆满结束.根据各系部选手提交作品类别,比赛分为"WEB网页设计 ...

  8. ArcMap:第一届全国大学生GIS技能大赛(滁州学院)详解-下午题

    目录 01 题目 02 数据 2.1 主要沟谷文件 2.2 DEM数字高程文件 2.3 气象站点数据 2.4 系统设计相关的DLL等文件 03 思路 3.1 作物生长条件的思路 3.1.1 对于条件1 ...

  9. 计算机应用与技术大赛,关于举办2017年燕山大学第一届计算机应用技术与程序设计大赛的通知...

    为了进一步深化我校计算机基础课程教学改革,提高学生计算思维与计算机应用能力,贯彻学校加强本科生实践教学及创新实践环节学分管理和认定办法等有关文件精神,促进创新实践活动的顺利开展,信息学院计算机教学实验 ...

最新文章

  1. 模态框获取id一直不变,都是同一个id值
  2. 【题解】P1080 国王游戏(贪心+高精python天下第一)
  3. 简析TCP的三次握手与四次分手【转】
  4. -bash: make: command not found的解决办法
  5. Python爬虫自学之第(零)篇——爬虫思路和request模块使用
  6. 2020胡润慈善榜揭晓:许家印以28亿元捐赠额成为中国首善
  7. ubuntu下解决csdn网页打不开的问题
  8. [转载]IEEE754标准的浮点数表示
  9. css3 之弹性布局
  10. 用C#来开发CAD插件,含源代码
  11. Excel数据分析项目——电商数据分析实战
  12. python数据分析的常用方法_16种常用的数据分析方法汇总
  13. 正确编译差分包签名(MTK系统)
  14. 入门级元宇宙AR avatar制作教程
  15. 这就是你日日夜夜想要的docker!!!---------TLS加密远程连接Docker
  16. 时间函数、取整函数、取余函数
  17. oracle报错1034,oracle数据库登陆报错ora-1034
  18. 2018-07-13心情日记
  19. element ui el-time-picker 时间选择其组件的坑点记录
  20. 使用SD Card Formatter工具格式化还原容量变少的SD卡TF卡

热门文章

  1. 剑桥大学哪些专业需要IB物理成绩?
  2. 活死细菌染色检测实例展示
  3. 前端的Docker入门与实践
  4. SQL计算两个日期之间的工作日天数
  5. Java数据结构之二分查找/插值查找/斐波那契查找
  6. React Native资源汇总
  7. 冷门布局大观——》中炮对鸳鸯炮进7卒
  8. 【转】Ansible 模块之 lineinfile 详细介绍
  9. 编曲软件-作曲大师向导功能讲解
  10. OpenCV学习-图像大小计算方法以及获取图像的尺寸和通道数