分析fastcache和freecache(一)

分析fastcache和freecache(一)

fastcache和freecache是兩個比較簡單的緩存實現,下面分析一下各自的實現,並學習一下其實現中比較好的方式。

fastcache

概述

fastcache是一個簡單庫,核心文件也就兩個:fastcache.gobigcache.go。其中後者是對前者場景的擴展,其實就是將大於64KB 的數據分段存儲。參見下面Limitations的第二條。

Limitations

  1. Keys and values must be byte slices. Other types must be marshaled before storing them in the cache.
  2. Big entries with sizes exceeding 64KB must be stored via distinct API.
  3. There is no cache expiration. Entries are evicted from the cache only on cache size overflow. Entry deadline may be stored inside the value in order to implement cache expiration.

根據官方的性能測試報告,其讀寫性能比較均衡,遠好於標準的Go map和sync.Map

GOMAXPROCS=4 go test github.com/VictoriaMetrics/fastcache -bench='Set|Get' -benchtime=10s
goos: linux
goarch: amd64
pkg: github.com/VictoriaMetrics/fastcache
BenchmarkBigCacheSet-4      	    2000	  10566656 ns/op	   6.20 MB/s	 4660369 B/op	       6 allocs/op
BenchmarkBigCacheGet-4      	    2000	   6902694 ns/op	   9.49 MB/s	  684169 B/op	  131076 allocs/op
BenchmarkBigCacheSetGet-4   	    1000	  17579118 ns/op	   7.46 MB/s	 5046744 B/op	  131083 allocs/op
BenchmarkCacheSet-4         	    5000	   3808874 ns/op	  17.21 MB/s	    1142 B/op	       2 allocs/op
BenchmarkCacheGet-4         	    5000	   3293849 ns/op	  19.90 MB/s	    1140 B/op	       2 allocs/op
BenchmarkCacheSetGet-4      	    2000	   8456061 ns/op	  15.50 MB/s	    2857 B/op	       5 allocs/op
BenchmarkStdMapSet-4        	    2000	  10559382 ns/op	   6.21 MB/s	  268413 B/op	   65537 allocs/op
BenchmarkStdMapGet-4        	    5000	   2687404 ns/op	  24.39 MB/s	    2558 B/op	      13 allocs/op
BenchmarkStdMapSetGet-4     	     100	 154641257 ns/op	   0.85 MB/s	  387405 B/op	   65558 allocs/op
BenchmarkSyncMapSet-4       	     500	  24703219 ns/op	   2.65 MB/s	 3426543 B/op	  262411 allocs/op
BenchmarkSyncMapGet-4       	    5000	   2265892 ns/op	  28.92 MB/s	    2545 B/op	      79 allocs/op
BenchmarkSyncMapSetGet-4    	    1000	  14595535 ns/op	   8.98 MB/s	 3417190 B/op	  262277 allocs/op

fastcache.go分析

fastcache的數據結構相對比較簡單,主要內容如下(省去了統計相關的結構體成員):

type Cache struct {
	buckets [bucketsCount]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
	...
}

Cache結構體中包含長度爲512的bucketsbucket中包含存儲數據的chunks數組。fastcache沒有緩存超時機制,chunksringbuffer,當chunks滿數據之後,新來的數據會放到chunk1中,以此類推。從這方面看,fastcache並沒有什麼神奇之處,但cache說白了也就2件事:

  1. 快速檢索數據,包括快速確定寫入的內存以及快速查找所需的數據
  2. 高效利用內存,不產生過多的內存碎片

後面看下fastcache如何利用bucket.mbucket.idxbucket.gen這三個參數來實現快速檢索數據,以及如何使用freeChunks來減少內存預分配。

Cache的初始化

Cache中的buckets的長度以及bucket中單個chunk的大小是固定的,入參的maxBytes僅會影響bucket.chunks的長度,即bucket中的chunk數目。從Cache結構體中可以看到其buckets的長度爲bucketsCount,即512個。

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
}

