在 Mattermost,我们最近能够扩展到10 万名用户。但我们不想就此止步,我们想更进一步。这篇文章详细介绍了这项努力如何导致我们将 Redis引入我们的架构,以及我们在大规模运行 Redis 方面学到的经验教训。
从历史上看,我们一直使用简单的内存 LRU 缓存来满足所有缓存需求。到目前为止,这种方法效果很好,但随着我们努力进一步扩展,我们开始遇到一些扩展瓶颈。
我们面临的一个主要问题是缓存失效。由于我们的内存缓存架构,Mattermost 集群中的每个应用节点都有自己的缓存。当某些东西失效时,它需要在所有节点上发生。这意味着每个节点都必须单独进行自己的数据库调用,以便稍后重新填充缓存条目。这导致了扩展问题,集群中添加的节点越多,问题就越严重。引入 Redis 作为外部缓存解决了这个问题,因为所有节点都会联系 Redis,因此只需进行一次数据库调用。
引入 Redis 还使我们能够使用其强大的发布-订阅机制来高效地执行大规模的集群广播。
但实现 Redis 并不像用 Redis 缓存替换 LRU 缓存那么简单。我天真地以为会这样。结果却要棘手得多。让我们来看看在这个过程中学到的一些教训。
选择库包
我们选择了https://github.com/redis/rueidis库,而不是更流行的https://github.com/redis/go-redis。
主要原因是 rueidis 默认可以做更多的事情,而 go-redis 则依赖用户做很多繁重的工作。例如:
- 并发查询的自动流水线化
- 客户端缓存
批处理不是可选的
Redis 有一个称为流水线的功能,允许在一次调用中发送多个命令。如果你想高效地使用 Redis,这是必须具备的功能,也是我们选择rueidis而不是go-redis 的原因之一。使用后者,你需要自己手动构建管道,但如果查询来自单独的 goroutine,rueidis则会自动将查询流水线化。
这不仅减少了往返时间(RTT),而且还减少了 Redis 本身的系统调用开销。
使用客户端缓存可实现两全其美的效果
即使我们批量处理传出的请求,访问网络也会产生无法忽略的成本。幸运的是,Redis 允许这样的功能。分配少量内存用作客户端缓存可以对减少应用程序延迟产生很大影响。但必须小心确定缓存的最佳大小。如果太小,则可能没有足够的改进。如果它太大,那么您将在尝试使缓存无效时遇到额外的网络开销。我们最终为每个连接使用了 32MiB。分析您的应用程序以找出最常用的缓存对象并得出一个合理的数字。
如果使用 GET/DELETE 时存在紧密循环,请使用 MGET/MDELETE
对于某些 API 调用,我们发现其性能一直很差,而且原因不明。 我们花了很长时间才弄明白,但最后终于明白,这是由于在紧密循环中使用了 Redis GET 命令。 我们的情况是这样的
var itemsToQueryFromDB []string |
这在使用内存缓存时效果很好,但在使用 Redis 时却非常糟糕。 因为每次调用的网络延迟都会因每个项目而不断累积。 项目越多,返回的时间就越长!我们通过使用 MGET 一次调用获取所有元素来解决这个问题,就像这样:
var itemsToQueryFromDB []string |
小心大量 SCAN 调用
我们有一些缓存,需要删除某些对象,为此我们需要遍历整个缓存。
例如,我们的会话缓存以会话 ID 为关键字,但要按用户 ID 删除所有会话对象,我们需要遍历所有会话对象,找出 ID 等于用户 ID 的对象,然后删除它们。 这就像
var toDelete []string |
由于 SCAN 调用量巨大,这给 Redis 的 CPU 带来了相当大的负载。 我们试着调整批量大小,但效果并不明显。 最后,我们不得不继续使用内存中的缓存来处理一些具有这种特性的缓存。
确保在使用 Pub-sub 时不自行发送信息
在使用 pub-sub 时,请记住 Redis 会将每条消息发送给每个订阅者,即使它是发布者! 因此,如果某个节点既是发布者又是订阅者,而你又不想让发布者节点接收到刚发布的事件,那么你就需要在事件中添加元数据,指明它来自哪个节点,然后忽略它(如果它来自发起节点)。