Ristretto 简介:一个高性能 GO 缓存

这个博客登上了 Golang subreddit[1] 的顶部,并且在 Hacker News[2] 的trending上排在前十位。一定要在那里参与讨论,并通过给我们一个 star[3],表达对我们的喜欢。

经过六个月的研发,我们自豪地宣布缓存 Ristretto[4]:一个高性能、并发、可设置内存上限的 Go 缓存的初始版本。他是抗争用、扩展性好、提供稳定的高命中率。


前言

这一切都始于 Dgraph[5] 中需要一个可设置内存上限的、并发的 Go 缓存。

我们四处寻找解决方案,但是没有找到一个合适的。然后我们尝试使用分片 map,通过分片驱逐来释放内存,这导致了我们的内存问题。

之后我们重新利用了 Groupcache 的 LRU[6], 用互斥锁保证线程安全。

使用了一年之后,我们注意到缓存存在严重的争用问题。一个 commit[7] 删除了该缓存,使我们的查询延迟显著改善了 5-10 倍。

本质上,我们的缓存正在减慢我们的速度!

我们得出的结论是,Go 中的并发缓存库已经坏了,必须修复。

在三月, 我们写了一篇关于 Go 中的缓存状态[8], 提到数据库和系统需要一个智能的、可设置内存上限的缓存的问题,它可以扩展到 Go 程序所处的多线程环境。

我们将这些设置为缓存的要求:

  1. 并发的;

  2. 缓存命中率高;

  3. Memory-bounded (限制为可配置的最大内存使用量);

  4. 随着核数和协程数量的增加而扩展;

  5. 在非随机key访问(例如 Zipf)分布下很好的扩展;

发布了博客文章[9]之后,我们组建了一个团队来解决其中提到的挑战,并创建了一个值得与非 Go 语言缓存实现进行比较的 Go 缓存库。

Caffeine[10] 是一个基于 Java 8 的高性能、近乎最优的缓存库。许多基于 Java 8 的数据库都在使用它, 比如 Cassandra,HBase,和 Neo4j。这里[11]有一篇关于 Caffeine 设计的文章。


Ristretto: Better Half of Espresso

从那以后我们阅读了文献[12], 广泛测试了实现 ,并讨论了在写一个缓存库时需要考虑的每一个变量。

今天,我们自豪地宣布它已经准备好供更广泛的 Go 社区使用和实验。

在我们开始讲解 Ristretto[4] 的设计之前, 这有一个代码片段展示了如何使用它:

func main() {
cache, err := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7,     // key 跟踪频率为(10M)
MaxCost:     1 << 30, // 缓存的最大成本 (1GB)。
BufferItems: 64,      // 每个 Get buffer的 key 数。
})
if err != nil {
panic(err)
}

cache.Set("key", "value", 1) // set a value
// 等待值通过 buffer
time.Sleep(10 * time.Millisecond)

value, found := cache.Get("key")
if !found {
panic("missing value")
}
fmt.Println(value)
cache.Del("key")
}

指导原则

Ristretto[4] 建立在三个指导原则之上:

  1. 快速访问;

  2. 高并发和抗争用;

  3. 可设置内存上限;

在这篇博文中,我们将讨论这三个原则以及如何在 Ristretto 中实现它们。


快速访问

尽管我们喜欢 Go 和它对功能的固执己见,但一些 Go 的设计决策阻止我们榨取我们想要的所有性能。

最值得注意的是 Go 的并发模型。由于对 CSP 的关注,大多数其他形式的原子操作被忽略了。这使得难以实现在缓存库中有用的无锁结构。例如, Go 不提供 thread-local 存储[13]。

缓存的核心是一个 hash map 和关于进入和出去的规则。如果 hash map 表现不佳,那么整个缓存将受到影响。

与 Java 不同, Go 没有无锁的并发 hashmap。相反,Go 的线程安全是必须通过显式获取互斥锁来达到。

我们尝试了多种实现方式(使用 Ristretto 中的store接口),发现sync.Map在读取密集型工作负载方面表现良好,但在写入工作负载方面表现不佳。

考虑没有 thread-local 存储,我们发现使用分片的互斥锁包装的 Go map具有最佳的整体性能。 特别是,我们选择使用 256 个分片,以确保即使在 64 核服务器上也能表现良好。

使用基于分片的方法,我们还需要找到一种快速方法来计算 key 应该进入哪个分片。这个要求和对 key 太长消耗太多内存的担忧,导致我们对 key 使用 uint64,而不是存储整个 key。理由是我们需要在多个地方使用 key 的哈希值,并且在入口处执行一次允许我们重用该哈希值,避免任何更多的计算。

为了生成快速 hash,我们从 Go Runtime 借用了 runtime.memhash[14]。该函数使用汇编代码快速生成哈希。

请注意,这个 hash 有一个随机化器,每当进程启动时都会初始化,这意味着相同的key不会在下一次进程运行时生成相同的哈希。但是,这对于非持久缓存来说没问题。在我们的实验[15]发现它可以在 10ns 内散列 64 字节的 key。

BenchmarkMemHash-32 200000000 8.88 ns/op
BenchmarkFarm-32    100000000 17.9 ns/op
BenchmarkSip-32      30000000 41.1 ns/op
BenchmarkFnv-32      20000000 70.6 ns/op

然后,我们不仅将此 hash 用作被存储的 key,而且还用于确定 key 应该进入的分片。这个确实引入了 key 冲突的机会,这是我们计划稍后处理的事情。


并发和抗争用

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值