golang 高性能無 GC 的緩存庫 bigcache 是怎麼實現的?

我們寫代碼的時候,經常會需要從數據庫裏讀取一些數據,比如配置信息或者諸如每週熱點商品之類的數據。

如果這些數據既不經常變化,又需要頻繁讀取,那比起每次都去讀數據庫,更優的解決方案就是將它們放到應用的本地內存裏,這樣可以省下不少數據庫 IO,性能嘎一下就上來了。

那麼現在問題就來了,假設我要在某個服務應用裏實現一個緩存組件去存各種類型的數據,該怎麼實現這個組件呢?

從一個 map 說起

最簡單的的方案就是使用 map,也就是字典,將需要保存的結構以 key-value 的形式,保存到內存中。比如系統配置,key 就叫 system_config,value 就是具體的配置內容。需要讀取數據就用 v = m[key]來獲取數據,需要寫數據就執行m[key] = v.

這樣看起來在單線程下是滿足需求了。但如果我想在多個線程(協程)裏併發讀寫這個緩存呢?那必然會發生競態問題。這就需要加個讀寫鎖了。讀操作前後要加鎖和解鎖,也就是改成下面這樣。

RLock()
v = m[key]
RUnLock()

寫操作也需要相應修改:

Lock()
m[key] = v
UnLock()

這在讀寫不頻繁的場景下是完全 ok 的,如果沒有什麼性能要求,服務也沒出現什麼瓶頸,就算新來的實習生笑它很 low,你也要有自信,這就是個好用的緩存組件。架構就是這樣,能快速滿足需求,不出錯就行。

但其實這個方案其實也有很大的問題,如果讀寫 qps 非常高,那麼就會有一堆請求爭搶同一個 map 鎖,這對性能影響太大了。怎麼解決呢?

將鎖粒度變小

上面的方案中,最大的問題是所有讀寫請求,都搶的同一個鎖,所以競爭才大,如果能將一部分請求改爲搶 A 鎖,另一部分請求改爲搶 B 鎖,那競爭就變小了。於是,我們可以將原來的一個 map,進行分片,變成多個 map,每個 map 都有自己的鎖。發生讀寫操作時,第一步先對 key 進行 hash 分片,獲取分片對應的鎖後,再對分片 map 進行讀寫。只有落在同一個分片的請求才會發生鎖爭搶。也就是說 map 拆的越細,鎖競爭就越小。

像這種將資源分割成多個獨立的分片(segments/shard),每個段都有一個對應的鎖來控制併發訪問的控制機制, 其實就是所謂的分片(段)鎖。看起來很完美,但其實還有問題。

gc 帶來的問題

像 C/C++這類語言中,用戶申請的內存需要由用戶自己寫代碼去釋放,一不小心忘了釋放那就會發生內存泄露,給程序員帶來了很大的心智負擔。爲了避免這樣的問題,一般高級語言裏都會自帶 GC,也就是垃圾回收(Garbage Collection),說白了就是程序員只管申請內存,用完了系統會自動回收釋放這些內存。比如 golang,它會每隔一段時間就去掃描哪些變量內存是可以被回收的。對於指針類型,golang 會先掃指針,再掃描指針指向的對象裏的內容。map 緩存裏放的東西少還好說,緩存裏的 key-value 一多,那就喜提多遍瘋狂掃描,浪費,全是浪費,golang 你糊塗啊。

那有沒有辦法可以減少這部分 gc 掃描 成本呢?有。golang 對於 key 和 value 都不含指針的的 map,會選擇跳過,不進行 gc 掃描。所以我們需要想辦法將 map 裏的內容改成完全不含指針。原來 map 中放的 key-value,key 和 value 都可能是指針結構體。

對於 key

原來 key 是用的字符串,在 golang 中字符串本質上也是指針,於是我們將它進行 hash 操作,將字符串轉爲整形。信息經過 hash 操作後,有可能會丟掉部分信息,爲了避免 hash 衝突時分不清具體是哪個 key-value,我們會將 key 放到 value 中一起處理,繼續看下面。

