學習VictoriaMetrics源碼的時候發現,VictoriaMetrics的緩存部分,使用了同一產品下的fastcache。下面分享閱讀fastcache源碼的的結論:
1.官方介紹
fastcache是一個用go語言實現的,很快的,線程安全的,內存緩存的,用於大量對象緩存的組件。
它的特點是:
- 快!CPU核越多越快,不信你看我下面的benchmark。
- 線程安全。多個協程可以同時讀寫單個cache實例。
- fastcache用於存儲大量的cache實體,而且不會被GC掃描。
- 當設定的cache空間滿了以后,fastcache會自動淘汰老數據。
- API賊簡單。
- 源碼也賊簡單。
- cache還可以保存在文件中,需要的時候能加載。
- 在Google的雲服務上也能跑得起來。(說明未使用很特殊的操作系統API)
作者valyala是fasthttp和VictoriaMetrics等作品的主要開發者。valyala大神有極其強悍的工程能力,很多看來已經很簡單的成熟組件被他又一次妙手生花,YYDS!
2. 性能
究竟有多快呢?作者做了一個對比:(這里主要看set操作)
- golang的標准map: 6.21 M次/s
- sync.Map庫:2.65 M次/s
- BigCache庫:6.20 M次/s
- fastcache庫:17.21 M次/s
換個角度看:
- 比golang的標准map快2.77倍
- 比sync.Map庫快6.49倍
- 比BigCache快2.78倍
快得我都不知道說啥好了……
3. 限制
當然也不是快就完美了,也是有些限制的。要根據這些限制來確定fastcache是否適合引入你的業務環境中:
- key和value都只能是[]byte類型,不是的話要自己序列化
- key長度+value長度+4不能超過64KB,否則就要使用額外的SetBig()方法
- 沒有緩存過期機制。只有在cache滿了以后才能淘汰舊數據。
- 可以自己把過期時間存儲在value中,讀出來的時候判斷一下。如果過期了,手動調用Del()方法來刪除。
- cache的總容量是預先設置好的,超過這個容量就要淘汰最早插入的值。
- 當然了,cache嘛,僅適合cache場景,不能用於無損的數據存儲。
- 最后:hash沖突的處理上,整個cache分為512個桶。如果兩個key的hashcode完全相同的話,新插入的值會替換掉舊的值,導致前一個值丟失……
- 發生hash沖突時僅僅只是原子累加到監控變量,讓你知道曾經發生過……
- 我認為這一點很不合理,給作者提了個issue
4. 源碼解讀
4.1 使用mmap分配內存
malloc_mmap.go中使用了unix.Mmap()來分配內存:
-
內存映射的方式可以直接向操作系統申請內存,這塊區域不歸GC管。所以不管你在這塊內存緩存了多少數據,都不會因為GC掃描而影響性能。
-
每次使用mmap申請內存的時候,申請了1024*64KB=64MB內存。
- 每64KB稱為一個chunk
- 所有的chunk放在一個隊列中
- 當隊列中所有的chunk都用完后,再申請64MB
-
chunk的管理:
var (
freeChunks []*[chunkSize]byte //相當於一個隊列,保存了所有未使用的chunk
freeChunksLock sync.Mutex //chunk的鎖
)
可以通過 func getChunk() []byte
函數獲取一個64KB的塊。如果freeChunks中沒有chunk了,就再通過mmap申請64MB。
- chunk的歸還
func putChunk(chunk []byte)
函數把有效的chunk放回freeChunks隊列。
繞過GC能帶來性能上的好處,但是這里分配的內存再也不會被釋放,直到進程重啟。
4.2 Cache類的實現
fastcache.go中是fastcache的主要代碼。
4.2.1 cache對象的結構
type Cache struct {
buckets [bucketsCount]bucket
bigStats BigStats
}
- bucketsCount這個常量值為512 。也就是說,cache對象的內部分布了512個桶。
- bigStats 是用於內部的監控上報的
4.2.2 新建cache對象
// func New(maxBytes int) *Cache
c := New(1024*1024*32) //cache的最小容量是32MB
New的源碼如下:
func New(maxBytes int) *Cache {
if maxBytes <= 0 {
panic(fmt.Errorf("maxBytes must be greater than 0; got %d", maxBytes))
}
var c Cache
maxBucketBytes := uint64((maxBytes + bucketsCount - 1) / bucketsCount)
for i := range c.buckets[:] {
c.buckets[i].Init(maxBucketBytes)
}
return &c
}
- maxBytes先按照512字節向上對齊
- 然后划分成512份
- 假設申請內存512MB,則每份1MB。也就是每個bucket 1MB內存。
- 分為512個桶,每個桶再單獨初始化
4.2.3 Set方法
func (c *Cache) Set(k, v []byte) {
h := xxhash.Sum64(k)
idx := h % bucketsCount
c.buckets[idx].Set(k, v, h)
}
非常簡單:對key計算一個hash值,然后對hash值取模,轉到具體的bucket對象里面去處理。
xxhash庫用匯編實現,是目前最快的hashcode計算的庫
4.2.4 SetBig方法
如果key+value+4超過了64KB,怎么辦?
- 先把value部分拆成若干個64KB-21字節,得到subvalue
- 對value取hash值,value_hashcode + index為subkey
- 以subkey + subvalue為參數,調用Set,分別插入各個部分
- 以value_hashcode, value_len為最終的last_value
- 以key, last_value為參數,調用Set
所以,超過64KB的部分是拆成很多小塊放入cache的。
4.3 bucket類的實現
4.3.1 bucket的結構
type bucket struct {
mu sync.RWMutex
// chunks is a ring buffer with encoded (k, v) pairs.
// It consists of 64KB chunks.
chunks [][]byte
// m maps hash(k) to idx of (k, v) pair in chunks.
m map[uint64]uint64
// idx points to chunks for writing the next (k, v) pair.
idx uint64
// gen is the generation of chunks.
gen uint64
getCalls uint64 // 以下都是用於統計的變量
setCalls uint64
misses uint64
collisions uint64
corruptions uint64
}
-
mu sync.RWMutex
: 每個bucket有一個讀寫鎖來處理並發。- 和sync.Map比起來,原理上也沒什么神秘的。把數據分散到512個桶,相當於競爭變為原來的1/512。
-
chunks [][]byte
: 這個是存儲數據的chunk的數組- chunk是上面提到的通過mmap分配的64KB的一個塊
- key+value的數據會被順序的放在chunk中,並記錄位於數組中的下標
- 一個chunk的空間用完后,會再通過
getChunk()
再申請64KB的塊。直到塊達到用戶規定的上限。- 假設每個bucket 1MB, 則共有1MB/64KB=16個chunk
- 第15個chunk滿了以后,又回到第0個chunk存儲,同時gen字段增加,說明是新的一代
-
m map[uint64]uint64
: 這里存儲每個hashcode對應的chunk中的偏移量。 -
idx uint64
: 這里記錄下次插入chunk的位置,插入完成后跳轉到數據的末位。 -
gen uint64
: 當所有的chunks都寫滿以后,gen的值加1,從第0塊開始淘汰舊數據。
這里有個明顯的缺點:假設hashcode都分布在較少的幾個bucket中,那么就導致某幾個bucket的數據頻繁淘汰,而其他的bucket還剩挺多空間。不過,這只是假設,並未有數據證明會有這種現象。
4.3.2 Set過程
源碼太多,此處直接貼結論:
- 每set 16384(2的14次方)次,執行一次clean操作
- clean操作遍歷整個map,移除chunk中因為回繞淘汰的數據
- key+value序列化的方式很簡單,順序存儲以下內容:
- 2字節key長度
- 2字節value長度
- key的內容
- value的內容
- 寫入chunk的時候加入了寫鎖
- 通過bucket的idx字段找到插入位置,然后按照上述序列化的方式拷貝數據
- 插入完成后得到了偏移位置,把key的hashcode作為鍵,把chunks中的偏移量為值,寫入字段m的map中
- value這里還有個細節:value是64位的uint64, value的低40位存儲偏移量,value的高24位存儲generation的信息。
4.3.3 Get過程
搞清楚了Set,Get就更簡單了:
- 首先在Cache類中,根據key的hashcode,確定選擇哪個bucket
- 查詢前加讀鎖
- 在m字段的map中,根據hashcode找到下標
- 根據下標確定key的位置
- 比較key的內容是否相等
- 最后返回value
4.3.4 Del過程
del僅刪除map中的key,而chunks中對應的位置只能等到下次回繞才能清理。
刪除的動作是滯后的,因此fastcache不適合刪除很多的業務場景。
5.總結
fastcache為什么快,因為用了這些手段:
-
使用mmap來成塊的分配內存。
- 每次直接向操作系統要64MB,這些內存都繞開了GC。
- 每次以64KB為單位請求一個塊
- 在64KB的塊內順序存儲,相當於更簡單的自己實現的分配算法
-
整個cache分成512個bucket
- 相當於有了512個map+512個讀寫鎖,通過這樣減少了競爭
- map類型的key和value都是整形,容量小,且對GC友好
- 淘汰用輪換的方法+固定次數的set后再清理,解決了(或者說繞開了)碎片的問題
希望對你有用,have fun 😃
來自我的公眾號:原文鏈接