探讨高性能 Golang KV Cache组件库的实现

603 阅读5分钟

首先定义本文里说的 KV Cache 是什么。

对本文来说,能支持对单组 Key-Value 的增删查改操作的Cache就算KVCache,最基本的 KV Cache组件库就是标准库里的map​。它基于 hash table​ 实现,增删查改的复杂度都是O(1), 但有如下问题:

  • 不支持并发读写。简单加锁性能又差
  • 无法限制Cache总大小。
  • 不支持设置过期时间

sync.Map​ 在它的基础上,提升了在读多写少场景下的并发读写性能。但它也没有更多的特性了。

一个好的KV Cache应该包括但不限于有以下特性:

  • 支持 Get, Set, Exists, Delete 操作
  • 并发安全
  • 高性能。单机、并发、各种不同的场景下的高性能
  • 能限制Cache总大小,当达到上限时自动写入失败或自动淘汰,以保护内存不会被意外吃满
  • 支持设置key的过期时间
  • 在支持上述特性的同时,空间利用率尽量高些

比较好的三方库包括但不限于 bigcache​, freecache​, groupcache​, ristretto​。

下面基于这些库总结其中好的经验。

通过分片提升并发性能

为了确保并发安全,对单个map的读写需要加锁。如果把整个Cache分为100个map, 那每次读写只有1/100的部分被锁住了。通过缩小锁的粒度可提升并发的性能。

Java的并发map就是采用了这种实现方式。它在多读和多写的场景中的性能相对更平衡。相比之下,Golang sync.map​对大量写的场景就不太适应。

可使用256个分片,再对key(或key的后n位数,或key的hash值)按256进行取模来确定进行哪个分片。

使用更快的hash函数

许多人一提到hash就只知道用md5,但md5由于是设计用来进行数据摘要的,其性能相比于一些专门设计来生成hash key的算法要差了很多。因此实际上不会用md5

现在比较常用的有murmur3​, fnv​, cityhash/farmhash​, 。 近几年比较火的有 xxhash​, wyhash

使用更紧凑的结构存数据

如果只分片, 那么缓存组件就是由256个 map[string]interface{}​ 组成。但是,更多的库选择不直接将数据存在map中,而是存到另一个更加紧凑的数据中,然后通过map[string]uint64​ 或map[string]entry​等方式,只将这个数据的“定位信息”存到map中。 查询时,先找到分片对应的map, 再从map中获取这个数据的“定位信息”,再从专门的数据结构中找到最终的数据)。

为什么要在中间多转一层呢?这是为了提升性能,降低Golang的GC负担。因为Golang的GC需要排序全部元素,所以当类型是map[xxx]*struct​,且value的结构又比较复杂时,GC上的负担会明显上升。先确保用到map的地方只有map[string]uint64​的话,无论存的结构体有多复杂,对GC的影响都不至于太大。

请注意,上面说map[string]uint64​或map[string]entry​也是为了方便理解。实际上许多库的map key也使用uint64, 因为它们用的hash function输出的结果就是一个uint64。 这样性能就比string更高了。

而对于“紧凑的数据结构”,也有不同的做法。

bigcache​采用的方法是预先在内存中分配一大块空间,把其中每个数据的位置信息存到map中。

freecache​的实现是一个环形链表,每个数据都有一个索引

还有些库是直接使用一个 [][]byte​ 或[]interface{}​ ,再把数组下标存到map中。

无论最终选择了哪种结构,使用这种方式一定比直接使用map[string]interface{}​ 在调用时要慢,因为无论如何也是中间多了一层。 这种做法仍是主流的原因还是考虑Golang GC,因为作为一个通用库,需要能适应各种情况,自然也包括当存的值是一个复杂strut时的情况。

但是也有例外,如ristretto​ 就是直接使用map[uint64]item​,

缓存过期时间

如果要实现这个功能,一般是把数据先封装成一个 entry​结构体,其中包含一个 expireAt​ 字段。创建时对expireAt​进行赋值,每次查询时再根据当前时间判断是否过期(懒查询)。

请注意是在查询时才判断,而非通过定时任务遍历的方法主动去查询。

Redis中有一个定时随机检查缓存是否过期的逻辑,如果在这方面有要求的话,可以参考。

容量限制 & 缓存淘汰

进行限制的目的是避免缓存过大把内存撑爆。因此不需要多精确。

可以通过限制Cache中数据的总数量的方式进行限制(freecache), 或对不同的数值赋一个粗略的权重,通过数量x权重的方式来估算(ristretto的做法)

bigcache​​中由于从一开始就分配了一大块固定尺寸的空间,天然就支持了对容量上限的限制。

而对于缓存淘汰,最简单的是LRU,一般需要额外维护一个数组。近年比较火的是TinyLFU, Ristretto​中就使用了TinyLFU。