Yandex 实践中的性能故障排查

标题:Семинар: Performance troubleshooting на практике

日期:2024/06/22

作者:Антон Суворов

链接:https://www.youtube.com/watch?v=d0SZtSF5m4U

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


谢谢。大家好。我是安东·苏沃洛夫(Anton Suvorov)。我在 Yandex 的基础设施服务部门工作,领导系统开发团队。我们的服务负责服务器的整个生命周期,从服务器被安装在数据中心机架上的那一刻起,直到其生命周期结束时提交拆除和回收请求为止。我们负责操作系统、主机上运行的所有守护进程、配置、进程间隔离以及保障此主机生命周期的配套服务。当编排器(Orchestrator)启动并开始承载开发者通过其服务创建的负载时,我们的职责范围就结束了。今天我们将探讨几个我们在生产环境中遇到的、并通过支持工单提交给我们进行实际调试的案例。为了重现这些问题,我们今天将使用一台主机和几台虚拟机。为了限制容器的资源使用,我们将使用我们内部使用的容器运行时 Porto,它已准备好在大型云环境中使用。作为示例,我们将使用用 Go 语言编写的服务,这些服务内部相当简单,只执行一个功能。但要重现实际的生产问题相当困难。需要引入一个大型服务,而如何启动它却不得而知。因此,我们将在这种“温室”环境中审视我们的问题。

为了生成负载,我们将使用 vegeta 工具。我们尝试过其他工具,但它是最方便的,并且用它很容易构建图表,以便直观地展示问题所在。为了让问题在“温室”条件下能够重现,我们将在旁边运行一个“恶意邻居”(bad neighbor),形式是 stress-ng,它将在 CPU 上生成负载。为了可视化,我们使用标准的 Prometheus 和 Grafana 技术栈。我们将审视用于查看系统性能的经典工具。通常,当问题提交给我们——“这里有个问题,我们的服务运行不佳,需要搞清楚”——任何调试都从这些工具开始。我们自己也无法完全理解哪里出了问题。

在性能问题排查中,一个精心构建、展示主机关键指标的仪表板(dashboard)非常有帮助。今天我们没有这样的仪表板,但当它存在时,它能极大地简化生活和问题调试。也就是说,可以立即确定机器上哪种可用资源出现瓶颈,以及性能问题应该朝哪个方向深挖。

第一个性能问题:与 CPU 子系统相关的问题。 通常,如果使用像 Go、Java 或 C# 这样带有动态管理运行时的语言,它们默认可能会创建过多的操作系统线程,线程之间的切换会占用相当多的时间。当你的服务器上运行着 1 万个线程时,要在生产环境中捕捉到这样的问题相当困难——很难知道是哪一个线程在拖慢速度,哪个服务可能有这么多线程,哪个服务不应该有这么多线程。当大量线程同时准备好执行时,问题就会显现出来。然后它们要么开始消耗 CPU 上可用的执行时间配额(要么同时消耗,要么长时间等待执行队列)。因此,响应时间增加,服务的行为不符合预期,尽管它本应按照开发人员的设想工作。

问题最常在容器环境中显现,当进程检测到过多的处理器核心(通过读取 cpuinfo 或使用 CPUID 指令来确定处理器拓扑结构)时。在容器中,可能检测到 256 或 384 个核心可用,而实际分配的时间配额却只有一个核心。如果再使用某个乘数(比如 2 或 4)来设置线程池大小或操作系统线程数量,那么问题就会特别突出。

让我们在 1000 个线程中启动我们的服务。我们知道这个服务在单核上可以轻松处理每秒 10,000 个请求。让我们向它施加指定的负载。现在在图表上我们应该会看到,服务无法应对负载,根本无法承受这个负载,并且响应时间过长。需要几秒钟让数据通过整个监控栈。监控栈对接收数据的响应越快,调试周期(调试、更改、构建、重启、再次调试)就越快。

现在我们看到图表上,99% 的响应耗时超过 10 秒,90% 接近 10 秒。总的来说,服务情况相当糟糕。相应地,它勉强能应付预期负载。在生产环境中,这通常表现为:我们有一个实例运行良好,而另一个完全相同的实例、完全相同的配置,但在另一台机器上却表现不佳。我们在图表上观察到未响应(non-answers)的增长,并且成功响应的数量在下降。

