第一屆天池 PolarDB 數據庫性能大賽

此次天池 PolarDB 數據庫性能大賽競爭至關激烈,眼睛一閉一睜成績就會被血洗,最後榜單成績是第三名,答辯翻車了,最終取得了大賽季軍。雲計算領域接觸的是最前沿的技術,阿里雲的 PolarDB 做爲雲原生數據庫里程碑式的革新產品,也爲此次比賽提供了最早進的硬件環境。node

整個比賽獲益良多,體會比較深的兩點:數據庫

  • 爲了充分使用新硬件, 榨乾硬件的紅利來達到極致的性能,必定要 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 查詢須要定位到分片的上界和下界,而後依次順序將分片進行遍歷。bash

全局架構

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 線程讀完能夠被重複利用的
  • 未被使用的

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,也是很是優秀的。

轉載請註明出處,歡迎關注個人公衆號:亞普的技術輪子

亞普的技術輪子
相關文章
相關標籤/搜索