[译] 用 Go 编写能存数百万条记录仍非常快的缓存服务

栏目: IT技术 · 发布时间: 4年前

内容简介:推荐阅读

点击上方蓝色“ Go语言中文网 ”关注我们, 领全套 Go 资料 ,每天学习 Go 语言

本文发布于 2016 年 3 月,但其中的设计技巧仍然有效。

我们的团队需要编写非常快速的缓存服务。目标非常明确,但可以通过多种方式实现。最后,我们决定尝试一些新的东西,并在 Go 中实现该服务。本文描述了我们是如何做到的以及由此产生的价值。

目录

  1. 需求

  2. 为什么用 Go

  3. 缓存

    1. 并发

    2. 移除

    3. 避免 GC

    4. BigCache

  4. HTTP 服务器

  5. JSON 反序列化

  6. 最终结果

  7. 总结

需求

根据需求,我们的服务应:

  • 使用 HTTP 协议处理请求

  • 支持 10K RPS (5k 写,5k 读)

  • cache 对象至少保持 10 分钟

  • 响应时间平均 5ms,99.9% 以外 10ms, 99.999% 以外 400ms

  • 处理包含 JSON 消息的 POST 请求,消息包含:

    • 包含 ID 和内容

    • 不超过 500 字节

  • 通过 POST 请求添加记录后,立即检索记录并通过 GET 请求返回 int,也就是保证一致性

简而言之,我们的任务是编写带有到期时间和 REST 接口的快速字典。

为什么是 Go?

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

在一家大公司(谷歌)和一个不断增长的用户社区的支持下,Go 发布已有一段时间了。它是一门编译,并发,命令式,结构化的编程语言。它还具有自动内存管理,因此比 C/C++ 看起来更安全,更容易使用。我们在用 Go 编写 工具 方面拥有相当丰富的经验,因此决定在此处使用它。我们已经有了一个 Go 的 开源项目 [1] ,现在我们想知道 Go 如何处理大流量。我们相信整个项目用 Go 仅需不到 100 行的代码即可完成任务,并且速度足以满足我们的需求。

缓存

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

  • 即使有数以百万计的记录也非常快

  • 提供并发访问

  • 在预定的时间后移除记录

基于第一点,我们决定放弃外部缓存,例如 Redis,Memcached 或 Couchbase,主要是因为网络上需要更多时间。因此,我们专注于内存中的缓存。在 Go 中已经有这种类型的缓存,即 LRU groupcache [2]go-cache [3]ttlcache [4]freecache [5] 等。只有 freecache 满足我们的需求。接下来的子章节会揭示为什么我们决定还是坚持自己实现,并描述了如何实现上述需求。

并发

我们的服务将同时接收许多请求,因此我们需要提供对缓存的并发访问。最简单的方法是将 sync.RWMutex 放在缓存访问功能的前面,以确保一次只能通过一个 goroutine 对其进行修改。但是,其他想对其进行修改的 goroutine 将被阻塞,使其成为瓶颈。为了消除此问题,可以应用 shards(分片)技术。分片背后的想法很简单。创建 N 个分片的数组,每个分片包含自己的带有锁的缓存实例。当需要缓存具有唯一 key 的记录时,首先通过函数 hash(key)%N 选择一个分片。在此之后,获取缓存锁并对缓存进行写入。记录读取是类似的。当分片的数量相对较多并且哈希函数返回的数字较分散时,锁争用几乎可以降至零。这就是我们决定在缓存中使用分片的原因。

移除(Eviction)

从缓存中移除元素的最简单方法是将其与 FIFO 队列一起使用。将记录添加到高速缓存后,将执行两个附加操作:

  • 包含 key 和创建时间戳的记录被添加到队列的末尾。

  • 从队列中读取最早的记录。将其创建时间戳与当前时间进行比较。如果晚于收回时间,则将队列中的元素及其在缓存中的对应记录一起删除。

由于在写入缓存期间已获取了锁,因此顺带执行移除操作。

避免 GC

