最近,我们团队的任务是编写一个非常快速的缓存服务。目标非常明确,但可以通过多种方式实现。最后,我们决定尝试一些新的东西并在Go中实现该服务。我们已经描述了我们是如何做到的,以及从中获得了什么价值。

目录:

要求

根据要求,我们的服务应该:

  • 使用 HTTP 协议处理请求
  • 处理 10k rps(写入 5k,读取 5k)
  • 缓存条目至少 10 分钟
  • 响应时间(在没有花费在网络上的时间的情况下测量)低于
    • 5ms – 平均值
    • 第 99.9 个百分位数为 10 毫秒
    • 第 99.999 个百分位数为 400 毫秒
  • 处理包含 JSON 消息的 POST 请求,其中每条消息:
    • 包含一个条目及其 ID
    • 不大于 500 字节
  • 在通过 POST 请求添加条目后立即通过 GET 请求检索条目并返回 int(一致性)

简单来说,我们的任务是编写一个带有过期和 REST 接口的快速字典。

为什么是Go?

我们公司的大多数微服务都是用 Java 或其他基于 JVM 的语言编写的,有些是用 Python 编写的。我们也有一个用 PHP 编写的单一的遗留平台,但除非必须,否则我们不会触及它。我们已经知道这些技术,但我们愿意探索一种新的技术。我们的任务可以用任何语言实现,因此我们决定用 Go 编写它。

在一家大公司和不断增长的用户社区的支持下,Go 已经推出了一段时间。它被宣传为一种编译的、并发的、命令式的、结构化的编程语言。它还具有托管内存,因此看起来比 C/C++ 更安全、更易于使用。我们对用 Go 编写的工具有很好的经验,并决定在这里使用它。我们在 Go 中有一个开源项目,现在我们想知道 Go 是如何处理大流量的。我们相信整个项目只需不到 100 行代码,而且速度足以满足我们的要求,仅仅因为 Go。

缓存

为了满足要求,缓存本身需要:

  • 即使有数百万个条目也非常快
  • 提供并发访问
  • 在预定的时间后驱逐条目

考虑到第一点,我们决定放弃RedisMemcachedCouchbase等外部缓存,主要是因为网络上需要额外的时间。因此,我们专注于内存缓存。在 Go 中已经有这种类型的缓存,即LRU 组 cachego-cachettlcachefreecache只有 freecache 满足了我们的需求。接下来的子章节揭示了为什么我们决定推出自己的产品,并描述上述特性是如何实现的。

并发

sync.RWMutexhash(key) % N

驱逐

从缓存中逐出元素的最简单方法是将其与FIFO队列一起使用。当一个条目被添加到缓存中时,会发生两个额外的操作:

  1. 在队列末尾添加一个包含键和创建时间戳的条目。
  2. 从队列中读取最旧的元素。它的创建时间戳与当前时间进行比较。当它晚于驱逐时间时,队列中的元素连同其在缓存中的相应条目一起被删除。

由于已获得锁,因此在写入缓存期间执行驱逐。

省略垃圾收集器

在 Go 中,如果你有一个映射,垃圾收集器 (GC) 将在标记和扫描阶段接触该映射的每个项目。当map足够大(包含数百万个对象)时,这可能会对应用程序性能产生巨大影响。

我们对我们的服务进行了一些测试,其中我们向缓存提供了数百万个条目,然后我们开始向一些不相关的 REST 端点发送请求,这些端点只进行静态 JSON 序列化(它根本没有触及缓存)。对于空缓存,此端点对于 10k rps 的最大响应延迟为 10 毫秒。当缓存被填满时,它在第 99 个百分位有超过一秒的延迟。指标表明堆中有超过 4000 万个对象,GC 标记和扫描阶段耗时超过 4 秒。测试表明,如果我们想满足与响应时间相关的要求,我们需要跳过缓存条目的 GC。我们怎么能这样做?嗯,有三个选择。

Malloc()Free()

第二种方法是使用freecacheFreecache 通过减少指针数量来实现零 GC 开销的 map。它将键和值保存在环形缓冲区中,并使用索引切片来查找条目。

map[int]intmap[int]intintintmap[int]int

在所有呈现的场景中,都需要输入(反)序列化。最终,我们决定尝试第三种解决方案,因为我们很好奇它是否可以工作,并且我们已经拥有了大多数元素——散列键(在分片选择阶段计算)和条目队列。

BigCache

为了满足本章开头提出的要求,我们实现了自己的缓存并将其命名为 BigCache。BigCache 提供分片、驱逐,并且它省略了缓存条目的 GC。因此,即使对于大量条目,它也是非常快速的缓存。

Freecache 是 Go 中唯一提供这种功能的可用内存缓存之一。Bigcache 是它的另一种解决方案,并以不同的方式减少 GC 开销,因此我们决定与它共享:bigcache有关 freecache 和 bigcache 之间比较的更多信息可以在github上找到。

HTTP 服务器

内存分析器向我们展示了在请求处理期间分配了一些对象。我们知道 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

我们听说 Go JSON 序列化器没有其他语言那么快。大多数基准测试都是在 2013 年完成的,所以在 1.3 版本之前。当我们看到issue-5683声称 Go 比 Python 慢 3 倍并且 邮件列表说它比 Python simplejson慢 5 倍时,我们开始寻找更好的解决方案。

如果您需要速度,JSON over HTTP 绝对不是最佳选择。不幸的是,我们所有的服务都使用 JSON 相互通信,因此合并一个新协议超出了这项任务的范围(但我们正在考虑使用avro,就像我们为Kafka所做的那样)。我们决定坚持使用 JSON。快速搜索为我们提供了一个名为ffjson的解决方案。

json.Unmarshal
json 16154 纳秒/操作 1875 年 B / 作品 37 分配/操作
ffjson 8417 纳秒/运算 1555 B / 操作 31 分配/操作

我们的测试证实 ffjson 比内置解组器快近 2 倍并且执行的分配更少。怎么可能做到这一点?

json.Unmarshal
json(无效的 json) 1027 纳秒/运算 384 B / 作品 9个分配/操作
ffjson(无效的 json) 2598 纳秒/运算 528 B / 操作 13 个分配/操作

更多关于 ffjson 如何工作的信息可以在这里找到。基准可在此处获得

最终结果

最后,我们将应用程序的最长请求时间从超过 2.5 秒加快到不到 250 毫秒。这些时间只发生在我们的用例中。我们相信,对于更多的写入或更长的驱逐周期,访问标准缓存可能需要更多时间,但使用 bigcache 或 freecache 可以保持在毫秒级别,因为消除了长时间 GC 暂停的根源。

下图比较了我们服务优化前后的响应时间。在测试期间,我们发送了 10k rps,其中 5k 用于写入,另外 5k 用于读取。驱逐时间设置为 10 分钟。测试时间为 35 分钟。

最终结果是孤立的,设置与上述相同。

概括

如果您不需要高性能,请坚持使用标准库。它们保证得到维护,并且具有向后兼容性,因此升级 Go 版本应该是顺利的。

我们用 Go 编写的缓存服务终于满足了我们的要求。我们大部分时间都在弄清楚 GC 暂停会对应用程序的响应能力产生巨大影响,因为它控制着数百万个对象。幸运的是,像bigcachefreecache这样的缓存解决了这个问题。