针对某个高并发系统不稳定问题,本文首先定位根因为系统内存索引切换时 GC 压力大导致。围绕此问题,基于让索引尽早晋升到老年代、让索引直接分配到老年代、加速索引复制、升级 GC 等思路做了持续探索优化,作者详细介绍整个排查和优化过程。
一、前言
关于 JVM 调优的话题网上众说纷纭:“99% 的情况下都不需要 JVM 调优,剩下那 1% 就是面试时背八股文”、“需要调优的不是 GC 参数而是业务代码”、“真正的高手会直接升级 JDK 版本”、“GC 调优是最后的手段”。以上说法都有道理,因为得益于 JDK 专家团队的长期优化,通常 JVM 在默认设置下就已能够提供出色的性能。
然而默认参数可能无法满足某些极端性能和稳定性要求的特殊场景。此时可尝试研究该系统的内存使用规律和特点,并对症下药,针对性地调整某些参数。通过调参改变 JVM 的行为模式,从而立竿见影地提高 JVM 在这些特殊场景下的性能表现。
对于组内某个高并发(十万级 QPS)、低延迟(毫秒级返回)系统的不稳定问题,本文首先定位根因为系统内存索引切换时 GC 压力大导致。围绕此问题,基于让索引尽早晋升到老年代、让索引直接分配到老年代、加速索引复制、升级 GC 等思路做了持续探索优化。在不加一台机器、不改变流量大小的前提下,系统成功率(抖动时)逐步优化效果为:95% => 98% => 99.5% => 99.995%,保障系统高可用。下文将详细介绍整个排查和优化过程。
二、问题背景
组内有一个业务系统 A,日常能达到十万级 QPS(大促峰值超 40W QPS),且上游系统基于同步调用,对 RT 非常敏感(毫秒级返回)。因此系统 A 不能轻易抖动,需要在超高流量下保持极致的服务稳定性:
某天发现上游系统有一些报错,需要排查解决:
三、排查过程
3.1 初步分析
查看上游系统报错日志,发现全都是同步调用请求超时,报错 TimeoutException。因此需要重点分析系统 A 是否有异常。
首先,在报错时间段业务流量并没有明显上涨,系统 A 的 CPU 水位、机器 load 也没有明显异常,因此可初步排除由于流量激增、超出系统最大负载所致。
其次,系统 A 执行请求的过程全都是一些内存计算逻辑,不需要远程调用数据库、中间件、外部系统,因此也可排除是外部依赖服务抖动/有瓶颈导致。
其次,虽然系统 A 的并发流量很高(单机高达数千 QPS),但每条请求之间不涉及同步/互斥的单机/分布式锁逻辑,因此也可排除由于锁导致请求等待超时所致。
经过以上初步分析,已排除流量激增、外部服务有瓶颈、并发锁等可能影响因素,但并未定位到根因,需要进一步深入分析。
3.2 定位根因
查看系统日志,发现服务抖动期间,该系统曾发生过一次热数据发布(系统索引切换):
说明:系统 A 在内存中加载了一份索引(可简单理解为一个比较复杂的大对象/数据结构),且系统 A 会不定期(最快每 15 分钟)更换旧索引、加载最新版本索引。
已知本系统的索引较大(约 0.5G),由于索引切换过程会产生大量新对象和内存垃圾,因此高度怀疑服务抖动与 GC 强相关。查看 gc.log,在系统抖动期间果然发现了长耗时的 YGC:
Object Copy:GC 时不再使用的对象会被清理释放,并整理剩余存活对象,整理过程存活对象会被复制到内存中新的位置
观察日志可发现 Object Copy 环节耗时明显异常,高达 200ms,且本过程会 STW(Stop The World)。因此服务抖动的根本原因已锁定:系统 A 加载的索引非常大,导致 YGC 时索引在堆内存的复制过程耗时久,复制期间业务线程被长时间暂停,导致上游请求大量超时报错。
四、优化过程
4.1 常规思路
针对 GC 暂停久这类常见问题,有如下一些常规优化思路:
然而,以上方法在本次场景中大多不适用。首先经排查代码并不存在 Bug,且索引体积已无更多压缩空间,且索引算法层面并不支持增量式更新只能全量更替。其次,加机器虽然能通过稀释单机请求量,让 STW 长暂停影响到的请求量更少,但并未从根本解决问题,且会导致机器资源大量浪费。另外使用堆外内存虽然可不受 GC 管理,但高频访问下序列化/反序列化开销无法容忍。
因此,综合来看只能考虑在 JVM 参数方面做优化:通过修改参数调整 JVM 的行为模式,让索引复制带来的负面影响尽可能小,保障服务高可用。
4.2 详细分析GC日志
根据 3.2 节分析,问题已归因为 YGC Object Copy 阶段复制索引时耗时太久,导致上游请求超时报错。本节进一步详细分析 GC 日志,更细粒度还原整个 GC 过程,探索有无潜在优化点。
已知当前 JVM 核心参数如下:
-Xms12g
-Xmx12g
-XX:MetaspaceSize=512m
-XX:MaxMetaspaceSize=512m
-XX:+UseG1GC
-XX:G1HeapRegionSize=16M
-XX:MaxGCPauseMillis=100
-XX:InitiatingHeapOccupancyPercent=45
-XX:+HeapDumpOnOutOfMemoryError
-XX:MaxDirectMemorySize=1g
通过集团 ATP 工具对原始 GC 日志进行可视化分析,下图中标出了各 GC 事件的时间点和变化曲线:
从图中可以分析出如下信息:
① 蓝色圆点:一个点代表一次 YGC,横轴上堆满了密密麻麻的蓝点,说明 YGC 发生非常频繁且耗时短,毫秒级即可完成清理,这是理想中的情况。符合预期
② 粉色折线:代表堆内存占用量的变化情况,可以看到整体呈锯齿形,不断快速上升和下降。由于系统流量较大且请求执行过程会不断产生一些朝生夕灭的临时对象,因此可看到粉色折线快速上升。当 Eden 区不足时触发 YGC 清理,内存释放完成即可看到粉色折线下降到低点。符合预期
③ 异常蓝点:远离横轴说明耗时久,它们就是刚刚在日志中手动找到的长耗时 YGC 记录。需重点关注
④ 紫色折线:代表老年代堆内存占用量的变化情况。相比之下老年代占用率上涨缓慢,因为大多数临时对象都在年轻代被清理掉了,不会进入老年代。然而观察发现每次长耗时 YGC 蓝点附近,都会伴随着紫色折线阶梯式上升。需重点关注
其次,还可发现长耗时 YGC 往往是成对出现的,有如下规律:成对出现、时间接近、耗时都长、第一次晋升量少、第二次晋升量多,如下图所示:
综上,整合目前所有已知线索:系统在每次切换索引时,都会超时抖动,且在抖动时间点会发现连续的两次长耗时 YGC(第二次 YGC 晋升量大)。
经分析以上现象符合预期,详细过程推演还原如下:
-
阶段一:系统创建新索引,相关对象默认被分配至 Eden 区;
-
阶段二:Eden 区空间不足,触发第一次 YGC,此时新索引被复制(Object Copy)到 Survivor 区,耗时久;
-
阶段三:新索引构造完成,并被 GcRoot 引用上,旧索引与 GcRoot 引用被断开;
-
阶段四:系统持续处理外部请求,Eden 区空间再次不足,触发第二次 YGC,此时旧索引被清理。新索引又被复制(Object Copy)到 Old 区(晋升),耗时久;
-
阶段五:后续即使外部流量再次把 Eden 区打满,YGC 也能毫秒级快速完成。因为只需快速清理临时对象即可,新索引已稳定在老年代不会再被腾挪复制;
4.3 一些尝试
至此,问题原因已非常清晰:每次新生成的索引会随着 YGC 连续复制多次,复制过程暂停久导致系统抖动。因此可考虑基于如下一些思路来针对性优化本问题,后文会逐个详细解释:
4.3.1 让索引尽早晋升到老年代
通常情况下,一个对象最初会被分配在 Eden 区,第一次 YGC 后进入 Survivor 区。此后每次 YGC 对象会在 S0 和 S1 之间反复腾挪,且每次腾挪后对象 age+1,当 age 大于默认阈值时会晋升到 Old 区。因此对象在堆内存中的流转路径是:Eden => S0 => S1 => S0 => S1 => ... => Old
由于本例中索引对象复制开销太大,因此可考虑让索引尽早晋升到老年代,避免在年轻代反复腾挪影响系统稳定性。有如下 JVM 参数可以达到此目的:
MaxTenuringThreshold
参数作用:表示对象在晋升到老年代之前,在年轻代中最多能够承受的GC 周期次数
然而,结合 4.2 节实际 GC 日志截图可知,G1GC 对大对象做了动态优化——直接晋升(Direct Tenuring),并没有让索引在 Survivor 内反复腾挪。索引实际流转路径是:Eden => S0 => Old,总共只涉及 2 次复制而非默认值 15 次。此时相当于已经默认设置了 MaxTenuringThreshold=1,流程如下:
-
阶段一:新索引分配至 Eden 区,此时 age=0;
-
阶段二:触发第一次 YGC,索引存活,由于 age < MaxTenuringThreshold = 1,此时索引从 Eden 复制到 S0,随后 age 增长为1;
-
阶段三:触发第二次 YGC,索引仍存活,此时由于 age = MaxTenuringThreshold = 1,则直接晋升并复制到 Old;
手动设置 MaxTenuringThreshold=1 重新实验。如下图所示,经实测索引流转路径仍然是 Eden => S0 => Old,证明以上猜想成立:
MaxTenuringThreshold=1
由此自然想到,能否更极端一点,让索引直接从 Eden 复制到 Old 而完全不经过 Survivor 区?因为 Eden => Old 相比 Eden => S0 => Old,复制次数从 2 次进一步压缩为 1 次,总暂停时间直接减半,系统稳定性预期将提升明显。因此考虑进一步设置 MaxTenuringThreshold=0,预期流程如下:
-
阶段一:新索引分配至 Eden 区,此时 age=0;
-
阶段二:触发第一次 YGC,此时由于 age = MaxTenuringThreshold = 0,则索引直接晋升并复制到 Old;
实验结果如下图,可知索引的确在第一次 YGC 时从 Eden 被直接复制到了 Old(因为清理后年轻代占用变为 0,否则年轻代清理后仍然会占用 400MB 左右):
MaxTenuringThreshold=0
总结:本次优化前,每次索引切换后会出现 2 次连续的长耗时 YGC,在不改任何一行业务代码、不加一台机器的前提下,仅通过设置 MaxTenuringThreshold=0,GC 长暂停时间直接减半。体现在系统监控上就是索引切换时报错量明显变少,服务抖动时成功率从 95% 提高至 98%:
InitialTenuringThreshold
InitialTenuringThreshold 参数和 MaxTenuringThreshold 的作用类似,都是用于调整对象晋升到老年代的年龄阈值
经实测,设置 InitialTenuringThreshold=1 也能达到类似上一节的效果,也能将索引复制次数从 2 次减少为 1 次,提高系统稳定性:
InitialTenuringThreshold=1
AlwaysTenure
前两小节的核心思想是让索引直接晋升并复制到老年代。恰好 AlwaysTenure 参数也能达到该目的,参数作用如字面含义:让对象总是晋升。经实测,设置 AlwaysTenure 后,也能将索引复制次数从 2 次减少为 1 次,提高系统稳定性:
AlwaysTenure
说明:
-
由于索引较大,Eden 区剩余空间可能无法容纳整个索引,因此上图总共经历了 3 次 YGC 清理释放,才让索引全部创建完成。其中每次 YGC 会把已构造好的索引局部晋升到老年代,前后总共 3 次 YGC 才把索引完整搬到了老年代。这与“AlwaysTenure 将索引复制次数从 2 次减少为 1 次”结论并不冲突
-
AlwaysTenure 相当于只使用 Eden 和 Old,而 Survivor 闲置。与之作用相反的参数是 NeverTenure,会让对象在年轻代中反复辗转而永远不晋升,意味着只使用了 Eden 和 Survivor 区,而 Old 区闲置。两个参数都比较极端,只有在特殊业务场景才考虑使用
-
降低晋升年龄阈值会让对象更容易进入老年代,通常会加重老年代 FGC 负担。而本业务场景比较特殊,对象的存活时间两极分化明显:一种是由 RPC 请求产生的朝生夕灭的对象,存活时间毫秒级;另一种则是巨型索引对象,存活时间最短都有数十分钟。因此就算把晋升年龄阈值改为 1,这些临时对象大概率已失活(被清理)而非存活(被晋升),故修改以上 JVM 参数不会加重本系统 FGC 负担
4.3.2 让索引直接分配到老年代
上节内容已将索引流转路径已从 Eden => Survivor => Old(2 次复制)优化为 Eden => Old(1 次复制)。能否更极端一点,让新索引在最初创建时,就一步到位直接分配到老年代(0 次复制)?这样索引复制导致服务抖动的问题将得到根治。思路如下:
围绕此思路,继续做了如下尝试:
PretenureSizeThreshold
参数作用:当对象的大小超过 PretenureSizeThreshold 时,该对象会直接分配到老年代
然而 PretenureSizeThreshold 参数对 G1GC 并不生效,实测也发现调整该参数后没有稳定性增益。
G1HeapRegionSize
参数作用:G1GC 将堆内存划分为多个大小相等的区域,这些区域被称为 Region,旨在提高垃圾收集的效率和灵活性。当待分配对象大小 > G1HeapRegionSize / 2 时,会被直接分配到老年代
然而,修改 G1HeapRegionSize 参数后继续观察,索引切换时系统仍然抖动,看 GC 日志索引流转路径仍然是 Eden => Survivor => Old,并没有达到预期效果。
原因分析:业务上索引虽然整体很大(约 500MB),但实际是由上百万个小对象组成的。索引的创建过程实际就是内部海量小对象逐个创建的过程,这些小对象被分配至 Eden(而非 Old)是合理的、符合预期的,因此从结果来看整个索引实际仍然被分配在 Eden 区。除非是 int[] arr = new int[1000000000] 这类情况,JVM 能在最初明确知道 arr 需要多少空间,才可直接分配到老年代。
4.3.3 加速索引复制过程
在不改变索引固有大小、索引复制次数的情况下,也可以考虑调节如下参数来提高复制速度、降低暂停时长:
参数名 | 作用 |
MaxGCPauseMillis | G1 会尽量将每次 GC 的停顿时间控制在这个目标时间内 |
ParallelGCThreads | 设置并行垃圾收集器使用的线程数,影响 YGC/FGC 的并行度 |
ConcGCThreads | 设置并发标记阶段使用的线程数,影响并发标记阶段的速度 |
实测调整以上参数无明显改善:MaxGCPauseMillis 只是一个目标值,然而复制索引固有耗时始终有那么久,作用不大。其次,经实测 GC 默认并发线程数已接近 CPU 核数,也无更多优化空间。
4.3.4 升级JDK11 - ZGC
传统的 CMS 和 G1 都存在各自的理论局限(例如 CMS 的内存碎片化,G1 只能在 STW 时移动对象,两者 STW 时长会随着活跃对象的增加而增加),这正是我们大索引复制所遇到的问题。
JDK11 中新增 ZGC,核心变化是引入了着色指针(Colored Pointers)和读屏障(Load Barriers)机制,解决对象复制过程中准确访问对象的问题,从 STW 优化为并发转移。核心原理如下:
ZGC中业务线程访问对象将触发“读屏障”,如果发现对象被复制移动了(通过“着色指针”实现),则“读屏障”会把读出来的指针更新到对象的新地址上,让业务线程始终访问到对象更新后、移动后的正确地址。对比之下G1只能先暂停并复制对象、更新指针地址,随后再解除暂停让业务线程访问对象
以上机制让 ZGC 可以有更高的并发度、更低的 STW 时长。对比 G1 如下:
经实测使用 ZGC 后稳定性有提升,但索引切换期间仍然会有轻微抖动。诊断分析 GC 日志发现此期间有 Allocation Stall(导致应用程序在尝试分配内存时暂时停止,直到有足够的内存可用):
ZGC
本系统每次分配新索引都需要约 500MB 内存,这是 JVM 无法预知的。对比监控可发现每次索引切换时,每个服务端 RT 尖刺均对应了一次堆内存占用尖刺,如下图:
ZGC 实测效果
由于 ZGC 在内存整理阶段是无锁复制,因此 GC 日志中没再发现耗时异常的记录,经实测服务成功率进一步提高到了 99.5%。但美中不足的是由于 Allocation Stall 问题,系统时常还是会有些小抖动。
4.4 问题复盘
回顾本问题,复盘为什么 YGC 的负面影响这么大,让本系统在索引切换时成功率跌至 95%?核心问题是本系统挑战本身就非常大,需要同时满足以下三个条件,缺一不可:
-
对延迟非常敏感:同步调用且毫秒级返回,不能长时间暂停,否则每次长暂停都会直接体现为业务监控上的报错
-
极高的内存压力:每次索引切换会带来 GB 级的内存消耗、清理和复制开销,这是导致 YGC 耗时久的根源
-
极高的并发量:总流量十万级 QPS,单机数千 QPS。GC 暂停时所有请求都将暂停处理,导致大量超时
这类似分布式系统中的 CAP 定理:一致性(Consistency)、可用性(Availability)、分区容错性(Partition Tolerance)无法兼得,最多能同时满足两个(CA、AP、CP)。回到本业务场景,如果只需满足任意两个条件,那么此案例中 YGC 带来的负面影响也将完全无关痛痒:
-
如果只需满足条件 1 和 2:虽然对 RT 敏感且索引切换暂停久,但系统流量小,影响面会小很多。例如超时时间是 50ms,YGC STW 49ms,则 YGC 结束后还剩余 1ms,此时要分别处理 1000 个请求(高流量)和 10 个请求(低流量),后者报错会少很多,压力不大;
-
如果只需满足条件 1 和 3:虽然对 RT 敏感且流量极高,但 GC 层面没什么压力,每次 YGC 能毫秒级完成,此时海量流量都能快速处理和响应,压力也不大;
-
如果只需满足条件 2 和 3:此时虽然 YGC 暂停久且流量高,但 RT 容忍度高。如果能把超时调大为 5s 或 10s,那么报错也会少很多。或者如果能接受改为异步调用(例如基于消息队列),那么 YGC 长暂停顶多只会导致消费暂停,消息队列堆积一些请求罢了,等 GC 完成后系统自然会快速泄洪,压力也不大;
4.5 彩蛋—索引无感切换
如上分析,个人理解目前在 JVM 调参方面已经优化到极致,索引切换时系统成功率也已经从 95% 提高到 99.5%。但面对日常的轻微尖刺报错(虽然相比最初已经缓解了很多),总会有强迫症,总会想能否把超时报错问题彻底根治解决?答案是:可以。
既然 JVM 层面始终避免不了 1 次大索引复制,那能否避其锋芒,通过调整系统的发布策略(分批 + 断流)让服务在索引切换期间(易超时报错)不要接流,等到索引已复制到老年代之后再接流?后续重新接流时,年轻代里都是一些临时的、可清理的 query 小对象,则 YGC 将会非常快(毫秒级完成),超时报错问题也就彻底根除?
恰好本应用所在的运维平台支持如下多种灰度发布方式。结合当前业务场景分析,选择「灰度断流」的方式最合适,能尽可能保证服务稳定性:
深入分析,如果只设置灰度断流并不能根治当前问题。因为索引切换不一定会触发 YGC,Eden 区耗尽才会触发 YGC。假设 Eden 区大小 3GB,索引大小 1GB,举两个例子:
-
Case1:假设索引切换前 Eden 区内存占用只有 1G,剩余空间能完整容纳新索引,索引分配后 Eden 占用为 2G < 3G,并不会触发 YGC。当重新接流且 Eden 区首次耗尽时,索引将被复制转移,此时 200ms 长暂停势必导致业务请求超时报错。如下图:
-
Case2:当本次索引切换前 Eden 区占用介于 [2GB, 3GB] 时,索引构造过程会由于 Eden 空间不足触发 YGC,此时一部分索引会被提前复制到 Old 区,缓解 YGC 长暂停问题:
综上,本方案实际只有(索引大小 / Eden区大小 = 33%)的概率能有缓解作用(且缓解的程度取决于当时 Eden 区已占用情况),剩余 67% 概率下完全没有缓解效果,系统依然会抖动明显。
如果称 Case2 中提前被复制到老年代的索引体积比例为“缓解程度”,则索引切换前 Eden 区占用量与本方案“缓解程度”的关系如下(红色线条):
分析到此萌生一种想法:能否通过一些人为手段,让每次索引切换时(断流),新分配的索引一定会因为 Eden 区空间不足,而被 YGC 全部复制到老年代?当重新接流时年轻代中就一定只有朝生夕灭的 query 对象,后续 YGC 将会非常快,保障系统彻底不受索引切换的影响。
答案是:可以。类似“预热”的思路,每次新索引切换后、重新接流前,手动构造一些临时的、不需要的对象,保证至少把 Eden 区耗尽一次,迫使新索引在断流期间一定被全部复制到老年代。详细流程如下图:
说明:上图是针对前面 Case1 的优化原理,本“预热”方案对 Case2 也同样有效,篇幅原因不再赘述。
综上,经过推演分析,分批断流发布 + Eden区“预热”的方式,预期能彻底消除索引复制时 YGC 长暂停导致的负面影响。而实现层面要达到这个效果,只需增加第三步的 3 行代码即可:
public boolean switchIndex(String indexPath){
try {
// 1.【断流】加载新索引
MyIndex newIndex = loadIndex(indexPath);
// 2.【断流】索引切换
this.index = newIndex;
// 3.【断流】Eden 区预热
for (int i = 0; i < 10000; i++) {
char[] tempArr = newchar[524288];
}
// 4.【断流】通知上层索引切换完成
return true;
// 5.【接流】重新接流,此后 YGC 都会很快
} catch (Exception e) {
return false;
}
}
注意:单个 tempArr[] 不能太大,少量多次,否则它会被 JVM 视为“大对象”而直接分配到老年代
重新发布以上代码,再次观察 GC 日志如下。重点关注②和③,可知 Eden 中的新索引被预热流量触发的 YGC 成功驱逐到了老年代,后续④重新接流时 YGC 都非常快,毫秒级完成:
分批断流 + Eden区预热
再次观察系统监控,系统稳定性提升立竿见影(红色箭头代表索引切换事件)。换句话说,如果不在图 2 中标注箭头,我们甚至根本猜不到哪些时间系统在切换索引:
历经种种尝试,至此终于彻底实现了索引无感切换,此后系统日常成功率均稳定在 99.995% 以上(个别失败为偶发性网络超时):
五、总结
针对组内某个高并发(10W+ QPS)、低延迟(毫秒级返回)、高内存压力(最快每 15 分钟一次 GB 级索引切换)系统不稳定问题,本文基于 JVM 调参做了一系列探索尝试,最终彻底实现了索引无感切换,让服务可用率稳定在 99.995%。有效的优化手段如下(有更好的思路欢迎留言讨论):
至此,未来无论系统 QPS 涨到多高、索引体积膨胀到多大、索引切换多么频繁,系统都能无感切换索引,稳定性不再受到任何影响。完结撒花~
通过设置MaxTenuringThreshold=0
和AlwaysTenure
等JVM参数,成功将索引复制次数从2次减少为1次,显著降低了GC暂停时间,系统成功率从95%提升至99.5%。此外,作者还提出了通过“预热”Eden区的方式,进一步优化了索引切换时的系统稳定性,最终实现了索引无感切换,系统成功率稳定在99.995%以上。