深入理解Freecache

本文详细探讨了Golang本地缓存库freecache的设计与实现,包括其优势、与Golang Map的性能对比,以及内部的set操作、过期与删除策略、entry索引等关键部分。freecache采用接近LRU的置换算法,支持高并发线程安全访问,并具有零GC开销,但存在不能动态扩容和非完全LRU等问题。

1. 简介

1.1 使用背景

  由于项目要求,需要利用缓存技术来降低API延迟,所以选择freecache来作为本地缓存实现。这个开源框架是纯Golang写的,本人出于好奇、安全性考察等因素,就仔细的研究了一下代码实现。项目的代码量不多,但是设计的很巧妙,值的好好研究和学习。

1.2 freecache介绍

  freecache是Golang版的本地缓存库,从github项目介绍看,freecache具有以下的优势:

  • 能存储数亿条记录(entry) 。
  • 零GC开销。
  • 高并发线程安全访问。
  • 纯Golang代码实现。
  • 支持记录(entry)过期。
  • 接近LRU的替换算法。
  • 严格限制内存的使用。
  • 提供一个测试用的服务器,支持一些基本 Redis 命令。
  • 支持迭代器。

1.3 freecache与Golang Map对比

  github官网给出的数据来看,set操作的性能是Golang内置Map的两倍,get操作是Golang内置Map的1/2。不过这个测试数据是单线程基准测出来的,在高并发的情况下,对比单锁保护的内置Map来说,性能会快好几倍。

1.4 freecache版本

  本篇文章的内容都是基于freecache v1.1.1版本的代码来进行介绍的。

2. freecache内部设计

2.1 freecache整体架构设计

可以通过一下get(key)操作,来了解freecahe的架构设计。

freecache整体架构设计

  • freecache将缓存划分为256个segment,对于一个key的操作,freecache通过hash方法(xxhash)计算得到一个64位的hashValue,并取低8位作为segId,定位到具体的segment,并对segment加锁。由于只对segment加锁,不同segment之间可以并发进行key操作,所以freecache支持高并发线程安全访问。
const (
	// segmentCount represents the number of segments within a freecache instance.
	segmentCount = 256
	// segmentAndOpVal is bitwise AND applied to the hashVal to find the segment id.
	segmentAndOpVal = 255
	......
)
// Cache is a freecache instance.
type Cache struct {
	locks    [segmentCount]sync.Mutex  // 每个segment都有自己的同步控制锁
	segments [segmentCount]segment     // 缓存划分为256个segment
}

// xxhash算法,算出64位哈希值
func hashFunc(data []byte) uint64 {
	return xxhash.Sum64(data)
}

......

// Get returns the value or not found error.
func (cache *Cache) Get(key []byte) (value []byte, err error) {
	// 1. 算出key的64位哈希值
	hashVal := hashFunc(key)
	// 2. 取低8位,得到segId
	segID := hashVal & segmentAndOpVal
	// 找到对应的segment,只对segment加锁
	// 同个segment的操作是串行进行,不同segment的操作是并行进行的
	cache.locks[segID].Lock()
	value, _, err = cache.segments[segID].get(key, nil, hashVal, false)
	cache.locks[segID].Unlock()
	return
}

......

  • segment底层实际上是由两个切片组成的复杂数据结构,其中一个切片用来实现环形缓冲区RingBuf,存储了所有的entry (entry=24 byte header + key + value)。另一个切片则是用于查找entry的索引切片slotData,slotData被逻辑上切分为256个slot,每个slot上的entry索引都是按照hash16有序排列的。可以看出,不管freecache缓存了多少数据,底层永远都只会有512个指针,所以freecache的对GC开销几乎为零。
// segment.go文件

type segment struct {
	// 环形缓冲区RingBuf,由一个固定容量的切片实现
	rb            RingBuf
	segId         int
	_             uint32
	missCount     int64
	hitCount      int64
	entryCount    int64
	totalCount    int64
	totalTime     int64
	timer         Timer
	totalEvacuate int64
	totalExpired  int64
	overwrites    int64
	touched       int64
	vacuumLen     int64
	slotLens      [256]int32
	slotCap       int32 
    // entry索引切片,容量动态扩展
	slotsData     []entryPtr
}