下面是bucket的初始化方法,需要注意的是其僅僅初始化了b.chunks的大小,並沒有初始化單個chunk的內存空間(即chunkSize字節)。chunk的初始化是在實際使用時從freeChunks申請的,這樣可以避免預先分配冗餘內存。這種方式有點類似底層的虛擬內存的概念,只有在真正使用的時候纔會分配內存。後面會看到freeChunks是如何申請內存的。

func (b *bucket) Init(maxBytes uint64) {
	if maxBytes == 0 {
		panic(fmt.Errorf("maxBytes cannot be zero"))
	}
	if maxBytes >= maxBucketSize {
		panic(fmt.Errorf("too big maxBytes=%d; should be smaller than %d", maxBytes, maxBucketSize))
	}
	maxChunks := (maxBytes + chunkSize - 1) / chunkSize
	b.chunks = make([][]byte, maxChunks)
	b.m = make(map[uint64]uint64)
	b.Reset()
}
chunk內存的申請和釋放

上面說了在Cache初始化時並沒有爲chunk申請內存,在實際使用chunk的時候(Set)纔會申請內存。下面是chunk的內存初始化方式。可以看到fastcache中使用unix.Mmap來爲chunk申請內存,這樣作可以避免GC的影響(當前缺點是需要手動維護內存)。當需要爲chunk申請內存時,會調用unix.Mmap來一次性申請chunksPerAlloc(即1024)個chunk,將其附加到freeChunks中,並從freeChunks中返回最後一個元素作爲初始化後的chunk。當然unix.Mmap需要在unix系統下才能生效。

freeChunks是個全局chunk數組,便於爲不同的chunk提供存儲。

func getChunk() []byte {
   freeChunksLock.Lock()
   if len(freeChunks) == 0 {
      // Allocate offheap memory, so GOGC won't take into account cache size.
      // This should reduce free memory waste.
      data, err := unix.Mmap(-1, 0, chunkSize*chunksPerAlloc, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_ANON|unix.MAP_PRIVATE)
      if err != nil {
         panic(fmt.Errorf("cannot allocate %d bytes via mmap: %s", chunkSize*chunksPerAlloc, err))
      }
      for len(data) > 0 {
         p := (*[chunkSize]byte)(unsafe.Pointer(&data[0]))
         freeChunks = append(freeChunks, p)
         data = data[chunkSize:]
      }
   }
   n := len(freeChunks) - 1
   p := freeChunks[n]
   freeChunks[n] = nil
   freeChunks = freeChunks[:n]
   freeChunksLock.Unlock()
   return p[:]
}

下面是trunk的回收方式,比較簡單,即將需要回收的trunk附加到freeChunks即可。

func putChunk(chunk []byte) {
	if chunk == nil {
		return
	}
	chunk = chunk[:chunkSize]
	p := (*[chunkSize]byte)(unsafe.Pointer(&chunk[0]))

	freeChunksLock.Lock()
	freeChunks = append(freeChunks, p)
	freeChunksLock.Unlock()
}
添加kv數據

fastcache使用Set來添加數據,但數據需要是[]byte類型。它首先會對k進行哈希,統一k的長度。並通過哈希的結果找出存放該數據的bucket索引。

func (c *Cache) Set(k, v []byte) {
   h := xxhash.Sum64(k)
   idx := h % bucketsCount
   c.buckets[idx].Set(k, v, h)
}

通過索引找到對應的bucket之後,下一步就是將數據存儲到bucket中的chunk中。