通常,任何问题的调试都始于我们称之为“我在哪里?”(Where am I?)的程序。也就是查看这是台什么机器,运行什么内核,什么操作系统(因为生产环境可能使用不同的发行版)。然后我们查看系统的整体负载。我们看到所有核心都已满载。在我们的例子中,在 top 命令中会看到 stress,它正在制造来自“邻居”的负载。还有我们的服务,它正试图争取执行时间来对抗这个负载。没什么不寻常的。然而,根据这些指标,我们无法判断服务具体哪里出了问题。

我们还可以看看 pidstat。通过 pidstat 的读数也看不到明显的负载,但有时能看到 CPU 等待(wait)的峰值。也就是说,我们预期存在某种等待。通常,当你在生产环境中调试别人的服务时,你不知道它是如何编写的,甚至不知道服务是做什么的。因此,经常需要查看系统调用来了解服务到底在做什么。为此使用 strace。现在我们来看几秒钟的统计信息。好的。我们看到我们正在研究的服务存在锁争用(lock contention),因为 futex 调用过于频繁。sched_yield 也经常被调用。我们看到进程相当频繁地被唤醒,并尝试执行其线程,进行调度,以便调度器为它们分配一些时间片(quantum)。调用了 epoll,我们在处理请求。在 write 中,我们响应这些请求。其他的,原则上不太重要。还可见系统调用经常被重启,因为不同线程之间存在争用。

在这种情况下,仅凭提供的统计工具很难猜出哪里出了问题。因此需要转向服务跟踪(tracing)。为此,通常使用经典的 perf。随着内核中 BPF 子系统的引入,现在经常转向使用 BPF,它是 perf 中可用工具的类似物。让我们看看总体统计信息。比如几秒钟,应该足够了。在这里我们没有看到任何异常。标红的问题是处理器在等待指令解码。从 perf 的角度看,原则上没有发生什么可怕的事情。我们再深入看看进程在做什么。这是 Go 语言服务运行的典型情况,运行时在操作系统线程之间切换 Goroutines,并处理客户端请求。在这种情况下,即使是记录调用图(call graphs)的 perf top 也帮不上什么大忙。

通常,当我们使用 perf sched 工具时,会发现这类问题。它首先记录所有带有所需元数据的上下文切换,然后可以分析它们,向我们展示服务发生了什么情况的脚本,或者关于延迟的统计信息,即进程是如何获得 CPU 时间的。我们记录几秒钟的统计数据,原则上这应该足够了。对系统性能影响不大,当然在统计数据上是可见的,但还不至于触发警报或点燃告警,表明出了问题。

现在,在我们的处理过程中,我们可以看到哪些任务存在过,它们执行了多长时间,以及切换了多少次。不幸的是,perf 并不总是来得及记录具体是哪些命令,但所有这些任务都是在我们的进程上下文中执行的。根据这里的统计数据,我们看到在主进程被统计的这 10 秒内,线程间切换平均耗时 3.6 毫秒,而最大延迟为 1.7 秒。原则上,内核中有一些任务切换时间非常长,例如在顶部(top)显示为 13 秒。是的,后面我们看到一切正常。这种任务切换等待时间超过毫秒的情况表明,有太多线程同时试图唤醒,而调度器无法将它们全部塞进该进程所受的限制中。

如果在具有 8 个处理器的虚拟机上运行,同时启动成千上万个线程,并且它们都在容器中同时变得活跃,也会观察到同样的情况。这个问题还可能因为存在也在竞争 CPU 时间的“邻居”而加剧。

为了纠正这种情况,我们需要稍微调整(tune)运行时,并以合理但数量较少的线程启动它,这些线程应能处理负载。为了在图表上清晰地看到上一次“风暴”何时结束、下一次何时开始,我们重启一下负载。现在再等几秒钟,让新数据进来。

调整了运行时的线程数量后,我们看到未响应降到了零,负载达到了计划的每秒 10,000 个请求,同时响应时间降到了合理范围。而服务代码本身没有任何改变。

