背景知识
本文需要对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库
名称 | Github | Star |
---|---|---|
BigCache | github.com/allegro/bigcach | 6.8k |
FreeCache | github.com/coocood/freecache | 4.7k |
FastCache | github.com/VictoriaMetrics/fastcache | 1.9k |
Ristretto | github.com/dgraph-io/ristretto | 4.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