該函數是fastcache的核心函數,

  1. 有效性校驗,確保k、v的長度不超過16bit,即2個字節,在第2步中會保存k、v的長度信息,因此此處是強制限制。

  2. chunk中保存的單個數據的格式如下,使用這種方式主要是爲了方便快速檢索k、v。

  3. 獲取該bucket中的chunks,注意一開始使用的時候chunks中的chunk是沒有初始化的

  4. b.idx表示當前chunks中的總數據偏移(但並不等於有效數據,如果某個chunk無法容納下一個數據,則會產生一定的碎片)。chunkIdx爲當前chunk的索引,idxNew爲添加新數據之後的總數據偏移,chunkIdxNew爲添加新數據之後的chunk索引

  5. 如果chunkIdxNew > chunkIdx說明當前chunk的剩餘空間無法保存新數據,此時需要一個新的chunk來保存新數據(此時索引爲chunkIdxchunk中會產生內存碎片)。

  6. 如果該bucket中的所有chunk都已經被佔滿,此時沒有空餘的chunk來保存新數據,此時會採用ringbuffer的方式,將新數據放到第一個chunk中

    6.1 更新數據偏移量,此時在第一個chunk中,因此偏移量爲0。bucket有一個b.gen成員,保存了當前bucket中chunks的循環使用次數,即第gen代數據。由於chunks是ringbuf,存儲空間會被循環利用,因此在查詢數據時需要對比數據存儲時的gen(存儲在b.m中)和當前gen,如果不相同,則說明老的數據已經被後來的數據覆蓋了。

    6.2 b.gen會保存到b.m的高24位,如果此時b.gen&((1<<genSizeBits)-1) == 0,則說明b.gen發生了溢出,此時需要將b.gen置0,重新計數。

    6.3 當重新使用chunks時,需要清理b.m中無效的數據

  7. 如果chunks中有空餘的chunk,則更新chunk索引和總數據偏移量。

  8. 清空chunk中的數據

  9. 獲取存儲數據的chunk,如果該chunk沒有初始化,則調用getChunk初始化chunk內存。

  10. chunk中添加該數據,包括數據頭(kvLenBuf)和k、v

  11. b.m中保存了該元素(索引爲k的哈希值)的相關信息,高24位保存了該數據所處的gen,低40位保存了該數據的起始位置(即保存該數據時的總數據偏移量,受限於chunkSize的大小,最多隻會佔用16bit)。

  12. 更新bucket中的總數據偏移量

  13. cleanLocked會清理b.m中的無效數據。那麼如何判斷哪些數據是無效的呢?有效數據有如下兩種情況:

    1. 如果數據的偏移量(idx)大於當前bucket的偏移量(bIdx),則說明該數據是上一代數據,則數據的genbucket的gen(bGen)有如下兩種關係:
      1. gen+1 == bGen
      2. gen == maxGen && bGen == 1
    2. 如果數據的偏移量(idx)小於當前bucket的偏移量(bIdx),,則說明該數據是本代數據,則要滿足gen == bGen

    不滿足上述兩種場景的數據都是無效數據,需要清理。

    func (b *bucket) cleanLocked() {
    	bGen := b.gen & ((1 << genSizeBits) - 1)
    	bIdx := b.idx
    	bm := b.m
    	for k, v := range bm {
    		gen := v >> bucketSizeBits
    		idx := v & ((1 << bucketSizeBits) - 1)
    		if (gen+1 == bGen || gen == maxGen && bGen == 1) && idx >= bIdx || gen == bGen && idx < bIdx {
    			continue
    		}
    		delete(bm, k)
    	}
    }
    
func (b *bucket) Set(k, v []byte, h uint64) {
	atomic.AddUint64(&b.setCalls, 1)
	if len(k) >= (1<<16) || len(v) >= (1<<16) { //<1>
		return
	}
	var kvLenBuf [4]byte // <2>
	kvLenBuf[0] = byte(uint16(len(k)) >> 8)
	kvLenBuf[1] = byte(len(k))
	kvLenBuf[2] = byte(uint16(len(v)) >> 8)
	kvLenBuf[3] = byte(len(v))
	kvLen := uint64(len(kvLenBuf) + len(k) + len(v))
	if kvLen >= chunkSize {
		return
	}

	chunks := b.chunks // <3>
	needClean := false
	b.mu.Lock()
	idx := b.idx // <4>
	idxNew := idx + kvLen
	chunkIdx := idx / chunkSize 
	chunkIdxNew := idxNew / chunkSize
	if chunkIdxNew > chunkIdx { // <5>
		if chunkIdxNew >= uint64(len(chunks)) { // <6>
			idx = 0  // <6.1>
			idxNew = kvLen
			chunkIdx = 0
			b.gen++
			if b.gen&((1<<genSizeBits)-1) == 0 { // <6.2>
				b.gen++
			}
			needClean = true // <6.3>
		} else {
			idx = chunkIdxNew * chunkSize // <7>
			idxNew = idx + kvLen
			chunkIdx = chunkIdxNew
		}
		chunks[chunkIdx] = chunks[chunkIdx][:0] // <8>
	}
	chunk := chunks[chunkIdx] // <9>
	if chunk == nil {
		chunk = getChunk()
		chunk = chunk[:0]
	}
	chunk = append(chunk, kvLenBuf[:]...) // <10>
	chunk = append(chunk, k...)
	chunk = append(chunk, v...)
	chunks[chunkIdx] = chunk
	b.m[h] = idx | (b.gen << bucketSizeBits) // <11>
	b.idx = idxNew //12
	if needClean { // <13>
		b.cleanLocked()
	}
	b.mu.Unlock()
}
獲取kv數據

