提到本地缓存大家都不陌生,只要是个有点经验的后台开发人员,都知道缓存的作用和弊端。本篇文章我们就来简单聊聊在 golang 做业务开发的过程中,本地缓存的一些可选的开源方案。分析它们的特点,以及内部的实现原理。

1.本地缓存需求分析

首先来梳理一下业务开发过程中经常面临的本地缓存的一些需求。我们一般做缓存就是为了能提高系统的读写性能,缓存的命中率越高,也就意味着缓存的效果越好。其次本地缓存一般都受限于本地内存的大小,所有全量的数据一般存不下。那基于这样的场景一方面是想缓存的数据越多,则命中率理论上也会随着缓存数据的增多而提高;另外一方面是想,既然所有的数据存不下那就想办法利用有限的内存存储有限的数据。这些有限的数据需要是经常访问的,同时有一定时效性(不会频繁改变)的。基于这两个点展开,我们一般对本地缓存会要求其满 足支持过期时间、支持淘汰策略。最后再使用自动管理内存的语言例如 golang 等开发时,还需要考虑在加入本地缓存后引发的 GC 问题。

分析完我们日常本地缓存的诉求,再结合我们日常开发用到的 golang 语言,我们可以提炼得到 golang 本地缓存组件必须具备以下几个能力:

分析清楚了我们的需求,也明确了我们需要的能力。那自然优先考虑 golang 内置的标准库中是否存在这样的组件可以直接使用呢?

很遗憾,没有。golang 中内置的可以直接用来做本地缓存的无非就是 map 和 sync.Map。而这两者中,map 是非并发安全的数据结构,在使用时需要加锁;而 sync.Map 虽然是线程安全的。但是需要在并发读写时加锁。此外二者均无法支持数据的过期和淘汰,同时在存储大量数据时,又会产生比较频繁的 GC 问题,更严重的情况下导致线上服务无法稳定运行。

既然标准库中没有我们满足上述需求的本地缓存组件,那我们就想只有两种解决方案了

  1. 业界是否有开源成熟的方案可供选择
  2. 业界无可用组件时,自己动手写一个

那首先面临的第一个问题就是方案的调研和选型,没有合适的方案时自己再来动手构建。下面我们就来给大家介绍下 golang 中哪些可以直接来使用的本地缓存组件吧。

2.golang 本地缓存组件概览

golang 中本地缓存方案可选的有如下一些:

下面通过笔者一段时间的调研和研究,将 golang 可选的开源本地缓存组件汇总为下表,方便大家在方案选型时作参考。

在上述方案中,freecache、bigcache、fastcache、ristretto、groupcache 这几个大家根据实际的业务场景首选,offheap 有定制需求时可考虑。

通过上表的总结,个人想再此再谈几点关于本地缓存组件的理解:
1.上述本地缓存组件中,实现零 GC 的方案主要就两种:
a.无 GC:分配堆外内存(Mmap)
b.避免 GC:map 非指针优化(map[uint64]uint32)或者采用 slice 实现一套无指针的 map
c.避免 GC:数据存入[]byte slice(可考虑底层采用环形队列封装循环使用空间)

2.实现高性能的关键在于:
a.数据分片(降低锁的粒度)

上述几种缓存组件每种组件的实现都是 1 和 2 的几个分支的组合。下面我们大概给大家介绍每种组件的核心原理。

3. 主流缓存组件实现原理剖析

在本节中我们会重点分析下 freecache、bigcache、fastcache、offheap 这几个组件内部的实现原理。

3.1 freecache 实现原理

首先分析下 freecache 的内部实现原理。在 freecache 中它通过 segment 来进行对数据分片,freecache 内部包含 256 个 segment,每个 segment 维护一把互斥锁,每一条 kv 数据进来后首先会根据 k 进行计算其 hash 值,然后根据 hash 值决定当前的这条数据落入到哪个 segment 中。

对于每个 segment 而言,它由索引、数据两部分构成。

索引:其中索引最简单的方式采用 map 来维护,例如 map[uint64]uint32 这种。而 freecache 并没有采用这种做法,而是通过采用 slice 来底层实现一套无指针的 map,以此避免 GC 扫描。

数据:数据采用环形缓冲区来循环使用,底层采用[]byte 进行封装实现。数据写入环形缓冲区后,记录写入的位置 index 作为索引,读取时首先读取数据 header 信息,然后再读取 kv 数据。

在 freecache 中数据的传递过程是:freecache->segment->(slot,ringbuffer)
下图是 freecache 的内部实现框架图。

