0%

应用缓存具有两种形式:缓存服务和进程内缓存。在大部分情景下,缓存服务已经可以解决应用缓存问题;但由于缓存服务需要经过网络通信,其性能一般是不如进程内缓存的。当性能要求较高时,就需要考虑采取进程内缓存的方案。并且,进程内缓存可以直接寻址,因此不仅可以缓存字符串,还可以缓存更加复杂的数据结构。不过同样地,进程内缓存也具有其缺点,诸如维护较为困难、缓存难以共享、影响进程内其他模块性能等。

一般而言,设计进程内缓存主要需要考虑以下几个方面的问题:

  1. 应用于高并发、高性能场景,需要较高的并发性能;
  2. 内存管理策略,如缓存淘汰机制,防止占用过多资源;
  3. CPU 占用低,不影响进程内其他模块的性能;
  4. 存储灵活多变的数据结构,如会话 token 等。

freecache 和 bigcache 是两个比较出色的使用 go 语言的进程内缓存的开源项目;另外 bigcache 还有一个衍生类项目 fastcache。下文将从进程内缓存需要考虑的几个问题出发,对比着几个项目的异同点。

实现高并发

freecache 和 bigcache 中实现高并发的策略是相同的:哈希表分片来减小锁的粒度。

map-slice.png

由于哈希表的时间复杂为 O(1),因此进程内缓存应当首选哈希表为数据结构;但由于哈希表并不是一个线程安全的数据结构,必须加锁来保护,并发的线程数量较多时,直接对哈希表进行加锁性能会非常差。哈希表分片是通过哈希值的方式将大哈希表拆分为多个小哈希表,这样只需要对每一个小哈希表进行加锁保护即可,这种方式降低了锁粒度,可以大幅度提升并发性能。这种处理比较常见。

CPU 占用(GC)

由于 go 是一种 GC 语言,进程内缓存就不得不考虑垃圾回收对性能的影响。在 go 中,使用指针、哈希表等非内存连续的结构会导致 GC 的时间大幅度增加,freecache 和 bigcache 中的设计都考虑到了这两点。虽然工程实现上有所区别,但是两者的核心思想都是相同的:使用线性数据结构来存储键值对,利用哈希表来存储地址偏移量,从而避免 GC 耗时。使用简单的代码就可以描述其原理:

1
2
3
4
5
6
7
8
origin := make(map[string][]byte)
// 写入方式
value := origin["key"]

better := make(map[int]int)
slice := make([][]byte,100)
// 写入方式
value := slice[better[hash("key")]]

freecache 中并没有使用 go 自建的哈希表作为索引,而是使用哈希函数加 slice 的形式自行搭建了一个哈希表;bigcache 中则是直接使用了 go 自建的哈希表作为索引。不考虑哈希函数优劣的前提下,freecache 这里的设计更好,因为只需要计算一次哈希值即可,而 bigcache 中则需要计算两次哈希值。

在线性存储结构方面的区别主要在于,bigcache 是支持自动扩容的(使用拷贝的方法)。

数据结构的支持

C++ 实现的进程内缓存通常都支持存储多种数据结构,不仅可以存储字符串、还可以存储更为复杂的数据结构;在网络服务器中,就可以使用本地缓存来存储 tcp 连接的描述符。而 go 实现的进程内缓存一般只支持存储字符串,复杂的数据结构只能够经过序列化后存储在缓冲中,这一方面是因为 go 在 1.18 版本前并不支持泛型,另一方面是因为 go GC 机制的存在。

理论上,不使用泛型,只使用interface{}也是可以实现存储不同的数据类型的,并且不会给 GC 带来额外的压力;但是问题在于,如果存储的数据结构比较复杂的话(比如数据结构中存在指针),也会大幅度影响 GC 的性能。使用以下实验分别测试在 1000W 数据量情况下,go GC 的时间;以下几组测试分别在不同的进程中完成,不存在相互的影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 简单数据类型,不含指针
type simple struct{
a int
b int
c int
}
// 复杂数据类型,含有指针
type complex struct{
s1 *simple
s2 *simple
}

