首先定义本文里说的 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。