什么是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的效果。