// 1000W int 数据类型
s1 := make([]int,10000000);
// 1000W 简单数据结构,接口存储
s2 := make([]any,10000000);
// 1000W 简单数据结构,指针存储
s3 := make([]*simple,10000000);
// 1000W 复杂数据结构,接口存储
s4 := make([]any,10000000);

四组的 GC 时间在 go 1.19 版本经过测试后,分别如下:

1
2
3
4
s1 GC time: 328 us
s2 GC time: 336 us
s3 GC time: 5228 us
s4 GC time: 19552 us

对比 s2 和 s4 可以看到,当数据量比较大时,内部含有指针的数据结构所需要耗费的 GC 时间甚至达到了约 20ms,相比于简单数据结构增长了 58 倍左右;这是一个比较可观的延迟。另外,对比 s1、s2 和 s3 三组实验,在切片中使用指针同样会导致全量的 GC 扫描,否则会导致 GC 时间大幅度增加。

由以上的实验,我们可以得出两个结论,go 实现的进程内缓存,如果要存储复杂数据结构,就必须使用非指针形式存储,并且要求数据结构内部尽量不存在过多指针,否则会导致性能大幅度降低。但这会带来一个问题:复杂的数据结构本身占用的内存可能会较大,每次写入和读出缓存时都会涉及到一次全量复制,这是非常耗时的一个过程。

针对这一点,我觉得可以结合sync.Pool的思路来实现零拷贝写入:在进程内缓存内部设置一个 hook 函数,用户可以通过设置 hook 来控制缓存所存储的数据类型,当用户需要时,直接将对象构造在缓存的内部,然后返回用户指针,这样就可以节约写入时的拷贝。并且,缓存内部失效的数据结构也可以被复用,可以节约对象构造的时间。

性能对比

根据 bigcache README 中的 benchmark 测试结果,freecache 在性能上会比 bigcache 略低,这里贴出 v3.1.0 版本,bigcache 主页的测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go version
go version go1.13 linux/amd64

go test -bench=. -benchmem -benchtime=4s ./... -timeout 30m
goos: linux
goarch: amd64
pkg: github.com/allegro/bigcache/v3/caches_bench
BenchmarkMapSet-8 12999889 376 ns/op 199 B/op 3 allocs/op
BenchmarkConcurrentMapSet-8 4355726 1275 ns/op 337 B/op 8 allocs/op
BenchmarkFreeCacheSet-8 11068976 703 ns/op 328 B/op 2 allocs/op
BenchmarkBigCacheSet-8 10183717 478 ns/op 304 B/op 2 allocs/op
BenchmarkMapGet-8 16536015 324 ns/op 23 B/op 1 allocs/op
BenchmarkConcurrentMapGet-8 13165708 401 ns/op 24 B/op 2 allocs/op
BenchmarkFreeCacheGet-8 10137682 690 ns/op 136 B/op 2 allocs/op
BenchmarkBigCacheGet-8 11423854 450 ns/op 152 B/op 4 allocs/op
BenchmarkBigCacheSetParallel-8 34233472 148 ns/op 317 B/op 3 allocs/op
BenchmarkFreeCacheSetParallel-8 34222654 268 ns/op 350 B/op 3 allocs/op
BenchmarkConcurrentMapSetParallel-8 19635688 240 ns/op 200 B/op 6 allocs/op
BenchmarkBigCacheGetParallel-8 60547064 86.1 ns/op 152 B/op 4 allocs/op
BenchmarkFreeCacheGetParallel-8 50701280 147 ns/op 136 B/op 3 allocs/op
BenchmarkConcurrentMapGetParallel-8 27353288 175 ns/op 24 B/op 2 allocs/op
PASS
ok github.com/allegro/bigcache/v3/caches_bench 256.257s

