OpenSearch 堆内存配置:Swap、页缓存与 50% 规则
OpenSearch 是一个开源的分布式搜索与分析套件,源自 Elasticsearch 的分叉,并在 Apache 2.0 许可证下维护。谈到内存配置时,相关指导往往被简化为几条经验法则:swapoff -a、vm.swappiness=1 或 bootstrap.memory_lock,并将可用内存的 50% 分配给 JVM 堆,其余留给 Lucene 和文件系统页缓存、OpenSearch 堆外缓存、网络缓冲区以及其他系统需求。
这些建议在文档、博客文章和运维指南中被反复提及,但它们的起源以及支撑这些具体数值的机制却很少被审视。毫无疑问,在大多数情况下,它们提供了一个合理且安全的起点,或者一个安全的上限,但安全默认值未必就是最优配置。
所有这一切引出了更多问题。这些默认值如何影响集群性能?最优的 JVM 堆比例是多少?JVM 让出的内存是否真的变成了文件系统页缓存,而这种权衡又在什么时候不再划算?读/写延迟与堆比例如何相关?这些问题在资源受限环境和云环境中尤为重要——在云中,长期合同可能使现有实例显著更便宜,从而使横向或纵向扩展成为一项艰难决策。在本文中,我们将尝试通过基准测试来回答这些问题。这是两幕系列文章中的第一幕。第一幕聚焦于识别我们在当前默认值下观察到的延迟问题的原因。第二幕将探讨对于读/写负载,其他堆比例值可能表现如何。在此过程中,我会分享整个调查中使用的工具和命令,使本文既能为你们提供实用参考,也能在我几个月后不可避免地需要重新追溯调查时,为我自己提供参考。
知识背景
这个故事还跨越了若干边界,例如 Kernel VM、JVM 和 Lucene。因此,提前概述本文这一部分提到的概念很重要,既是为了提供背景,也可以视需要丰富 AI 上下文——如果你想总结全部内容的话。
环境
我在 Azure 上使用了 Aiven for OpenSearch,并将集群指标导出到 Thanos。OpenSearch Benchmark 指标 并不能提供我们回答问题所需的全部信息,尤其是 Linux PSI 和交换活动等主机级指标。将集群指标导出到 Thanos,使我们可以稍后使用 PromQL 查询来获取调查所需的额外指标。
集群由 3 个节点组成:
- CPU:AMD EPYC 7763v (Milan) vCPU / RAM:2 vCPU,8 GiB
- 每节点磁盘大小:
175 GiB - Azure 区域:
azure-westeurope - Azure 磁盘:
PremiumV2_LRS - Azure SKU:
Standard_D2as_v5 - OpenSearch 版本:
3.6.0 - JDK:
java-21-openjdk-headless - GC:
G1GC
第一幕:延迟与额外的一个 GB
症状:整个集群的查询延迟升高,客户在内核和 Azure 镜像升级后首次报告了该问题。OpenSearch 本身的负载模式没有变化,集群配置和设置也没有变化。监控面板给出了第一条线索。在下方截图中,绿色竖线标记了升级发生的时刻。在那之后,页缓存增长了大约 1 GB,而此前接近于零的 I/O PSI 开始出现显著尖峰。没有任何东西崩溃,没有触发告警,而且从 JVM 的角度看,一切似乎都很正常。
[LOADING...]
[LOADING...]
那么,那额外的一个 GB 页缓存从何而来?实际上什么也没有被释放。它移动了。有趣的部分不只是内存去了交换分区,而是哪部分内存去了。当你运行着成千上万个集群时,总有一小部分集群运行在边缘附近:它们相对稳定,却又足够敏感,以至于哪怕很小的变化也可能明显影响性能。就像一颗接近生命末期的恒星,它们可能看起来一直稳定,直到有什么东西打破了平衡。页缓存变化的直接原因很快就找到了:Azure 会应用不同于 Linux 内核默认值的调优参数,而旧镜像没有应用这些参数。一旦应用,更大的缓冲区和 read_ahead 增加了文件系统缓存的占用,对匿名内存施加了额外压力,并最终将其中一部分推入交换分区。但与其止步于此,不如把这次事件当作一次机会,来试验堆比例,并使 OpenSearch 实例的行为更加明确、更少依赖此类环境变化。
证据
它位于交换分区上;没有任何内存被锁定
首先,查看节点本身正在发生什么:
grep -E 'MemFree|MemAvailable|Cached|SwapFree' /proc/meminfo
交换设备是 dm-crypt
然后确认它确实有地方可去,以及是在什么样的设备上:
swapon --show
dm-* 交换设备是需要捕捉并牢记的有趣细节。这是一个加密设备,因此一旦请求一个页,就可能驱动更多 I/O -> 更多 dm-crypt 分配 -> 更大的高阶压力。这是一个自我强化的循环,也是读放大的一个很好的例子。
分页是实时发生的,而非历史遗留
下一步合乎逻辑的做法,是检查这是实时分页,还是仅仅是陈旧的历史尾巴。vmstat 1 5——即 Linux 虚拟内存统计工具——应该能给出确切答案,其中换入和换出的交换块非零(标记为 si、so):
vmstat 1 5
5 个样本中有 4 个的 si/so 非零,表明分页过程正在实时发生,swpd 在这五秒内也在攀升,这是很好的证据。
被换出的页是匿名页,而非文件支撑页
我们还要检查进程每个映射的交换内存消耗,以确认交换与堆相关:
最大的被换出区域是
但为什么我们上面看到的是一个与堆相关的区域?有几个线索。区域大小 0x708400000 - 0x7ffe00000 正好是 4,154,458,112 bytes = 3,962 MiB,因为我们使用 -Xms == -Xmx,而且整个区域在启动时就被提交;JVM 进程中没有任何其他东西是单个连续的约 4 GB 匿名 rw-p 映射。其次,它是地址空间中最低的映射,smaps_rollup [rollup] 行正好从 708400000 开始:
cat /proc/$PID/smaps_rollup
JIT 代码缓存也被换出
解码交换量最高的区域:
- 位于
708400000的1160380 kB——是 JVM 堆,即压缩 oops 堆基址,并且与smaps_rollup中[rollup]的起始位置匹配。 - 几十个
61444 kB区域——这些区域很可能与本地/堆外内存有关:Netty、JNI、Lucene native 等。 - 标记为
rwxp的43084 kB——JIT 代码缓存,也被换出,这是一个坏迹象。
这些区域加起来几乎正好等于我们之前观察到的约 2.5 GB 交换内存:冷堆区域、本地/堆外分配、代码缓存,可能还有线程栈。实际上,这意味着两个后果:
-
GC 会放大交换延迟。有 1 GB 的 JVM 堆被换出。G1 在混合收集期间不一定会触及所有这些页,但任何访问到已换出页的 GC 阶段都会触发主缺页,并且必须通过 dm-crypt 设备将其换回。因此,短暂的 GC 工作可能产生显著更长的暂停。
-
被换出的页也可能包含可执行代码。下一次调用某个已被换出的编译方法时,可能在该代码运行前触发主缺页。由此产生的延迟可能落在原本随机的请求上,并且很难直接归因于 GC、索引 I/O 或查询本身。
持续匿名内存颠簸的证据
workingset_refault_anon 统计回收后的匿名内存重新缺页事件;它不统计唯一页数。结合 pswpin 和 pswpout,它显示了自启动以来累积了多少匿名内存分页。
grep -E 'workingset_(refault|activate)_anon|pswpin|pswpout' /proc/vmstat
这些计数器是累积的,因此每 10 分钟获取一次它们可以清楚地表明,这不是一次性驱逐。这是一个持续发生的过程:将近 8900 万个匿名页被反复换出并重新缺页换入。
vm.swappiness = 1 与 GC 放大
在这个故事中,vm.swappiness=1 从集群创建之初就已设置。它做了 Linux 定义它应做的事,但并未提供我们想要的保护。我怀疑,在资源最受限的部署中,这一点尤其重要。我们怎么知道?上面的整个结果就是一个反例。swappiness 会偏向内核在回收文件支撑页和匿名页之间的选择。它并不会阻止匿名页被换出。即使设置为 1,在持续内存压力下这种情况仍会发生。对于一个资源受限、索引大小是 RAM 数倍的节点来说,文件系统缓存承受压力并非异常情况;这就是正常的运行状态。这有两个重要后果:
-
它不是自愈的。没有任何东西会按计划主动将匿名内存换回。被换出的页只有在再次被访问时才会回到 RAM,而按其定义,冷内存可能长时间保持未被触碰。结果,冷的 JVM 内存可能无限期留在交换分区中。
-
在有什么东西触碰它之前,它可能一直不可见。在稳态下,堆的冷尾部可以留在交换分区中而不产生明显症状。当这些页再次被触碰时,问题就会显现,导致主缺页,并可能放大 GC 和请求延迟。
关键要点
因此,延迟问题的根因是过大的堆,加上页缓存压力(堆的冷尾部已被换出)。
vm.swappiness=1并没有保护堆。它只是影响哪些内存会被回收;它并不能阻止匿名内存被换出。- 在生产环境中,不应将
vm.swappiness=1与其他默认配置一起依赖。过大的堆可能导致难以检测的 GC 放大。 - 大多数被换出的内存根本不是堆,而是 malloc arena 和 JIT 代码缓存,它们都不在
Xmx之内,因此堆指标没有显示出来。 - 在
dm-crypt上使用交换会加剧该问题,导致更长的 GC 暂停和随机请求延迟尖峰。出于安全要求,生产环境中dm-crypt可能无法避免。修复办法不是再调整一次 swappiness。要做的是两件事:停止提交你不使用的堆,并让你确实提交的堆不可被驱逐。
后续
在第二幕中,我们将回答本文开头提出的剩余问题,并更仔细地考察 bootstrap.memory_lock 引入的权衡。该设置使堆常驻并免受交换影响,但它也会把 jvm_heap_ratio 从一个软默认值变成永久的内存承诺。问题于是变成:什么样的堆比例最适合不同的读和写工作负载?还有一点需要提前说明:在资源受限的环境中,合并风暴可能扭曲基准测试结果,使本来良好的堆比例看起来很差。见下方截图。
[LOADING...]
DZone 贡献者表达的观点仅代表他们自己。