// ringbuf.go文件

type RingBuf struct {
	begin int64
	end   int64
	// 存储了所有entry
	// 每个entry由三部分组成:24个字节的头部header、key、value
	data  []byte
	index int
}

2.2 set操作分析

2.2.1 流程图

  这里我给出了set操作的代码流程图,主要的流程都已经画了出来,如果还想了解更多实现细节,可以结合源码进行理解。
在这里插入图片描述
  对于一个key的set操作,首先判断key是否存在,不存在的情况处理比较简单,直接追加到环尾;如果存在的话,则看一下原来为entry预留的value容量是否充足,充足的话,直接覆盖,否则删掉原来的entry,并将新的entry追加到环尾,新的entry会给value预留多一点空间。

2.2.2 set操作为什么高效

  • 采用二分查找,极大的减少查找entry索引的时间开销。slot切片上的entry索引是根据hash16值有序排列的,对于有序集合,可以采用二分查找算法进行搜索,假设缓存了n个key,那么查找entry索引的时间复杂度为log2(n * 2^-16) = log2(n) - 16。
  • 对于key不存在的情况下(找不到entry索引)。
    • 如果Ringbuf容量充足,则直接将entry追加到环尾,时间复杂度为O(1)。
    • 如果RingBuf不充足,需要将一些key移除掉,情况会复杂点,后面会单独讲解这块逻辑,不过freecache通过一定的措施,保证了移除数据的时间复杂度为O(1),所以对于RingBuf不充足时,entry追加操作的时间复杂度也是O(1)。
  • 对于已经存在的key(找到entry索引)。
    • 如果原来给entry的value预留的容量充足的话,则直接更新原来entry的头部和value,时间复杂度为O(1)。
    • 如果原来给entry的value预留的容量不足的话,freecache为了避免移动底层数组数据,不直接对原来的entry进行扩容,而是将原来的entry标记为删除(懒删除),然后在环形缓冲区RingBuf的环尾追加新的entry,时间复杂度为O(1)。

2.2.3 近乎LRU的entry置换算法

  前面介绍可以发现,freecache追加新entry时候,如果RingBuf的可用容量不足时,会从环头开始,通过近乎LRU的置换算法,将旧数据删掉,空出足够的空间出来,具体流程如一下流程图所示:
在这里插入图片描述
  这部分流程可以在segment的evacuate方法找到,这里我们主要关注这一段代码leastRecentUsed := int64(oldHdr.accessTime)*atomic.LoadInt64(&seg.totalCount) <= atomic.LoadInt64(&seg.totalTime)

func (seg *segment) evacuate(entryLen int64, slotId uint8, now uint32) (slotModified bool) {
	
	......
	
	// RingBuf容量不足的情况
	for seg.vacuumLen < entryLen {
		
		......
		
		// entry是否过期
		expired := oldHdr.expireAt != 0 && oldHdr.expireAt < now
		// LRU entry最近使用情况
		leastRecentUsed := int64(oldHdr.accessTime)*atomic.LoadInt64(&seg.totalCount) <= atomic.LoadInt64(&seg.totalTime)
		if expired || leastRecentUsed || consecutiveEvacuate > 5 {
			
			// entry如果已经过期了,或者满足置换条件,则删除掉entry
			......
			
		} else {
			// 如果不满足置换条件,则将entry从环头调换到环尾
			newOff := seg.rb.Evacuate(oldOff, int(oldEntryLen))
			// 更新entry的索引
			seg.updateEntryPtr(oldHdr.slotId, oldHdr.hash16, oldOff, newOff)
			......
		}
	}
	return
}

  如何理解这行代码呢?换个写法,可能容易理解一点:
int64(oldHdr.accessTime) <= atomic.LoadInt64(&seg.totalTime)/atomic.LoadInt64(&seg.totalCount)

  • oldHdr.accessTime:entry最近一次访问的时间戳。
  • seg.totalCount:RingBuffer中entry的总数,包括过期和标记删除的entry。
  • seg.totalTime:RingBuffer中每个entry最近一次访问的时间戳总和。