两者的代码结构其实比较类似,差别之处主要在于过期键的清理、哈希表的实现方面;肉眼是无法观察出两者的优劣的,使用 pprof 工具来分析 benchmark 时 freecache 的 CPUProfile,得到以下的结果。

QQ20230127-195003.png

根据火焰图,可以看出 freecache 的写入流程中,函数segment.lookup()Timer.Now()两个函数的耗时时间是比较长的。其中segment.loopup()函数的主要功能是处理哈希冲突,火焰图中大规模出现该函数证明了 freecache 中的哈希函数是略微欠缺打磨的,并且解决哈希冲突时,freecache 需要比较 slice 值,这也是一个性能比较差的地方,即火焰图中的 函数freecache.(*RingBuf).EqualAt

另一点,则是Timer.Now()函数的耗时,我们看一下 freecache 中的 Timer 实现:

1
2
3
4
5
6
7
8
9
10
11
// Helper function that returns Unix time in seconds
func getUnixTime() uint32 {
return uint32(time.Now().Unix())
}

// Default timer reads Unix time always when requested
type defaultTimer struct{}

func (timer defaultTimer) Now() uint32 {
return getUnixTime()
}

由于每一次 Write 都会使用一次时间戳,所以使用defaultTimer会带来非常严重的性能问题;经过查看源码,作者这里提供了另外一个 cachedTimer,它会缓存一个时间戳,并使用一个 goroutine 维护,每秒钟更新一次;但是并没有将该定时器作为 benchmark 代码中的默认定时器。将定时器修改后,该部分耗时可以忽略不计,这里不再放测试结果。

用户接口

freecache 提供了几个较高性能的用户接口:

1
2
3
4
5
// 查询后将值作为输入调用函数 fn
func (cache *Cache) GetFn(key []byte, fn func([]byte) error) (err error)

// 不进行拷贝,返回切片在 cache 中的地址
func (cache *Cache) GetWithBuf(key, buf []byte) (value []byte, err error)

gnet 是一个基于 Reactor 模式的高性能 go 网络库,工作原理类似 netty 和 libuv,直接使用 epoll 或 kqueue 系统调用来构建网络应用,这使其有着非常好的性能表现。本文章着重讲在阅读 gnet v2.2.3源码中注意到的一些问题。

主从 Reactor 通信方式

主从 Reactor 模式中,主 Reactor 与从 Reactor 模式之间需要通信机制来实现任务的调度。由于主从 Reactor 运行在不同的 goroutine 上,所以很轻松地可以想到使用 channel 进行通信。但是考虑到主 Reactor 与从 Reactor 直接的通信是一读一写的模式,gnet 中使用的是无锁队列的通信机制。这种一读一写的方式是比较经典的无锁队列使用场景了。gnet 中的无锁队列是一个非常经典的实现。

在学习 gnet 框架的时候,恰好我所写的一个玩具项目中也有类似的场景;因为我尝试模仿 gnet 的做法,将 channel 替换为无锁队列。但是在做出这样的修改后,我发现软件的性能竟然比修改之前要下降了。这让我比较疑惑,于是我测试了 gnet 中无锁队列的性能以及 channel 的性能。测试环境为 Macos Ventura, 双核四线程 CPU,8GB 内存,go 1.19 版本;测试的方法一共有两种,一种是测试写入侧完成写入的耗时,第二种是测试读取侧完成读取的耗时。测试结果如下,其中 channel 的缓冲区选取 1000:

测试编号 数据量10W 数据量100W 数据量500W 数据量1000W
queue 完成写入 8 ms 89 ms 647 ms 1228 ms
channel 完成写入 6 ms 64 ms 528 ms 961ms
queue 完成读取 14 ms 159 ms 703 ms 1854 ms
channel 完成读取 11 ms 142 ms 558 ms 1414 ms