在生产环境中,对于一些小容器或小型虚拟机,建议使用系数 2.4 作为操作系统线程的数量。对于一些大型容器,可以使用 1、1.5、2,具体取决于容器大小。也就是说,对于 32 核的容器,可以使用 32 个线程,最多 64 个。但这仍然可能取决于具体负载特性。如果是某个具有大量 I/O 的服务,那么可能需要增加线程数量。这里的一切都需要“量体裁衣”(tune under key),针对具体负载进行调整。

现在可以用同样的工具查看我们的服务,发现统计数据上这里基本没什么变化。是的,它正在处理这每秒 10,000 个请求,同时消耗不到一个核心。我们取进程的 PID。查看相同的信息。再看看 strace。可以看到系统调用重启变少了。原则上,futex 的调用容器也变少了。尽管负载没有改变。如果在 perf top 中查看,画面几乎相同。一个典型的 Go 应用程序,处理请求。同时,可能运行时的开销稍微低了些,进程主要在处理 I/O。perf stat 很可能也不会有什么指示性,无论是在有问题的情况下,还是在我们修复服务之后。看起来都差不多。那么现在让我们检查一下 perf sched。同样记录 5-10 秒。看看调度情况如何变化,这个进程中线程被唤醒了多少次。

我们看到,现在我们的主进程平均切换时间约为 100 微秒。最大等待时间约为 2.5 毫秒。而之前是 1.75 秒。因此我们在这里无可辩驳地改善了一切。

现在考虑另一个问题,这个问题在近期出现的大型系统中很典型:系统有两个插槽,但有 256 个或更多核心。 在这样的系统中,内存分布是不均匀的。也就是说,如果你的线程在一个插槽上执行,而进程的内存却在另一个插槽上,那么就需要通过处理器间链路(inter-processor link)访问内存,这相应地增加了内存访问时间。此外,在某些系统上,这不仅会导致访问自己远程 NUMA 节点内存的进程性能下降,还会导致驻留在该远程 NUMA 节点上并访问自己内存的进程性能下降。这样的“邻居”不仅会拖慢自己,还会拖慢另一个 NUMA 节点上的邻居。

现在让我们启动一个不好的场景:我们的服务启动在一个 NUMA 节点上,它被调度在一个 CPU 集合(CPU-set)上,但假设该 NUMA 节点上没有空闲内存,内核在另一个 NUMA 节点上为它分配了内存。因为负载可能分布不均,尽管负载均衡调度器尽力相对均匀地分配所有资源,但这仍然是一个尚未可靠解决的难题,因此这里做的是“尽力而为”(best effort)。好的,服务启动了。启动每秒 300 个请求的负载,现在我们会看到图表上出现下一次“风暴”,表明我们的服务出了问题。

服务内部只是生成 8 兆字节的随机数据并计算 SHA-256 哈希。这样就模拟了某种内存密集型任务,比如图形计算、路径查找或过滤。总之,是典型的内存密集型任务。我们看到服务试图启动并加速到请求的 300 个请求。在这种情况下,它甚至可能加速成功,但 99% 的响应时间已经接近极限,接近 10 秒。因此可以得出结论,服务不符合 SLA(服务水平协议)。尽管我们确切知道,在相邻主机上,相同的服务、相同的环境、相同的设置,所有响应都在一秒内完成。服务能处理负载,但我们的请求中特意没有设置超时。它达到了每秒 300 个请求的平台期。让我们看看这个服务内部发生了什么,以及从性能概览工具的角度来看,这样的问题是什么样子。

这里内核更新一些。操作系统相同。补丁稍新一点。查看负载。但主机负载不高,尽管上面有任务。负载平均值(load average)不高。它已经运行了相当一段时间。在 top 中,我们看到我们的进程被限制在 8 个核心上。同时消耗着,嗯,几乎全部的 8 个核心。有时我们看到 CPU 使用率超过 8 核,因为这里的测量不是瞬时的。原则上,如果不使用 CPU 集合或某些显式的 CPU 集合,调度器无法精确切割出刚好 8 个核心。这个线程只在这个核心上运行,那个只在这个核心上运行。总是会有或多或少的偏差。更何况系统里还有其他进程也需要 CPU 时间,调度器也需要安排它们到核心上执行。

