链接:https://about.gitlab.com/blog/2022/11/28/how-we-diagnosed-and-resolved-redis-latency-spikes/?continueFlag=942986d1d503b78fd935ad0b88d007cb
声明:本文为 CSDN 翻译,未经许可禁止转载。
本文的背景是一个 Redis 慢性延迟的问题,在本文中,我们将利用 BPF 和剖析工具,结合标准指标来揭示系统幕后的一些鲜为人知的行为。

除了工具和技能之外,我们还将利用迭代假设考验方法来构建系统动力行为模型,可以通过这个模型理解哪些成分会影响问题的严重性和触发条件。
终极,我们找到了根本缘故原由,相应的补救方法虽然有效,但没什么新意。我们创造了一个包含三个阶段的环路,它有两个不同的饱和点,还找到了一个大略的修复方法来冲破这个环路。在此过程中,我们利用了一系列技能来检讨系统行为的各个方面,包括栈采样阐发、热图和火焰图、实验性的微调、源代码剖析和二进制剖析、指令级 BPF 检测,以及在特定进入和退出条件下的定向延迟注入。
问题先容:慢性延迟GitLab 利用了大量 Redis,我们乃至为特定功能建立了单独的 Redis 集群。本文先容的 Redis 实例的用场是 LRU 缓冲。
这个缓存有慢性延迟的问题,两年多前开始间歇性地发生,最近几个月越来越糟,每隔几分钟,就会涌现突发性的超高延迟,相应的吞吐量也会低落,导致 SLO(Service Level Objective,做事水平目标)恶化。这些延迟峰值影响了面向用户的相应韶光,并耗费了大量干系功能的缺点预算,以是我们要设法办理这个问题。
图:Redis 要求的速率峰值(仅包含相应速率超过1秒的要求),每个峰值对应一次驱逐突发
在之前的事情中,我们已经完成了多项优化。之后,情形有所好转,并持续了一段韶光,但后来延迟增长再次浮出水面,成为了一个主要的长期扩展问题。我们还打消了外部触发的可能性,例如要求泛滥、连接速率峰值、主机资源竞争等。这些延迟峰值是由于内存利用量达到驱逐阈值(maxmemory)造成的,与客户端流量的变革模式或其他与 Redis 竞争 CPU 韶光、内存带宽或网络 I/O 的进程无关。
最初,我们以为 Redis 6.2 新推出的驱逐节流机制可以降落我们的驱逐突发开销。结果却创造没有任何帮助,不过该机制办理了另一个问题:防止由 performEvictions 调用运行韶光过长导致的停顿。比较之下,在剖析过程中,我们创造我们的问题(无论是 Redis 升级之前还是之后)与大量调用导致 Redis 吞吐量降落有关,而不是由于一些调用过慢导致 Redis 完备停滞。
为了找出瓶颈以及潜在的办理方案,我们须要调查 Redis 事情负载驱逐爆发期间的行为。
Redis 驱逐的一些背景知识
当时,缓存的订阅数量超过了预期,导致试图保存的缓存键数量超过了 maxmemory 设置的阈值,因此发生 LRU 缓存驱逐并不虞外,但这种驱逐额外开销的密集程度还是令人不安。
Redis 实质上是单线程的。除了少数例外,“主”线程会连续实行所有任务,包括处理客户端要乞降驱逐等。在任务 X 上花费的韶光过多,则意味着实行任务 Y 的韶光就会减少,类似于行列步队的行为。
每当 Redis 达到其 maxmemory 阈值时,就会通过驱逐一些键来开释内存,直到规复至 maxmemory 以下。然而,与预期相反,内存利用率与逐驱率的指标(如下所示)表明,驱逐率并不是连续或稳定的,而是会溘然爆发,并开释比预期更多的内存。每次驱逐爆发后,直到内存利用率再次攀升至 maxmemory 阈值,才会再开始驱逐。
图:Redis 内存利用量在每次驱逐突发期间低落 300~500 MB
图:键的驱逐峰值与上面显示的内存利用低落的韶光和大小相同等
为什么会发生这种过量的驱逐?这一贯是核心的谜团。最初,我们以为找出这个问题的缘故原由,就能知道若何才能平滑驱逐率、分散开销并避免延迟峰值。结果,我们创造这些爆发是须要避免的交互浸染,我们后面会详细先容。
驱逐突发导致 CPU 饱和
如上所示,我们创造这些延迟峰值完备是由缓存驱逐率的峰值引发的,但我们仍旧不明白为什么驱逐汇合中在几秒内持续发生,而且每隔几分钟发生一次。
作为第一步,我们须要验证驱逐突发与延迟峰值之间的因果关系。
为了对此进行测试,我们利用 perf 在 Redis 主线程上运行 CPU 采样阐发。然后对阐发结果进行过滤,找出调用 performEvictions 函数时的样本。我们利用 flamescope 将阐发的 CPU 利用情形绘制成了亚秒级的偏移热图,个中 X 轴上每个柱体表示一秒,分布在 Y 轴上的格子中,每个格子表示 20 毫秒。这种可视化风格可以突出显示亚秒级的活动模式。比较这两个热图可以创造,在驱逐突发期间,CPU 险些完备被 performEvictions 霸占,主线程上的其他代码路径险些没有占用任何 CPU 资源。
图:Redis 主线程的 CPU 占用韶光,不包括 performEvictions 的调用
图:同一份阐发的别的部分,仅显示 performEvictions 的调用
这些结果证明,驱逐突发导致主线程上的其他任务抢占不到 CPU 资源,这成为了吞吐量瓶颈,并导致 Redis 的相应韶光延迟增加。这些 CPU 利用率爆发常日会持续几秒钟,由于持续韶光太短,不会触发警报,但仍旧会影响用户。
作为参考,下面的火焰图显示了 performEvictions 花费 CPU 韶光的详情。把稳:
performEvictions 的调用与 processCommand(处理所有客户端要求)同步进行。
performEvictions 会开始自己实行删除。虽然从名称来看,函数 dbAsyncDelete 是异步删除,但它仅在特定条件下将删除委托给赞助线程,而这种情形对付此事情负载来说很少见。
performEvictions 的单次调用速率
Redis 的每个传入要求都通过调用 processCommand 来处理,并且结束时总是会调用 performEvictions 函数。performEvictions 的调用常日是空操作,在检讨未超过 maxmemory 阈值后立即返回。但是,如果超过阈值,它就会持续驱逐缓存键,直到达到 mem_tofree 目标值或超过每次调用的韶光限定。
前面显示的 CPU 热图证明, performEvictions 调用花费了大部分 CPU 韶光,最多长达几秒钟。
作为补充,我们还丈量了单词调用的时钟韶光。
我们利用 funclatency 命令行工具(BPF 工具 BCC 套件的一部分),通过检测 performEvictions 函数的进入和退出来丈量调用持续韶光,并将这些丈量值以 1 秒为间隔聚合到直方图中。在没有发生驱逐时,调用的延迟很低(每次调用 4~7 毫秒)。这是上面先容的空操作的情形(包括每次调用2.5毫秒的检测开销)。但在驱逐爆发期间,结果转变为双峰分布,包括空操作调用(速率非常快)与主动实行驱逐(非常慢)的调用:
$ sudo funclatency-bpfcc --microseconds --timestamp --interval 1 --duration 600 --pid $( pgrep -o redis-server ) '/opt/gitlab/embedded/bin/redis-server:performEvictions'
...
23:54:03
usecs : count distribution
0 -> 1 : 0 | |
2 -> 3 : 576 | |
4 -> 7 : 1896 ||
8 -> 15 : 392 | |
16 -> 31 : 84 | |
32 -> 63 : 62 | |
64 -> 127 : 94 | |
128 -> 255 : 182 | |
256 -> 511 : 826 | |
512 -> 1023 : 750 | |
这次丈量还确认并量化了每秒处理的 Redis 要求的吞吐量低落:performEvictions(以及 processCommand)的调用率从驱逐开始前低落到其正常值的 20%,从每秒 2.5 万次调用低落到5 千次调用。
这对客户端产生了巨大影响:新要求的到达速率是完成速率的 5 倍。最主要的是,我们很快就会看到这种不对称是导致驱逐爆发的缘故原由。
实验:调优能否缓解驱逐导致的 CPU 饱和?
到目前为止的剖析表明,驱逐操作花费了大量 Redis 主线程的 CPU 韶光。但我们还有一些主要的问题没有得到办理,但这些信息足够我们开展一些实验来测试潜在的缓解方法:
我们能否分散驱逐开销,使其花费更长的韶光到达目标值,并缩减占用的主线程韶光?
lazyfree 机制操持了许多键的异步删除操作,这是否会导致开释的内存超过预期?Lazyfree 是一项可选功能,许可 Redis 主线程将开销较大的任务委托给异步赞助线程,比如删除超过 64 个元素的键。这些异步驱逐操作不会被立即计入驱逐循环的内存目标,因此如果有很多键符合 lazyfree 的条件,就有可能在驱逐循环内进行过多次迭代。
然而,这两个方法都行不通:
将 maxmemory-eviction-tenacity 降落到最小设定仍旧没能将 performEvictions 的开销降到足以避免要求累积。它确实提高了相应率,但新要求的到达率仍旧远远超过了相应率,因此这不是一种有效的缓解方法。
禁用 lazyfree-lazy-eviction 并不能阻挡驱逐突发时开释的内存量远远超过 maxmemory。这些 lazyfrees 只包含一小部分内存回收。这打消了导致内存开释过多的可能缘故原由之一。
在打消了两种潜在的缓解方法以及一项假设之后,我们回到了核心问题:为什么在每次驱逐爆发结束时都会额外开释数百兆字节的内存?
为什么会涌现驱逐突发并开释过多内存?
每轮驱逐的目的是开释勉强够用的内存,规复到 maxmemory 阈值以下。
随着内存分配的需求稳定,驱逐率同样该当趋于稳定。写入缓存的速率看起来确实很稳定。那么,为什么驱逐会密集爆发,而不是平滑地发生?为什么内存利用量溘然减少了数百兆字节,而不是数百字节?
我们须要探索一些可能性:
驱逐是否仅在大型键被逐出时结束,自发地开释足够的内存,然后停滞驱逐一段韶光?不,内存利用量低落远大于数据集中最大的键。
延迟的 lazyfree 驱逐是否会导致驱逐循环超出其目标,开释比预期更多的内存?不,根据上述实验,这个假设不成立。
是否有什么缘故原由导致驱逐循环有时打算出的 mem_tofree 目标是一个超大值?这一点,我们接下来连续探索。答案是否定的,但这给我们带来了新的见地。
是否是由于反馈回路导致驱逐以某种办法自我放大?如果真是这样,这种状态发生和停滞的条件呢?事实证明这是精确的。
这些都是合理且可考验的假设,每个假设都指向办理延迟问题的不同方案。我们已经打消了前两个假设。
为了测试后两个,我们构建了自定义 BPF 工具,在每次调用 performEvictions 开始时检讨 mem_tofree 的打算。
利用 bpftrace 不雅观察 mem_tofree 的打算
我个人最喜好这次调查的这一部分,这项实验让我们对问题的性子有了新的认识。
如上所述,我们剩下的两个假设是:
mem_tofree 的目标是一个超大值
自我放大反馈回路
为了甄别二者,我们利用 bpftrace 来检测 mem_tofree 的打算,检讨其输入变量和结果。
这组丈量检讨的因此下内容:
每次调用 performEvictions 是否真的是为了开释少量内存,大约为每个缓存条款标均匀大小?如果 mem_tofree 靠近数百兆字节,那将证明第一个假设成立,而且还可以揭示哪部分的打算产生了这么大的一个值。否则,就会打消第一个假设,解释反馈回路的假设更有可能发生。
复制缓冲区的大小是否会对反馈机制的 mem_tofree 产生很大影响?每次驱逐都会添加到这个缓冲区中,就像正常写入一样。如果这个缓冲区变大(可能部分是由于驱逐),然后溘然缩小,就会导致内存利用量自动大幅低落,同时驱逐结束并造成内存利用量即刻减少。这是驱逐推动反馈循环的一种潜在办法。
为了检讨 mem_tofree 的打算(脚本),我们须要单独取出 performEvictions 调用函数 getMaxmemoryState 的代码,并进行逆向工程,找到精确的指令,并检讨每个源代码级的变量。根据这些数据,我们天生了以下变量的直方图:
mem_reported = zmalloc_used_memory() // All used memory tracked by jemalloc
overhead = freeMemoryGetNotCountedMemory() // Replication output buffers + AOF buffer
mem_used = mem_reported - overhead // Non-exempt used memory
mem_tofree = mem_used - maxmemory // Eviction goal
把稳:我们自定义的 BPF 检测只能用于 redis-server 的这个构建,由于检测会附加到特定的虚拟地址上,而不同的Redis构建中这些地址并不一定相同。但这个方法能够通用化:利用 BPF 在函数调用过程中检讨源代码变量,而无需重构二进制文件。由于我们查看的是函数的中间状态,并且由于编译器内联了这个函数调用,以是我们须要通过二进制剖析找到精确的检测点。常日,查看函数的参数或返回值更随意马虎且更易于移植,但在这种情形下这样做并不能知足我们的哀求。
结果:
打消第一个假设:每次调用 performEvictions 都会产生一个小目标值 (mem_tofree < 2 MB)。这意味着,每次调用 performEvictions 的开销都很小。Redis 内存利用率快速低落的秘密不可能是由非常大的 mem_tofree 目标值一次性驱逐一大批键造成的。相反,肯定有许多调用共同导致内存利用量降落。
复制输出缓冲区始终很小,打消了潜在的反馈循环机制之一。
令人惊异的是,mem_tofree 的大小常日为 16 KB~64 KB,大于常见的缓存条款。这种大小差异表明,缓存键并不是内存压力的紧张来源,一旦开始驱逐爆发就会永久存在。
上述所有结果都符合反馈回路的假设。
除了回答最初的问题之外,我们还得到了一个额外的结果,同时丈量 mem_tofree 和 mem_used 时我们还创造了一个主要情形:内存回收是一个完备不同于驱逐爆发的阶段。
三阶段回路
上述结果表明,驱逐和内存回收之间存在完备独立,现在我们可以大略地绘制出由驱逐引发的延迟峰值循环的三个阶段。
图:比较每个阶段内存和 CPU 的利用率与要求率和相应率
第 1 阶段:不饱和(7~15 分钟)
内存利用量低于maxmemory。此阶段不会发生驱逐。
内存利用量有机增长,直到达到 maxmemory,进入下一阶段。
阶段 2:内存和 CPU 饱和(6~8 秒)
内存利用量达到 maxmemory,驱逐开始。
驱逐只发生在这个阶段,而且是间歇性地、频繁发生。
内存的需求常常超过可用容量,反复将内存利用推到 maxmemory 以上。在这个阶段,内存利用量在 maxmemory 阈值线上来回振荡,一次驱逐少量内存,刚好回到 maxmemory 阈值以下。
阶段 3:快速回收内存(30~60 秒)
此阶段不会发生驱逐。
在这个阶段,一贯持有大量内存的进程开始快速稳定地开释内存。
没有运行驱逐的开销,CPU 韶光再次回到要求处理上(第2个阶段积压的要求)。
内存利用量快速稳定地低落。到此阶段结束时,已开释数百兆字节。接下来,循环回到阶段1,重新开始。
从第2个阶段向第3个阶段过渡时,驱逐溘然结束,由于内存利用量保持在 maxmemory 阈值以下。
在过渡的某个韶光点,内存压力溘然降落,这表明,第2个阶段花费内存的某个成分开始开释内存,且开释速率超过了花费速率,从而降落了前一阶段占用的内存空间。
这个神秘的内存消费者在第2个阶段时的需求不断膨胀,到了第3个阶段却开始开释内存,它究竟是谁?
答案揭晓
阶段转换建模为我们供应了一些假设必须知足的约束。这个神秘的内存消费者必须知足以下条件:
在驱逐爆发触发的条件下,在不到10秒的韶光内(第2个阶段的持续韶光),内存的利用量迅速膨胀到数百兆字节。
在驱逐爆发触发的条件下,在短短几十秒的韶光内(第3个阶段的持续韶光),快速开释内存。
答案:客户端输入/输出缓冲区知足这些约束,它便是这个神秘的内存消费者。
以下是我们假设的全体经由:
在第1个阶段,Redis 主线程的 CPU 利用率已很高。进入第2个阶段,驱逐开始,驱逐开销导致主线程的 CPU 容量饱和,相应速率迅速低落,且低于要求的传入速率。
要求的到达速率与相应之间的吞吐量不匹配本身便是导致驱逐突发的放大器。随着二者的差距扩大,驱逐占用的韶光比例也会增加。
要求积压造成内存需求增长,而积压的要求会越来越多,直到客户端停滞,要求的到达率低落至与相应率匹配。随着客户端停滞,要求的到达率低落,内存压力、驱逐率和 CPU 开销也随之低落。
当要求的到达率低落匹配相应率的平衡点,内存需求得到知足,并停滞驱逐,第2个阶段结束。没有了驱逐的开销,更多CPU韶光用于处理积压的要求,因此相应率会不断增加,直到超过要求的到达率。这个规复阶段可以稳步花费积压的要求,并逐渐开释内存(第3个阶段)。
在要求积压的问题得到办理后,要求的到达率和相应率会再次匹配。CPU的利用率规复到第1个阶段的标准,内存利用量暂时低落,低落速率取决于第2个阶段积压的要求最大值。
我们通过延迟注入实验证明了这一假设,而且这个结果支持结论:额外的内存需求源于相应率低于要求的到达率。
补救方法:如何避免进入驱逐突发循环
通过以上调查,我们搞清楚了问题的症结,下面我们来探索办理方案。
当知足以下所有条件时,Redis 的驱逐就会自我放大:
内存饱和:内存利用量达到 maxmemory 限定,导致驱逐开始。
CPU 饱和:Redis 主线程的正常事情负载花费的 CPU 靠近一个完全的核心,而驱逐开销将其推向饱和。这将导致相应速率降至要求的到达率以下,要求缓冲的增加导致内存需求增加,涌现自我放大的效果。
许多生动的客户端:只要要求的到达率超过相应率,饱和就会持续。客户端停滞后,要求的到达率不会再增加,但如果 Redis 有许多活动客户端仍在发送要求,则饱和会持续更永劫光并且影响更大。
可行的补救方法包括:
通过以下办法避免内存饱和,使内存利用量峰值低于 maxmemory 限定:
缩短缓存的存活韶光(TTL);
增加 maxmemory(并根据须要增加主机的内存,但请把稳具有多个 NUMA 节点的主机上的 numa_balancing CPU 开销);
调度客户端行为,避免写入不必要的缓存条款;
将缓存拆分到多个实例上(分片或功能分区,有助于避免内存和 CPU 饱和)。
通过以下办法避免 CPU 饱和,使事情负载的 CPU 利用率峰值加上驱逐开销小于 1 个 CPU 内核:
利用处理单线程指令的速率最快的处理器;
将 redis-server 进程(特殊是它的主线程)与任何其他竞争的 CPU 密集型进程(专用主机、任务集、cpuset)隔离开来;
调度客户真个行为,避免不必要的缓存查找或写入;
将缓存拆分到多个实例上(分片或功能分区,有助于避免内存和 CPU 饱和);
减少 Redis 主线程的事情(io-threads、lazyfree);
降落驱逐韧性(在我们的实验中带来的收益甚微)。
还有一些潜在的补救方法,比如利用 Redis 的新功能。一个思路是,不要将客户端缓冲区等临时分配的内存打算在 maxmemory 的限定之内,而是只让 maxmemory 限定键的存储。还有一种办法,我们可以限定驱逐占用主线程韶光的最高比例,这样主线程的大部分韶光仍旧可用于处理要求,而不是用于驱逐开销。
不幸的是,这些方法在办理一个故障的同时有可能引发另一个故障,比如降落由于驱逐导致 CPU 饱和的风险,同时有可能导致进程花费的内存增加,从而导致主机或 cgroup 饱和,并引发内存不敷。两相权衡下来,孰优孰劣也未可知。
我们的办理方案
我们已经优化了 CPU 的利用效率,接下来我们的把稳力紧张放在避免内存饱和上。
为了提高缓存的内存利用效率,我们评估了哪些类型的缓存键利用的空间最多,以及自末了一次访问以来它们累积了多少 IDLETIME。根据内存利用阐发,我们找到了一些很少利用的缓存条款(摧残浪费蹂躏空间),首先我们来调度处于空闲状态较多的键,并突出显示一些切入点对缓存进行功能分区。
我们决定同时改进多个缓存的利用效率。我们的目标是避免慢性内存饱和,紧张方法包括:
逐步将缓存的默认存活韶光从 2 周减少到 8 小时(帮助很大!
);
将某些缓存键换到客户端缓存(有效地避免非共享缓存条款占用共享缓存空间);
将一组缓存键分区到一个单独的 Redis 实例上。
缩减存活韶光是最大略的办理方案,但结果证明帮助很大。对付缩减存活韶光,我们最担心的是缓存未命中增加,从而导致根本举动步伐其他部分的事情量增加。有些缓存未命中的开销特殊高,并且我们的指标不足风雅,无法量化每种类型的缓存条款未命中时的本钱。因此,我们采取迭代的办法,逐步调度存活韶光,并严格监控 SLO 不达标的情形。幸运的是,我们的推断是精确的:缩减存活韶光并没有显著降落缓存命中率,缓存未命中的增加也没有对下贱子系统造成明显影响。
事实证明,缩减存活韶光足以将内存利用量持续降落到其饱和点以下。
刚开始的时候,增加 maxmemory 并没有任何浸染,由于我们估量最初的内存需求峰值(在提升效率之前)会超过我们为 Redis 投入的虚拟内存。但是,当内存需求降落到饱和以下之后,我们就可以为将来的增长情形预留空间,并重新启用饱和警报。
结果
下图显示了 Redis 的内存利用摆脱了长期的饱和状态:
不雅观察我们调度存活韶光的这段韶光,可以看到驱逐引发的延迟峰值随着内存利用量降到饱和点以下而消逝了:
这些驱逐引发的延迟峰值是导致 Redis 缓存非常慢的最大缘故原由。
办理了这个缓慢的根源,用户体验显著改进。下图中1年的回顾只显示了改进的长尾部分,未能展示全部收益。每个事情日大约有 200 万个 Redis 要求的处理韶光超过1秒,但在 8 月中旬我们修复这个问题后全面低落:
总结
我们通过不懈的努力,终于办理了一个长期存在的延迟问题,我们在此过程中积累了很多履历。
总的来说,我们提升了多方面的效率,冲破了由缓存驱逐引发的一系列周期性的问题。如今,内存需求远低于饱和点,并肃清了导致开拓团队预算超标以及用户经历间歇性相应延迟峰值。
要点总结
下面,总结一下我们学习到的有关 Redis 驱逐行为的知识:
键存储和客户端连接缓冲区共享同一个内存预算(maxmemory)。客户端连接的缓冲需求激增司帐算在maxmemory之内制,办法与插入键或键大小激增的打算办法相同。
Redis 的驱逐在其主线程的前台实行。performEvictions 占用的是处理客户端要求的韶光,因此,在驱逐突发期间,Redis 的吞吐量上限较低。
如果驱逐开销导致主线程的 CPU 饱和,则相应率会低于要求的到达率,从而导致要求积压(这会花费内存),而客户端也会体验到要求相应减慢。
用于保存待处理要求的内存需求增加,导致驱逐爆发,直到大量客户端停滞,要求的到达率回落到相应率以下。当到达平衡点时,驱逐停滞,驱逐开销消逝,Redis 快速处理积压的要求,积压的要求占用的内存被开释。
触发此循环须要知足以下所有条件:
Redis 配置了 maxmemory 限定,且内存需求超过了这个限定。内存饱和导致驱逐开始。
在正常事情负载下,Redis 主线程的 CPU 利用率非常高,驱逐操作使其达到 CPU 饱和。这会导致相应率降落到要求率以下,从而导致要求积压和高延迟。
许多活动客户端连接。驱逐突发的持续韶光和客户端连接缓冲区占用的内存大小与活动客户真个数量成比例增加。
避免内存或 CPU 饱和可以防止触发这种循环。在我们的这个例子中,我们通过缩减存活韶光的办法,轻松地避免了内存饱和的问题。