深入理解go缓存库freecache的使用

2022-07-18,

目录
  • 1 初始化
  • 2 读写流程
  • 3 总结

go开发缓存场景一般使用map或者缓存框架,为了线程安全会使用sync.map或线程安全的缓存框架。

缓存场景中如果数据量大于百万级别,需要特别考虑数据类型对于gc的影响(注意string类型底层是指针+len+cap,因此也算是指针类型),如果缓存key和value都是非指针类型的话就无需多虑了。但实际应用场景中,key和value是(包含)指针类型数据是很常见的,因此使用缓存框架需要特别注意其对gc影响,从是否对gc影响角度来看缓存框架大致分为2类:

  • 零gc开销:比如freecache或bigcache这种,底层基于ringbuf,减小指针个数;
  • 有gc开销:直接基于map来实现的缓存框架。

对于map而言,gc时会扫描所有key/value键值对,如果其都是基本类型,那么gc便不会再扫描。下面以freecache为例分析下其实现原理,代码示例如下:

func main() {
   cachesize := 100 * 1024 * 1024
   cache := freecache.newcache(cachesize)

   for i := 0; i < n; i++ {
      str := strconv.itoa(i)
      _ = cache.set([]byte(str), []byte(str), 1)
   }

   now := time.now()
   runtime.gc()
   fmt.printf("freecache, gc took: %s\n", time.since(now))
   _, _ = cache.get([]byte("aa"))

   now = time.now()
   for i := 0; i < n; i++ {
      str := strconv.itoa(i)
      _, _ = cache.get([]byte(str))
   }
   fmt.printf("freecache, get took: %s\n\n", time.since(now))
}

1 初始化

freecache.newcache会初始化本地缓存,size表示存储空间大小,freecache会初始化256个segment,每个segment是独立的存储单元,freecache加锁维度也是基于segment的,每个segment有一个ringbuf,初始大小为size/256。freecache号称零gc的来源就是其指针是固定的,只有512个,每个segment有2个,分别是rb和slotdata(注意切片为指针类型)。

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 // 索引指针
}

func newcachecustomtimer(size int, timer timer) (cache *cache) {
    cache = new(cache)
    for i := 0; i < segmentcount; i++ {
        cache.segments[i] = newsegment(size/segmentcount, i, timer)
    }
}
func newsegment(bufsize int, segid int, timer timer) (seg segment) {
    seg.rb = newringbuf(bufsize, 0)
    seg.segid = segid
    seg.timer = timer
    seg.vacuumlen = int64(bufsize)
    seg.slotcap = 1
    seg.slotsdata = make([]entryptr, 256*seg.slotcap) // 每个slotdata初始化256个单位大小
}

2 读写流程

freecache的key和value都是[]byte数组,使用时需要自行序列化和反序列化,如果缓存复杂对象不可忽略其序列化和反序列化带来的影响,首先看下set流程:

_ = cache.set([]byte(str), []byte(str), 1)

set流程首先对key进行hash,hashval类型uint64,其低8位segid对应segment数组,低8-15位表示slotid对应slotsdata下标,高16位表示slotsdata下标对应的[]entryptr某个数据,这里需要查找操作。注意[]entryptr数组大小为slotcap(初始为1),当扩容时会slotcap倍增。

每个segment对应一个lock(sync.mutex),因此其能够支持较大并发量,而不像sync.map只有一个锁。

func (cache *cache) set(key, value []byte, expireseconds int) (err error) {
   hashval := hashfunc(key)
   segid := hashval & segmentandopval // 低8位
   cache.locks[segid].lock() // 加锁
   err = cache.segments[segid].set(key, value, hashval, expireseconds)
   cache.locks[segid].unlock()
}

func (seg *segment) set(key, value []byte, hashval uint64, expireseconds int) (err error) {
   slotid := uint8(hashval >> 8)
   hash16 := uint16(hashval >> 16)
   slot := seg.getslot(slotid)
   idx, match := seg.lookup(slot, hash16, key)

   var hdrbuf [entry_hdr_size]byte
   hdr := (*entryhdr)(unsafe.pointer(&hdrbuf[0]))
   if match { // 有数据更新操作
      matchedptr := &slot[idx]
      seg.rb.readat(hdrbuf[:], matchedptr.offset)
      hdr.slotid = slotid
      hdr.hash16 = hash16
      hdr.keylen = uint16(len(key))
      originaccesstime := hdr.accesstime
      hdr.accesstime = now
      hdr.expireat = expireat
      hdr.vallen = uint32(len(value))
      if hdr.valcap >= hdr.vallen {
         // 已存在数据value空间能存下此次value大小
         atomic.addint64(&seg.totaltime, int64(hdr.accesstime)-int64(originaccesstime))
         seg.rb.writeat(hdrbuf[:], matchedptr.offset)
         seg.rb.writeat(value, matchedptr.offset+entry_hdr_size+int64(hdr.keylen))
         atomic.addint64(&seg.overwrites, 1)
         return
      }
      // 删除对应entryptr,涉及到slotsdata内存copy,ringbug中只是标记删除
      seg.delentryptr(slotid, slot, idx)
      match = false
      // increase capacity and limit entry len.
      for hdr.valcap < hdr.vallen {
         hdr.valcap *= 2
      }
      if hdr.valcap > uint32(maxkeyvallen-len(key)) {
         hdr.valcap = uint32(maxkeyvallen - len(key))
      }
   } else { // 无数据
      hdr.slotid = slotid
      hdr.hash16 = hash16
      hdr.keylen = uint16(len(key))
      hdr.accesstime = now
      hdr.expireat = expireat
      hdr.vallen = uint32(len(value))
      hdr.valcap = uint32(len(value))
      if hdr.valcap == 0 { // avoid infinite loop when increasing capacity.
         hdr.valcap = 1
      }
   }
   
   // 数据实际长度为 entry_hdr_size=24 + key和value的长度    
   entrylen := entry_hdr_size + int64(len(key)) + int64(hdr.valcap)
   slotmodified := seg.evacuate(entrylen, slotid, now)
   if slotmodified {
      // the slot has been modified during evacuation, we need to looked up for the 'idx' again.
      // otherwise there would be index out of bound error.
      slot = seg.getslot(slotid)
      idx, match = seg.lookup(slot, hash16, key)
      // assert(match == false)
   }
   newoff := seg.rb.end()
   seg.insertentryptr(slotid, hash16, newoff, idx, hdr.keylen)
   seg.rb.write(hdrbuf[:])
   seg.rb.write(key)
   seg.rb.write(value)
   seg.rb.skip(int64(hdr.valcap - hdr.vallen))
   atomic.addint64(&seg.totaltime, int64(now))
   atomic.addint64(&seg.totalcount, 1)
   seg.vacuumlen -= entrylen
   return
}