当试图优化执行时,如果不小心地使用 sched_setaffinity 系统调用,并且不检查在尝试为特定线程设置某些不合理的核心亲和性(affinity)时它返回了什么,也经常会出现同样的问题。因为在容器环境中生存时,顶层的编排器、调度系统、容器运行时可能会显式限制应用程序将在哪个 CPU 集合上运行。

为了多样化,让我们用 strace 看看我们的进程在做什么。是的,它只有大约 20 行输出,而我们是用 GOMAXPROCS=16 启动它的。原则上,很难通过 strace 判断这个进程在忙什么。可以查看具体调用了哪些系统调用及其参数,但似乎我们只会看到数据传输、读取 HTTP 请求以及形成和发送 HTTP 响应。嗯,还有一些操作系统不同线程间 Goroutine 的切换。让我们看看 perf top。嗯,这里也很难看到一个完整的画面,说“啊哈,问题就在这里”。我们目前可以得出的结论是,标准库数学包中的随机数生成器使用得相当密集,还有一些辅助函数只是将缓冲区从一个地方复制到另一个地方。当然,还有 SHA-256 的计算。没有额外的 I/O,但如果存在 I/O,画面会更模糊,更难猜测在这种情况下应该朝哪个方向看。

对于具有多个处理器、多个连接到不同处理器的内存库(banks)的特殊 NUMA 系统,有一个工具 numastat,它可以显示访问本地内存和远程内存的次数。嗯,通过 watch 命令查看非常方便。是的,我们看到大量请求从另一个节点跳转到节点 1。这样的图表原则上可以放到某个主机健康仪表板上,查看是否存在远程访问非常有用。这可能预示着不同 NUMA 节点间负载均衡不均的问题。这时,自动的内核再平衡器(rebalancer)可能会有帮助,它可以将内存从一个 NUMA 节点移动到另一个节点,但进程可能拥有大量标记为不可移动(non-movable)的内存,在这种情况下,平衡器无法帮助我们。在某些平台上,可以获取访问远程内存的计数器。可以通过运行 perf list 并搜索 remote 来查看它们。我们有一组关于远程链路访问的计数器。让我们看看它。是的,嗯,可以使用这样的命令启动格式。这是为了在某个精确校准的时间间隔内收集数据。在计数器上,我们看到在 5 秒内,从第三个内存控制器读取或写入了大约 130 千兆字节的数据。

为了确定进程是如何启动的,必须查看 cgroups 中的内容。对于 Porto,我们使用 cgroup v1。cgroup。CPU Set 控制器。是的,我们看到我们的 cgroup。以及可用的控制接口(knobs)。这是允许在此 cgroup 中进程执行的 CPU 核心的有效集合。我们看 lscpu。看到这是 NUMA Node 0。但同时,在 cpuset.mems 中我们会看到允许的是 NUMA Node 1。

现在重启我们的负载。并在好的场景下启动一切:内存和 CPU 时间都在同一个插槽内,甚至同一个 NUMA 节点内分配。是的,原则上现在可以看到我们通过处理器间链路的访问减少了。之前是 130 GB,现在是 10 GB。在相同的 numastat 上,我们会看到 OtherNode 的数量增长并不迅猛。是的,也就是说在 2 秒内,有时这里可能没有增长。这意味着处理器之间没有为访问远程内存而奔波。还需要大约 10-20 秒,负载数据才能到达监控系统并正确、可靠地显示出来。现在 300 点已经达到,之后每秒稳定处理 300 个请求。同时响应时间现在保持在 300 毫秒以内。而且这是 99 百分位(percentile)。如果尝试计算第 100 个,我们这里不太可能看到超过半秒的情况,这原则上满足我们的要求。

现在可以看看我们的 cgroup。相应地,允许分配的内存来自 NUMA Node 0。响应时间在 100 毫秒以内。处理器也仍在 NUMA Node 0 上。可以认为系统是平衡的,一切按预期运行。

现在考虑 I/O 延迟问题。 这类问题在生产环境中通常出现,要么是由于应用程序架构规划不当,要么是由于对 I/O 同步标志(synchronization flags)使用不当或不小心造成的。启动我们的测试应用程序,并对其施加每秒 1000 个请求的测试负载。现在数据将到达监控系统,我们将在图表上看到应用程序在负载下的行为。这需要大约 10-20 秒。