有了Set的基礎,Get就相對簡單很多。

  1. 首先從b.m中獲取該k對應的元數據
  2. 校驗該數據是否合法,邏輯跟cleanLocked一樣
  3. 如果合法,則通過偏移量找到對應的chunk
  4. 獲取數據在其所在的chunk中的偏移量
  5. 找到kvLenBuf中保存的k、v長度
  6. 校驗數據中的k是不是跟所需要的k一樣,這麼做的目的是防止哈希衝突的情況下獲取到異常數值。如果合法則返回對應的v即可
func (b *bucket) Get(dst, k []byte, h uint64, returnDst bool) ([]byte, bool) {
	atomic.AddUint64(&b.getCalls, 1)
	found := false
	chunks := b.chunks
	b.mu.RLock()
	v := b.m[h] // <1>
	bGen := b.gen & ((1 << genSizeBits) - 1)
	if v > 0 {
		gen := v >> bucketSizeBits
		idx := v & ((1 << bucketSizeBits) - 1)
		if gen == bGen && idx < b.idx || gen+1 == bGen && idx >= b.idx || gen == maxGen && bGen == 1 && idx >= b.idx { // <2>
			chunkIdx := idx / chunkSize // <3>
			if chunkIdx >= uint64(len(chunks)) {
				// Corrupted data during the load from file. Just skip it.
				atomic.AddUint64(&b.corruptions, 1)
				goto end
			}
			chunk := chunks[chunkIdx]
			idx %= chunkSize // <4>
			if idx+4 >= chunkSize {
				// Corrupted data during the load from file. Just skip it.
				atomic.AddUint64(&b.corruptions, 1)
				goto end
			}
			kvLenBuf := chunk[idx : idx+4] // <4>
			keyLen := (uint64(kvLenBuf[0]) << 8) | uint64(kvLenBuf[1])
			valLen := (uint64(kvLenBuf[2]) << 8) | uint64(kvLenBuf[3])
			idx += 4
			if idx+keyLen+valLen >= chunkSize {
				// Corrupted data during the load from file. Just skip it.
				atomic.AddUint64(&b.corruptions, 1)
				goto end
			}
			if string(k) == string(chunk[idx:idx+keyLen]) { // <5>
				idx += keyLen
				if returnDst {
					dst = append(dst, chunk[idx:idx+valLen]...)
				}
				found = true
			} else {
				atomic.AddUint64(&b.collisions, 1)
			}
		}
	}
end:
	b.mu.RUnlock()
	if !found {
		atomic.AddUint64(&b.misses, 1)
	}
	return dst, found
}

總結

  • fastcache的chunk內存分配方式比較好,它沒有預先分配大量內存,而是動態申請的方式。其次內存申請使用了手動申請的方式(mmap),以此避免GC的影響。
  • fastcache的數據存儲時包含了一個元數據頭,元數據裏面保存了該數據的數據偏移量以及k、v長度等數據,通過這種方式可以快速定位數據所在的位置。像大部分存儲的WAL存儲方式也是採用的這種TLV或LV方式。
  • fastcache的緩存採用的是ringbuffer的方式,並沒有超時機制。數據的存儲和查找都是通過哈希的方式進行的,因此檢索速度很快。
  • fastcache的代碼比較少,可以直接移植
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章