Go中的緩存現(xiàn)狀
這篇文章登上了Golang 在Reddit subreddit板塊的頂部,并在Hacker News 首頁排名到第二名。歡迎各位來閱讀討論,并在Github上面給我們一個小星星。
每個數(shù)據(jù)庫都需要一個智能的緩存系統(tǒng)。緩存需要保存最近最頻繁訪問的內(nèi)容,并且支持配置一些限制上的配置。
作為一個圖形數(shù)據(jù)庫,Dgraph可以在每次查詢中,訪問數(shù)千甚至數(shù)百萬的key。這個功能主要依賴于他中間結果的數(shù)量。由于通過鍵值對訪問數(shù)據(jù)庫會導致磁盤上的查詢操作,出于對性能方面的考慮(磁盤訪問速度不及內(nèi)存),我們希望優(yōu)化這塊的性能。
通常的訪問模式都遵循 ZipFian分布,訪問頻率最高的key,比其他的key訪問次數(shù)要多很多。從Dgraph中也能看到這一點(熱點Key的問題)。
我們非常高興能用Go語言來實現(xiàn)我們的Dgraph 組件,關于為什么Go語言適合做后端開發(fā),這個內(nèi)容太多了,在這里不贅述了。盡管Go的生態(tài)還不夠健全,但不能否認Go是一個很不錯的編程語言,而且我們也不會用別的語言來替代Go。
關于Go生態(tài)缺失的怨言隨處可見。但是我覺得Go是成熟的,他已經(jīng)實現(xiàn)了對機器內(nèi)核的快速編譯,執(zhí)行和利用內(nèi)核完成工作。但是作為一個致力于構建高并發(fā)的編程語言,對于性能上仍然有一些缺陷,并發(fā)庫可以很好地擴展內(nèi)核數(shù)量。對于并發(fā)的數(shù)組和字典,用戶可以自由的使用和練習。對于串行語言來說,這樣是合理的,但是對于以并行構建的編程語言,這點上似乎有一些缺陷。
特別的是,Go缺少并發(fā)的LRU/LFU 緩存,這兩者可以很好地擴展到全局緩存中。在這片博客里面,我會帶你一起來了解一下通常情況下的各種處理方式,包括在我的的Dgraph中進行的一些測試。Aman 同時也會展示一些目前Go生態(tài)中的設計理念,性能,命令率等的一些實踐內(nèi)容。
緩存框架的必備需求
- 并發(fā)
- 內(nèi)存限制(限制最大的可使用空間)
- 在多核和多goroutines之間更好的擴展
- 在非隨機密鑰的情況下,很好地擴展(eg. Zipf)
- 更高的緩存命中率
Go map 與 sync.Mutex的結合使用
Go map 結合 sync.Mutex 是應對緩存的常見形式(獨占所)。但這也確實會導致所有的Goroutines同時在一個地方鎖住, 產(chǎn)生嚴重的鎖競爭問題。而且也不能對內(nèi)存的使用量做限制。所以對于有內(nèi)存限制要求的場景,這個方案不適用。
不滿足 上面的2,3,4條
Go maps 與 lock striping
這個方式的原理與上面的一樣,但是鎖的粒度更小(詳見這里),很多程序員錯誤的認為,降低鎖的粒度可以很好地避免競爭,特別是在分片數(shù)超過程序的線程數(shù)時(GOMAXPROCS)
在我們嘗試編寫一個簡單的內(nèi)存限制緩存的時候,我們也是這樣做的。為了保證內(nèi)存可以在釋放之后還給操作系統(tǒng)。我們定期掃描我們的分片,然后釋放掉創(chuàng)建的map,方便以后被再次使用。這種粗淺的方式卻很有效,并且性能優(yōu)于LRU(后面會解釋),但是也有很多不足。
- Go請求內(nèi)存很容易,但釋放給操作系統(tǒng)卻很難。當碎片被清空的同時,goroutines去訪問key的時候,會開始分配內(nèi)存空間,此時之前的內(nèi)存空間并沒有被完全釋放,這導致內(nèi)存的激增,甚至會出發(fā)OOM錯誤。
- 我們沒有意識到,訪問的模式還受Zipf定律的束縛。最常訪問的幾個key仍然存在幾個鎖,因此產(chǎn)生goroutines的競爭問題。這種方式不滿足多核之間的擴展的需求。
不滿足 上面的2,4條
LRU 緩存
Go 里面,groupcache 實現(xiàn)了一個基本的LRU 緩存,在通過lock striping實現(xiàn)失敗之后,我們通過引入lock的方式優(yōu)化了LRU的這部分內(nèi)容,使它支持了并發(fā)。雖然這樣解決了上面描述的內(nèi)存激增的問題,但是我們意識到他同樣地會引入競爭的問題。
這個緩存的大小同樣也依賴于緩存的條數(shù),而不是他們消耗的內(nèi)存量。在Go的堆上面計算復雜的數(shù)據(jù)結構所消耗的內(nèi)存大小是非常麻煩的,幾乎不可能實現(xiàn)。我們嘗試了很多方式,但是都無法奏效。緩存被放入之后,大小也在不停地變化(我們計劃之后避免這種情況)
我們無法預估緩存會引起多少的競爭。在使用了近一年的情況下,我們意識到緩存上面的競爭有多嚴重,刪除掉這塊之后,我們的緩存效率提高了10倍。
在這塊的實現(xiàn)上,每次讀取緩存會更新鏈表中的相對位置。因此每個訪問都在等待一個互斥鎖。此外LRU的速度比Map要慢,而且在反復的進行指針的釋放,維護一個map和一個雙向鏈表。盡管我們在惰性加載上面不斷地優(yōu)化,但依然遭受到競爭的而影響。
不滿足3,4
分片LRU 緩存
我們沒有實際的去嘗試,但是依據(jù)我們的經(jīng)驗,這只會是一個暫時的解決方法,而且并不能很好地擴展。(不過在下面的測試里面,我們依然實現(xiàn)了這個解決方案)
不滿足4
流行的緩存實現(xiàn)方式
許多方法的優(yōu)化點是節(jié)省GC在map碎片上花費的時間。GC的時間會隨著map存數(shù)數(shù)量的增加而增大。減少的方案就是分配更少的數(shù)量,單位空間更大的區(qū)域,在每個空間上存儲更多的內(nèi)容。這確實是一個有效地方法,我們在Badger里面大量的使用了這個方法(Skiplist,Table builder 等)。 很多Go流行的緩存框架也是這么做的。
BigCache的緩存
BigCache會通過Hash的方式進行分片。 每個分片都包含一個map和一個ring buffer。無論如何添加元素,都會將它放置在對應的ring buffer中,并將位置保存在map中。如果多次設置相同的元素,則ring buffer中的舊值則會被標記為無效,如果ring buffer太小,則會進行擴容。
每個map的key都是一個uint32的 hash值,每個值對應一個存儲著元數(shù)據(jù)的ring buffer。如果hash值碰撞了,BigCache會忽略舊key,然后把新的值存儲到map中。預先分配更少,更大的ring buffer,使用map [uint32] uint32
是避免支付GC掃描成本的好方法
FreeCache
FreeCache 將緩存分成了256段,每段包括256個槽和一個ring buffer存儲數(shù)據(jù)。當一個新的元素被添加進來的時候,使用hash值下8位作為標識id,通過使用LSB 9-16的值作為槽ID。將數(shù)據(jù)分配到多個槽里面,有助于優(yōu)化查詢的時間(分治策略)。
數(shù)據(jù)被存儲在ring buffer中,位置被保存在一個排序的數(shù)組里面。如果ring buffer 內(nèi)存不足,則會利用LRU的策略在ring buffer逐個掃描,如果緩存的最后訪問時間小于平均訪問的時間,就會被刪掉。要找到一個緩存內(nèi)容,在槽中是通過二分查找法對一個已經(jīng)排好的數(shù)據(jù)進行查詢。
GroupCache
GroupCache使用鏈表和Map實現(xiàn)了一個精準的LRU刪除策略的緩存。為了進行公平的比較,我們在GroupCache的基礎上,實現(xiàn)了一個包括256個分片的切片結構。
性能對比
為了比較各種緩存的性能,我們生成了一個zipf分布式工作負載,并使用n1-highcpu-32機器運行基準測試。下表比較了三個緩存庫在只讀工作負載下的性能。
只讀情況
我們可以看到,由于讀鎖是無消耗的,所以BigCache的伸縮性更好。FreeCache和GroupCache讀鎖是有消耗的,并且在并發(fā)數(shù)達到20的時候,伸縮性下降了。(Y軸越大越好)
只寫情況
在只寫的情況下,三者的性能表現(xiàn)比較接近,F(xiàn)reeCache比另兩個的情況,稍微好一點。
讀寫情況(25% 寫,75%讀)
兩者混合的情況下,BigCache看起來是唯一一個在伸縮性上表現(xiàn)完美的,正如下一節(jié)所解釋的那樣,命中率對于Zipf工作負載是不利的。
命中率比較
下面的表格中展示了三個框架的命中率。FreeCache非常接近GroupCache實現(xiàn)的LRU策略。然而,BigCache在zipf分布式工作負載下表現(xiàn)不佳,原因如下:
- BigCache不能有效地利用緩沖區(qū),并且可能會在緩沖區(qū)中為同一個鍵存儲多個條目。
- BigCache不更新訪問(讀)條目,因此會導致最近訪問的鍵被刪除。
CACHE SIZE (# OF ELEM) | 10000 | 100000 | 1000000 | 10000000 |
---|---|---|---|---|
BigCache | - | 37% | 52% | 55% |
FreeCache | - | 38% | 55% | 90% |
GroupCache | 29% | 40% | 54% | 90% |
所以說,沒有哪個框架能滿足所有緩存的需求。
那還有什么沒說的么?
其實也沒什么了,Go中并沒有一個能滿足所有場景的智能緩存框架,如果你發(fā)現(xiàn)了有這種,請快快聯(lián)系我。
與此同時,我們遇到了Caffeine,一個Java的庫,被用于使用Cassandra, Finagle 和一些其他的數(shù)據(jù)庫系統(tǒng)。他使用的是TinyLFU,一個高效的緩存接納策略,并使用各種技術來擴展和執(zhí)行,隨著線程和內(nèi)核數(shù)量的增長,同時提供接近最佳命中率。您可以在這篇文章中了解它是如何工作的。
Caffeine 滿足了我開始提到的所有的5個需求,所以我正在考慮構建一個Go版本的Caffeine。他不僅能滿足我們的需求,同事也可能填補Go語言中并發(fā),高性能,內(nèi)存限制的緩存框架的空白。如果你也想?yún)⑴c或者你已經(jīng)有類似的成果了,請聯(lián)系我。
感謝
我們想要感謝 Benjamin Manes 幫助我們對Caffeine進行一些Go版本的性能測試(Code here),我們還要感謝Damian Gryski為我們提供了基準緩存命中率的基本框架(這里),我們還修改了它,來滿足我們的需要。他已經(jīng)接受了我們對于他代碼庫(GitHub)的修改。
感謝閱讀,如果方便的話,給我們Github 點個星星吧。
via:
翻譯
原文鏈接:The State of Caching in Go
作者:Manish Rai Jain
譯者:JYSDeveloper