go内存缓存 fastecache原理浅析

go内存缓存 fastecache原理浅析

  • 1. 内存缓存
    • 1.1 为什么要使用内存缓存
    • 1.2 什么时候使用
    • 1.3 fastecache
    • 1.4 以 sync.map 实现缓存存在的问题
      • 1.4.1 造成消耗的原因:
    • 1.5 fastecache 原理
    • 1.6 总结

1. 内存缓存

1.1 为什么要使用内存缓存

  • 相对于文件 IO 操作:速度快,效率高。
  • 相对于 Redis 这类分布式缓存:不会受限于网卡等原因。

1.2 什么时候使用

  • 愿意消耗一些内存空间来提升速度。
  • 预料到某些键会被多次查询。
  • 缓存中存放的数据总量不会超出内存容量。

1.3 fastecache

  • 特点

    • 快速,性能在多核 CPU 上表现更好。
    • 线程安全的,并发 goroutine 可以读写单个缓存实例。
    • fastcache 设计用于存储大量 K/V 数据而无需 GC 开销。
    • Fastcache 在创建期间达到设置的最大大小时会自动驱逐旧条目。
    • 简单 API
  • 性能

    Fastcache performance is compared with BigCache, standard Go map and 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

1.4 以 sync.map 实现缓存存在的问题

go map 和 string 中 GC 的时候都会扫描每个指针,那么在这个巨大的 map 中是包含了非常多的指针的,所以造成了巨大的资源消耗。

type Map struct {
	// 当涉及到对dirty数据的操作的时候,需要使用这个锁
	mu Mutex
	// 只读,操作完的数据存放在这里
	read atomic.Value // readOnly sync.Map.Store时回在里面存一份。
	// dirty数据包含当前的map包含的entries,它包含最新的entries(包括read中未删除的数据,虽有冗余,但是提升dirty字段为read的时候非常快,不用一个一个的复制,而是直接将这个数据结构作为read字段的一部分),有些数据还可能没有移动到read字段中。
	// 对于dirty的操作需要加锁,因为对它的操作可能会有读写竞争。
	// 当dirty为空的时候, 比如初始化或者刚提升完,下一次的写操作会复制read字段中未删除的数据到这个数据中。
	dirty map[interface{}]*entry //entry {unsafe.Pointer}
	// 当从Map中读取entry的时候,如果read中不包含这个entry,会尝试从dirty中读取,这个时候会将misses加一,
	// 当misses累积到 dirty的长度的时候, 就会将dirty提升为read,避免从dirty中miss太多次。因为操作dirty需要加锁。
	misses int
}

// src/runtime/string.go
type stringStruct struct {
    str unsafe.Pointer
    len int
}

1.4.1 造成消耗的原因:

(1) Go 中内存分配
TCMalloc(Thread cache malloc) 将内存划分为多个级别,以减少锁的粒度。在 TCMalloc 内部,内存管理分为两部分:线程内存和页堆(page heap)。
go内存缓存 fastecache原理浅析_第1张图片
Go 为每个逻辑处理器(P)提供了一个称为 mcache 的本地内存线程缓存,mcache 包含所有级别大小的 mspan 作为缓存。mspan 中包含 scan—包含指针的对象和 noscan —不包含指针的对象。GC 无需遍历 noscan 对象。
(2) Go GC 机制
go 程序在SWT时进行垃圾回收,会搜索所有的指针应用。举个简单的例子:申明一个map对象map[string]interface{} A, 它会在栈区存储对象A,在堆区存放数据B、C、D、E。当我释放E时进行的操作是讲map[E]直接赋值为nil。这样堆区的E数据就没有被引用到,在下次gc的时候E将不会被标记,最终被回收。
go内存缓存 fastecache原理浅析_第2张图片

//go:notinheap
type mspan struct {
	next *mspan     // next span in list, or nil if none
	prev *mspan     // previous span in list, or nil if none
	list *mSpanList // For debugging. TODO: Remove.
	freeindex uintptr
	...
	//Go分配的内存会被染色标记。
	allocBits  *gcBits
	gcmarkBits *gcBits
    ...
}

1.5 fastecache 原理

在理解上面的问题后就能很清楚的理解fastecache是如何去规避这些性能问题了。
如果所有的 string 字节都在一个单一内存片段中,通过偏移来追踪某个字符串在这段内存中的开始和结束位置。不在需要在大数组中存储指针。fastecache 原理

cahche 的实现

const bucketsCount = 512
const chunkSize = 64 * 1024
const bucketSizeBits = 40
const genSizeBits = 64 - bucketSizeBits
const maxGen = 1<<genSizeBits - 1
const maxBucketSize uint64 = 1 << bucketSizeBits