总结: freecache 通过利用数据分片减小锁的粒度,然后再存储时索引并没有采用内置的 map 来维护而是采用自建 map 减少指针来避免 GC,同时数据存储时采用预先分配内存然后后边循环使用。通过上述两种方法保证了在堆上分配内存同时减少 GC 对系统性能的影响。

3.2 bigcache 实现原理

bigcache 和 freecache 类似,也是一个零 GC、高性能的 cache 组件,但是它的实现和 freecache 还是有些差异,这儿有篇介绍 bigcache 设计原理的,内容稍长感兴趣的可以阅读下,下面我们介绍一下 bigcache 的实现原理。

bigcache 同样是采用分片的方式构成,一个 bigcache 对象包含 2^n 个 cacheShard 对象,默认是 1024 个。每个 cacheShard 对象维护着一把 sync.RWLock 锁(读写锁)。所有的数据会分散到不同的 cacheShard 中。

每个 cacheShard 同样由索引数据构成。索引采用 map[uint64]uint32 来存储,数据采用 entry([]byte)环形队列存储。索引中存储的是该条数据在 entryBuffer 写入的位置 pos。每条 kv 数据按照 TLV 的格式写入队列。

不过值得注意的是,和 bigcache 和 freecache 不同的一点在于它的环形队列可以自动扩容。同时 bigcache 中数据的过期是通过全局的时间窗口维护的,每个单独的 kv 无法设置不同的过期时间。

下面是 bigcache 的内容实现原理框架图。

总结:bigcache 思路和 freecache 大体相同,只不过在索引存储时更为巧妙,直接采用内置的 map 结构加上基础数据类型来实现。同时底层存储数据的队列也可以根据空间大小来决定是否扩容。唯一的缺陷是无法针对每个 key 进行设置不同的过期时间。这个个人认为如果想用 bigcache 同时想要这个特性,可以进行二次开发一下。

通过来看,bigcache 性能要比 freecache 稍微好一点。大家可以思考下他们性能的差异可能会在哪里呢?

3.3 fastcache 实现原理

本节介绍下 fastcache 的实现原理,根据 fastcache 官方文档介绍,它的灵感来自于 bigcache。所以整体的思路和 bigcache 很类似,数据通过 bucket 进行分片。fastcache 由 512 个 bucket 构成。每个 bucket 维护一把读写锁。在 bucket 内部数据同理是索引、数据两部分构成。索引用 map[uint64]uint64 存储。数据采用 chunks 二维的切片(二维数组)存储。不过值得注意的是 fastcache 有一个很大的特性是,它的内存分配是在堆外分配的,而不是在堆上分配的。堆外分配的内存。这样做也就避免了 golang GC 的影响。下图是 fastcache 内部实现框架图。

总结: fastcache 一方面充分利用了分片来降低锁的粒度,另一方面在索引存储时采用了对 map 的优化,同时在分配内存时,直接从堆外申请内存,自己实现了分配和释放内存的逻辑。通过上述手段使得 GC 的影响降到了最低。fastcache 唯一的缺陷是官方提供的版本没有提供针对 kv 数据的过期时间这个特性。所以如果需要这个特性的话,需要自己动手二次开发。整体从性能上来看是比 bigcache 和 freecache 都更优。

3.4 offheap 实现原理

本节介绍下 offheap 的相关内容,offheap 其实功能就比较简单了,就是一个基于堆外内存构建的哈希表。它通过直接调用系统调用函数来分配内存。然后在内部通过数组来实现哈希表。实现过程中当发生哈希冲突时,它是采用探测法来解决。由于是在堆外分配的内存上构建的哈希表。导致它的 GC 开销非常的小。下图是 offheap 的内部实现框架图。

总结:offheap 内部由于是采用探测法解决哈希冲突的,所以当哈希冲突严重时数据删除、查询都会带来非常复杂的处理流程。而且性能也会有一些损耗。可以作为学习和研究的项目还是非常不错的。

4.总结

本文主要从日常需求出发,分析了日常业务过程中对本地缓存的需求,再调研了业界可选的一些组件并进行了对比,希望对本地缓存选型上起到一些参考和帮助。最后再对其中比较重要的几个组件如 freecache、bigcache、fastcache、offheap 等做了内部实现的简单介绍。上述内容只是从架构层面展开介绍,后续有时间再从源码层面做一些分析。由于篇幅限制本篇内容并未对 map、sync.Map、go-cache、groupcache 进行介绍。感兴趣的读者可以自行搜索资料进行阅读。如果大致理解了上述原理的童鞋也可以自己动手实践起来,造个轮子看看。

5.参考资料

  1. Go 语言入门学习之 Groupcache 源码分析