......

 // entry header struct in ring buffer, followed by key and value.
 // entry头部信息,24个字节的开销
type entryHdr struct {
	accessTime uint32 // entry最近一次访问的时间戳
	expireAt   uint32 // entry过期时间点
	keyLen     uint16 // key的长度
	hash16     uint16 // entry的hash16值
	valLen     uint32 // value长度
	valCap     uint32 // entry为value预留的容量,valLen <= valCap 
	deleted    bool   // entry删除标记
	slotId     uint8  // entry索引所在slot的id
	reserved   uint16 // 预留字段,2个字节
}

......

// 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 {
	......
	totalCount    int64      // number of entries in ring buffer, including deleted entries.
	totalTime     int64      // used to calculate least recent used entry.
	......
}

  所以atomic.LoadInt64(&seg.totalTime)/atomic.LoadInt64(&seg.totalCount)表示RingBuf中的entry最近一次访问时间戳的平均值,当一个entry的accessTime小于等于这个平均值,则认为这个entry是可以被置换掉的。这里我简单的总结一下freecache的entry置换算法:

  • 最理性的情况下,即消息不过期、没有消息被标记删除、key被set进去之后,就没有再被访问过,在这种情况下,确实可以完全满足LRU算法,不过这种情况是不会发生的。
  • freecache选择将accessTime小于等于平均accessTime的entry进行置换,从大局来看,确实是将最近较少使用的缓存置换出去,从某种程度来将,是一种近LRU的置换算法。
  • freecache为什么不完全实现LRU置换算法呢?如果采用hash表+数组来实现LRU算法,维护hash表所带来的空间开销先不说,找出来的entry在环中的位置还是随机的,这种随机置换会产生空间碎片,如果要解决碎片问题性能将会大打折扣。如果不采用hash表来实现,则需要遍历所有entry索引,而且同样也会产生空间碎片。
  • 在特殊情况下,环头的数据都比较新时,会导致一直找不到合适的entry进行置换,空出足够的空间,为了不影响set操作的性能,当连续5次出现环头entry不符合置换条件时,第6次置换如果entry还是不满足置换条件,也会被强制置换出去。

2.2.4 BingBuf追加entry的例子讲解

  假设某一时刻RingBuf的初始状态如下所示:

在这里插入图片描述

  下一个时刻,set了一个新的key,经过判断,需要追加一个新的entry6 (1599652990),大小为300B,由于RingBuf的容量还是充足,直接追加到环尾就行了,如下图所示:

在这里插入图片描述

  接着又来了一个新的key,经过判断,需要追加一个新的entry7 (1599652991),大小为400B,这次RingBuf的容量不足了,需要置换环头的旧数据,空出足够的空间,置换过程如下:

在这里插入图片描述

2.3 过期与删除实现

2.3.1 key过期

  • 对于过期的数据,freecache会让它继续存储在RingBuf中,RingBuf从一开始初始化之后,就固定不变了,是否删掉数据,对RingBuf的实际占用空间不会产生影响。
  • 当get到一个过期缓存时,freecache会删掉缓存的entry索引(但是不会将缓存从RingBuf中移除),然后对外报ErrNotFound错误。
  • 当RingBuf的容量不足时,会从环头开始遍历,如果key已经过期,这时才会将它删除掉。
  • 如果一个key已经过期时,在它被freecache删除之前,如果又重新set进来(过期不会主动删除entry索引,理论上有被重新set的可能),过期的entry容量充足的情况下,则会重新复用这个entry。
  • freecache这种过期机制,一方面减少了维护过期数据的工作,另一方面,freecache底层存储是采用数组来实现,要求缓存数据必须连续,缓存过期的剔除会带来空间碎片,挪动数组来维持缓存数据的连续性不是一个很好的选择。

