目录

手写缓存 GigaCache


项目地址:xgzlucario/GigaCache

前言

偶然间阅读到了 Go 编写的缓存库 bigcache,觉得其中规避 GC 的设计非常巧妙,便有了深入学习的兴趣。加上缓存的实现比较简单,于是我决定开启一个 手写系列,用于记录学习和思考的过程。这个系列的主要内容是学习当下工业界主流的中间件,并从零代码开始,实现一个功能类似的项目,并达到实际可用的效果。这个系列将以缓存作为开头,在本文中,我将介绍我是如何使用 Go 设计并实现一个高性能缓存库的。

设计思想

在 bigcache 的官方博客中,介绍了设计时的一些关键细节:

并发处理

缓存会同时收到许多请求,因此需要保证高并发场景下的访问速度。要解决这个问题,可以使用 sharding(分片技术),即创建 N 个分块组成的数组,每个分块包含独立的 带锁 缓存实例。当插入一条键值对时,首先通过函数 hash(key) % N 为其选择一个分片,再进行后续操作。当分块数量足够多时,锁的竞争几乎可以降到零。

分片技术应用非常广泛,但也存在缺点。在哈希散列时,它会将 key 随机打乱到各个分块中,使缓存整体无序,无法进行有序遍历操作。

淘汰策略

FIFO 队列

从缓存剔除元素最简单的方式就是与 FIFO 队列结合使用,即先写入的数据(老的数据)先被淘汰。淘汰时从队头元素开始将过期时间与当前时间比较,如果过期,则将队列中的元素删除。bigcache 就是这样设计的。

主动淘汰

另一种淘汰的方法是通过定期对缓存中的项进行随机探测,删除其中过期的项。并在查询或遍历操作检测到过期的项时,进行主动删除。使用这种淘汰策略的典型是 Redis。

忽略 GC

Go 的垃圾收集器采用了 CMS(并发标记清除)算法,包含标记、清除、重新标记三个阶段,其中标记、清除阶段都是与用户线程并发执行,只有重新标记阶段需要 STW,此时会暂停所有用户线程的执行。


当缓存中有成千上万个条目时,STW 会造成较大的系统延迟,严重时可达几毫秒甚至几十毫秒,在性能敏感的系统中,这样的延迟显然无法接受。因此如何降低 GC pause time 是设计缓存的重点之一。在 bigcache 中,工程师们用到了在 Go 1.5 version 之后的一个特性:

如果一个 map 的键和值都使用 不含指针 的类型,那么 GC 时将忽略其内容。


这里的指针既包括结构体指针,也包括像 stringslice 这样的包含底层指针的 复合类型。假如我们像 map[string]string 这样将键值对的 raw bytes 直接保存,在 GC 时,垃圾收集器会遍历触及 map 中的所有字符串,此时时间复杂度为 O(n)

而另一种办法是,利用上述特性,将索引的结构定义为 map[uint64]uint64,而将 raw bytes 集中存储在一个大的数据容器中统一管理,这样索引在 GC 扫描时被视为了一个整体,时间复杂度下降为了 O(1),于是我们从根源上解决了大 map 带来的 STW 影响。

下面是第一种和第二种方法构建的索引性能比较:

package main

import (
	"flag"
	"fmt"
	"runtime"
	"runtime/debug"
	"strconv"
	"time"
)

var previousPause time.Duration

func gcPause() time.Duration {
	runtime.GC()
	var stats debug.GCStats
	debug.ReadGCStats(&stats)
	pause := stats.PauseTotal - previousPause
	previousPause = stats.PauseTotal
	return pause
}

func main() {
	c := ""
	entries := 0
	flag.StringVar(&c, "cache", "map[string]string", "cache to bench.")
	flag.IntVar(&entries, "entries", 2000*10000, "number of entries to test")
	flag.Parse()

	fmt.Println(c)
	fmt.Println("entries:", entries)

	// gen test data
	testData := make([]string, 0, entries)
	for i := 0; i < entries; i++ {
		testData = append(testData, strconv.Itoa(i))
	}

	start := time.Now()
	switch c {
	case "map[string]string":
		m := make(map[string]string, entries)
		for _, k := range testData {
			m[k] = k
		}

	case "map[int]int":
		m := make(map[int]int, entries)
		for i := range testData {
			m[i] = i
		}
	}

	cost := time.Since(start)

	var mem runtime.MemStats
	var stat debug.GCStats

	runtime.ReadMemStats(&mem)
	debug.ReadGCStats(&stat)

	fmt.Println("alloc:", mem.Alloc/1024/1024, "mb")
	fmt.Println("gcsys:", mem.GCSys/1024/1024, "mb")
	fmt.Println("heap inuse:", mem.HeapInuse/1024/1024, "mb")
	fmt.Println("heap object:", mem.HeapObjects/1024, "k")
	fmt.Println("gc:", stat.NumGC)
	fmt.Println("pause:", gcPause())
	fmt.Println("cost:", cost)
}

运行得到如下结果:

map[string]string
entries: 20000000
alloc: 1613 mb
gcsys: 34 mb
heap inuse: 1613 mb
heap object: 9748 k
gc: 2
pause: 147.501µs
cost: 4.097010605s

map[int]int
entries: 20000000
alloc: 622 mb
gcsys: 24 mb
heap inuse: 622 mb
heap object: 0 k
gc: 2
pause: 91.365µs
cost: 2.853862997s

