最近咱们在Redis集群中发现了一个有趣的问题。在花费大量时间进行调试和测试后,经过更改key过时,咱们能够将某些集群中的Redis内存使用量减小25%。html
Twitter内部运行着多个缓存服务。其中一个是由Redis实现的。咱们的Redis集群中存储了一些Twitter重要的用例数据,例如展现和参与度数据、广告支出计数和直接消息。redis
早在2016年初,Twitter的Cache团队就对Redis集群的架构进行了大量更新。Redis发生了一些变化,其中包括从Redis 2.4版到3.2版的更新。在此更新后,出现了几个问题,例如用户开始看到内存使用与他们的预期或准备使用的内存不一致、延迟增长和key清除问题。key的清除是一个很大的问题,这可能致使本应持久化的数据可能被删除了,或者请求发送到数据原始存储。数据库
受影响的团队和缓存团队开始进行初步的调查。咱们发现延迟增长与如今正在发生的key清除有关。当Redis收到写入请求但没有内存来保存写入时,它将中止正在执行的操做,清除key而后保存新key。可是,咱们仍然须要找出致使这些新清除的内存使用量增长的缘由。后端
咱们怀疑内存中充满了过时但还没有删除的key。有人建议使用扫描,扫描的方法会读取全部的key,而且让过时的key被删除。缓存
在Redis中,key有两种过时方式,主动过时和被动过时。扫描将触发key的被动过时,当读取key时, TTL将会被检查,若是TTL已过时,TTL会被删除而且不返回任何内容。Redis文档中描述了版本3.2中的key的主动过时。key的主动过时以一个名为activeExpireCycle的函数开始。它以每秒运行几回的频率,运行在一个称为cron的内部计时器上。activeExpireCycle函数的做用是遍历每一个密钥空间,检查具备TTL集的随机kry,若是知足过时kry的百分比阈值,则重复此过程直到知足时间限制。bash
这种扫描全部kry的方法是有效的,当扫描完成时,内存使用量也降低了。彷佛Redis再也不有效地使key过时了。可是,当时的解决方案是增长集群的大小和更多的硬件,这样key就会分布得更多,就会有更多的可用内存。这是使人失望的,由于前面提到的升级Redis的项目经过提升集群的效率下降了运行这些集群的规模和成本。架构
Redis版本2.4和3.2之间,activeExpireCycle的实现发生了变化。在Redis 2.4中,每次运行时都会检查每一个数据库,在Redis3.2中,能够检查的数据库数量达到了最大值。版本3.2还引入了检查数据库的快速选项。“Slow”在计时器上运行,“fast” 运行在检查事件循环上的事件以前。快速到期周期将在某些条件下提早返回,而且它还具备较低的超时和退出功能阈值。时间限制也会被更频繁地检查。总共有100行代码被添加到此函数中。函数
最近咱们有时间回过头来从新审视这个内存使用问题。咱们想探索为何会出现regression,而后看看咱们如何才能更好地实现key expiration。咱们的第一个想法是,在Redis中有不少的key,只采样20是远远不够的。咱们想研究的另外一件事是Redi 3.2中引入数据库限制的影响。性能
缩放和处理shard的方式使得在Twitter上运行Redis是独一无二的。咱们有包含数百万个key的key空间。这对于Redis用户来讲并不常见。shard由key空间表示,所以Redis的每一个实例均可以有多个shard。咱们Redis的实例有不少key空间。Sharding与Twitter的规模相结合,建立了具备大量key和数据库的密集后端。测试
每一个循环上采样的数字由变量
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP
该测试有一个控件和三个测试实例,能够对更多key进行采样。500和200是任意的。值300是基于统计样本大小的计算器的输出,其中总key数是整体大小。在上面的图表中,即便只看测试实例的初始数量,也能够清楚地看出它们的性能更好。这个与运行扫描的百分比的差别代表,过时key的开销约为25%。
虽然对更多key进行采样有助于咱们找到更多过时key,但负延迟效应超出了咱们的承受能力。
上图显示了99.9%的延迟(以毫秒为单位)。这代表延迟与采样的key的增长相关。橙色表明值500,绿色表明300,蓝色表明200,控制为黄色。这些线条与上表中的颜色相匹配。
在看到延迟受到样本大小影响后,我想知道是否能够根据有多少key过时来自动调整样本大小。当有更多的key过时时,延迟会受到影响,可是当没有更多的工做要作时,咱们会扫描更少的key并更快地执行。
这个想法基本上是可行的,咱们能够看到内存使用更低,延迟没有受到影响,一个度量跟踪样本量显示它随着时间的推移在增长和减小。可是,咱们没有采用这种解决方案。这种解决方案引入了一些在咱们的控件实例中没有出现的延迟峰值。代码也有点复杂,难以解释,也不直观。咱们还必须针对每一个不理想的群集进行调整,由于咱们但愿避免增长操做复杂性。
咱们还想调查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。
- end -