请求量增长到目标值每秒 1000 个请求,但响应时间已经超过 1 秒,甚至 2.5 秒,并且还在增长。甚至可能在某些时候应用程序停止响应。根据图表判断,我们大约在每秒 1000 个请求处达到了平台期。现在看看应用程序内部发生了什么,以及系统层面能看到什么。

我们看到我们的应用程序间歇性地使用大约 20-30% 的 CPU。它被限制在一个核心上,并以正确的扩展参数启动,即最多 8 个线程。但在 atop 中,没有明显引人注目的东西,除了系统时间(system time)增加了。这可以在 CPU 使用率总览的红色指示器上看到。我们将更详细地查看那里发生了什么。像往常一样,看看我们的应用程序到底在忙什么。收集 5-10 秒的统计数据。或许能弄清楚发生了什么。我们的 futex 调用相当多,写操作和读操作也很多。同时,接受请求的速率不是很高。仅凭 strace 很难明确判断应用程序在忙什么。嗯,与其他考虑的情况相比,这里有一些线索。

接下来开始查看系统的整体行为。查看 I/O 子系统。设备 vda,它作为主文件系统挂载。是的,如果查看。是的,我们看到那里是我们的 rootfs,没有其他东西。iostat 显示我们的虚拟设备已满载。同时每秒写入量相当大。接近每秒 3200-2800 次写入(IOPS)。

为了找出系统中究竟是哪个应用程序在使用 I/O,通常使用 iotop 工具,它会显示内核线程(kernel thread)正在积极地写入 XFS 日志(journal),以及我们应用程序的线程也在积极地向磁盘写入。并且它们也生成了相当大量的 I/O 操作。

为了理解 I/O 到底发生了什么,要么可以用 BPF 工具跟踪发送到块设备的具体请求及其执行时间,要么可以在 perf top 中查看跟踪。这里我们看到系统调用占用了非常多的时间。如果查看具体是哪些系统调用,我们会进入 sys_write 内部。沿着调用栈向下,我们看到使用了同步写入。从这个跟踪可以得出结论,我们的进程使用的是同步 I/O。为了确认这一点,需要查看进程表,更准确地说,是查看 procfs 中的详细信息。第三个文件描述符(fd)。这是我们的请求日志。并查阅文档,看看使用了哪些 I/O 标志,哪些位(bits)代表什么。这里,数字 6(O_SYNC)在我看来代表同步标志,它自动对元数据和日志应用 sync。因此,对于每个请求,我们在 iostat 中看到的不是一个写入请求,而是三个。我们知道负载是每秒 1000 个请求(RPS),平均下来,写入被放大了三倍。

如果必须将数据保存到磁盘,同时不通过日志触发写入,那么要么用 O_DSYNC 标志打开文件,要么使用 fdatasyncsync_file_range 系统调用,它们会显式地将写缓冲区刷新到块设备并提交更改。这样就不会因为需要写入日志的数据而产生额外的 I/O 子系统负载。现在的情况是,对于每个 I/O 操作,首先添加一条日志记录说明需要写入这些数据,然后执行实际的 I/O 操作将数据写入文件中的位置,接着生成另一个 I/O 操作,在日志中表明该写入已成功完成,在恢复时无需重放(replay)。

这是应用程序架构的问题,开发人员在这里可以自由选择他们认为合适的、适用于其具体任务的任何方法:要么将写入缓冲一段时间,要么按容量缓冲写入以减少 I/O 操作次数。解决这个问题,嗯,可以购买更快的 SSD,服务响应会在一段时间内表现不错,但最终,仅靠硬件无限期地掩盖问题是行不通的。钱可能会先花完。