在 Go 中,对于 map,垃圾收集器(GC)将在标记(mark)和扫描(scan)阶段遍历该 map 的每个条目。当 map 足够大(包含数百万个对象)时,这可能会对应用程序性能产生巨大影响。

我们对服务进行了一些测试,在该服务中向缓存添加了数百万个记录,然后我们开始将请求发送到一些不相关的 REST 端点(endpoint),仅执行静态 JSON 序列化(根本不涉及缓存)。缓存为空时,此端点的最大响应延迟为 10ms,10k rps。当缓存被填满时,它在 99 的百分位上有超过一秒钟的延迟。度量标准表明,堆中有超过 4000 万个对象,GC 标记和扫描阶段花费了四秒钟。该测试向我们表明,如果要满足与响应时间有关的要求,则需要跳过 GC 以获取缓存项。我们该怎么做?好吧,有三个选择。

GC 仅限于堆,因此第一个选择是堆外。有一个项目可以帮助解决这个问题,称为 offheap [6] 。它提供了自定义函数 Malloc()和 Free() 来管理堆外部的内存。但是,将需要实现依赖于那些功能的缓存。

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

为缓存条目避免 GC 的第三种方式与 Go 1.5 版本修复的一个 issue 有关( issue 9477 [8] )。此优化表明,如果你使用的是 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。BigCache 提供分片,移除(即淘汰),并且避免了缓存条目的 GC 问题。结果,即使对于大量记录,它也是非常快的缓存。

Freecache 是 Go 中唯一提供这种功能的可用内存中缓存之一。Bigcache 是它的替代解决方案,并以不同的方式减少了 GC 开销,因此我们决定分享它: bigcache [9] 。有关 freecache 和 bigcache 比较的更多信息,请参见 GitHub [10]

HTTP Server

内存探查器(profiler)向我们展示了在请求处理期间分配了一些对象。我们知道 HTTP 处理程序将成为我们系统的热点。我们的 API 非常简单。我们仅接受 POST 和 GET 从缓存中上传和下载元素。我们实际上仅支持一个 URL 模板,因此不需要功能齐全的路由器。我们通过剪切前 7 个字母从 URL 中提取了 ID,它对我们来说很好用。

当我们开始开发时,Go 1.6 发布了 RC 版。我们减少请求处理时间的第一个尝试是将其更新到最新的 RC 版本。在我们的场景中,性能几乎相同。我们开始寻找更有效的方法,然后找到了 fasthttp [11] 。它是一个提供零分配 HTTP 服务器的库。根据文档,在综合测试中,它通常比标准 HTTP 处理程序快 10 倍。在我们的测试过程中,结果证明它仅快 1.5 倍,但还是更好!

fasthttp 通过减少 HTTP Go 程序包完成的工作来提升其性能。例如:

  • 将请求生存期限制为实际处理的时间

  • header 是延迟解析的(我们真的不需要 header)

不幸的是,fasthttp 并不是标准 http 的真正替代。它不支持路由或 HTTP/2,并声称不能支持所有 HTTP 边缘情况。对于具有简单 API 的小型项目而言,这很好,因此对于正常(非超级性能)项目,我们会坚持使用默认 HTTP。

[译] 用 Go 编写能存数百万条记录仍非常快的缓存服务 fasthttp vs nethttp

JSON 反序列化

在对应用程序进行性能分析时,我们发现该程序在 JSON 反序列化上花费了大量时间。内存探查器还报告说 json.Marshal 处理了大量数据。这并不令我们感到惊讶。对于 10k rps,每个请求 350 个字节对于任何应用程序来说都是重要的有效负载(payload)。尽管如此,我们的目标是速度,所以我们对其进行了调研。

我们听说 Go JSON 序列化程序的速度不如其他语言快。大多数基准测试是在 2013 年完成的,因此这是在 1.3 版之前做的测试。当我们看到 issue-5683 [12] 声称 Go JSON 比 Python 慢 3 倍,而 邮件列表 [13] 说它比 Python simplejson [14] 慢 5 倍时,我们开始寻找更好的解决方案。

