介紹一個golang庫:fastcache


學習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倍

快得我都不知道說啥好了……

img

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()來分配內存:

  1. 內存映射的方式可以直接向操作系統申請內存,這塊區域不歸GC管。所以不管你在這塊內存緩存了多少數據,都不會因為GC掃描而影響性能。

  2. 每次使用mmap申請內存的時候,申請了1024*64KB=64MB內存。

    • 每64KB稱為一個chunk
    • 所有的chunk放在一個隊列中
    • 當隊列中所有的chunk都用完后,再申請64MB
  3. chunk的管理:

var (
	freeChunks     []*[chunkSize]byte  //相當於一個隊列,保存了所有未使用的chunk
	freeChunksLock sync.Mutex  //chunk的鎖
)

可以通過 func getChunk() []byte 函數獲取一個64KB的塊。如果freeChunks中沒有chunk了,就再通過mmap申請64MB。

  1. 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,怎么辦?

  1. 先把value部分拆成若干個64KB-21字節,得到subvalue
  2. 對value取hash值,value_hashcode + index為subkey
  3. 以subkey + subvalue為參數,調用Set,分別插入各個部分
  4. 以value_hashcode, value_len為最終的last_value
  5. 以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為什么快,因為用了這些手段:

  1. 使用mmap來成塊的分配內存。

    • 每次直接向操作系統要64MB,這些內存都繞開了GC。
    • 每次以64KB為單位請求一個塊
    • 在64KB的塊內順序存儲,相當於更簡單的自己實現的分配算法
  2. 整個cache分成512個bucket

    • 相當於有了512個map+512個讀寫鎖,通過這樣減少了競爭
    • map類型的key和value都是整形,容量小,且對GC友好
    • 淘汰用輪換的方法+固定次數的set后再清理,解決了(或者說繞開了)碎片的問題

希望對你有用,have fun 😃

來自我的公眾號:原文鏈接


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM