本文介绍了twitter如何通过优化key的过期淘汰使得在一些集群中,redis的内存使用降低了25%。除此之外,该文章介绍的问题解决思路也有非常大的借鉴意义: 发现问题-检查变更-测试变更-分析变更-选择解决方案。对于解决方案的选择也相当有趣,当你发现修改一行代码可以让性能提升的时候,但是无法搞清楚为啥能提升,那么你是否会选择使用这行代码呢。
Even though removing the if statement caused such a drastic improvement we were uncomfortable making the change without being able to explain why it was better
最终的结果是twitter的工程师并没有使用这行代码,因为当你不知道它的原理的时候,可能表面可以带来提升,但是背后是否会有啥隐患是你无法把握的。
最后twitter使用添加可配置的参数来调节key过期的策略,同时向redis提交了一个PR,但是该pr最终没有合入,redis最终选择了另一个类似的PR,没有合入的原因是
I'm not sure it should be exposed to the user in such terms of iterations / divisor, that are implementation details.
对比两个pr的区别,前一个暴露了除法的实现细节,后一个则是通过直接指定一个值来进行配置,对用户屏蔽了除法的实现。应用配置不应该对用户暴露代码的实现细节,这也是在代码设计上非常重要的一个点。
以下为原文翻译,原文链接 : https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/improving-key-expiration-in-redis
最近,我们遇到了一个有趣的问题,我们在 Redis 集群中遇到了一些性能问题。在花费大量时间进行调试和测试之后,我们能够通过更改key过期将 Redis 在我们的某些集群中的内存使用量减少多达 25%。
Twitter 在内部运行多个缓存服务。其中之一是由 Redis 支持的。我们的 Redis 集群存储一些 Twitter 最重要用例的数据,例如印象和参与数据、广告支出计数和直接消息。
问题及背景
早在 2016 年初,Twitter 的缓存团队对我们的 Redis 集群的架构进行了大规模更新。核心是从从 Redis 2.4 版到 3.2 版的更新。在此更新之后,出现了几个问题。用户开始看到内存使用与他们预期或配置使用的不一致、延迟增加和key驱逐。key驱逐是一个大问题,因为预计会持久的数据被删除,或者由于缓存miss而回源。
初步调查
受影响的团队与缓存团队一起开始调查。我们发现延迟增加与现在发生的key驱逐有关。当 Redis 收到写入请求但没有内存来保存写入时,它会停止正在做的事情,驱逐一个key,然后保存新的key。我们仍然需要找到导致这些新驱逐的内存使用量增加发生在哪里。
我们怀疑内存中充满了已过期但尚未删除的密钥。有人建议的一个想法是使用扫描,它会读取所有密钥,从而删除过期的密钥。
在 Redis 中有两种方式可以使key过期,主动和被动。扫描将触发被动key过期,当eky被读取时,将检查 TTL,如果过期则将其丢弃并且不返回任何内容。 Redis 文档中描述了版本 3.2 中的主动过期机制。它从一个名为 activeExpireCycle 的函数开始。它运行在 Redis 称为 cron 的内部计时器上,每秒运行几次。该函数的作用是循环遍历每个键空间,检查设置了 TTL 的随机键,如果过期键达到百分比阈值,则重复此过程直到达到时间限制。
这种扫描所有键的想法奏效了,扫描完成后内存使用量下降。 Redis 似乎不再主动使key过期。不幸的是,当时的解决方案是增加集群的大小和更多的硬件,这样key就会分布得更多,并且会有更多的可用内存。这令人失望,因为前面提到的升级 Redis 的项目通过提高效率来减少运行这些集群的大小和成本。
检查redis版本变更
在 2.4 和 3.2 版本之间,activeExpireCycle 的实现发生了变化。在 2.4 中,每次运行时都会检查每个数据库,在 3.2 中,现在可以检查的数据库数量有一个最大值。 3.2 版还为该功能引入了一个快速选项。 “慢”在计时器上运行,“快”在检查事件循环上的事件之前运行。快速到期周期会在某些条件下提前返回,并且函数超时和退出的阈值也较低。时间限制也被更频繁地检查。该函数总共添加了 100 行代码。
进一步分析
最近我们有时间回过头来重新审视这个内存使用问题。我们想探索为什么会出现退化,然后看看我们如何才能更好地使key过期。我们的第一个理论是,在一个 Redis 实例中有这么多的键,采样 20 是不够的。我们要调查的另一件事是 3.2 中引入的数据库限制的影响。
规模和处理分片的方式使得在 Twitter 上运行 Redis 是独一无二的。我们拥有包含数百万个键的大型键空间。这对于 Redis 的用户来说并不典型。分片由一个键空间表示,因此每个 Redis 实例都可以有多个分片。我们的 Redis 实例有很多键空间。分片与 Twitter 的规模相结合,创建了具有大量密钥和数据库的密集后端。
改动内容测试验证
每个循环上的采样数由变量 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 配置。我决定测试三个值并在其中一个有问题的集群中运行它们,然后运行扫描,并测量内存使用前后的差异。较大的变化表明有大量过期数据等待收集。该测试最初对内存使用有积极的结果。
该测试有一个控件和三个对更多键进行采样的测试实例。数字 500 和 200 是任意的。值 300 基于统计样本大小计算器的输出,其中总键是总体大小。在上面的图表中,即使只看测试实例的起始数字,也很明显它们表现得更好。与运行扫描的百分比差异表明,过期key的开销约为 25%。
尽管对更多key进行采样有助于找到更多过期密钥,但负面延迟影响超出了我们的容忍范围。
上图显示了 99.9% 的延迟(以毫秒为单位)。这表明延迟与采样键的增加相关。橙色使用值 500,绿色使用 300,蓝色使用 200,控件为黄色。线条与上表中的颜色相匹配。
在看到延迟受样本大小的影响后,我想看看是否可以根据要过期的key数量自动调整样本大小。当有更多的键过期时,延迟会受到影响,但是当没有更多的工作要做时,我们会扫描更少的键并更快地执行。
这个想法最有效,我们能够看到内存使用量较低,延迟没有受到影响,并且度量跟踪样本大小显示它随着时间的推移而增加和减少。但是,我们没有采用这种解决方案。它引入了一些在我们的控制实例中没有出现的延迟峰值。代码也有点复杂,难以解释,而且不直观。我们还必须为每个不理想的集群调整它,因为我们希望避免增加操作复杂性。
分析版本间性能差距
我们还想调查 Redis 版本之间的变化。新版本引入了一个名为 CRON_DBS_PER_CALL 的变量。这设置了每次运行此 cron 时将检查的最大数据库数。为了测试它的影响,我们简单地注释掉了这些行
//if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum;
这将比较有限制和每次运行检查所有数据库之间的效果。我们的基准测试结果令人兴奋。虽然我们的测试实例只有一个数据库,但从逻辑上讲,这行代码在修改版本和未修改版本之间没有区别。该变量将始终设置。
我们开始研究为什么注释掉的这一行会产生如此巨大的差异。由于这是一个 if 语句,我们首先怀疑的是分支预测。我们利用 gcc 的 __builtin_expect 来改变代码的编译方式。它对性能没有任何影响。
接下来,我们查看了生成的汇编,看看到底发生了什么。
if 语句编译为 3 个重要指令,mov、cmp 和 jg。 mov 会将一些内存加载到一个寄存器中, cmp 将比较两个寄存器并根据结果设置另一个寄存器,而 jg 将根据另一个寄存器的值进行条件跳转。跳转到的代码将是 if 块或 else 中的代码。我把if语句取出来,把编译好的程序集放到Redis中。然后我通过注释掉不同的行来测试每条指令的效果。我测试了 mov 指令,看看是否存在加载内存或 cpu 缓存问题的某种性能问题,但没有区别。我测试了 cmp 指令,没有任何区别。当我在包含 jg 指令的情况下运行测试时,延迟又回到了未修改的水平。在找到这个之后,我测试了它是只是跳转还是这个特定的 jg 指令。我添加了非条件跳转指令 jmp 来跳转,然后跳转回正在运行的代码,并且没有性能损失。
我们花了一些时间查看不同的性能指标,并尝试了 cpu 手册中列出的一些自定义指标。关于为什么一条指令会导致这样的性能问题,尚无定论。我们有一些与指令缓存缓冲区和执行跳转时的 cpu 行为相关的理论,但时间已用完,并决定将来可能会回到这一点。
最终解决方案
既然我们对问题的原因有了更好的了解,我们就需要为这个问题选择一个解决方案。我们的决定是进行简单的修改,即能够在启动选项中配置稳定的样本量。我们能够找到一个在延迟和内存使用之间进行良好权衡的值。尽管删除 if 语句导致了如此巨大的改进,但我们在无法解释为什么它更好的情况下做出改变感到不舒服。
该图是部署到的第一个集群的内存使用情况。隐藏在橙色后面的粉红色顶线是集群的内存使用中值。橙色的顶行是一个控制实例。图表的中间部分是新变化的金丝雀。第三部分显示了一个控制实例被重新启动以与金丝雀进行比较。重新启动后控件上的内存使用量迅速增加。
最终这是一项相当大的调查,其中包括一些工程师和多个团队,但集群大小减少了 25% 是一个非常好的结果,我们学到了很多东西!我们想再看一下这段代码,看看我们可以在其他一些专注于性能和调优的团队的帮助下进行哪些优化。似乎还有很多潜在的收获。
Reference:
https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/improving-key-expiration-in-redis
https://github.com/redis/redis/pull/5843
https://github.com/redis/redis/commit/84b01f63dbe28d5541e09313d35deacf4344ab16