seg.evacuate会评估ringbuf是否有足够空间存储key/value,如果空间不够,其会从空闲空间尾部后一位(也就是待淘汰数据的开始位置)开始扫描(oldoff := seg.rb.end() + seg.vacuumlen - seg.rb.size()),如果对应数据已被逻辑deleted或者已过期,那么该块内存可以直接回收,如果不满足回收条件,则将entry从环头调换到环尾,再更新entry的索引,如果这样循环5次还是不行,那么需要将当前oldhdrbuf回收以满足内存需要。

执行完seg.evacuate所需空间肯定是能满足的,然后就是写入索引和数据了,insertentryptr就是写入索引操作,当[]entryptr中元素个数大于seg.slotcap(初始1)时,需要扩容操作,对应方法见seg.expand,这里不再赘述。

写入ringbuf就是执行rb.write即可。

func (seg *segment) evacuate(entrylen int64, slotid uint8, now uint32) (slotmodified bool) {
   var oldhdrbuf [entry_hdr_size]byte
   consecutiveevacuate := 0
   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)
      if oldhdr.deleted { // 已删除
         consecutiveevacuate = 0
         atomic.addint64(&seg.totaltime, -int64(oldhdr.accesstime))
         atomic.addint64(&seg.totalcount, -1)
         seg.vacuumlen += oldentrylen
         continue
      }
      expired := oldhdr.expireat != 0 && oldhdr.expireat < now
      leastrecentused := int64(oldhdr.accesstime)*atomic.loadint64(&seg.totalcount) <= atomic.loadint64(&seg.totaltime)
      if expired || leastrecentused || consecutiveevacuate > 5 {
      // 可以回收
         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 {
         // 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)
      }
   }
}

freecache的get流程相对来说简单点,通过hash找到对应segment,通过slotid找到对应索引slot,然后通过二分+遍历寻找数据,如果找不到直接返回errnotfound,否则更新一些time指标。get流程还会更新缓存命中率相关指标。

func (cache *cache) get(key []byte) (value []byte, err error) {
   hashval := hashfunc(key)
   segid := hashval & segmentandopval
   cache.locks[segid].lock()
   value, _, err = cache.segments[segid].get(key, nil, hashval, false)
   cache.locks[segid].unlock()
   return
}
func (seg *segment) get(key, buf []byte, hashval uint64, peek bool) (value []byte, expireat uint32, err error) {
   hdr, ptr, err := seg.locate(key, hashval, peek) // hash+定位查找
   if err != nil {
      return
   }
   expireat = hdr.expireat
   if cap(buf) >= int(hdr.vallen) {
      value = buf[:hdr.vallen]
   } else {
      value = make([]byte, hdr.vallen)
   }

   seg.rb.readat(value, ptr.offset+entry_hdr_size+int64(hdr.keylen))
}

定位到数据之后,读取ringbuf即可,注意一般来说读取到的value是新创建的内存空间,因此涉及到[]byte数据的复制操作。

3 总结

从常见的几个缓存框架压测性能来看,set性能差异较大但还不是数量级别的差距,get性能差异不大,因此对于绝大多数场景来说不用太关注set/get性能,重点应该看功能是否满足业务需求和gc影响,性能压测比较见:https://golang2.eddycjy.com/posts/ch5/04-performance/

缓存有一个特殊的场景,那就是将数据全部缓存在内存,涉及到更新时都是全量更新(替换),该场景下使用freecache,如果size未设置好可能导致部分数据被淘汰,是不符合预期的,这个一定要注意。为了使用freecache避免该问题,需要将size设置"足够大",但也要注意其内存空间占用。

到此这篇关于深入理解go缓存库freecache的使用的文章就介绍到这了,更多相关go freecache内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

《深入理解go缓存库freecache的使用.doc》

下载本文的Word格式文档,以方便收藏与打印。