golang BigCache源码解析
July 5, 2024
golang的本地缓存方案有不少,如freecache、bigcache、groupcache等。很久前看过groupcache的源码,对其singleflight的机制印象挺深的,但是印象中没有对gc做特殊的优化。最近刚好看到了了bigcache的介绍,其内部实现重点考虑了gc对于性能的影响。所以翻出其代码快速阅读了一遍其核心思路,特此记录。
网上相关的介绍已经不少了,这里重点介绍几个核心设计点。
背景
bigcache的设计背景可以见原团队的这篇文章。
简单总结其需求,要求实现以下几点:
- 高性能。
- 支持并发访问。
- 支持淘汰,包括过期淘汰和空间慢时的老数据清理。
所以在设计方案上做了如下考虑:
- 提供lib集成:不考虑外部缓存方案,如redis、memcached等。而是采用lib的方式可以集成到服务自身。减少网络IO等带来的延迟。仓库内部也提供了一个简单的server实现。
- 数据分区:支持并行访问,数据存储上做了分区。高效的缓存基本都是基于hash,hash表的并发写操作都是要加锁的。为支持并发,所以存储拆分为多个区块。根据key的hash值将数据打散(shard)到多个区块中,减少锁的影响。而在每一个区块中,都维护了一个map来作为核心hash表,shard内对hash表的写操作仍需要加锁。
- 淘汰策略:最常见的方案就是采用类似memcached的LRU方案,在内存中维护一个LUR的双向链表。每次访问都修会改item中对应的双向链表的指针,将其位置放到链表最前端。淘汰时优先从链表尾部开始删除item。而BigCache采用FIFO的方式,不允许每个key指定不同的过期时间。
淘汰会带来gc的问题,在内存分配、释放频率高的情况下会出现gc pause。缓解、优化的方法有很多,比如修改gc频率、使用sync.Pool等,zero采用最直接的办法去掉gc,即Zero GC。具体实现:
- 索引:使用map[uint64]uint64方式存储,避免gc。其中key、value分别为原始key的hash值和在数据存储buf中的偏移量。
- 数据:使用类似FIFO方式数据结构。从尾部append数据,从头部evict数据。当尾部空间写满时从头部开始复用存储空间。删除数据时仅设置标记位,不真正清除。
核心设计点
BigCache
BigCache为最外层的存储服务对象,其内部维护多个数据存储的分片。
初始化时根据配置创建多个(默认1024)分片。在初始化过程中还会创建一个用于定期淘汰数据的goroutine,定期去各个shard中删除已过期的数据。
Shard
分片(Shard)为核心数据机构,负责实际的数据存储,数据结构如下:
- entries:实际的数据存储空间,底层为一大块连续的[]byte 。细节在BytesQueue部分介绍。
- hashmap:即hash表,用于快速定位key所在entries的位置(偏移)。
存储时首先会将首先会将用户写入的数据(key+value)打包(Pack/Encode)到一块连续的内存(代码中称为 Entry ),为了查找、遍历等操作,里面包含了key、value数据外还有部分元数据。
Entry 打包过程如下:
Entry由以下几部分组成:
- 元数据
- timestamp(8):用于判断过期
- hash(8):key的hash值,同时也承担起标识位的功能(删除时置0)。
- key: len(2) + key string(不定长):类似TLV(Type Length Value)结构,先存储key长度,再存储实际key string。
- entry:即value。这里没有记录value的长度,因为Entry在写入shard之前还会有一个字段(类似protobuf的varint编码),记录Entry的总长。
Entry 构建好后会被写入存储BytesQueue中,同时也会将 hashkey 和 BytesQueue中的位置记录到hashmap中。
写操作的整体流程如下:
设计要点:
- 由于使用 map[uint64]uint64 的方式存储索引,没有使用开链或者开放地址等方法来解决hash冲突的问题,所以当遇到hash冲突时只能先清理老节点,然后插入新节点。
- 除了定时器删除外,插入过程中也会尝试清理部分数据。尽可能保存较高的内存利用率。在redis、memcached等中间件中也有类似操作,将耗时较长的逻辑(比如hash扩容&迁移)打散到各个API中调度,避免一次长时间的阻塞。
- 写入失败时(空间不足)一定会删除老数据,底层为FIFO,所以被清理的节点为最早写入的数据。
读取和删除的流程就比较简单了。
- 读:先根据hashmap定位Entry在BytesQueue中的位置,然后比较key,解析value即可。
- 删:BytesQueue中的Entry会被打上删除标识(hash设置为0)。索引hashmap中的值会被删除,但是不会导致gc。
BytesQueue
BytesQueue 的设计为bigcache中稍复杂的部分了,但其整体思路还是很简单的,整体思路采用类似ringbuf的FIFO数据结构,其逻辑结构大致如下:
BytesQueue 由一块 []byte 组成,用于连续存储一系列的数据节点。分别有两个指针head、tail用于指向链表的逻辑头尾节点。而leftMarginIndex、rightMarginIndex指向ringBuf在[]byte 中的物理开始、结束位置,即记录存储实际占用的空间范围。
- 每个节点存储的是一个varint编码的Entry长度和Entry数据,所以每节点长度是变长的。Entry在 []byte 中的起始位置(偏移量)即为hashmap中的value。
- 新数据节点会被Append到tail节点,空间不足、过期淘汰时优先淘汰head节点数据。
- head和tail之间的节点也有可能被用户主动删除,由于是一块连续的 []byte ,所以空间不会被释放,而是设置删除标志位。
数据结构定义:
- full:是否已满。已满时需要扩容,扩容大小超过最大限制时无法写入。
- array:使用make分配的存储空间。即实际数据的引用[]byte
- capacity、maxCapacity:当前、最大容量。存储空间按当前*2倍大小扩容。注意不会缩容。
- head:Ringbuf链表逻辑头部节点,优先从此节点开始淘汰。
- tail:Ringbuf逻辑尾部节点,新增的节点会被追加自此。
- rightMargin:Ringbuf中最右侧的节点。用于标识Ringbuf空间的物理尾部。
因为head淘汰后的空间是可以被重复利用的,所以Ringbuf会存在三种状态:
- head < tail:此时head和tail之间是连续的。head ~ tail之间为有效数据(可能含delete节点),header之前为已过期,tail之后为剩余空间。
- head > tail:表明head和tail之间非连续,数据物理上被拆分成了两块,即[tail, rightMargin]之间 和 [leftMarginIndex, tail]之间。
- head = tail:可能有两种情况。节点为0表示为空。也可能刚好存储完最后一个节点,空间已满。
所以插入和删除的操作的重点就是copy数据本身和移动head、tail指针位置。理解其思路之后,再看代码就简单了。
写逻辑:
删逻辑:查找Entry节点,然后设置标记位。代码略。
清理逻辑:从header节点清理,同时右移head地址。当移动到物理最尾部的节点时又调整回物理头部节点leftMarginIndex。
扩容
索引使用map,所以不用考虑。主要考虑BytesQueue的扩容。当tail节点之后的剩余空间不足,而且head之前的空间也不足时(数据没有删除、也没有evict)时需要扩容。
扩容的逻辑也比较简单,就是按capacity的倍速来扩容。但是需要考虑当前存储的数据的迁移,除了直接将老的Ringbuf数据copy过去之外,还需要考虑一种特殊情况:
tail > head,即逻辑tail节点位于head之前。此时tail ~ head之间的空间是不足以存储下一个节点的,而新增节点时一定会追加到tail节点之后的,所以如果不做调整,那么扩容出来的空间仍然无法使用。
这里有两种解决方案:
- 将老Ringbuf中的两段数据按序分别copy到新的Ringbuf,这样可以保障在新Ringbuf中还是会严格按照FIFO的逻辑执行清理逻辑。问题是会有两次copy。
- bigcache中实际采用的方法比较粗暴,将tail ~ head之间的空间浪费掉,塞入一个空间点。然后一次性copy到新的ringbuf种,并将head调整为起始位置,tail调整为rightMargin。显然这里会导致Ringbuf中扩容后copy过去的数据不再是严格按照FIFO的方式排列了,但是减少了一次copy。
BenchMark
本地(M1机器)跑了下benchmark,对比golang各种存储lib的压测性能数据如下:
Set
Cache Type | Operation | Iterations | ns/op | B/op | allocs/op |
Map | SetForStruct | 3844 | 1061894 | 697047 | 19746 |
SyncMap | SetForStruct | 1605 | 3049876 | 1852003 | 69778 |
OracamanMap | SetForStruct | 2865 | 1773481 | 1226940 | 20195 |
FreeCache | SetForStruct | 1291 | 3669389 | 6984541 | 40290 |
BigCache | SetForStruct | 1460 | 3221069 | 3768975 | 42460 |
Map | SetForBytes | 3327 | 1495231 | 2096561 | 29749 |
SyncMap | SetForBytes | 1395 | 3501797 | 3134407 | 80034 |
OracamanMap | SetForBytes | 2234 | 2211788 | 2989552 | 30277 |
FreeCache | SetForBytes | 1603 | 3452192 | 7865272 | 30540 |
BigCache | SetForBytes | 1510 | 2774104 | 4650031 | 32713 |
结论:
- 非并发场景下BigCache相对于原生的Map要差(50%+),与SyncMap差不多。因为有锁操作。Struct操作对比更明显,因为BigCache需要将其序列化后存储。
- alloc的次数相差不大。虽然bigcache中BytesQueue对内存有一定的预分配,但是此处效果不明显。而且相对Map应该也有锁操作的影响。
Get
Cache Type | Key Type | Iterations | ns/op | B/op | allocs/op |
Map | Struct | 42625320 | 115.5 | 23 | 1 |
Map | Bytes | 42207109 | 125.4 | 23 | 1 |
SyncMap | Struct | 37394498 | 127.9 | 23 | 1 |
SyncMap | Bytes | 36241047 | 128.8 | 23 | 1 |
OracamanMap | Struct | 34276094 | 142.7 | 23 | 1 |
OracamanMap | Bytes | 33538933 | 145.0 | 23 | 1 |
FreeCache | Struct | 9026136 | 521.4 | 263 | 7 |
FreeCache | Bytes | 20102252 | 240.1 | 135 | 2 |
BigCache | Struct | 9558199 | 493.2 | 279 | 8 |
BigCache | Bytes | 24627061 | 198.7 | 151 | 3 |
结论:
- 非并发场景下BigCache相对于原生的Map要差(50%-),比SyncMap也要差。应该主要受序列化的影响。
SetParallel
Cache Type | Key Type | Iterations | ns/op | B/op | allocs/op |
SyncMap | Struct | 8729923 | 479.6 | 70 | 5 |
OracamanMap | Struct | 47110821 | 109.4 | 30 | 2 |
FreeCache | Struct | 37602955 | 131.7 | 54 | 4 |
BigCache | Struct | 42386787 | 99.24 | 179 | 4 |
SyncMap | Bytes | 8266707 | 604.9 | 198 | 6 |
OracamanMap | Bytes | 41447361 | 116.9 | 141 | 3 |
FreeCache | Bytes | 34734166 | 135.0 | 141 | 3 |
BigCache | Bytes | 40498724 | 147.0 | 646 | 3 |
结论:
- 写并发场景下BigCache相对于SyncMap性能要优(400%+)。因为存在分片的缘故。
GetParallel
Cache Type | Key Type | Iterations | ns/op | B/op | allocs/op |
SyncMap | Struct | 121969327 | 34.86 | 23 | 1 |
OracamanMap | Struct | 100000000 | 43.17 | 23 | 1 |
FreeCache | Struct | 30949936 | 157.2 | 263 | 7 |
BigCache | Struct | 31723624 | 149.3 | 279 | 8 |
SyncMap | Bytes | 143515130 | 31.83 | 23 | 1 |
OracamanMap | Bytes | 100000000 | 47.13 | 23 | 1 |
FreeCache | Bytes | 50028662 | 95.23 | 135 | 2 |
BigCache | Bytes | 62721355 | 73.63 | 151 | 3 |
结论:
- 读并发场景下BigCache相对于SyncMap性能要差(50%),Struct存在序列化时差距效果更明显。猜测是因为仅有读锁的情况下二者的逻辑差别不大。
结语
bigCache的特点非常明显,优势和劣势总结如下:
优势
- 数据分片+读写锁,支持并行写入
- 性能高效,无GC
- 支持自动扩容
劣势
- 不支持LRU淘汰。这个对热点数据比较明显的场景会是个大问题。热点数据淘汰可能会导致cache命中率严重拉低。
- 无法自动缩容。
- 如果有频繁的delete,而evict执行速度跟不上,可能会导致内存漏洞较多。内存利用率不高。
其他可优化点:看代码的过程中,也看到了一些可以优化的点。
- 减少copy:写入BytesQueue时多次用到临时存储,先拼装buf然后写入,这里可以优化为一次写入,减少cpu损耗。
- get场景减少内存分配,或者使用sync.Pool。内存分配方式应该由上层决定。
参考