freecache介绍

1,932 阅读4分钟

什么是freecache

github.com/coocood/fre…
Long lived objects in memory introduce expensive GC overhead, With FreeCache, you can cache unlimited number of objects in memory without increased latency and degraded throughput.

freecache是一个高性能本地缓存系统,它有以下特性:

  • 通过优秀的内存管理方案,实现了 go 语言的零 gc
  • 线程安全,锁粒度较小,支持高并发
  • 支持设置过期时间,动态逐出过期缓存
  • 模拟了lru淘汰算法,会在容量不足的时候优先淘汰较少访问的数据

这几个优秀特性使得他非常适合用在生产环境中作为本地缓存。

 // 用法示例 
cacheSize := 1024 * 1024 //1MB 
cache := freecache.NewCache(cacheSize) 
key := []byte("abc") 
val := []byte("def") 
expire := 60 // expire in 60 seconds 
cache.Set(key, val, expire) 
got, err := cache.Get(key) 

设计

整体架构

  • go语言原生的map有一个问题,当map的元素较多时,gc造成的性能损失会变得很大。当map元素超过千万时,单次gc造成的停顿达百毫秒以上(Large maps cause significant GC pauses
  • freecache绕过了原生map,通过优秀的内存管理方案实现了零GC
  • cache被分为256个segment。这样当写入某个key时,只需要锁定单个segment。这个设计主要是为了减少锁粒度,增加并发性能。
  • 每个segement拥有1个ringbuffer和256个slots,slots存储索引,ringbuffer存储数据

segment

// a segment contains 256 slots, a slot is an array of entry pointers ordered by hash16 value
// the entry can be looked up by hash value of the key.
type segment struct {
rb RingBuf  // ring buffer that stores data
segId int
_ uint32
missCount int64
hitCount int64
entryCount int64
totalCount int64  // number of entries in ring buffer, including deleted entries.
totalTime int64  // used to calculate least recent used entry.
timer Timer  // Timer giving current time
totalEvacuate int64  // used for debug
totalExpired int64  // used for debug
overwrites int64  // used for debug
touched int64  // used for debug
vacuumLen int64  // up to vacuumLen, new data can be written without overwriting old data.
slotLens [256]int32  // The actual length for every slot.
slotCap int32  // max number of entry pointers a slot can hold.
slotsData []entryPtr  // shared by all 256 slots
}

segment是freecache的精髓,是真正索引和存储数据的地方。freecache的具体key/value都是存在ringbuffer中,索引存在slotsData中。
freecache做到0GC,主要是segement的设计有以下特点:

  • 减少指针使用
    • 使用slotsData模拟指针
    • 每个segment只有rb/slotsData两个slice会用到指针,全局看共有2*256共512个指针
  • 减少内存申请
    • 初始化时申请固定大小的内存作为ringbuffer,循环使用

slotsData

// entry pointer struct points to an entry in ring buffer 
// 不包含具体的key value信息 
type entryPtr struct { 
   offset   int64  // entry offset in ring buffer 
 hash16   uint16 // entries are ordered by hash16 in a slot. 
 keyLen   uint16 // used to compare a key 
 reserved uint32 
} 
  • slotsData是[]entryPtr,用于存索引
  • 每个entry可以理解为一个key-value pair,entryPtr是一个指向ringbuffer某一位置的指针
  • 每个segement包含256个slots,每个slots内部的entryPtr是按照hash16排序的
    • 拆分256个slots是为了减少每个slot下的entryPtr个数,从而提升索引的效率

ringbuffer

// Ring buffer has a fixed size, when data exceeds the 
// size, old data will be overwritten by new data. 
// It only contains the data in the stream from begin to end 
type RingBuf struct { 
   begin int64 // beginning offset of the data stream. 
 end   int64 // ending offset of the data stream. 
 data  []byte 
   index int //range from '0' to 'len(rb.data)-1' 
} 

ringbuffer逻辑上是一个环,具体实现是一大块连续的内存空间([]byte),当写满时会从头部开始淘汰。如下图,尾部空间容量不足,头部的A被淘汰,新写入的D有一部分存在头部。

 
// entry header struct in ring buffer, followed by key and value. 
type entryHdr struct { 
   accessTime uint32// 上一次访问时间,用于淘汰策略 
   expireAt   uint32// 过期时间 
   keyLen     uint16// key长度 
   hash16     uint16// hash值 
   valLen     uint32// value长度 
   valCap     uint32// value容量,这里的设计跟go slice设计类似,超出容量双倍扩容 
   deleted    bool// 用于惰性删除 
   slotId     uint8 
   reserved   uint16 
} 

具体在ringbuffer中存的是key-value pair数据,每个key-value pair存在ringbuffer中分为3个部分,entryHdr/key/value,entryHdr存了长度,hash等信息
无法复制加载中的内容

set/get流程

 
func entryPtrIdx(slot []entryPtr, hash16 uint16) (idx int) { 
   high := len(slot) 
   for idx < high { 
      mid := (idx + high) >> 1 
      oldEntry := &slot[mid] 
      if oldEntry.hash16 < hash16 { 
         idx = mid + 1 
      } else { 
         high = mid 
      } 
   } 
   return 
} 
 
func (seg *segment) lookup(slot []entryPtr, hash16 uint16, key []byte) (idx int, match bool) { 
   //二分查找hash16 
   idx = entryPtrIdx(slot, hash16) 
   //找到了对应的hash16,并不代表找到了真正的key,顺序对比具体的key 
   for idx < len(slot) { 
      ptr := &slot[idx] 
      if ptr.hash16 != hash16 { 
         break 
      } 
      //seg.rb.EqualAt的具体意义是在ringbuffer中的某段数据是否跟参数相等 
      //在这里用来check是否找到了真正的key 
      match = int(ptr.keyLen) == len(key) && seg.rb.EqualAt(key, ptr.offset+ENTRY_HDR_SIZE) 
      if match { 
         return 
      } 
      idx++ 
   } 
   return 
} 
 
以上代码是从slot中找到对应的entryPtr,大致流程如下 
  • 二分查找hash16
  • 顺序对比真正的key是否equal
    • hash16相等并不代表key相等

    • 可能会发生hash碰撞

淘汰策略

func (seg *segment) evacuate(entryLen int64, slotId uint8, now uint32) (slotModified bool) { 
   var oldHdrBuf [ENTRY_HDR_SIZE]byte 
   consecutiveEvacuate := 0 
   //循环淘汰队伍头部的entry,只到腾出足够空间 
   for seg.vacuumLen < entryLen { 
      oldOff := seg.rb.End() + seg.vacuumLen - seg.rb.Size() 
      seg.rb.ReadAt(oldHdrBuf[:], oldOff) 
      oldHdr := (*entryHdr)(unsafe.Pointer(&oldHdrBuf[0])) 
      oldEntryLen := ENTRY_HDR_SIZE + int64(oldHdr.keyLen) + int64(oldHdr.valCap) 
      //首先判断entry是否已被删除 
      //freecache的删除是惰性删除,只是把元素标记为delete,直到空间不足时才真正执行删除操作 
      if oldHdr.deleted { 
         consecutiveEvacuate = 0 
         atomic.AddInt64(&seg.totalTime, -int64(oldHdr.accessTime)) 
         atomic.AddInt64(&seg.totalCount, -1) 
         seg.vacuumLen += oldEntryLen 
         continue 
      } 
      // 判断entry是否过期 
      expired := oldHdr.expireAt != 0 && oldHdr.expireAt < now 
      // 判断entry最近是否被使用 
      leastRecentUsed := int64(oldHdr.accessTime)*atomic.LoadInt64(&seg.totalCount) <= atomic.LoadInt64(&seg.totalTime) 
      if expired || leastRecentUsed || consecutiveEvacuate > 5 { 
         // 过期或者不常用的entry被淘汰 
         seg.delEntryPtrByOffset(oldHdr.slotId, oldHdr.hash16, oldOff) 
         if oldHdr.slotId == slotId { 
            slotModified = true 
         } 
         consecutiveEvacuate = 0 
         atomic.AddInt64(&seg.totalTime, -int64(oldHdr.accessTime)) 
         atomic.AddInt64(&seg.totalCount, -1) 
         seg.vacuumLen += oldEntryLen 
         if expired { 
            atomic.AddInt64(&seg.totalExpired, 1) 
         } else { 
            atomic.AddInt64(&seg.totalEvacuate, 1) 
         } 
      } else { 
         // 常用entry不淘汰,移到队伍尾部 
         // evacuate an old entry that has been accessed recently for better cache hit rate. 
         newOff := seg.rb.Evacuate(oldOff, int(oldEntryLen)) 
         seg.updateEntryPtr(oldHdr.slotId, oldHdr.hash16, oldOff, newOff) 
         consecutiveEvacuate++ 
         atomic.AddInt64(&seg.totalEvacuate, 1) 
      } 
   } 
   return 
} 

ringbuffer可以理解为一个循环队列,空间不足时需要淘汰队伍头部的entry。freecache的淘汰策略不是简单的FIFO,而是通过记录上一次的访问时间,优先淘汰最近未被使用的entry,实现了类似LRU的效果。

Reference