etcd 在超大規模數據場景下的性能優化

概述

etcd是一個開源的分佈式的kv存儲系統, 最近剛被cncf列爲沙箱孵化項目。etcd的應用場景很廣,不少地方都用到了它,例如kubernetes就用它做爲集羣內部存儲元信息的帳本。本篇文章首先介紹咱們優化的背景,爲何咱們要進行優化, 以後介紹etcd內部存儲系統的工做方式,以後介紹本次具體的實現方式及最後的優化效果。git

優化背景

因爲阿里巴巴內部集羣規模大,因此對etcd的數據存儲容量有特殊需求,以前的etcd支持的存儲大小沒法知足要求, 所以咱們開發了基於etcd proxy的解決方案,將數據轉儲到了tair中(可類比redis))。這種方案雖然解決了數據存儲容量的問題,可是弊端也是比較明顯的,因爲proxy須要將數據進行搬移,所以操做的延時比原生存儲大了不少。除此以外,因爲多了tair這個組件,運維和管理成本較高。所以咱們就想究竟是什麼緣由限制了etcd的存儲容量,咱們是否能夠經過技術手段優化解決呢?github

提出瞭如上問題後咱們首先進行了壓力測試不停地像etcd中注入數據,當etcd存儲數據量超過40GB後,通過一次compact(compact是etcd將不須要的歷史版本數據刪除的操做)後發現put操做的延時激增,不少操做還出現了超時。監控發現boltdb內部spill操做(具體定義見下文)耗時顯著增長(從通常的1ms左右激增到了8s)。以後通過反覆屢次壓測都是如此,每次發生compact後,就像世界發生了中止,全部etcd讀寫操做延時比正常值高了幾百倍,根本沒法使用。redis

etcd內部存儲工做原理


etcd存儲層能夠當作由兩部分組成,一層在內存中的基於btree的索引層,一層基於boltdb的磁盤存儲層。這裏咱們重點介紹底層boltdb層,由於和本次優化相關,其餘可參考上文。算法

etcd中使用boltdb做爲最底層持久化kv數據庫,boltdb的介紹以下:數據庫

Bolt was originally a port of LMDB so it is architecturally similar. 
Both use a B+tree, have ACID semantics with fully serializable transactions, and support lock-free MVCC using a single writer and multiple readers.
Bolt is a relatively small code base (<3KLOC) for an embedded, serializable, transactional key/value database so it can be a good starting point for people interested in how databases work。

如上介紹,它短小精悍,能夠內嵌到其餘軟件內部,做爲數據庫使用,例如etcd就內嵌了boltdb做爲內部存儲k/v數據的引擎。
boltdb的內部使用B+ tree做爲存儲數據的數據結構,葉子節點存放具體的真實存儲鍵值。它將全部數據存放在單個文件中,使用mmap將其映射到內存,進行讀取,對數據的修改利用write寫入文件。數據存放的基本單位是一個page, 大小默認爲4K. 當發生數據刪除時,boltdb不直接將刪掉的磁盤空間還給系統,而是內部將他先暫時保存,構成一個已經釋放的page池,供後續使用,這個所謂的池在boltdb內叫freelist。例子以下:後端

 

 

紅色的page 43, 45, 46, 50 頁面正在被使用,而page 42, 44, 47, 48, 49, 51 是空閒的,可供後續使用。數組

以下etcd監控圖當etcd數據量在50GB左右時,spill 操做延時激增到了8s網絡

 

問題分析

因爲發生了用戶數據的寫入, 所以內部B+ tree結構會頻繁發生調整(如再平衡,分裂合併樹的節點)。spill操做是boltdb內部將用戶寫入數據commit到磁盤的關鍵一步, 它發生在樹結構調整後。它釋放不用的page到freelist, 從freelist索取空閒page存儲數據。數據結構

經過對spill操做進行更深刻細緻的調查,咱們發現了性能瓶頸所在, spill操做中以下代碼耗時最多:併發

// arrayAllocate returns the starting page id of a contiguous list of pages of a given size.
// If a contiguous block cannot be found then 0 is returned.
func (f *freelist) arrayAllocate(txid txid, n int) pgid {
      ...
 var initial, previd pgid
 for i, id := range f.ids {
     if id <= 1 {
         panic(fmt.Sprintf("invalid page allocation: %d", id))
     }

     // Reset initial page if this is not contiguous.
     if previd == 0 || id-previd != 1 {
         initial = id
     }

     // If we found a contiguous block then remove it and return it.
     if (id-initial)+1 == pgid(n) {
         if (i + 1) == n {
             f.ids = f.ids[i+1:]
         } else {
             copy(f.ids[i-n+1:], f.ids[i+1:]) # 複製
             f.ids = f.ids[:len(f.ids)-n]
         }

         ...
         return initial
     }

     previd = id
 }
 return 0
}

