第一届天池 PolarDB 数据库性能大赛
這次天池 PolarDB 數(shù)據(jù)庫性能大賽競爭相當(dāng)激烈,眼睛一閉一睜成績就會被血洗,最后榜單成績是第三名,答辯翻車了,最終取得了大賽季軍。云計算領(lǐng)域接觸的是最前沿的技術(shù),阿里云的 PolarDB 作為云原生數(shù)據(jù)庫里程碑式的革新產(chǎn)品,也為這次比賽提供了最先進的硬件環(huán)境。
整個比賽獲益良多,體會比較深的兩點:
- 為了充分使用新硬件, 榨干硬件的紅利來達(dá)到極致的性能,一定要 Benchmark Everything,經(jīng)驗并不一定是對的,實踐出真知。
- 從大的解決方案,到小的細(xì)節(jié)優(yōu)化,需要足夠的勇氣嘗試不同的思路,從而不斷完善,達(dá)到最優(yōu)解。
比賽背景
賽題剖析
核心設(shè)計思想
以 Range 為核心,同時兼顧隨機寫和隨機讀的性能。
全局架構(gòu)
隨機寫和隨機讀都會根據(jù) key 定位到具體的數(shù)據(jù)分片,轉(zhuǎn)變?yōu)榫唧w某個 DB 分片的讀寫操作。Range 查詢需要定位到分片的上界和下界,然后依次順序?qū)⒎制M行遍歷。
DB 劃分為多個分片的作用:
DB分片需要支持范圍查詢:DB 分片內(nèi)和分片之間數(shù)據(jù)有序。
存儲方案
關(guān)鍵參數(shù)
隨機寫設(shè)計思路
隨機寫關(guān)鍵點
Open DB 階段
細(xì)節(jié)決定成敗,因為三個階段都會重新打開 DB,所以 Open DB 階段也成為優(yōu)化的關(guān)鍵一環(huán)。
索引結(jié)構(gòu):
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)); 復(fù)制代碼Drop Cache 優(yōu)化
Drop Cache 一共包含清理 PageCache、dentries 和 inodes,可根據(jù)參數(shù)控制。
sysctl -w vm.drop_cache = 1 // 清理 pagecache sysctl -w vm.drop_cache = 2 // 清理 dentries(目錄緩存)和 inodes sysctl -w vm.drop_cache = 3 // 清理 pagecache、dentries 和 inodes 復(fù)制代碼PageCache 是重災(zāi)區(qū),盡可能在使用 PageCache 的地方做一些細(xì)節(jié)優(yōu)化。
隨機讀
隨機讀核心就是實現(xiàn)點查詢 O(logn)。
Range
Range 核心設(shè)計思想
- 預(yù)讀先行,保證預(yù)讀和 Range 線程可以齊頭并進。
- 預(yù)讀將整個數(shù)據(jù)分片加載至緩存,保證 Range 線程完全讀取緩存。
- 盡可能提高預(yù)讀線程的速度,打滿 IO。
- 建立緩存池,循環(huán)復(fù)用多個緩存片。
- 典型的生產(chǎn)者 / 消費者模型, 需要控制好緩存片的等待和通知。
Range 架構(gòu)設(shè)計
Range 的架構(gòu)采用 8 個 DB 分片作為緩存池,從下圖可以看出緩存片分為幾種狀態(tài):
- 正在被讀取
- 可以被 Range 線程讀取
- Range 線程讀完可以被重復(fù)利用的
- 未被使用的
當(dāng)緩存池 8 個分片全部被填滿,將重新從頭開始,重復(fù)利用已被釋放的緩存分片。針對 Range 范圍查詢采用 2 個預(yù)讀線程持續(xù)讀取數(shù)據(jù)分片到可用的緩存片中,Range 線程順序從緩存中獲取數(shù)據(jù)進行遍歷。整個過程保證預(yù)讀先行,通過等待/通知控制 Range 線程與預(yù)讀線程齊頭并進。
緩存片的使用注意點:
可以看出這是一個典型的生產(chǎn)者消費者模型,需要做好預(yù)讀線程和 Range 線程之間的協(xié)作:
緩存片數(shù)據(jù)結(jié)構(gòu)
class CacheItem { public:CacheItem();~CacheItem();void WaitAllDataSegmentReady(); // Range 線程等待當(dāng)前緩存片所有段被讀完uint32_t GetUnReadDataSegment(); // 預(yù)讀線程獲取還未讀完的數(shù)據(jù)段bool CheckAllSegmentReady(); // 檢測所有段是否都讀完void SetDataSegmentReady(); // 設(shè)置當(dāng)前數(shù)據(jù)段預(yù)讀完成,并向 Range 線程發(fā)送通知void ReleaseUsedRef(); // 釋放緩存片引用計數(shù)uint32_t mDBShardingIndex; // 數(shù)據(jù)庫分片下標(biāo)void *mCacheDataPtr; // 數(shù)據(jù)緩存uint32_t mUsedRef; // 緩存片引用計數(shù) uint32_t mDataSegmentCount; // 緩存片劃分為若干段uint32_t mFilledSegment; // 正在填充的數(shù)據(jù)段計數(shù),用于預(yù)讀線程獲取分片時候的條件判斷uint32_t mCompletedSegment; // 已經(jīng)完成的數(shù)據(jù)段計數(shù)pthread_mutex_t mMutex;pthread_cond_t mCondition; }; 復(fù)制代碼Range 制勝點
在 Range 階段如何保證預(yù)讀線程能夠充分利用 CPU 時間片以及打滿 IO?采取了以下優(yōu)化方案:
Busy Waiting 架構(gòu)
Range 線程和預(yù)讀線程的邏輯是非常相似的,Range 線程 Busy Waiting 地去獲取緩存片,然后等待所有段都預(yù)讀完成,遍歷緩存片,釋放緩存片。預(yù)讀線程也是 Busy Waiting 地去獲取緩存片,獲取預(yù)讀緩存片其中一段進行預(yù)讀,通知 Range 該線程,釋放緩存片。兩者在獲取緩存片唯一的區(qū)別就是 Range 線程每次獲取不成功會 usleep 讓出時間片,而預(yù)讀線程沒有這步操作,盡可能把 CPU 打滿。
// 預(yù)讀線程 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(); 復(fù)制代碼因為比賽環(huán)境的硬件配置很高,這里使用忙等去壓榨 CPU 資源可以取得很好的效果,實測優(yōu)于條件變量阻塞等待。然而在實際工程中這種做法是比較奢侈的,更好的做法應(yīng)該使用無鎖的架構(gòu)并且控制自旋等待的限度,如果自旋超過限定的閾值仍沒有成功獲得鎖,應(yīng)當(dāng)使用傳統(tǒng)的方式掛起線程。
預(yù)讀線程綁核
為了讓預(yù)讀線程的性能達(dá)到極致,根據(jù) CPU 親和性的特點將 2 個預(yù)讀線程進行綁核,減少線程切換開銷,保證預(yù)讀可以打滿 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; } 復(fù)制代碼以上兩個優(yōu)化完成后,Range(順序讀)的成績相當(dāng)穩(wěn)定,不會出現(xiàn)較大幅度的波動。
整體工程優(yōu)化
memcpy 4k 加速
利用 SSE 指令集 對 memcpy 4k 進行加速:
String 黑科技
自定義實現(xiàn)了 STL Allocator 步驟:
- 申請內(nèi)存空間
- 構(gòu)造函數(shù)
- 析構(gòu)函數(shù)
- 釋放空間
- 替換 basic_string allocator
為了防止自定義 Allocator 分配的內(nèi)存被外部接口回收,將分配的 string 保存在 threadlocal 里,確保引用計數(shù)不會變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;} 復(fù)制代碼失敗的嘗試
最佳成績
整個比賽取得的最佳成績是 414.27s,每個階段不可能都達(dá)到極限成績,這里我列出了每個階段的最佳性能。
思考與展望
這是比賽初期設(shè)計的架構(gòu),個人認(rèn)為還是最初實現(xiàn)的一個分片對應(yīng)單獨一組數(shù)據(jù)文件的架構(gòu)更好一些,每個分片還是分為索引文件、MergeFile 以及一組數(shù)據(jù)文件,數(shù)據(jù)文件采用定長分配,采用鏈表連接。這樣方便擴容、多副本、遷移以及增量備份等等。當(dāng)數(shù)據(jù)量太大沒辦法全量索引時,可以采用稀疏索引、多級索引等等。這個版本的性能評測穩(wěn)定在 415~416s,也是非常優(yōu)秀的。
轉(zhuǎn)載請注明出處,歡迎關(guān)注我的公眾號:亞普的技術(shù)輪子
總結(jié)
以上是生活随笔為你收集整理的第一届天池 PolarDB 数据库性能大赛的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Gmail Api 的解读及例子
- 下一篇: mysql创表的工种_[MySQL基础]