對於 value

我們可以構造一個超大的 byte 數組 buf,將原來的 key value 等信息經過序列化,變成二進制 01 串。將它存放到這個超大 buf 中,並記錄它在 超大 buf 中的位置 index。然後將這個位置 index 信息放到 map 的 value 位置上,也就是從 key-velue,變成了 key-index。

同時爲了防止 buf 數組變得過大,佔用過多內存導致應用 oom,還可以採用 ringbuf 的結構,寫到尾部就重頭開始寫,如果 ringbuf 空間不夠,還能對它進行擴容

寫操作

對於寫操作,程序先將 key 進行 hash,得到所在分片 map,加鎖。

然後解鎖,結束流程。

讀操作

對於讀操作,程序同樣先對 key 進行 hash,得到分片 map。加鎖,從分片 map 裏拿到 value 對應的 index,拿着這個 index 到 ringbuf 數組中去獲取到 value 的值,然後解鎖,結束流程。

到這裏,我們可以發現 map 的 key 和 value 都被改成了整形數字,也就省下了大量的 gc 掃描,大大提升了組件性能。其實這就是有名的高性能無 GC 的緩存庫 github.com/allegro/bigcache 的實現原理。

bigcache 的使用

它的使用方法大概像下面這樣。

package main

import (
    "fmt"
    "github.com/allegro/bigcache/v3"
)

func main() {
    // 設置 bigcache 配置參數
    cacheConfig := bigcache.Config{
        Shards: 1024, // 分片數量,提高併發性
    }

    // 初始化 bigcache
    cache, _ := bigcache.NewBigCache(cacheConfig)

    // 寫緩存數據
    key := "歡迎關注"
    value := []byte("小白debug")
    cache.Set(key, value)

    // 讀緩存數據
    entry, _ := cache.Get(key)

    fmt.Printf("Entry: %s\n", entry)
}

說白了就是 Get 方法讀緩存數據,Set 方法寫緩存數據,比較簡單。現在,大概原理和使用方法我們都懂了,我們再來看下 bigcache 中,兩個我認爲挺巧妙的設計點。

ringbuf 中的數據格式

在前面的介紹中,我猜你心裏可能有疑問,程序從 ringbuf 讀寫 value 的時候,ringbuf 裏面放的都是 01 二進制數組,程序怎麼知道該讀多少 bit 纔算一個完整 value?bigcache 的解法非常值得學習,它重新定義了一個新的數據格式。

當讀取 ringbuf 時,我們會先讀到 length,有了它,我們就能在 ringbuf 裏拿到 header 和 data,header 裏又含有 key 的長度,這樣就能在 data 裏將 key 和 value 完整區分開來。

很多網絡傳輸框架中都會用到類似的方案,後面有機會跟大家細聊。

ringbuffer 的第 0 位

另外,還有個巧妙的設計是,在 bigcache 中, ringbuffer 的第 0 位並不用來存放任何數據,這樣如果發現 分片 map 中得到數據的 index 爲 0,就可以直接認爲沒有對應的緩存數據,那就不需要跑到 ringbuffer 裏去撈一遍數據了,覺得學到了,記得在右下角給我點個贊。

bigcache 的缺點

bigcache 性能非常好,但也不是完全沒有問題。比較明顯的是,它讀寫數據時,用的都是 byte 數組,但我們平時寫代碼用的都是結構體,爲了讓結構體和 byte 數組互轉,我們就需要用到序列化和反序列化,這些都是成本。

另外它的緩存淘汰策略也比較粗暴,用的是 FIFO,不支持 LRU 或 LFU 的淘汰策略。

總結

最後

不管是空間換時間還是時間換空間,適合、夠用,就是最好的,架構總是在做折中。這就像我們做的 go/java 後端訓練營,你不能要求它效果好,又要求它價格低。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/4ufuA3n9aaFcQ9zDItUHaQ