如果需要速度,基于 HTTP 的 JSON 绝对不是最佳选择。不幸的是,我们所有的服务都以 JSON 相互通信,因此采用新协议超出了此任务的范围(但我们正在考虑使用 avro [15] ,就像我们对 Kafka [16] 所做的那样)。我们决定坚持使用 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 如何工作的更多信息,请参见 此处 [17] 。基准测试 在这里 [18]

最终结果

最后,对于最耗时的请求,我们将时间从 2.5 秒以上缩短到了 250 毫秒以下。这些时间仅发生在我们的场景中。我们相信,对于大量的写入操作或更长的移除(淘汰)周期,对标准缓存的访问可能会花费更多的时间,但是对于 bigcache 或 freecache 来说,访问时间可以保持在毫秒级,因为消除了长时间 GC 暂停这个根源。

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

[译] 用 Go 编写能存数百万条记录仍非常快的缓存服务

response times before and after optimizations

使用上述相同的设置进行单独测试(读写单独)的最终结果如下。

[译] 用 Go 编写能存数百万条记录仍非常快的缓存服务

final results

总结

如果您不需要高性能,请使用标准库。确保维护性,因为它们具有向后兼容性,因此升级 Go 版本会很顺利。

我们用 Go 编写的缓存服务终于满足了我们的需求。大多数时候,我们花时间弄清楚 GC 暂停可能会对应用程序的响应性产生巨大影响,因为它控制着数百万个对象。幸运的是,像 bigcache 或 freecache 这样的缓存可以解决此问题。

原文链接:https://allegro.tech/2016/03/writing-fast-cache-service-in-go.html

作者:Łukasz Drumiński、Tomasz Janiszewski

参考资料

[1]

开源项目: https://github.com/allegro/marathon-consul/#marathon-consul-

[2]

LRU groupcache: https://github.com/golang/groupcache/tree/master/lru

[3]

go-cache: https://github.com/patrickmn/go-cache

[4]

ttlcache: https://github.com/diegobernardes/ttlcache

[5]

freecache: https://github.com/coocood/freecache

[6]

offheap: https://godoc.org/github.com/glycerine/offheap

[7]

freecache: https://github.com/coocood/freecache

[8]

issue 9477: https://github.com/golang/go/issues/9477

[9]

bigcache: https://github.com/allegro/bigcache

[10]

GitHub: https://github.com/allegro/bigcache#bigcache-vs-freecache

[11]

fasthttp: https://github.com/valyala/fasthttp

[12]

issue-5683: https://github.com/golang/go/issues/5683

[13]

邮件列表: https://groups.google.com/forum/#!topic/golang-nuts/zCBUEB_MfVs

[14]

simplejson: https://pypi.org/project/simplejson/

[15]

avro: https://avro.apache.org/

[16]

Kafka: https://allegro.tech/2015/08/spark-kafka-integration.html

[17]

此处: https://journal.paul.querna.org/articles/2014/03/31/ffjson-faster-json-in-go/

[18]

在这里: https://gist.github.com/janisz/8b20eaa1197728e09d6a

推荐阅读

喜欢本文的朋友,欢迎关注“ Go语言中文网

[译] 用 Go 编写能存数百万条记录仍非常快的缓存服务

Go语言中文网启用微信学习交流群,欢迎加微信: 274768166 ,投稿亦欢迎


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

第二曲线:跨越“S型曲线”的二次增长

第二曲线:跨越“S型曲线”的二次增长

[英]查尔斯·汉迪(Charles Handy) / 苗青 / 机械工业出版社 / 2017-6 / 49.00

S型曲线是每个组织和企业在预测未来时一定会参考的工具,一切事物的发展都逃不开S型曲线(“第一曲线”)。 然而,从公司组织、企业治理、市场的变化,到个人职业发展、社会人际关系以及未来的教育与社会价值,多维度地探讨这个世界需要重新以不同的角度来思考问题,不能够总是停留在“第一曲线”的世界。 如果组织和企业能在第一曲线到达巅峰之前,找到带领企业二次腾飞的“第二曲线”,并且第二曲线必须在第一曲......一起来看看 《第二曲线:跨越“S型曲线”的二次增长》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具