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

被折叠的 条评论
为什么被折叠?



