做者 | 阿里雲智能事業部高級開發工程師 陳星宇(宇慕)redis
概述算法
etcd是一個開源的分佈式的kv存儲系統, 最近剛被cncf列爲沙箱孵化項目。etcd的應用場景很廣,不少地方都用到了它,例如kubernetes就用它做爲集羣內部存儲元信息的帳本。本篇文章首先介紹咱們優化的背景,爲何咱們要進行優化, 以後介紹etcd內部存儲系統的工做方式,以後介紹本次具體的實現方式及最後的優化效果。數據庫
優化背景後端
因爲阿里巴巴內部集羣規模大,因此對etcd的數據存儲容量有特殊需求,以前的etcd支持的存儲大小沒法知足要求, 所以咱們開發了基於etcd proxy的解決方案,將數據轉儲到了tair中(可類比redis))。這種方案雖然解決了數據存儲容量的問題,可是弊端也是比較明顯的,因爲proxy須要將數據進行搬移,所以操做的延時比原生存儲大了不少。除此以外,因爲多了tair這個組件,運維和管理成本較高。所以咱們就想究竟是什麼緣由限制了etcd的存儲容量,咱們是否能夠經過技術手段優化解決呢?數組
提出瞭如上問題後咱們首先進行了壓力測試不停地像etcd中注入數據,當etcd存儲數據量超過40GB後,通過一次compact(compact是etcd將不須要的歷史版本數據刪除的操做)後發現put操做的延時激增,不少操做還出現了超時。監控發現boltdb內部spill操做(具體定義見下文)耗時顯著增長(從通常的1ms左右激增到了8s)。以後通過反覆屢次壓測都是如此,每次發生compact後,就像世界發生了中止,全部etcd讀寫操做延時比正常值高了幾百倍,根本沒法使用。網絡
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算法,新增了另外兩個數據結構forwardMap和backwardMap, 他們的具體含義以下面註釋所說。
當一個頁面被釋放時,他經過查詢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 時間
`
在數據量更大的場景下,併發度更高的狀況下新算法提高倍數會更多。
總結
此次優化將boltdb中freelist分配的內部算法由O(n)降爲O(1), 釋放部分從O(nlgn)降爲O(1), 解決了在超大數據規模下etcd內部存儲的性能問題,使etcd存儲100GB數據時的讀寫操做也像存儲2GB同樣流暢。而且此次的新算法徹底向後兼容,無需作數據遷移或是數據格式變化便可使用新技術帶來的福利!
目前該優化通過2個多月的反覆測試, 上線使用效果穩定,而且已經貢獻到了開源社區link,在新版本的boltdb和etcd中,供更多人使用。