现在考虑主缺页中断(major page-faults)的问题。 当为应用程序或容器分配了一定量的内存,同时应用程序开始使用匿名内存并接近限制时,问题通常就会出现。然后系统意识到它可以将映射自磁盘的页面换出(evict),以便为应用程序分配更多的匿名内存。这个问题的常见表现是:应用程序的代码页被换出内存,当处理器开始执行指令时,它需要等待内核的缺页中断处理程序很长时间,才能将页面从磁盘加载进来。这种情况不断发生:请求被处理了,代码页被换出了。此外,如果应用程序使用内存映射 I/O,情况会进一步恶化,因为缺页中断也会发生在它们本应发生的地方(即某个文件被映射到内存,然后应用程序尝试读取它——通常是包含参考数据、数据库或某些二进制数据的文件,在 C++ 或 Go 中使用指针可以快速遍历)。

现在启动我们的应用程序,这次我们将以不同的方式施加负载,使用不同的图表,因为我们实验室安装的 Prometheus 不太能处理负载生成系统报告的数以百万计的不同时间序列(series),因此控制台中的图表会稍微即兴一些。同时我会解说我们将在这里看到的所有内容。这里字体不大,可能在录像中难以阅读。

顶部是我们的请求图表。绿线是 200 状态码响应的数量。蓝线,如果它出现,原则上表示每秒的总请求负载。红线则是 500 错误或零响应。在第二个图表上,我们看到 95% 的响应时间超过了 15 秒。这里时间以微秒为单位。我们的服务在施加给它的负载下勉强应付。同时它至今尚未崩溃。如果我们查看系统中发生了什么,会看到我们的服务在负载方面会出现在顶部。并且看到它几乎永久地进入了 D 状态(disk sleep/uninterruptible sleep)或睡眠(sleeping)状态。总之,它会非常糟糕。

同时,在系统总览图表上,如果有其他“邻居”进程,我们看不到任何迹象表明我们缺少 I/O 或者进程开始遇到磁盘瓶颈。尽管内存充足,而且它似乎也没有从磁盘读取太多。让我们看看 I/O 子系统发生了什么。我们看到非常高的每秒读取请求数(IOPS),而且每秒读取的数据量也非常大。整整 100 兆字节。

在这种情况下,查看 procfs 中 vmstat 的输出通常很有用。但默认输出内容太多。我们只关心缺页中断。使用 watch 命令结合查看非常方便。可以看到我们发生了缺页中断,并且发生了相当多的主缺页中断(major page-faults),在这里标记为 pgmajfault。在完善的主机健康仪表板上,这个指标肯定应该有,因为它能立刻指明问题方向。原则上,这表明存在磁盘子系统问题。即使磁盘子系统显示一切正常,也可能没有达到系统容量上限,但服务仍然会变慢。

我们看到服务完全停止响应,现在响应时间超过了 30 秒(图表不再显示),成功的请求数量非常少,我们设定的负载是每秒 1000 个请求(RPS)。它偶尔会响应,在图表上我们看到一些“栅栏”。可以在 perf top 中查看我们的进程。首先需要找到它的 PID。在 ps 的输出中,我们看到进程处于 D 状态。看看我们的进程在忙什么。我们看到内核中处理内存管理的函数(如 free_pages, reclaim_high, shrink_node 等)占用了相当多的时间。另外看到缺页中断处理程序也占用了大量时间。

为了理解进程中到底发生了哪些缺页中断,如果我们已经在 vmstat 中看到很多主缺页中断,那么为了确定是哪个进程产生了这些缺页中断,需要对所有活动进程运行 perf,并在缺页中断计数器上查看 perf stat。让我们看看计数器组。嗯,可以… 收集 5 秒的统计数据。在 5 秒内,我们发生了近 5500 次主缺页中断。嗯,这大约对应于我们施加给该服务的每秒 1000 个请求的负载水平。

如何解决这样的问题?解决这类问题的一个方法是将其可执行文件锁定在内存中。为此,可以使用 vmtouch 工具,加上 -l 标志,并指定我们二进制文件的路径。这样 vmtouch 本身会保持运行,但可能在这种情况下,我们会立即在 cgroup 中达到内存限制。我们可以重启服务。理论上,它现在分配了 100 兆字节。进程本身分配了大约 30-40 兆字节,带有调试符号的二进制文件本身大约也占这么多。甚至没那么大,总共才 7 兆字节。但总的来说,100 兆字节应该够了。

