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为最外层的存储服务对象,其内部维护多个数据存储的分片。

Loading...

初始化时根据配置创建多个(默认1024)分片。在初始化过程中还会创建一个用于定期淘汰数据的goroutine,定期去各个shard中删除已过期的数据。

Shard

分片(Shard)为核心数据机构,负责实际的数据存储,数据结构如下:

Loading...
  • entries:实际的数据存储空间,底层为一大块连续的[]byte 。细节在BytesQueue部分介绍。
  • hashmap:即hash表,用于快速定位key所在entries的位置(偏移)。

存储时首先会将首先会将用户写入的数据(key+value)打包(Pack/Encode)到一块连续的内存(代码中称为 Entry ),为了查找、遍历等操作,里面包含了key、value数据外还有部分元数据。

Entry 打包过程如下

Loading...

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中。

写操作的整体流程如下:

Loading...

设计要点

  • 由于使用 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 ,所以空间不会被释放,而是设置删除标志位。

数据结构定义

Loading...
  • full:是否已满。已满时需要扩容,扩容大小超过最大限制时无法写入。
  • array:使用make分配的存储空间。即实际数据的引用[]byte
  • capacity、maxCapacity:当前、最大容量。存储空间按当前*2倍大小扩容。注意不会缩容。
  • head:Ringbuf链表逻辑头部节点,优先从此节点开始淘汰。
  • tail:Ringbuf逻辑尾部节点,新增的节点会被追加自此。
  • rightMargin:Ringbuf中最右侧的节点。用于标识Ringbuf空间的物理尾部。

因为head淘汰后的空间是可以被重复利用的,所以Ringbuf会存在三种状态

  1. head < tail:此时head和tail之间是连续的。head ~ tail之间为有效数据(可能含delete节点),header之前为已过期,tail之后为剩余空间。
  2. head > tail:表明head和tail之间非连续,数据物理上被拆分成了两块,即[tail, rightMargin]之间 和 [leftMarginIndex, tail]之间。
  3. head = tail:可能有两种情况。节点为0表示为空。也可能刚好存储完最后一个节点,空间已满。

所以插入和删除的操作的重点就是copy数据本身和移动head、tail指针位置。理解其思路之后,再看代码就简单了。

 

写逻辑

Loading...

删逻辑:查找Entry节点,然后设置标记位。代码略。

清理逻辑:从header节点清理,同时右移head地址。当移动到物理最尾部的节点时又调整回物理头部节点leftMarginIndex。

Loading...

扩容

索引使用map,所以不用考虑。主要考虑BytesQueue的扩容。当tail节点之后的剩余空间不足,而且head之前的空间也不足时(数据没有删除、也没有evict)时需要扩容。

💡
这里不排查head和tail之间已经有部分节点被用户主动删除了,但是这里没有类似malloc的buddy allocator回收机制,所以此部分空间当前只能浪费,形成一些内存碎片。只有当evict的逻辑移动到此节点时这部分内存才会被回收。

扩容的逻辑也比较简单,就是按capacity的倍速来扩容。但是需要考虑当前存储的数据的迁移,除了直接将老的Ringbuf数据copy过去之外,还需要考虑一种特殊情况:

tail > head,即逻辑tail节点位于head之前。此时tail ~ head之间的空间是不足以存储下一个节点的,而新增节点时一定会追加到tail节点之后的,所以如果不做调整,那么扩容出来的空间仍然无法使用。

这里有两种解决方案:

  1. 将老Ringbuf中的两段数据按序分别copy到新的Ringbuf,这样可以保障在新Ringbuf中还是会严格按照FIFO的逻辑执行清理逻辑。问题是会有两次copy。
  2. bigcache中实际采用的方法比较粗暴,将tail ~ head之间的空间浪费掉,塞入一个空间点。然后一次性copy到新的ringbuf种,并将head调整为起始位置,tail调整为rightMargin。显然这里会导致Ringbuf中扩容后copy过去的数据不再是严格按照FIFO的方式排列了,但是减少了一次copy。
Loading...

BenchMark

本地(M1机器)跑了下benchmark,对比golang各种存储lib的压测性能数据如下:

Loading...

Set

Cache TypeOperationIterationsns/opB/opallocs/op
MapSetForStruct3844106189469704719746
SyncMapSetForStruct16053049876185200369778
OracamanMapSetForStruct28651773481122694020195
FreeCacheSetForStruct12913669389698454140290
BigCacheSetForStruct14603221069376897542460
MapSetForBytes33271495231209656129749
SyncMapSetForBytes13953501797313440780034
OracamanMapSetForBytes22342211788298955230277
FreeCacheSetForBytes16033452192786527230540
BigCacheSetForBytes15102774104465003132713

结论

  • 非并发场景下BigCache相对于原生的Map要差(50%+),与SyncMap差不多。因为有锁操作。Struct操作对比更明显,因为BigCache需要将其序列化后存储。
  • alloc的次数相差不大。虽然bigcache中BytesQueue对内存有一定的预分配,但是此处效果不明显。而且相对Map应该也有锁操作的影响。

Get

Cache TypeKey TypeIterationsns/opB/opallocs/op
MapStruct42625320115.5231
MapBytes42207109125.4231
SyncMapStruct37394498127.9231
SyncMapBytes36241047128.8231
OracamanMapStruct34276094142.7231
OracamanMapBytes33538933145.0231
FreeCacheStruct9026136521.42637
FreeCacheBytes20102252240.11352
BigCacheStruct9558199493.22798
BigCacheBytes24627061198.71513

结论:

  • 非并发场景下BigCache相对于原生的Map要差(50%-),比SyncMap也要差。应该主要受序列化的影响。

SetParallel

Cache TypeKey TypeIterationsns/opB/opallocs/op
SyncMapStruct8729923479.6705
OracamanMapStruct47110821109.4302
FreeCacheStruct37602955131.7544
BigCacheStruct4238678799.241794
SyncMapBytes8266707604.91986
OracamanMapBytes41447361116.91413
FreeCacheBytes34734166135.01413
BigCacheBytes40498724147.06463

结论

  • 写并发场景下BigCache相对于SyncMap性能要优(400%+)。因为存在分片的缘故。

GetParallel

Cache TypeKey TypeIterationsns/opB/opallocs/op
SyncMapStruct12196932734.86231
OracamanMapStruct10000000043.17231
FreeCacheStruct30949936157.22637
BigCacheStruct31723624149.32798
SyncMapBytes14351513031.83231
OracamanMapBytes10000000047.13231
FreeCacheBytes5002866295.231352
BigCacheBytes6272135573.631513

结论

  • 读并发场景下BigCache相对于SyncMap性能要差(50%),Struct存在序列化时差距效果更明显。猜测是因为仅有读锁的情况下二者的逻辑差别不大。

结语

bigCache的特点非常明显,优势和劣势总结如下:

优势

  • 数据分片+读写锁,支持并行写入
  • 性能高效,无GC
  • 支持自动扩容

劣势

  • 不支持LRU淘汰。这个对热点数据比较明显的场景会是个大问题。热点数据淘汰可能会导致cache命中率严重拉低。
  • 无法自动缩容。
  • 如果有频繁的delete,而evict执行速度跟不上,可能会导致内存漏洞较多。内存利用率不高。

其他可优化点:看代码的过程中,也看到了一些可以优化的点。

  • 减少copy:写入BytesQueue时多次用到临时存储,先拼装buf然后写入,这里可以优化为一次写入,减少cpu损耗。
  • get场景减少内存分配,或者使用sync.Pool。内存分配方式应该由上层决定。

参考

 

See all postsSee all posts