Redis 集群问题:修复 Redis 中的密钥过期问题
在 Redis 集群中发现了一个有趣的问题。经过花费大量时间调试和测试,我们通过更改密钥过期时间,在某些集群中能够将 Redis 内存使用量减少 25%。
Twitter 在内部运营多种缓存服务。其中之一实现了Redis。我们的 Redis 集群存储一些有关 Twitter 使用案例的重要数据,例如印象和参与度数据、广告支出计数和直接消息。
问题背景
早在2016年初,Twitter Cache团队就对Redis集群架构进行了大量更新。 Redis 发生了一些变化,包括从 Redis 2.4 更新到 3.2。在此更新之后,出现了几个问题,例如用户开始看到内存使用情况与他们预期或准备使用的情况相反、延迟增加以及按键擦除问题。擦除密钥是一个大问题,可能会导致应保留的数据被删除,或者可能会向原始数据存储发送请求。
初步调查
受影响团队和缓存团队已开始初步调查。我们发现与目前正在进行的关键解决方案相关的延迟有所增加。当Redis收到写入请求但没有内存来存储写入时,它会停止正在执行的操作,删除键,然后存储新键。然而,我们仍然需要找出是什么原因导致新清理的内存使用量增加。
我们怀疑内存中充满了过期但尚未删除的密钥。有些人建议使用扫描。 scan 方法读取所有密钥并删除过期的密钥。
在Redis中,key有两种过期方式,主动过期和被动过期。扫描将触发被动密钥过期。读取密钥后,将检查 TTL。如果 TTL 已过期,则 TTL 将被清除并且不会返回任何内容。 Redis 文档中描述了版本 3.2 中的活动密钥过期。活动密钥过期从一个名为 activeExpireCycle 的函数开始。它在称为 cron 的内部计时器上每秒运行几次。 activeExpireCycle 函数迭代每个键空间,使用一组 TTL 检查随机覆盖,如果满足已过期覆盖的阈值百分比,则重复该过程,直到满足时间限制。
这种扫描所有老鼠的方法非常高效,而且扫描完成后还可以减少内存使用。 Redis 似乎不再有效地使密钥过期。然而,当时的解决方案是增加集群大小和更多硬件,以更多地分散密钥并提供更多可用内存。这是令人失望的,因为前面提到的 Redis 升级项目通过提高集群的效率来减少运行这些集群的规模和成本。
Redis 版本:发生了什么变化?
在Redis 2.4和3.2之间,activeExpireCycle的实现发生了变化。在Redis 2.4中,每个数据库在每次启动时都会进行检查,而在Redis 3.2中,可以检查的数据库数量达到最大。 3.2 版还引入了快速数据库检查选项。 “慢”在计时器上运行,“快”在检查事件循环中的事件之前运行。快速过期周期在某些条件下会提前返回,并且超时和终止选项的阈值也较低。时间限制也将被更频繁地检查。此功能总共添加了 100 行代码。
进一步调查
我们最近有时间再次回顾这个内存使用问题。我们想要调查为什么会发生回归,然后看看如何更好地实现密钥过期。我们的第一个想法是,Redis 中有很多键,只有 20 个样本是不够的。我们想要探索的另一件事是 Redi 3.2 中引入的数据库限制的影响。
分片的扩展和处理方式使得 Twitter 的 Redis 流量独一无二。我们有一个包含数百万个密钥的密钥空间。这对于 Redis 用户来说是不寻常的。分片由键空间表示,因此每个 Redis 实例可以有多个分片。我们的 Redis 实例有很多关键位置。共享与 Twitter 的规模相结合,创建了一个具有大量密钥和数据库的密集后端。
过期测试改进
每个循环中采样的数量由变量
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP
配置。我决定测试这三个值并在有问题的集群之一上运行它们,然后运行扫描并测量前后内存使用情况的差异。如果前后内存使用量差异较大,则说明有大量过期数据等待组装。该测试最初在内存使用方面取得了积极的结果。该测试有一个控件和三个可以对多个键进行采样的测试实例。 500和200是任意的。值 300 是统计样本大小计算器的输出,其中键的总数是总体大小。在上表中,即使仅查看测试实例的初始数量,也可以清楚地看出它们的性能更好。运行检查的百分比差异表明过期密钥的开销约为 25%。
虽然多密钥采样可以帮助我们找到更多过期密钥,但延迟的负面影响超出了我们的承受能力。
上图显示 99.9% 的延迟(以毫秒为单位)。这表明延迟与采样密钥数量的增加有关。橙色代表数值500,绿色代表300,蓝色代表200,控件为黄色。这些线条对应于上表中的颜色。
在看到延迟受样本大小影响后,我想知道是否可以根据过期密钥的数量自动调整样本大小。当多个密钥过期时,延迟会受到影响,但当没有更多事情可做时,我们会扫描更少的密钥并更快地启动。
这个想法基本上是有效的,我们可以看到内存使用率较低,延迟不受影响,并且跟踪样本大小的指标显示它随着时间的推移而增加和减少。但是,我们没有接受这个解决方案。该解决方案引入了一些我们的控制实例中不存在的延迟峰值。代码也有点复杂,难以解释且不直观。我们还需要针对每个不太理想的集群进行调整,因为我们希望避免增加操作复杂性。
探索版本之间的兼容性
我们还想探索 Redis 版本之间的变化。新版本的Redis引入了一个名为CRON_DBS_PER_CALL的变量。此变量设置每次运行此 cron 时要检查的数据库的最大数量。为了测试这个变量的影响,我们简单地注释掉了这些行。
//if (dbs_per_call > server.dbnum || timelimit_exit)dbs_per_call = server.dbnum;复制代码
比较每次运行时检查所有有限制的数据库与检查所有无限制的数据库的效果。我们的基准测试结果非常令人兴奋。然而,我们的测试实例只有一个数据库,逻辑上这行代码不区分修改版本和未修改版本。变量总是被设置的。
99.9% 以微秒为单位测量。未修改的 Redis 位于顶部,修改后的 Redis 位于底部。
我们开始研究为什么评论这句话会产生如此巨大的影响。由于这是一个 if 语句,因此我们的第一个怀疑是分支预测。我们使用
gcc’s__builtin_expect
来更改代码的编译方式。但是,这对性能没有影响。接下来,我们将查看生成的报告以了解到底发生了什么。
我们将if命令组装成三个重要的指令mov、cmp和jg。 mov 将一部分内存加载到寄存器中,cmp 比较两个寄存器并根据结果设置下一个寄存器,jg 根据第二个寄存器的值进行条件跳转。要跳转到的代码将是 if 或 else 块中的代码。我取出if语句,将编译好的程序集放入Redis中。然后我通过注释不同的行来测试每条指令的效果。我测试了mov指令,看看加载内存或CPU缓存是否存在性能问题,但没有发现任何差异。我测试了 cmp 命令,没有发现任何区别。当我使用包含的 jg 指令运行测试时,延迟恢复到不变的水平。发现这一点后,我测试了这只是一个跳转还是一个特定的 jg 指令。我添加了一条无条件 jmp 跳转指令,可以跳转然后跳回正在运行的代码,而不会造成任何性能损失。
我们花了一些时间探索各种性能指标,并尝试了 CPU 手册中列出的一些自定义指标。对于该指令为何会导致这样的性能问题,我们没有结论。我们对指令缓冲区和 CPU 在执行跳转时的行为有一些想法,但我们已经没有时间了,如果可能的话,我们会在将来再讨论它。
解决方案
现在我们已经很好地了解了问题的原因,我们必须选择解决问题的方案。我们的决定是进行简单的修改,以在启动选项中配置稳定的样本大小。这样我们就可以在延迟和内存使用之间找到一个很好的平衡。即使删除 if 语句带来了如此显着的改进,如果我们无法解释原因,也很难做出改变。
该图表示第一个部署的集群的内存使用情况。顶行(粉色)隐藏在橙色后面,表示集群的平均内存使用情况。橙色顶行是该控件的一个实例。图表的中间部分是新的变化趋势。第三部分与浅黄色相比显示了控件的重新启动实例。重新启动后,控件的内存使用量迅速增加。
这是一项涉及工程师和多个团队的相当大的调查,集群大小减少 25% 是一个非常好的结果,我们从中学到了很多东西!我们想重新审视这段代码,看看发生了什么,在其他性能和调优团队的帮助下我们可以进行哪些优化。
对这项研究做出重大贡献的其他工程师包括 Mike Barry、Rashmi Ramesh 和 Bart Robinson。
-完-
作者:Matthew Tejo
翻译:徐野
作者:运维
链接:https://34d45ccecceim。 9
来源:Dig Kim
版权归作者所有。商业转载请联系作者获得许可。非商业转载请注明来源。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。