结果表明,map[int]int 的性能有很大提升,内存和 GC 开销也大幅减小,堆对象扫描结果甚至只有 0k。

实际上,bigcache 使用环形 buffer 队列作为容器来管理 raw bytes 数据,并通过 map[uint64]uint32 结构作为索引,uint64 类型的 key 通过 hash(kstr) 计算得到,uint32 类型的 value 代表 offset,是条目在 buffer 中的起始位置,这样就能通过 key 定位到要查询的值。

开始实现

功能设计

首先,GigaCache 目标是作为一个高性能缓存库,支持过期时间,包含以下功能点:

  1. 支持 Set() SetTx() Get() Scan() Delete() 等基本方法
  2. 支持上亿级数据量的 key-value 存储
  3. 键值对独立过期时间(TTL)支持,精确到秒级

同时,系统的性能和延迟对于缓存来说非常重要,还需要考虑:

  1. 系统接口的性能调优
  2. 因为所有数据保存在内存中,需要尽可能降低内存占用
  3. 需要优化 GC 开销和长尾延迟,将系统最长延迟控制在可接受范围内

架构图

GigaCache

/posts/gigacache/cache.png
GigaCache

这是 GigaCache 的架构图。一个 Cache 是若干 buckets 的集合,负责将数据通过哈希算法分配到不同的桶中。每个桶拥有独立的锁。在程序运行过程中,桶会不断地淘汰其中过期的键值对,产生内存碎片。当内存碎片比例过高时,桶会将其中的数据迁移到新桶,以合并碎片数据,减少内存使用。


Bucket

bucket 是数据存储的地方,其中包含两个重要的数据结构:索引 idxMap 和 容器 data。索引保存着 key 到实际数据的映射信息,容器则是数据真正存放的地方。 start 和 offset 使用 32 bits 存储,在默认分片数量为 1024 的情况下,每个 bucket 能存储最多 1024 * (1<<32) B 的数据。若无法满足存储需要,提高分片数量即可。

/posts/gigacache/keyidx.png
Key & Idx defination

这是 bucket 中键和值的类型定义。Key 是 uint64 的哈希值,由 hash(kstr) 计算得到。Idx 是由 uint32 类型的 start 和 uint32 类型的 ttl 组合而成。start 表示 Key 在容器 data 中的起始位置,ttl 表示过期时间戳(精确到秒)。

/posts/gigacache/hash.png
hash function

上图说明了一个 key 字符串是如何通过哈希函数定位到其存储的位置的。首先通过第一步分片计算 fnv32(key) % shard_num 选择一个桶,然后通过 xxh3(key) 计算出字符串对应的 Key 值保存在索引中。

分片和索引使用不同的哈希函数,能够 降低哈希冲突的概率,这是因为不同的哈希函数能带来不同的 分布特性。经过 fnv32 映射到某一个桶中的数据,对 fnv32 来说具有相同的特性,但对于 xxh3 函数来说就是完全不同的。因此在经过 xxh3 映射为索引后,发生哈希冲突的概率远比使用相同哈希函数要更小。

/posts/gigacache/bucket.png
bucket
  • Put():插入操作将键值对追加插入 data 末尾,并将其在数组中的位置存到索引 map 中。键值对在 data 中的组织形式是 [key_size, val_size, key, value]
  • Get():读操作先读索引,获取 start 和 offset,然后直接从 data 中取数据。
  • Delete():这里删除操作是软删除,仅从索引中删除,并没有从容器中实际删除。bucket 通过 alloc 和 inused 两个字段来监控有多少内存空间是有效的。由于每次软删除都会产生内存碎片,当 bucket 中内存碎片率达到一定比例时(默认 40%),会触发 migrate 操作,将有效的键值对迁移到新的数组中,以合并碎片化的数据。

淘汰策略

GigaCache 的淘汰策略使用了 分治思想,即把一个复杂或耗时的问题分解成若干相同或相似的子问题,将子问题分别求解,原问题的解即为子问题解的合并。这个思想是很多高效算法的基础。

具体来说,Redis 在 rehash 操作时使用了分治思想。当 Redis 中的哈希表扩容时,首先会创建一个新表,并在执行每个 Set 操作时,顺带将一小部分旧表数据移到新表中。经过若干次操作后,旧表的数据全部迁移完成,rehash 操作完成。

在这个过程中,分治的意义:

将哈希表扩容这个大动作带来的巨大开销均摊到每个小动作上,保证了每个操作的公平性,也大大降低了系统的瞬时暂停时间(在扩容时系统将无法处理外部请求)。

GigaCache 中的淘汰策略借鉴了这种思想。具体来说,淘汰算法 eliminate 在每次获取 写锁 时:

  1. 随机探测一部分键值对,并删除其中过期的部分(产生一部分内存碎片)
  2. 当连续一定次数探测到未过期键值对时,算法退出
  3. 当内存碎片数量过大时,做一次 rehash 操作(数据都迁移到新桶,消除内存碎片)

这样便将 遍历淘汰 这个大动作的开销分摊到了每个小动作中,降低了系统的最大延迟时间。

参考文章

Writing a very fast cache service with millions of entries in Go

SwissMap: A smaller, faster Golang Hash Table | DoltHub Blog