Introducing Ristretto: A High-Performance Go Cache
Table of Contents
经过六个多月的研发,我们很荣幸地宣布初始版本的 Ristretto:高性能、并行、内存绑定的 Go 缓存。它具有抗竞争性,良好的可伸缩性,并始终具有很高的命中率。
1 前言
一切始于 Dgraph 需要内存可控的并发 Go 缓存。我们四处寻找也未曾发现一个好的解决方案。后来,我们尝试使用分片 map,它具有分片驱逐功能来释放内存,但导致出现内存问题。然后,我们重新利用 Groupcache 的 LRU,使用互斥锁来确保线程安全。大约一年后,我们注意到缓存遇到严重的争用。删除该缓存的操作使查询延迟大大提高了 5 到 10 倍。本质上,缓存拖慢了速度!
我们得出的结论是,Go 中的并发缓存缺失,我们必须自己实现一个。 3 月,我们写了关于 State of Caching in Go 的文章,提到数据库和系统需要智能且内存可控的缓存的问题,其中该缓存可以在 golang 这样的多线程环境中扩展。特别地,我们认为缓存应该满足:
- 高并发
- 高命中率
- 内存可控(可配置的最大内存使用限制)
- 随着核心和 goroutine 数量的增加,可以很好地扩展
- 在非随机 key 访问分布(例如 Zipf)下可以很好地扩展。
发布博客文章后,我们成立了一个团队来解决其中提到的挑战,并创建了一个值得与非 Go 实现的缓存进行比较的 Go 缓存库。尤其是基于 Java 8 的 Caffeine,它是一个高性能,几乎是最佳的缓存库。它被许多基于 Java 的数据库使用,例如 Cassandra,HBase 和 Neo4j。有一篇关于 Caffeine 设计的 文章 。
2 Ristretto: Better Half of Espresso
从那以后,我们阅读了文献1, 2, 3,实现也进行了广泛测试,并讨论了编写缓存库时要考虑的每个变量。今天,我们很自豪地宣布,它已经准备好供 Go 社区更广泛的使用和试验。
在我们开始解释 Ristretto 的设计之前,这里是一个代码片段,展示了如何使用它:
func main() { cache, err := ristretto.NewCache(&ristretto.Config{ NumCounters: 1e7, // Num keys to track frequency of (10M). MaxCost: 1 << 30, // Maximum cost of cache (1GB). BufferItems: 64, // Number of keys per Get buffer. }) if err != nil { panic(err) } cache.Set("key", "value", 1) // set a value // wait for value to pass through buffers time.Sleep(10 * time.Millisecond) value, found := cache.Get("key") if !found { panic("missing value") } fmt.Println(value) cache.Del("key") }
3 指导原则
Ristretto 建立在三个指导原则上:
- 快速访问
- 高并发和抗竞争性
- 内存可控。
在此博客文章中,我们将讨论这三个原则以及如何在 Ristretto 中实现它们。
4 快速访问
尽管我们喜欢 Go 及其在功能方面的坚定立场,但 Go 的某些设计决策阻止了我们挤出想要的所有性能。最值得注意的是 Go 的并发模型。由于对 CSP 的关注,大多数其他形式的原子操作都被忽略了。这使得难以实现在缓存库中有用的无锁结构。例如,Go 不提供线程本地存储。
缓存的核心是 hash map,其中包含有关进出的规则。如果 hash map 执行不佳,则整个缓存将受到影响。与 Java 不同,Go 没有无锁并发的 hash map。相反,Go 中的线程安全是通过显式获取互斥锁来实现的。
我们尝试了多种实现方式(使用 Ristretto 中的 store 接口),发现 sync.Map 对于读取繁重的工作负载表现良好,但对于写入工作负载则表现不佳。考虑到没有线程本地存储,我们发现分片(shared)互斥包装的 Go map 具有最佳的整体性能。特别是,我们选择使用 256 个分片(shared)以确保即使在 64 核服务器上也能很好地执行。
使用基于分片(shared)的方法,我们还需要找到一种快速的方法来计算 key 应放入哪个分片(shared)。这种要求以及对长 key 消耗过多内存的担忧,导致我们将 uint64 代表 key,而不是存储整个 key。这样做的理由是,我们需要在多个位置进行 key 的散列,在入口处执行一次散列即可使我们重新使用该散列,从而避免了任何其他计算。
为了生成快速哈希,我们从 Go Runtime 借用了 runtime.memhash 。此函数使用汇编代码快速生成哈希。请注意,哈希具有一个随机化器,该随机化器会在进程启动时进行初始化,这意味着相同的 key 在下一次进程运行时不会生成相同的哈希。但是,对于非永久性缓存来说,这没关系。在我们的实验中,我们发现它可以在 10ns 内散列 64 字节 key。
然后,我们不仅使用此哈希作为存储的 key,而且还弄清楚了 key 应放入的分片(shared)。这确实会带来 key 碰撞的机会,这是我们计划在以后处理的事情。
5 并发和竞争
要实现高命中率,需要管理元数据,元数据和缓存中存在的内容以及缓存中应存在的内容有关。跨 goroutines 使得平衡缓存的性能和可伸缩性变得非常困难。幸运的是,有一篇名为 BP-Wrapper 的论文,它描述了一个系统框架,该框架使得任何替换算法几乎都可以无争用地锁定。论文介绍了两种缓解争用的方法:预取(prefetching) 和批处理(batching)。我们仅使用批处理。
批处理正是我们所需要的。与其为每个元数据改变获取互斥锁,不如在获取互斥锁并处理突变之前等待环形缓冲区填满。如该论文所述,这几乎没有开销,从而大大降低了竞争。
我们将此方法用于所有获取(Gets) 和设置(Sets) 缓存。
5.1 Gets
当然,所有对缓存的获取都会立即得到服务。困难的部分是捕获 Get,因此我们可以跟踪 key 访问。在 LRU 缓存中,通常将 key 放在链接列表的开头。在基于 LFU 的缓存中,我们需要增加条目的点击计数器。这两个操作(修改列表和计数)都需要对缓存的全局结构进行线程安全地访问。 BP-Wrapper 建议使用批处理来递增命中计数器,但是问题是我们如何在不获取另一个锁的情况下实现此批处理过程。
这听起来像是使用 Go channels 的完美场景,事实确实如此。不幸的是,通道的吞吐量性能并不满足我们的使用。取而代之的是,我们设计了一种使用 sync.Pool 的好方法,以实现分离、有损的环形缓冲区,这些缓冲区性能出色,数据丢失很少。
Pool 存储的任何条目都可以随时自动删除,而不另行通知。这就引入了一层有损行为。Pool 中的每个条目实际上都是一批 key。这批填满后,将其推送到某个 channel。故意将 channel 设置较小,以避免消耗太多的 CPU 周期来处理它。如果通道已满,则删除该批次。这引入了第二层有损行为。一个 goroutine 从内部通道中提取此批次并处理 key,从而更新其命中计数器。
AddToLossyBuffer(key): stripe := b.pool.Get().(*ringStripe) stripe.Push(key) b.pool.Put(stripe) Once buffer fills up, push to channel: select { case p.itemsCh <- keys: p.stats.Add(keepGets, keys[0], uint64(len(keys))) return true default: p.stats.Add(dropGets, keys[0], uint64(len(keys))) return false } p.itemCh processing: func (p *tinyLFU) Push(keys []uint64) { for _, key := range keys { p.Increment(key) } }
使用 sync.Pool 而非其他任何内容(切片,互斥锁等)获得的性能优势主要是由于内部使用的线程本地存储,而 Go 的用户无法将其作为公共 API 使用。
5.2 Sets
Set 缓冲区的要求与 Get 稍有不同。在 Gets 中,我们对 key 进行缓冲,仅在缓冲区填满后才对其进行处理。在 Sets 中,我们希望尽快处理 key。因此,我们使用一个通道来捕获 Sets,如果通道已满,则将它们丢弃以避免竞争。几个后台 goroutine 从通道中选择集并处理该 Set 操作。
select { case c.setBuf <- &item{key: hash, val: val, cost: cost}: return true default: // drop the set and avoid blocking c.stats.Add(dropSets, hash, 1) return false }
与 Gets 一样,此方法旨在优化抗竞争性。但是,有一些注意事项。
5.2.1 注意事项
Ristretto 中的 Sets 将在缓冲区中排队,控制权返回给调用者,然后用缓冲区更新缓存。这有两个副作用:
- 不能保证 set 一定会应用。可以立即删除它以避免争用,或者以后可以被接纳策略拒绝。
- 调用返回给用户后,应用了 set 也可能需要花费几毫秒的时间。用数据库术语来说,这是一个最终的一致性模型。
但是,如果缓存中已存在 key,Set 将立即更新该 key。这是为了避免缓存的 key 保留陈旧的值。
6 抗竞争性
Ristretto 针对竞争性进行了优化。在繁重的并发负载下,这确实表现良好,我们将在下面的吞吐量基准中看到。但是,它将损失一些元数据以换取更好的吞吐量性能。
有趣的是,由于 key 访问分布的性质,信息丢失不会损害命中率。如果我们确实丢失了元数据,当 key 访问分布不统一是,命中率也会统一丢失(If we do lose metadata, it is generally lost uniformly while the key access distribution remains non-uniform. )(译注:什么意思)。因此,如下图所示,我们仍然可以实现较高的命中率,并且命中率下降很小。
7 内存可控
7.1 key 成本
无限大的缓存实际上是不可能的。高速缓存必须有大小限制。许多缓存库会将缓存大小视为元素数。我们发现这种方法很幼稚。当然,它可以在值大小相同的情景下工作。但是,大多数情况下值都是不同大小的。一个值可能要花几个字节,几千字节,甚至几兆字节。将它们视为具有相同的内存成本是不现实的。
在 Ristretto 中,我们将成本附加到每个 key 值。用户可以在调用 Set 时指定该成本是多少。我们将此成本与缓存的 MaxCost 相比较。当缓存以最大容量运行时,高成本的条目可能会取代许多低成本条目。该机制非常不错,因为它适用于所有不同的工作情景,包括幼稚的方法(其中每个 key 值花费 1)。
7.2 接纳策略:TinyLFU
什么应该进入缓存?是由接纳策略决定。显然,如果新条目比当前条目“更有价值”就接受。但是,如果您考虑跟踪与“价值”问题相关的相关条目信息所需的开销(延迟和内存),则这将成为一个挑战。
尽管提高命中率的策略有据可查,但大多数 Go 缓存库根本没有接纳策略。实际上,许多 LRU 收回实现都将最新 key 视为最有价值。
此外,大多数 Go 缓存库使用纯 LRU 或近似 LRU 作为其驱逐策略。尽管具有 LRU 近似的质量,但某些工作更适合 LFU 驱逐策略。我们在跟踪各种基准测试发现了这种情况。
对于接纳策略,我们研究了一篇名为 TinyLFU: A Highly Efficient Cache Admission Policy 的新颖有趣的论文。在很高的层次上,TinyLFU 提供了三种方法:
- Increment(key uint64)(译注:增量)
- Estimate(key uint64) int (referred as ɛ)(译注:估值)
- Reset
该论文对此进行了最好的解释,但是 TinyLFU 是一种与逐出无关的准入策略,旨在以很少的内存开销提高命中率。主要思想是 仅在新条目的估值(estimate)高于被逐出的条目的估值时才允许接纳 。我们在 Ristretto 中使用 Count-Min Sketch 实现了 TinyLFU。它使用 4 位计数器来近似条目的访问频率(也就是 ɛ)。与使用普通 key-频率 map 相比,每个 key 的这种小成本使我们能够跟踪更大范围的全局 key 空间样本。
TinyLFU 还通过 Reset 功能保持 key 访问的新近度。 N 个 key 递增后,计数器减半。因此,一段时间未访问的 key,其计数器重置为零;为最近出现的 key 铺平道路。
7.3 驱逐策略:Sampled LFU
当高速缓存达到容量时,每个传入 key 都应替换高速缓存中存在的一个或多个 key。不仅如此,传入 key 的 ɛ (译注:估值)应该比被逐出的 key 的 ɛ 高。要查找低 ɛ 的 key,我们使用了 Go map 迭代提供的 randomness 来选择一个 key 样本,并在它们上循环查找最低估值(ɛ)的 key。
然后,我们将此 key 的估值(ɛ)与传入 key 进行比较。如果输入的 key 具有较高的估值(ɛ),则此 key 将被逐出(逐出策略)。否则,输入 key 将被拒绝(接纳策略)。重复此机制,直到可以将传入 key 的成本适合放入高速缓存中为止。因此,单个输入 key 可能逐出一个以上的 key。请注意,传入 key 的成本不会影响如何选择退出 key。
使用这种方法,各种工作负载下的 LFU 策略的命中率在 1%以内。这意味着,在同一个小 package 中,我们可以获得接纳策略、保守的内存使用以及较低竞争方面的优势。
// Snippet from the Admission and Eviction Algorithm incHits := p.admit.Estimate(key) for ; room < 0; room = p.evict.roomLeft(cost) { sample = p.evict.fillSample(sample) minKey, minHits, minId := uint64(0), int64(math.MaxInt64), 0 for i, pair := range sample { if hits := p.admit.Estimate(pair.key); hits < minHits { minKey, minHits, minId = pair.key, hits, i } } if incHits < minHits { p.stats.Add(rejectSets, key, 1) return victims, false } p.evict.del(minKey) sample[minId] = sample[len(sample)-1] sample = sample[:len(sample)-1] victims = append(victims, minKey) }
8 DoorKeeper
在我们将新 key 放入 TinyLFU 中之前,Ristretto 首先使用布隆过滤器(bloom filter)检查该 key 是否之前已被查看过。仅当 key 在布隆过滤器中已经存在时,才将其插入 TinyLFU。这是为了避免长时间不被看到的长尾 key 污染 TinyLFU。(译注:缓存穿透)
计算 key 的估值(ɛ) 时,如果该条目包含在布隆过滤器中,则其频率估值为 TinyLFU 的估值加 1。在重置 TinyLFU 的过程中,也会清除 Bloom 过滤器。
9 Metrics
尽管是可选的,但了解缓存的行为方式是重要的。我们希望确保不仅可以实现与缓存相关的跟踪指标,而且这样做的开销也足够低,可以打开和保持打开状态。
除了命中和遗漏之外,Ristretto 还跟踪其他指标,例如 key 及其添加,更新和收回的成本,sets 丢失或拒绝以及 gets 丢失或保留的成本。所有这些数字有助于了解各种工作负载上的缓存行为,并为进一步优化铺平道路。
我们最初使用原子计数器。但开销很大。我们将原因归结为False Sharign 。考虑一个多核系统,其中不同的原子计数器(每个 8 字节)位于同一高速缓存行(通常为 64 字节)中。对这些计数器之一进行的任何更新都会导致其他计数器被标记为无效。这将强制为拥有该高速缓存的所有其他核心重新加载高速缓存,从而在高速缓存行上创建写争用。
为了实现可伸缩性,我们确保每个原子计数器完全占用完整的缓存行。因此,每个内核都在不同的缓存行上工作。 Ristretto 通过为每个度量分配 256 个 uint64 来使用此功能,在每个活动 uint64 之间保留 9 个未使用的 uint64。为了避免额外的计算,重新使用 key 哈希值以确定要增加的 uint64。
Add: valp := p.all[t] // Avoid false sharing by padding at least 64 bytes of space between two // atomic counters which would be incremented. idx := (hash % 25) * 10 atomic.AddUint64(valp[idx], delta) Read: valp := p.all[t] var total uint64 for i := range valp { total += atomic.LoadUint64(valp[i]) } return total
读取度量标准时,将读取并汇总所有 uint64,以获取最新数值。使用这种方法,指标跟踪仅会增加大约 10%的缓存性能开销。
10 基准测试
既然您了解了 Ristretto 中存在的各种机制,那么让我们看一下与其他流行的 Go 缓存相比的命中率和吞吐量基准。
10.1 命中率
使用 Damian Gryski 的 cachetest 和我们自己的基准测试套件来衡量命中率。两种实用程序的命中率数字相同,但是我们增加了读取某些跟踪格式(特别是 LIRS 和 ARC)以及 CSV 输出的功能,以便于绘制图形。如果要编写自己的基准测试或添加跟踪格式,请使用 sim 软件包。
为了更好地了解改进的空间,我们添加了一种理论上最优化的缓存实现,该实现使用未来的知识来逐出在其整个生命周期内命中次数最少的条目。请注意,这是千篇一律的 LFU 驱逐策略,其他千篇一律的策略可能会使用 LRU。根据工作量,LFU 或 LRU 可能更适合,但是我们发现通透的 LFU 对于与 Ristretto 的 Sampled LFU 进行比较很有用。
10.1.1 搜索
将该跟踪描述为“大型商业搜索引擎响应各种 Web 搜索请求而发起的磁盘读取访问。”
10.1.2 数据库
此跟踪被描述为“在商业站点上运行的数据库服务器,该服务器在商业数据库之上运行 ERP 应用程序。”
10.1.3 循环
此跟踪演示了循环访问模式。在本基准及以下基准中,我们不能包括 Fastcache,Freecache 或 Bigcache 实现,因为它们的最小容量要求会使结果产生偏差。一些跟踪文件很小,并且需要较小的容量来进行性能测量。
10.1.4 CODASYL
将该跟踪描述为“在一小时内对 CODASYL 数据库的引用”。请注意,与这里的其他相比,Ristretto 的表现受到影响。这是因为 LFU 驱逐策略不适合此工作负载。
11 未来的改进
您可能已经在 CODASYL 基准测试中注意到,Ristretto 的性能在 LRU 繁重的工作负载中受到影响。但是,对于大多数工作负载而言,我们的 LFU 采样策略表现都很好。问题变成了“我们如何才能兼得两全”?
在名为“自适应软件缓存管理”的论文 中,探讨了这个确切的问题。基本思想是在主缓存段之前放置一个 LRU“窗口”,并使用爬山技术(hill-climbing)自适应地调整窗口大小,以使命中率最大化。Caffeine 已经取得了很好的效果。我们相信 Ristretto 将来也会从中受益。
12 特别感谢
我们衷心感谢本·马内斯。他的知识渊博和专心,无私的分享是我们取得任何进展的重要因素,我们很荣幸与他就缓存的所有内容进行了多次对话。如果没有他在我们内部 Slack 频道中的指导、支持和 99.9% availabilit,Ristretto 就是不可能的。
我们还要感谢 Damian Gryski 对 Ristretto 进行的基准测试和编写 TinyLFU 实现参考的帮助。
13 结论
我们的目标是构建与 Caffeine 具有竞争力的缓存库。尽管还不够完善,但通过使用一些新技术,我们确实创造了明显比 Go 世界中大多数缓存更好的东西,其他人可以从中学习。
在 Dgraph 中使用此缓存的一些初步实验看起来很有希望。我们希望在接下来的几个月中将 Ristretto 集成到 Dgraph 和 Badger 中。请检查一下,也许可以使用 Ristretto 来加快工作量!