此次天池 PolarDB 數據庫性能大賽競爭至關激烈,眼睛一閉一睜成績就會被血洗,最後榜單成績是第三名,答辯翻車了,最終取得了大賽季軍。雲計算領域接觸的是最前沿的技術,阿里雲的 PolarDB 做爲雲原生數據庫里程碑式的革新產品,也爲此次比賽提供了最早進的硬件環境。node
整個比賽獲益良多,體會比較深的兩點:數據庫
設計出有利於 Range 而且兼顧讀寫性能的架構相當重要。
儘量挖掘 Open 階段的細節優化點
。Drop Cache 的時間也計入總成績,儘量減小 PageCache 的使用
。以 Range 爲核心,同時兼顧隨機寫和隨機讀的性能。緩存
多個 DB 分片
減小鎖衝突,提升併發度。數據分離
,可以使用塊偏移地址表示數據位置。Mmap 讀寫數據
。索引全量加載到內存,保證索引分區內和分區之間有序。
大塊緩存
,保證順序讀。並行加載 DB 分片
,每一個分片的加載過程是獨立的。隨機寫和隨機讀都會根據 key 定位到具體的數據分片,轉變爲具體某個 DB 分片的讀寫操做。Range 查詢須要定位到分片的上界和下界,而後依次順序將分片進行遍歷。bash
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 分片做爲緩存池,從下圖能夠看出緩存片分爲幾種狀態:
當緩存池 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,也是很是優秀的。
轉載請註明出處,歡迎關注個人公衆號:亞普的技術輪子