2.3.2 key删除

  • freecache有一下两种情况会进行删除key操作:
    • 外部主动调用del接口删除key。
    • set缓存时,发现key已经存在,但是为entry预留的cap不足时,会选择将旧的数据删掉,然后再环尾追加新的数据。
  • freecache的删除机制也是懒删除,删除缓存时,只会删掉entry索引,但是缓存还是会继续保留在RingBuf中,只是被标记为删除,等到RingBuf容量不足需要置换缓存时,才会对标记为删除的缓存数据做最后的删除工作。
  • freecache删除一个key,需要搜索entry索引和标记缓存数据,搜索entry索引的时间复杂度前面已经分析过了,为O(log2(n) - 16),而标记缓存数据的时间复杂度为O(1),所以删除操作性能上还是挺不错的。

2.4 entry索引

2.4.1 前提说明

  256个slot底层其实是共用同一个entry索引切片seg.slotsData,下面的所有图文描述的数组下标值,都是站在整个entry索引切片seg.slotsData看的,描述的结果可能会和freecache源码计算得到的结果不一致,不过不影响我们理解entry索引相关操作的原理。如果要和代码实际计算的值对应上,在entry索引切片没有扩容之前,可以减掉1024就是代码里slot的下标值;在扩容之后,减掉2048就是代码里slot的下标位置。

2.4.2 entry索引二分查找实现

2.4.2.1 源码分析

  entry索引二分查找实现最关键的源码为以下这两个函数,请参考注解给出的源码分析。

// 二分查找实现,找到第一个entryPtr.hash16 >= hash16的entry索引下标
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
}

// slot的entryPtr按照hash16从小到大排序,存在哈希冲突,可能会存在多个hash16值一样的entryPtr。
// 查询算法大致如下:
// 1. 调用entryPtrIdx()找到第一个entryPtr.hash16 >= hash16的索引下标(二分查找):idx。
// 2. 存在哈希冲突,可能会存在多个hash16值一样的entryPtr,需要判断是否命中key的entry索引:
//    2.1 如果slot[idx].hash16 != hash16,找不到key的entry索引,查询结束,返回idx和false;
//        否则继续往下执行。
//    2.2 如果slot[idx].keyLen != len(key), 哈希冲突,slot[idx]不是key的entry索引,idx++,
//        重新返回到2.1;否则继续往下执行。
//    2.3 读取slot[idx]索引所指entry的key值,如果不等于我们要找的key,出现哈希冲突,slot[idx]
//        不是key的entry索引,idx++,重新返回到2.1;否则说明找到了索引,返回idx和true。
func (seg *segment) lookup(slot []entryPtr, hash16 uint16, key []byte) (idx int, match bool) {
	idx = entryPtrIdx(slot, hash16)
	for idx < len(slot) {
		ptr := &slot[idx]
		if ptr.hash16 != hash16 {
			break
		}
		match = int(ptr.keyLen) == len(key) && seg.rb.EqualAt(key, ptr.offset+ENTRY_HDR_SIZE)
		if match {
			return
		}
		idx++
	}
	return
}
2.4.2.1 图文分析

  假设某一时刻entry索引切片seg.slotsData的状态如下:

在这里插入图片描述
  如果我们要找key(2, 1, “b”),带入源码,其查询过程如下所示,最终将返回1025和true。

在这里插入图片描述
  如果我们要找的key(2, s, “bb”)呢?16位的hash16允许哈希冲突的存在,查找key(2, s, “bb”)的索引过程,除了hash16要对上,所指向entry的key长度和值也都要一样。最终可以看到,其实找不到key(2, s, “bb”)的索引,如下所示,最终将返回1026和false。

在这里插入图片描述

2.4.3 插入新的entry索引

  freecache每set一个新的key,都会创建对应的entry索引,并添加到索引切片相应的slot上。前面已经介绍过set操作的流程,假设我们要set一个key(3, 2, “cc”),会调用lookup方法判断这个key(3, 2, “cc”)是否已经存在了,查找key(3, 2, “cc”)索引的过程如下图所示,最终lookup将会返回1029和false。