根据测试结果,无锁队列的性能确实会比 channel 的性能要低,相同数据量下,使用无锁队列的耗时是使用 channel 耗时的约1.2倍左右。

在得到测试结果后,我以为是我的测试用例有问题,于是花费了很多时间去搜寻资料无果。后来,我尝试替换其他的无锁队列进行测试,于是我选择了github.com/yireyun/go-queue项目进行测试,但测试结果与 gent 中的无锁队列相似,仍然是比 channel 要慢的。于是我开始疑惑,为什么 gnet 中要选择无锁队列来作为消息传递的方式。

在思考无果后,我又开始查询资料,最终找到了可能的答案。这是 go-queue 项目中的一个 issue,它指出 go-queue 会比官方的 channel 速度要慢约10%左右,在其中的一个回复中有如下内容:

yireyun commented on Jul 25, 2022

已经在 MacPro M1 基于go1.17.12 上确认了”Chan 比 Queue 快”;并没有用之前机器和go版本回归测试,初步结论是 chan 比以前快多了; go1.8.3 的 chan 在高并发情况下,性能会急速下降,但在 go1.17.14 上发现高并发,chan性能下降缓慢。

go-queue 确实是一个四年前的项目,其测试环境为 go1.8.3,这让我回想起在知乎曾经看到的一篇文章,“Golang号称高并发,但高并发时性能不高”,里面有这样一段内容:

管道chan吞吐极限10,000,000,单次Put,Get耗时大约100ns/op,无论是采用单Go程,还是多Go程并发(并发数:100, 10000, 100000),耗时均没有变化,Go内核这对chan进行优化。

而 gnet v2 是基于 go 1.11 开发的,因此我猜测 gnet 这里的设计可能是因为 channel 在低版本 go 中性能表现较差。而在高版本 go 中,数据量 10W 左右的级别无锁队列与 channel 并没有表现出较大的性能差异,因此这里并没有对其进行修改。毕竟主从 Reactor 之间更多地只会传递 *conn 指针,很难会出现 10W 以上这种级别的新连接同时到达的情况。

连接管理优化

这个问题在 gnet 的 issue 中有提到,目前解决方案还没有加入到正式版本中。gnet 中,每一个从 Reactor 都需要管理分配到的所有连接,为了区分这些连接,gent 目前采用的是哈希表的方式,将每一个连接的 [file descriptor,*conn] 注册到哈希表中。

1
2
3
4
5
6
7
8
9
10
type eventloop struct {

...

connCount int32 // number of active connections in event-loop
udpSockets map[int]*conn // client-side UDP socket map: fd -> conn
connections map[int]*conn // TCP connection map: fd -> conn

...
}

这种设计在非 GC 语言中可能没有问题,但是在 GC 语言中可能会带来性能问题。GC 扫描对于连续的数据结构是非常快的,但是对于哈希表这种非连续存储的数据结构就会相对较慢。如果使用值存储的形式,哈希表会为键值对分配出一块的连续的内存,而使用指针存储时,值会逃逸到堆上,这样在扫描时就无法采取连续扫描的方式。如果在此基础上,再使用指针来存储比较大的对象,可能会导致扫描哈希表的耗时大大增加。

由于 gnet 主要面对的是类似 redis 和 HaProxy 的场景,这种场景下应用层的处理时间很短,网络库的性能对软件的整体性能影响非常大。而恰好这种情景都是作为后端的基础架构来使用的,客户端数量可能会非常多。当服务端需要处理的连接数较大时,直接使用哈希表来存储指针可能会造成 GC 耗时增加,从而造成比较严重的性能劣化。

gnet 后期可能会依据 bigcache 的模式做出优化,将哈希表更改为二级索引的数据结构,从而实现 GC 的线性扫描。

1
2
3
4
5
6
7
// 原方案
map[int]*conn

// 优化思路,fd 作为索引在哈希表中存储slice下标
map[int]int
// 在 slice 中存储 *conn 指针
slice[]*conn