这次天池 PolarDB 数据库性能大赛竞争相当激烈,眼睛一闭一睁成绩就会被血洗,最后榜单成绩是第三名,答辩翻车了,最终取得了大赛季军。云计算领域接触的是最前沿的技术,阿里云的 PolarDB 作为云原生数据库里程碑式的革新产品,也为这次比赛提供了最先进的硬件环境。
整个比赛获益良多,体会比较深的两点:
设计出有利于 Range 并且兼顾读写性能的架构至关重要。
尽可能挖掘 Open 阶段的细节优化点
。Drop Cache 的时间也计入总成绩,尽可能减少 PageCache 的使用
。以 Range 为核心,同时兼顾随机写和随机读的性能。
多个 DB 分片
减少锁冲突,提高并发度。数据分离
,可使用块偏移地址表示数据位置。Mmap 读写数据
。索引全量加载到内存,保证索引分区内和分区之间有序。
大块缓存
,保证顺序读。并行加载 DB 分片
,每个分片的加载过程是独立的。随机写和随机读都会根据 key 定位到具体的数据分片,转变为具体某个 DB 分片的读写操作。Range 查询需要定位到分片的上界和下界,然后依次顺序将分片进行遍历。
全局架构
DB 划分为多个分片的作用:
DB分片需要支持范围查询:DB 分片内和分片之间数据有序。
存储方案
将 4k 聚合成 16K 后进行落盘
。数据文件与 DB 分片设计为一对多的方式
,每个文件管理多个分片。随机写过程
Key 转化为 uint64_t
,根据 uint64_t 的高 11 位定位 DB 分片以及数据文件。采用 Mmap 映射文件读写
。对象复用
:每个分片独立一份 16k Buffer 复用,减少资源开销。先写数据文件再更新索引
,索引直接使用指针地址赋值,避免内存拷贝。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);
细节决定成败,因为三个阶段都会重新打开 DB,所以 Open DB 阶段也成为优化的关键一环。
posix_fallocate 预先分配文件空间
。批量读取
索引文件将 KeyOffset 加载到内存 Vector。索引的结构非常简单,只记录key 和 逻辑偏移offset,offset * 4096 计算出数据的物理偏移地址。快排
对 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 一共包含清理 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 的地方做一些细节优化。
POSIX_FADV_DONTNEED
则将指定的磁盘文件中数据从 Page Cache 中换出,稳定提升 20 ~ 30ms。随机读核心就是实现点查询 O(logn)
。
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 的架构采用 8 个 DB 分片作为缓存池,从下图可以看出缓存片分为几种状态:
Range 顺序读架构
当缓存池 8 个分片全部被填满,将重新从头开始,重复利用已被释放的缓存分片。针对 Range 范围查询采用 2 个预读线程持续读取数据分片到可用的缓存片中,Range 线程顺序从缓存中获取数据进行遍历。整个过程保证预读先行,通过等待/通知控制 Range 线程与预读线程齐头并进。
缓存片读取方式
可以看出这是一个典型的生产者消费者模型,需要做好预读线程和 Range 线程之间的协作:
一把锁和一个条件变量
,控制缓存片的等待和通知。引用计数
以及 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 阶段如何保证预读线程能够充分利用 CPU 时间片以及打满 IO?采取了以下优化方案:
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(顺序读)的成绩相当稳定,不会出现较大幅度的波动。
利用 SSE 指令集 对 memcpy 4k 进行加速
:
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));
}
}
自定义实现了 STL 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;}
整个比赛取得的最佳成绩是 414.27s,每个阶段不可能都达到极限成绩,这里我列出了每个阶段的最佳性能。
历史成绩记录
第一版设计架构
这是比赛初期设计的架构,个人认为还是最初实现的一个分片对应单独一组数据文件的架构更好一些,每个分片还是分为索引文件、MergeFile 以及一组数据文件,数据文件采用定长分配,采用链表连接。这样方便扩容、多副本、迁移以及增量备份等等。当数据量太大没办法全量索引时,可以采用稀疏索引、多级索引等等。这个版本的性能评测稳定在 415~416s,也是非常优秀的。