分析fastcache和freecache(一)
fastcache和freecache是兩個比較簡單的緩存實現,下面分析一下各自的實現,並學習一下其實現中比較好的方式。
fastcache
概述
fastcache是一個簡單庫,核心文件也就兩個:fastcache.go
和bigcache.go
。其中後者是對前者場景的擴展,其實就是將大於64KB 的數據分段存儲。參見下面Limitations
的第二條。
Limitations
- Keys and values must be byte slices. Other types must be marshaled before storing them in the cache.
- Big entries with sizes exceeding 64KB must be stored via distinct API.
- 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的buckets
,bucket
中包含存儲數據的chunks
數組。fastcache沒有緩存超時機制,chunks
爲ringbuffer
,當chunks
滿數據之後,新來的數據會放到chunk1
中,以此類推。從這方面看,fastcache並沒有什麼神奇之處,但cache說白了也就2件事:
- 快速檢索數據,包括快速確定寫入的內存以及快速查找所需的數據
- 高效利用內存,不產生過多的內存碎片
後面看下fastcache如何利用bucket.m
、bucket.idx
和bucket.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的核心函數,
-
有效性校驗,確保k、v的長度不超過16bit,即2個字節,在第2步中會保存k、v的長度信息,因此此處是強制限制。
-
chunk
中保存的單個數據的格式如下,使用這種方式主要是爲了方便快速檢索k、v。
-
獲取該bucket中的
chunks
,注意一開始使用的時候chunks
中的chunk
是沒有初始化的 -
b.idx
表示當前chunks
中的總數據偏移(但並不等於有效數據,如果某個chunk無法容納下一個數據,則會產生一定的碎片)。chunkIdx
爲當前chunk
的索引,idxNew
爲添加新數據之後的總數據偏移,chunkIdxNew
爲添加新數據之後的chunk
索引
-
如果
chunkIdxNew > chunkIdx
說明當前chunk的剩餘空間無法保存新數據,此時需要一個新的chunk來保存新數據(此時索引爲chunkIdx
的chunk
中會產生內存碎片)。 -
如果該
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
中無效的數據 -
如果
chunks
中有空餘的chunk
,則更新chunk
索引和總數據偏移量。 -
清空
chunk
中的數據 -
獲取存儲數據的
chunk
,如果該chunk
沒有初始化,則調用getChunk
初始化chunk
內存。 -
在
chunk
中添加該數據,包括數據頭(kvLenBuf
)和k、v -
b.m
中保存了該元素(索引爲k的哈希值)的相關信息,高24位保存了該數據所處的gen
,低40位保存了該數據的起始位置(即保存該數據時的總數據偏移量,受限於chunkSize
的大小,最多隻會佔用16bit)。
-
更新bucket中的總數據偏移量
-
cleanLocked
會清理b.m
中的無效數據。那麼如何判斷哪些數據是無效的呢?有效數據有如下兩種情況:- 如果數據的偏移量(
idx
)大於當前bucket的偏移量(bIdx
),則說明該數據是上一代數據,則數據的gen
和bucket
的gen(bGen
)有如下兩種關係:gen+1 == bGen
- gen == maxGen && bGen == 1
- 如果數據的偏移量(
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
就相對簡單很多。
- 首先從
b.m
中獲取該k
對應的元數據 - 校驗該數據是否合法,邏輯跟
cleanLocked
一樣 - 如果合法,則通過偏移量找到對應的
chunk
- 獲取數據在其所在的
chunk
中的偏移量 - 找到
kvLenBuf
中保存的k、v長度 - 校驗數據中的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的代碼比較少,可以直接移植