以前etcd內部內部工做原理講到boltdb將以前釋放空閒的頁面存儲爲freelist供以後使用,如上代碼就是freelist內部page再分配的函數,他嘗試分配連續的n個page頁面供使用,返回起始頁page id。 代碼中f.ids是一個數組,他記錄了內部空閒的page的id。例如以前上圖頁面裏f.ids=[42,44,47,48,49,51]

當請求n個連續頁面時,這種方法經過線性掃描的方式進行查找。當遇到內部存在大量碎片時,例如freelist內部存在的頁面大可能是小的頁面,好比大小爲1或者2,可是當須要一個size爲4的頁面時候,這個算法會花很長時間去查找,另外查找後還需調用copy移動數組的元素,當數組元素不少,即內部存儲了大量數據時,這個操做是很是慢的。

優化方案

由上面的分析, 咱們知道線性掃描查找空頁面的方法確實比較naive, 在大數據量場景下很慢。前yahoo的chief scientist Udi Manber曾說過在yahoo內最重要的三大算法是 hashing, hashing and hashing!(From algorithm design manual)

所以咱們的優化方案中將相同大小的連續頁面用set組織起來,而後在用hash算法作不一樣頁面大小的映射。以下面新版freelist結構體中的freemaps數據結構。

type freelist struct {
 ...
   freemaps       map[uint64]pidSet           // key is the size of continuous pages(span), value is a set which contains the starting pgids of same size
   forwardMap     map[pgid]uint64             // key is start pgid, value is its span size
   backwardMap    map[pgid]uint64             // key is end pgid, value is its span size
   ...
}

 

 

除此以外,當頁面被釋放,咱們須要儘量的去合併成一個大的連續頁面,以前的算法這裏也比較簡單,是個是耗時的操做O(nlgn).咱們經過hash算法,新增了另外兩個數據結構forwardMapbackwardMap, 他們的具體含義以下面註釋所說。

當一個頁面被釋放時,他經過查詢backwardMap嘗試與前面的頁面合併,經過查詢forwardMap嘗試與後面的頁面合併。具體算法見下面mergeWithExistingSpan函數。

// mergeWithExistingSpan merges pid to the existing free spans, try to merge it backward and forward
func (f *freelist) mergeWithExistingSpan(pid pgid) {
    prev := pid - 1
    next := pid + 1

    preSize, mergeWithPrev := f.backwardMap[prev]
    nextSize, mergeWithNext := f.forwardMap[next]
    newStart := pid
    newSize := uint64(1)

    if mergeWithPrev {
        //merge with previous span
        start := prev + 1 - pgid(preSize)
        f.delSpan(start, preSize)

        newStart -= pgid(preSize)
        newSize += preSize
    }

    if mergeWithNext {
        // merge with next span
        f.delSpan(next, nextSize)
        newSize += nextSize
    }

    f.addSpan(newStart, newSize)
}

新的算法借鑑了內存管理中的segregated freelist的算法,它也使用在tcmalloc中。它將page分配時間複雜度由O(n)降爲O(1), 釋放從O(nlgn)降爲O(1),優化效果很是明顯。

實際優化效果

如下測試爲了排除網絡等其餘緣由,就測試一臺etcd節點集羣,惟一的不一樣就是新舊算法不一樣, 還對老的tair做爲後端存儲的方案進行了對比測試. 模擬測試爲接近真實場景,模擬100個客戶端同時向etcd put 1百萬的kv對,kv內容隨機,控制最高5000qps,總計大約20~30GB數據。測試工具是基於官方代碼的benchmark工具,各類狀況下客戶端延時以下

舊的算法時間

有一些超時沒有完成測試,

 

新的segregated hashmap

 

 

etcd over tail 時間

 

 

方案完成耗時性能提高倍數新的hashmap算法210s1x舊array算法4974s24xetcd over tair17058x

在數據量更大的場景下,併發度更高的狀況下新算法提高倍數會更多。

總結

此次優化將boltdb中freelist分配的內部算法由O(n)降爲O(1), 釋放部分從O(nlgn)降爲O(1), 解決了在超大數據規模下etcd內部存儲的性能問題,使etcd存儲100GB數據時的讀寫操做也像存儲2GB同樣流暢。而且此次的新算法徹底向後兼容,無需作數據遷移或是數據格式變化便可使用新技術帶來的福利!
目前該優化通過2個多月的反覆測試, 上線使用效果穩定,而且已經貢獻到了開源社區link,在新版本的boltdb和etcd中,供更多人使用。

 

原文連接

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索