// fastecache申明的结构体
type Cache struct {
	buckets [bucketsCount]bucket //大小为512的桶
	bigStats BigStats //包含存取大 k/v 的方法
}
// 清空cache
func (c *Cache) Reset() {
	for i := range c.buckets[:] {
		c.buckets[i].Reset()
	}
	c.bigStats.reset()
}
// BigStats 包含GetBig/SetBig方法.
type BigStats struct {
	GetBigCalls uint64
	SetBigCalls uint64
	TooBigKeyErrors uint64
	InvalidMetavalueErrors uint64
	InvalidValueLenErrors uint64
	InvalidValueHashErrors uint64
}
func (bs *BigStats) reset() {
	atomic.StoreUint64(&bs.GetBigCalls, 0)
	atomic.StoreUint64(&bs.SetBigCalls, 0)
	atomic.StoreUint64(&bs.TooBigKeyErrors, 0)
	atomic.StoreUint64(&bs.InvalidMetavalueErrors, 0)
	atomic.StoreUint64(&bs.InvalidValueLenErrors, 0)
	atomic.StoreUint64(&bs.InvalidValueHashErrors, 0)
}

桶的实现

type bucket struct {
	mu sync.RWMutex
	// 存储键值对的区块s
	chunks [][]byte
	// 键盘值对的块索引,hash(k) idx of (k, v) pair in chunks.
	m map[uint64]uint64
	// 桶里的下一个块的索引。
	idx uint64
	// 区块重写次数, gen is the generation of chunks.
	gen uint64
	getCalls    uint64
	setCalls    uint64
	misses      uint64
	collisions  uint64
	corruptions uint64
}
// 初始化桶
func (b *bucket) Init(maxBytes uint64) T
    // maxBucketSize uint64 = 1 << 40 1T; chunkSize = 64 * 1024
	maxChunks := (maxBytes + chunkSize - 1) / chunkSize
	b.chunks = make([][]byte, maxChunks)
	b.m = make(map[uint64]uint64)
	b.Reset()
}

使用

func New(maxBytes int) *Cache {
	var c Cache
    //maxBytes >= 0 ,bucketsCount=512
	maxBucketBytes := uint64((maxBytes + bucketsCount - 1) / bucketsCount) //桶的大小
	for i := range c.buckets[:] {
		c.buckets[i].Init(maxBucketBytes)//初始化桶
	}
	return &c
}
func (c *Cache) Set(k, v []byte) {
	h := xxhash.Sum64(k) //计算key的hash值
	idx := h % bucketsCount //计算key对应的桶
	c.buckets[idx].Set(k, v, h)
}
func (b *bucket) Set(k, v []byte, h uint64) {
 // 限定 k v 大小不能超过 2bytes 64k
 if len(k) >= (1<<16) || len(v) >= (1<<16) {
  return
 }
 // 4个byte 设置每条数据的数据头
 var kvLenBuf [4]byte
 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
 }
 b.mu.Lock()
 // 当前索引位置
 idx := b.idx
 // 存放完数据后索引的位置
 idxNew := idx + kvLen
 // 根据索引找到在 chunks 的位置
 chunkIdx := idx / chunkSize
 chunkIdxNew := idxNew / chunkSize
 // 新的索引是否超过当前索引
 // 因为还有chunkIdx等于chunkIdxNew情况,所以需要先判断一下
 if chunkIdxNew > chunkIdx {
  // 校验是否新索引已到chunks数组的边界
  // 已到边界,那么循环链表从头开始
  if chunkIdxNew >= uint64(len(b.chunks)) {
   idx = 0
   idxNew = kvLen
   chunkIdx = 0
   b.gen++
   // 当 gen 等于 1<
   // 也就是用来限定 gen 的边界为1<
   if b.gen&((1<<genSizeBits)-1) == 0 {
    b.gen++
   }
  } else {
   // 未到 chunks数组的边界,从下一个chunk开始
   idx = chunkIdxNew * chunkSize
   idxNew = idx + kvLen
   chunkIdx = chunkIdxNew
  }
  // 重置 chunks[chunkIdx]
  b.chunks[chunkIdx] = b.chunks[chunkIdx][:0]
 }
 chunk := b.chunks[chunkIdx]
 if chunk == nil {
  // 开辟块空间
  chunk = getChunk()
  // 初始化切片
  chunk = chunk[:0]
 }
 // 将数据 append 到 chunk 中
 chunk = append(chunk, kvLenBuf[:]...)
 chunk = append(chunk, k...)
 chunk = append(chunk, v...)
 b.chunks[chunkIdx] = chunk
 // 因为 idx 不能超过bucketSizeBits,所以用一个 uint64 同时表示gen和idx
 // 所以高于bucketSizeBits位置表示gen
 // 低于bucketSizeBits位置表示idx
 b.m[h] = idx | (b.gen << bucketSizeBits)
 b.idx = idxNew
 b.mu.Unlock()
}
// 块分配
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 := syscall.Mmap(-1, 0, chunkSize*chunksPerAlloc, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE)
		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[:]
}

示意图:
cache 结构,真正存放数据的地方是 chunks 二维数组.
go内存缓存 fastecache原理浅析_第3张图片
chunk 的结构
go内存缓存 fastecache原理浅析_第4张图片

1.6 总结

fasteCache应用了一些比较hack的方式规避了go 内存分配和Gc在处理大数据量上可能引发的一些性能问题。它不支持缓存过期等因此在使用时一定要结合具体的应用场景予以取舍。

你可能感兴趣的:(go内存缓存 fastecache原理浅析)