Golang KV缓存库大对比:Bigcache vs Freecache vs Fastcache vs Ristretto

1,487 阅读6分钟

背景知识

本文需要对Golang和KV缓存相关知识有一定了解,最好读过相关源码。也可以把本文当作读这几个库的源码前的导读。

相关知识我在另一篇文章(探讨高性能 Golang KV Cache组件库的实现 - 掘金 (juejin.cn))里也有讨论。因此这里先只简单过一下基础知识。

最简单的KV库自然是标准库里的map, 但它的问题是:

  • 并发读写性能差。它的并发读写只能通过加锁实现,这样性能就极大下降
  • 占内存大&GC负载高。 如果要做成通用KV, 只能选择 map[string]interface{}​。这样当存的数据多了之后占的内存比较多,因为map本身就是空间换时间的数据结构;而且一旦存的值是一个结构体指针,其GC负载会非常高。
  • 无法限制尺寸&没有淘汰机制,数据会一直增长下去。

性能上稍进阶一些的选择是标准库里的sync.Map​,或者自行实现类似于java中的ConcurrentHashMap​ 的分片式map,即预先进行分片(32/64/128...),每一个片里是一个map+单独的锁, 通过key的hash再取余,定位到唯一的一个分片上。

其中, sync.Map​ 更适合于读多写少的场景,或键的频率分布更“自然”(比如符合zipf分布)的场景,因为它的实现思路是读写分离,专门提供一个不需要加锁的只读部分。 而分片map在各种场景下的性能会更均衡。

它们极大提升了并发的读写性能,但还是无法解决上面所述的后两个问题。

一般来说,为了实现上述需求,会有如下实现方式,需要将它们组合起来:

  • 分片式的map, 其原理是通过减小锁的粒度来提升并发性能。
  • 预先分配一块连续的内存空间来存储数据,并使用map记录每个数据在上面的位置+长度。这样可以提升查询性能和GC性能。
  • 避免使用 map[str]interface{}​。一般是使用 map[string]uint64​ , map的值是这个数据在上述内存空间里的地址或索引。因为map[string]*struct{}​ 这种结构对GC非常不友好,每次都要嵌套式遍历完整个map里每一个结构体。
  • 键值的过期时间:一般是给每一个存的值封装成一个带有时间戳的结构体,比如struct{Value interface{}, ExpireAt int64}​, 写入时记录其过期时间, 之后进行lazy式的删除,也就是只在读取时才判断是否过期。
  • 使用更快的hash函数。如果在这里还用md5的话性能上就太差了。而bigcache中使用的fnv虽然已经快多了,但还是有更快的选择。关于hash,请参考本人的这篇文章(一些 hash function 基本知识个人总结 - 掘金 (juejin.cn))

下面以Golang中的主流KV库为例,对他们相关技术点进行对比研究。

四个主流KV库

名称GithubStar
BigCachegithub.com/allegro/bigcach6.8k
FreeCachegithub.com/coocood/freecache4.7k
FastCachegithub.com/VictoriaMetrics/fastcache1.9k
Ristrettogithub.com/dgraph-io/ristretto4.9k

下面我们一个一个说:

BigCache

它是其中star数最多的一个。而且这个库的作者把它的实现思路完完整整分享了出来: Writing a very fast cache service with millions of entries in Go · allegro.tech

核心思路是通过把数据存到连续的内存空间里,并用map记录其索引,来减少对GC的负载。

实现方法: 分片 + map of index + queue

分片: 使用一个数组实现分片,每个键hash后取余,进入不同的分片。

map of index: key是索引值,value是数据在内存空间里的索引。

queue: 即前面说的内存空间,但是实现为了一个 queue of bytes的结构。每个分片都有一个独立的queue。

一次读取的过程: 通过hash找到对应分片 -> 查map找到索引 -> 使用索引从内存中取数据

对它性能的评价:

相比于简单的分片map, 性能已经非常好了,但你可能没想到,它还是这四个库里性能最差的一个。 因为先用map查索引,再用索引查数据,两次查询,相比下面其它几个库的实现还是慢了。

FreeCache

最老的一个。

其核心思路也是把数据连续的内存空间里。但它并不需要map, 而是完全使用对索引的操作来实现的。当然,它的查询复杂度并非是O(1), 不过一个非常轻的O(n) 其实是比一个比较重的O(1)要快的。可以参考本人的文章。

实现方法: 分片+ringbuffer

可见它与bigcache的思路非常相近。但它由于用一个O(n)的小遍历操作代替了map存索引,能实现比bigcache稍高的读写性能(当然,在Bigcache自己的benchmark里,它是比bigcache慢的)和相近的GC负载。但代价是需要预分配内存。

因此,如果只是对Bigcache和Freecache进行比较,bigcache或许是一个更普适的选择。

Fastcache

实现方法: 还是分片​ ​+ map+ ringbuf。

它相当于在Bigcache的基础上做了一些小优化,放弃了对key过期的管理。那么它为什么比前两者的性能都要高呢?原因如下:

  • 使用xxhash代替fnv (更快的hash函数)
  • 放弃了对过期时间的处理。
  • 精简其它功能带来的性能提升
  • 针对大value的特殊处理,使它可以节省更多内存。 固定一个chunk的大小,将大value拆成多个chunk,并记录这个key下面所有chunk的索引。这样可以使内存的利用率更高。否则,对chunk大小的选择只能照顾最大的值,造成较大的内存浪费

Ristretto

其中读写性能最快的一个。关于它更详细的介绍,可以看官方这篇文章 Introducing Ristretto: A High-Performance Go Cache - Dgraph Blog

思路: 它并没有选择使用一个连续的内存空间,而是简单使用了分片+map。

这样使得它省去了一次通过索引再取数据的操作。

然后它又基于Count-Min sketch实现了Tiny-LFU的淘汰策略, 在理论上比普通LRU和LFU性能和效果上都更好。

它在hash function上也有优化——如果是数字则不进行额外的hash, 否则使用xxhash或标准库hash。这样又相比只使用xxhash更快了一点。

那么GC呢? 确实,由于没有使用连续内存分配的方式,它在GC负载上会明显比其它几个要高。这就是另一个权衡了:要更高的读写,还是要更低的GC负载。或者换句话来说:使用这个库所带来的读写性能提升,能不能cover它造成的GC性能损失。

另外,由于Bigcache和Freecache诞生的时间比较早,当时Golang GC性能比较差,使用这种连续大内存的设计确实能获得比较大的性能收益。现在Golang GC性能极大提升,相比之下收益就没那么明显了。

总结

重性能轻GC, 选Ristretto

重GC但不需要过期功能: 选Fastcache

重GC也需要过期功能:选bigcache