在这里插入图片描述
  根据前面的介绍,由于key(3, 2, “cc”)不存在entry索引,说明这个key不存在,可以直接追加新的entry到环尾,并在slot上插入对应的entry索引。
  我们知道slot上的entry索引都是按照hash16值有序排列的,那么要在那个位置进行插入呢?如何插入呢?
  如果仔细观察查找key(3, 2 “cc”)的索引的过程,可以发现,当lookup返回true时,返回的下标是key(3, 2 “cc”)的entry索引在slot中的位置;当lookup返回false时,返回的下标是第一个hash16值大于key(3, 2 “cc”)的hash16值的entry索引下标,这个下标值正是新的entry索引要插入的位置。
  要插入的位置找到了,那如何插入呢?为了保证slot上entry索引按照hash16值有序排列,freecache在插入新的entry索引之前,会将插入位置及之后的数据向后移动一个位置,让再将新的entry索引插入到对应的位置。

在这里插入图片描述

2.4.3 entry索引切片的自动扩容机制

  接着上面,假设又set了一个新的key(3, 2, “cd”),经过二分查找搜索,发现是一个新的key(3, 2, “cd”),追加对应的entry到RingBuf之后,需要插入新的entry索引,插入位置为1030。
  这时,不能简单的将1030及之后的索引往后挪动一个位置,要知道,256个slot都是共用一个索引切片的seg.slotsData的,当slot满了时,如果直接往后挪动一个位置,会覆盖掉下一个slot的数据,导致数据错乱。
  对此,当slot容量满时,freecache会进行扩容,只不过是整个索引切片的容量扩到原来的两倍,然后将每个slot上的数据挪动新的位置,之后在重新将新的entry索引插入进去。

func (seg *segment) insertEntryPtr(slotId uint8, hash16 uint16, offset int64, idx int, keyLen uint16) {
	// slot满了时,整个entry索引切片进行扩容
	if seg.slotLens[slotId] == seg.slotCap {
		seg.expand()
	}
	// 添加新的entry索引
	seg.slotLens[slotId]++
	atomic.AddInt64(&seg.entryCount, 1)
	slot := seg.getSlot(slotId)
	// slot上,idx当前位置的entry索引及之后的所有entry索引都往后挪动一个位置
	copy(slot[idx+1:], slot[idx:])
	// 插入新的entry索引
	slot[idx].offset = offset
	slot[idx].hash16 = hash16
	slot[idx].keyLen = keyLen
}


func (seg *segment) expand() {
	newSlotData := make([]entryPtr, seg.slotCap*2*256)
	for i := 0; i < 256; i++ {
		off := int32(i) * seg.slotCap
		copy(newSlotData[off*2:], seg.slotsData[off:off+seg.slotLens[i]])
	}
	seg.slotCap *= 2
	seg.slotsData = newSlotData
}

3. 总结

3.1 freecache的不足

  • 需要一次性申请所有缓存空间。用于实现segment的RingBuf切片,从缓存被创建之后,其容量就是固定不变的,申请的内存也会一直被占用着,空间换时间,确实避免不了。
  • freecache的entry置换算法不是完全LRU,而且在某些情况下,可能会把最近经常被访问的缓存置换出去。
  • entry索引切片slotsData无法一次性申请足够的容量,当slotsData容量不足时,会进行空间容量x2的扩容,这种自动扩容机制,会带来一定的性能开销。
  • 由于entry过期时,不会主动清理缓存数据,这些过期缓存的entry索引还会继续保存slot切片中,这种机制会加快entry索引切片提前进行扩容,而实际上除掉这些过期缓存的entry索引,entry索引切片的容量可能还是完全充足的。
  • 为了保证LRU置换能够正常进行,freecache要求entry的大小不能超过缓存大小的1/1024,而且这个限制还不给动态修改,具体可以参考github上的issues

3.2 使用freecache的注意事项

  • 缓存的数据如果可以的话,大小尽量均匀一点,可以减少RingBuf容量不足时的置换工作开销。
  • 缓存的数据不易过大,这样子才能缓存更多的key,提高缓存命中率。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值