同时重启我们的负载,以便在我们开始施加负载时看到一个清晰的空白图表。在这种情况下,请求量将接近每秒 1000 个。可能现在内存会用尽。缓存页面换出算法停滞不前,页面会被低效地换出,但服务会以某种方式响应。相应地,响应时间会飙升到天际。我们在最坏情况下看到,95 百分位的请求响应时间约为 22 秒。是的,与此同时,容器突然收到了 OOM(内存不足)终止。我们仍然需要增加内存量。缺页中断应该会减少。重启一切。并重启我们的负载。在这种参数下,服务目前还活着,甚至还在响应。尽管 95 百分位的响应时间已经超过了 15 秒,并且可能增长到 20 秒或更多。这取决于运气,哪些页面被换出或从磁盘加载回来。

另一方面,如果我们下面有一个足够高性能的 NVMe 磁盘,我们不仅可以尝试通过将二进制文件锁定在内存中来改善情况(这对我们处理负载很有帮助),还可以调整块设备的 readahead 参数。默认值,我记得大约是 128 千字节。可以将其设置为 4 千字节。页面大小。好了。之后看看我们服务的响应时间如何变化,以及它是否还能承受这样的负载。需要一些时间让缓存预热(warm up),更频繁使用的页面被加载到内存中。

这种修复方法有一个明显的缺点:该参数是针对整个块设备调整的,如果该块设备上其他服务使用磁盘的模式与此不同,与默认模式不匹配,或者它们期望有较大的 readahead,那么这些服务可能会出现问题。好了。我们看到现在大约每秒 1000 个请求,但我们读取的数据量明显低于之前的数值。之前我们接近 128 兆字节。只是虚拟机分配了每秒 100 兆字节的配额(quota),我们正触及这个限制。在云端的租用服务器上,很可能也是如此。

要彻底解决这个问题,需要为容器分配足够的内存。在我们的例子中,我们的文件有 8 千兆字节,嗯,至少应该有一半,以便服务能有效利用缓存,达到高命中率。嗯,让我们设置为 4 千兆字节,看看在这种大缓存下,服务在负载下的表现如何。重启图表以便更清晰,因为请求速率下降了。嗯,稳定地看到绿线,我们接近每秒 1000 个请求,显示 1000 RPS,没有未响应(non-answers),最差响应时间,嗯,目前是 50 毫秒。它大约在缓慢地跳跃。我们看到一些跳跃,可能是缓存未命中,或者代码页被换出,但由于它们使用相当频繁,被换出的概率会较低。但图表上仍然可能出现一些峰值。读取量减少到少于每秒 1000 次(主缺页中断),仍然发生一些换出,但我们只读取大约 4 兆字节,readahead 不太大。也就是说,如果将其恢复到 128 的值,我们只会看到读取数据量的增加,而且并非总是如此。同时,在增加 readahead 之后,我们看到图表上的“栅栏”稍微降低了一点。我们的目标 RPS 服务仍然能够承受。让我们看看 perf 中发生了什么。在 5 秒内,总共只有 1000 次主缺页中断,而我们开始时是每秒 1000 次。再看看 perf top。现在看到内核函数稍微下降了一些。是的,负责内存分配的函数不再占据执行时间排名的前列。我们看到调度器排到了前面,但原则上这并不可怕。

结束

到此我们结束对实验室示例的探讨,这些示例在真实负载下调试起来非常困难和痛苦,尤其当没有重启权限,或者根本不知道服务是如何构建的、负载特性是什么的时候。在这种情况下,必须阅读服务代码,与开发人员交流,以理解设计的初衷、应有的工作方式,以及具体服务会在哪种资源上遇到瓶颈。

在实践中,今天讨论的所有类型的问题都相当常见。有时某人会被不均衡地调度(settle),或者在服务被调度到某台机器时,其内存跨 NUMA 节点分布不均,导致平衡器无法应对,并且自动平衡器本身也可能产生额外的远程 NUMA 节点访问,这在某些平台上会拖慢系统中的所有进程,因为内存控制器是同一个,内存条是相同的,这本身也是一个共享资源。主缺页中断的问题在实践中也非常常见,要么是二进制文件被换出且内存不足(但 OOM killer 尚未介入),服务开始疯狂变慢,在图表上炸出各种响应时间。

到此结束。感谢大家的关注。