日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 运维知识 > 数据库 >内容正文

数据库

第一届天池 PolarDB 数据库性能大赛

發(fā)布時間:2023/12/29 数据库 33 豆豆
生活随笔 收集整理的這篇文章主要介紹了 第一届天池 PolarDB 数据库性能大赛 小編覺得挺不錯的,現(xiàn)在分享給大家,幫大家做個參考.

這次天池 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)解。

比賽背景

  • 以 Optane SSD 為背景,實現(xiàn)高效的 KV 存儲引擎。
  • 實現(xiàn) Write、Read 和 Range 接口。
  • 正確性檢測:保證進程意外退出不會造成數(shù)據(jù)丟失。
  • 在規(guī)定時間內(nèi)完成隨機寫、隨機讀、Range(順序讀)三個性能評測階段。
  • 賽題剖析

  • 正確性檢測 kill -9 模擬進程意外退出,需要保證數(shù)據(jù)不丟失。
  • 只有 2G 物理內(nèi)存可用。
  • 64個線程并發(fā)順序讀取,每個線程各使用 Range 有序(增序)遍歷全量數(shù)據(jù) 2 次。設(shè)計出有利于 Range 并且兼顧讀寫性能的架構(gòu)至關(guān)重要。
  • 隨機寫、隨機讀、順序讀三個階段都會重新 Open DB,盡可能挖掘 Open 階段的細(xì)節(jié)優(yōu)化點。
  • Drop Cache 的時間也計入總成績,盡可能減少 PageCache 的使用。
  • 核心設(shè)計思想

    以 Range 為核心,同時兼顧隨機寫和隨機讀的性能。

  • 劃分多個 DB 分片減少鎖沖突,提高并發(fā)度。
  • Key/Value 數(shù)據(jù)分離,可使用塊偏移地址表示數(shù)據(jù)位置。
  • 充分利用 PageCache 避免進程退出不丟失數(shù)據(jù),引入 Mmap 讀寫數(shù)據(jù)。
  • 索引全量加載到內(nèi)存,保證索引分區(qū)內(nèi)和分區(qū)之間有序。
  • Range 階段需要大塊緩存,保證順序讀。
  • Open DB 并行加載 DB 分片,每個分片的加載過程是獨立的。
  • 全局架構(gòu)

    隨機寫和隨機讀都會根據(jù) key 定位到具體的數(shù)據(jù)分片,轉(zhuǎn)變?yōu)榫唧w某個 DB 分片的讀寫操作。Range 查詢需要定位到分片的上界和下界,然后依次順序?qū)⒎制M行遍歷。

    DB 劃分為多個分片的作用:

  • 降低鎖沖突。
  • 提高讀、寫、Open 并發(fā)度。
  • 降低數(shù)據(jù)定位時間。
  • 有利于 Range 大塊緩存數(shù)據(jù)。
  • DB分片需要支持范圍查詢:DB 分片內(nèi)和分片之間數(shù)據(jù)有序。

    存儲方案

  • 根據(jù) Key 的高 11 位定位 DB 分片以及分片所對應(yīng)的文件。
  • 單個 DB 分片視角:每個 DB 分片主要分為索引文件、MergeFile、數(shù)據(jù)文件。其中索引文件存儲數(shù)據(jù)的 Key 以及 Offset,MergeFile 的作用用于聚合 IO,將 4k 聚合成 16K 后進行落盤。
  • 數(shù)據(jù)文件視角:比賽初期數(shù)據(jù)文件與 DB 分片采用了 1 對 1 的架構(gòu)設(shè)計,后期為了降低隨機IO,提高寫入速度,數(shù)據(jù)文件與 DB 分片設(shè)計為一對多的方式,每個文件管理多個分片。
  • 關(guān)鍵參數(shù)

  • 64 個數(shù)據(jù)文件,2048 個 DB 分片,數(shù)據(jù)文件與 DB 分片是一對多的關(guān)系。
  • 4 個 Value 合并為 16K 落盤,減少磁盤交互次數(shù)。
  • 創(chuàng)建 8 個 DB 分片、1G 的緩存池。
  • 2 個 Range 預(yù)讀線程,每個 DB 分片均分成 2 段并發(fā)預(yù)讀。
  • 隨機寫設(shè)計思路

  • 當(dāng)需要寫入數(shù)據(jù)時,首先定位到數(shù)據(jù)分片以及數(shù)據(jù)文件,進行加鎖操作。
  • 先將數(shù)據(jù)寫入 MergeBuffer,更新索引。
  • 當(dāng)下一個數(shù)據(jù)來時執(zhí)行同樣的操作,當(dāng) MergeBuffer 填滿 16K,使用 DIO 將 16K 數(shù)據(jù)批量刷盤。
  • 隨機寫關(guān)鍵點

  • Key 轉(zhuǎn)化為 uint64_t,根據(jù) uint64_t 的高 11 位定位 DB 分片以及數(shù)據(jù)文件。
  • 索引以及 Megre IO 無法避免 kill -9 檢測,采用 Mmap 映射文件讀寫。
  • 對象復(fù)用:每個分片獨立一份 16k Buffer 復(fù)用,減少資源開銷。
  • 先寫數(shù)據(jù)文件再更新索引,索引直接使用指針地址賦值,避免內(nèi)存拷貝。
  • Value 采用 DIO 16K 字節(jié)對齊寫入。
  • 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);復(fù)制代碼

    Open DB 階段

    細(xì)節(jié)決定成敗,因為三個階段都會重新打開 DB,所以 Open DB 階段也成為優(yōu)化的關(guān)鍵一環(huán)。

  • posix_fallocate 預(yù)先分配文件空間。
  • 64 個線程并發(fā)加載 2048 個 DB 分片,每個分片的加載都是獨立的。
  • 順序、批量讀取索引文件將 KeyOffset 加載到內(nèi)存 Vector。索引的結(jié)構(gòu)非常簡單,只記錄key 和 邏輯偏移offset,offset * 4096 計算出數(shù)據(jù)的物理偏移地址。
  • 采用快排對 Key 進行從小到大排序,相同 Key 的 Offset 也從小到大排列。方便實現(xiàn)點查詢和范圍查詢。考慮到性能評測階段基本沒有重復(fù)的key,所以 Open 階段去除了索引的去重工作,改為上界查詢。
  • 索引結(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)化。

  • 將每個分片 16K 合并 I/O 的在 close 時強制寫入磁盤的數(shù)據(jù)文件。
  • 索引加載完成后,調(diào)用 POSIX_FADV_DONTNEED 則將指定的磁盤文件中數(shù)據(jù)從 Page Cache 中換出,穩(wěn)定提升 20 ~ 30ms。
  • 隨機讀

    隨機讀核心就是實現(xiàn)點查詢 O(logn)。

  • 根據(jù) Key 定位 DB 分片以及數(shù)據(jù)文件。
  • 二分查找 DB 分片的索引數(shù)據(jù),得到 Key 所對應(yīng) Value 數(shù)據(jù)的 offset 上界。
  • 根據(jù)數(shù)據(jù)的分區(qū)號和偏移地址采用 DIO 讀取 Value數(shù)據(jù)。
  • 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; 復(fù)制代碼

    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ù)讀線程齊頭并進。

    緩存片的使用注意點:

  • 將每個 DB 分片分為 2 段并發(fā)讀取,每段 64m,提高分片預(yù)讀的速度。
  • Range 線程需要等待預(yù)讀線程完成分片的預(yù)讀之后才可以進行讀取。
  • 可以看出這是一個典型的生產(chǎn)者消費者模型,需要做好預(yù)讀線程和 Range 線程之間的協(xié)作:

  • 每個緩存片持有一把鎖和一個條件變量,控制緩存片的等待和通知。
  • 每個緩存片采用引用計數(shù)以及 DB 分片號判斷是否可用。
  • 預(yù)讀線程將 DB 分片分成 2 個段進行并發(fā)讀取,每段讀完通知 Range 線程。Range 線程收到信號后判斷所有段是否都讀完,如果都讀完則根據(jù)索引有序遍歷整個緩存片。
  • 緩存片數(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)化

  • Key 轉(zhuǎn)化為 uint64_t 借助 bswap 指令。
  • 盡可能加上分支預(yù)測 unlikely。
  • 對象復(fù)用,減少資源開銷。
  • 位移、& 操作代替除法、取余運算。
  • 批量讀取索引數(shù)據(jù),做好邊界處理。
  • memcpy 4k 加速

    利用 SSE 指令集 對 memcpy 4k 進行加速:

  • 直接操作匯編,使用 SSE 的 movdqu 指令。
  • 數(shù)據(jù)結(jié)構(gòu)需要 8 字節(jié)對齊。
  • 針對 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));} } 復(fù)制代碼

    String 黑科技

  • 目標(biāo):隨機讀階段實現(xiàn)零拷貝。
  • 原因:由于 String 分內(nèi)的內(nèi)存不是 4k 對齊,所以沒辦法直接用于 DIO 讀取,會額外造成一次內(nèi)存拷貝。
  • 實現(xiàn):使用自定義的內(nèi)存分配器,確保分配出的內(nèi)存 $string[0] 位置是 4k 對齊的,然后強轉(zhuǎn)為標(biāo)準(zhǔn)的 String 供后續(xù)使用。
  • 自定義實現(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ù)制代碼

    失敗的嘗試

  • 隨機讀建立 4k 緩存池,一次緩存 16k 數(shù)據(jù),限制緩存數(shù)量。但是實測命中率很低,性能下降。
  • PageCache 大塊預(yù)讀,通過 posix_fadvise 預(yù)讀、釋放緩存片來提高預(yù)讀速度。最終結(jié)果優(yōu)于直接使用 PageCache,仍無法超過 DIO。
  • 最佳成績

    整個比賽取得的最佳成績是 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)容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網(wǎng)站內(nèi)容還不錯,歡迎將生活随笔推薦給好友。