本文发布于 2016 年 3 月,但其中的设计技巧仍然有效。
我们的团队需要编写非常快速的缓存服务。目标非常明确,但可以通过多种方式实现。最后,我们决定尝试一些新的东西,并在 Go 中实现该服务。本文描述了我们是如何做到的以及由此产生的价值。
根据需求,我们的服务应:
简而言之,我们的任务是编写带有到期时间和 REST 接口的快速字典。
我们公司中的大多数微服务都是用 Java 或另一种基于 JVM 的语言编写的,有些是用 Python 编写的。我们也有一个用 PHP 编写的单体式旧平台,但是除非有必要,否则我们不会碰它。我们已经知道这些技术,但是我们愿意探索一种新技术。我们的任务可以用任何语言实现,因此我们决定用 Go 编写。
在一家大公司(谷歌)和一个不断增长的用户社区的支持下,Go 发布已有一段时间了。它是一门编译,并发,命令式,结构化的编程语言。它还具有自动内存管理,因此比 C/C++ 看起来更安全,更容易使用。我们在用 Go 编写工具方面拥有相当丰富的经验,因此决定在此处使用它。我们已经有了一个 Go 的开源项目,现在我们想知道 Go 如何处理大流量。我们相信整个项目用 Go 仅需不到 100 行的代码即可完成任务,并且速度足以满足我们的需求。
为了满足需求,缓存本身需要:
基于第一点,我们决定放弃外部缓存,例如 Redis,Memcached 或 Couchbase,主要是因为网络上需要更多时间。因此,我们专注于内存中的缓存。在 Go 中已经有这种类型的缓存,即 LRU groupcache,go-cache,ttlcache,freecache 等。只有 freecache 满足我们的需求。接下来的子章节会揭示为什么我们决定还是坚持自己实现,并描述了如何实现上述需求。
我们的服务将同时接收许多请求,因此我们需要提供对缓存的并发访问。最简单的方法是将 sync.RWMutex 放在缓存访问功能的前面,以确保一次只能通过一个 goroutine 对其进行修改。但是,其他想对其进行修改的 goroutine 将被阻塞,使其成为瓶颈。为了消除此问题,可以应用 shards(分片)技术。分片背后的想法很简单。创建 N 个分片的数组,每个分片包含自己的带有锁的缓存实例。当需要缓存具有唯一 key 的记录时,首先通过函数 hash(key)%N 选择一个分片。在此之后,获取缓存锁并对缓存进行写入。记录读取是类似的。当分片的数量相对较多并且哈希函数返回的数字较分散时,锁争用几乎可以降至零。这就是我们决定在缓存中使用分片的原因。
从缓存中移除元素的最简单方法是将其与 FIFO 队列一起使用。将记录添加到高速缓存后,将执行两个附加操作:
由于在写入缓存期间已获取了锁,因此顺带执行移除操作。
在 Go 中,对于 map,垃圾收集器(GC)将在标记(mark)和扫描(scan)阶段遍历该 map 的每个条目。 当 map 足够大(包含数百万个对象)时,这可能会对应用程序性能产生巨大影响。
我们对服务进行了一些测试,在该服务中向缓存添加了数百万个记录,然后我们开始将请求发送到一些不相关的 REST 端点(endpoint),仅执行静态 JSON 序列化(根本不涉及缓存)。缓存为空时,此端点的最大响应延迟为 10ms,10k rps。当缓存被填满时,它在 99 的百分位上有超过一秒钟的延迟。度量标准表明,堆中有超过 4000 万个对象,GC 标记和扫描阶段花费了四秒钟。该测试向我们表明,如果要满足与响应时间有关的要求,则需要跳过 GC 以获取缓存项。我们该怎么做?好吧,有三个选择。
GC 仅限于堆,因此第一个选择是堆外。有一个项目可以帮助解决这个问题,称为 offheap。它提供了自定义函数 Malloc()和 Free() 来管理堆外部的内存。但是,将需要实现依赖于那些功能的缓存。
第二种方法是使用 freecache。Freecache 通过减少指针数量实现了具有零 GC 开销的 map。它将键和值保留在环形缓冲区中,并使用索引切片查找条目。
为缓存条目避免 GC 的第三种方式与 Go 1.5 版本修复的一个 issue 有关(issue 9477)。此优化表明,如果你使用的是 key 和 value 中没有指针的 map,则 GC 将忽略其内容。这是用堆,但为 map 中的条目避免 GC 的一种方法。但是,这并不是最终的解决方案,因为 Go 中的所有内容基本上是基于指针构建的:结构,切片甚至数组。只有诸如 int 或 bool 之类的基本类型才不是指针。那我们可以用 map[int]int 做什么?由于我们已经生成了 hashed key,以便从缓存中选择适当的分片(在并发中进行了描述),因此我们可以将它们重新用作 map[int]int 中的键。但是 int 类型的值呢?我们可以将哪些信息保留为 int?我们可以保留条目的偏移量。另一个问题是如何保留这些条目以便再次避免 GC?可以分配大量的字节数组,并且可以将条目序列化为字节并保留在其中。为此,map[int]int 中的值可能指向一个偏移量,该偏移量是条目在目标数组中开始的位置。而且由于 FIFO 队列用于保留条目并控制其移除(在 Eviction 中进行了描述),因此可以基于一个巨大的字节数组重新构建它,该 map 中的值也将指向该数组。
在所有以上指出的方案中,都需要条目(反)序列化。最终,我们决定尝试第三种解决方案,因为我们想知道它是否会起作用,并且实际中我们会有大量元素—hashed key(在分片选择阶段计算)和条目队列。
为了满足本章开头提出的需求,我们实现了自己的缓存并将其命名为 BigCache。BigCache 提供分片,移除(即淘汰),并且避免了缓存条目的 GC 问题。结果,即使对于大量记录,它也是非常快的缓存。
Freecache 是 Go 中唯一提供这种功能的可用内存中缓存之一。Bigcache 是它的替代解决方案,并以不同的方式减少了 GC 开销,因此我们决定分享它:bigcache。有关 freecache 和 bigcache 比较的更多信息,请参见 GitHub。
内存探查器(profiler)向我们展示了在请求处理期间分配了一些对象。我们知道 HTTP 处理程序将成为我们系统的热点。我们的 API 非常简单。我们仅接受 POST 和 GET 从缓存中上传和下载元素。我们实际上仅支持一个 URL 模板,因此不需要功能齐全的路由器。我们通过剪切前 7 个字母从 URL 中提取了 ID,它对我们来说很好用。
当我们开始开发时,Go 1.6 发布了 RC 版。我们减少请求处理时间的第一个尝试是将其更新到最新的 RC 版本。在我们的场景中,性能几乎相同。我们开始寻找更有效的方法,然后找到了 fasthttp。它是一个提供零分配 HTTP 服务器的库。根据文档,在综合测试中,它通常比标准 HTTP 处理程序快 10 倍。在我们的测试过程中,结果证明它仅快 1.5 倍,但还是更好!
fasthttp 通过减少 HTTP Go 程序包完成的工作来提升其性能。例如:
不幸的是,fasthttp 并不是标准 http 的真正替代。它不支持路由或 HTTP/2,并声称不能支持所有 HTTP 边缘情况。对于具有简单 API 的小型项目而言,这很好,因此对于正常(非超级性能)项目,我们会坚持使用默认 HTTP。
在对应用程序进行性能分析时,我们发现该程序在 JSON 反序列化上花费了大量时间。内存探查器还报告说 json.Marshal 处理了大量数据。这并不令我们感到惊讶。对于 10k rps,每个请求 350 个字节对于任何应用程序来说都是重要的有效负载(payload)。尽管如此,我们的目标是速度,所以我们对其进行了调研。
我们听说 Go JSON 序列化程序的速度不如其他语言快。大多数基准测试是在 2013 年完成的,因此这是在 1.3 版之前做的测试。当我们看到 issue-5683 声称 Go JSON 比 Python 慢 3 倍,而邮件列表说它比 Python simplejson 慢 5 倍时,我们开始寻找更好的解决方案。
如果需要速度,基于 HTTP 的 JSON 绝对不是最佳选择。不幸的是,我们所有的服务都以 JSON 相互通信,因此采用新协议超出了此任务的范围(但我们正在考虑使用 avro,就像我们对 Kafka 所做的那样)。我们决定坚持使用 JSON。通过快速搜索找到了一个名为 ffjson 的解决方案。(polaris 注:此文是 2016 年写的,一方面标准库性能更好了,另一方面,也有更多 JSON 库可供选择。)
ffjson 文档声称它比标准 json.Unmarshal 快 2-3 倍,并且使用的内存更少。
json | 16154 ns/op | 1875 B/op | 37 allocs/op |
---|---|---|---|
ffjson | 8417 ns/op | 1555 B/op | 31 allocs/op |
我们的测试证实 ffjson 比内置 unmarshaler 快了近 2 倍,并且内存分配的次数更少。它是如何做到这一点的?
首先,为了从 ffjson 的所有功能中受益,我们需要为我们的结构生成一个 unmarshaller。生成的代码实际上是一个解析器,它扫描字节并用数据填充对象。如果您看一下 JSON 语法,您会发现它确实很简单。ffjson 充分利用了结构的外观,仅解析结构中指定的字段,并在发生错误时快速失败。标准库的 marshaler 使用昂贵的反射调用来在运行时获取结构定义。另一个优化是减少不必要的错误检查。json.Unmarshal 将无法更快地执行较少的分配,并跳过反射调用。
json (invalid json) | 1027 ns/op | 384 B/op | 9 allocs/op |
---|---|---|---|
ffjson (invalid json) | 2598 ns/op | 528 B/op | 13 allocs/op |
有关 ffjson 如何工作的更多信息,请参见此处。基准测试在这里。
最后,对于最耗时的请求,我们将时间从 2.5 秒以上缩短到了 250 毫秒以下。这些时间仅发生在我们的场景中。我们相信,对于大量的写入操作或更长的移除(淘汰)周期,对标准缓存的访问可能会花费更多的时间,但是对于 bigcache 或 freecache 来说,访问时间可以保持在毫秒级,因为消除了长时间 GC 暂停这个根源。
下表比较了优化服务前后的响应时间。在测试期间,我们发送了 10k rps,从中写入 5k,读取另外 5k。移除时间设置为 10 分钟。测试时间为 35 分钟。
使用上述相同的设置进行单独测试(读写单独)的最终结果如下。
如果您不需要高性能,请使用标准库。确保维护性,因为它们具有向后兼容性,因此升级 Go 版本会很顺利。
我们用 Go 编写的缓存服务终于满足了我们的需求。大多数时候,我们花时间弄清楚 GC 暂停可能会对应用程序的响应性产生巨大影响,因为它控制着数百万个对象。幸运的是,像 bigcache 或 freecache 这样的缓存可以解决此问题。