一个模板把 AEM 拖进 Full GC 死亡螺旋:300% CPU、7 GB 堆、310 万行堆栈

线上报的是 504,最省事的处理是重启。10 月 30 日这篇复盘里,作者没有重启,反而在 300% CPU 的机器上坐了三个小时,把证据一份份掏了出来。

事情发生在一套 AEM Author 上:AWS EC2、前面挂 ALB、底层是 TarMK 的段存储。上午开始有人喊「Author 在抛 504」,负载均衡器持续超时,编辑们被锁在门外。

登机器看 top,Java 进程 CPU 300%。磁盘正常,内存正常,iowait 是 0.0。

先分清是 AEM 挂了,还是前面那层挂了

这一步决定了后面三小时往哪查。作者的顺序是先绕开所有中间层,直接打本机端口:

bash 复制代码
curl -s -m 60 -o /dev/null -w "%{http_code} %{time_total}s\n" \
  http://localhost:4502/libs/granite/core/content/login.html

60 秒超时,挂住,没有返回码。df -h、free -m、top 里的 iowait 全部正常。

三个信息凑在一起,结论很清楚:不是 ALB 的问题,不是 Dispatcher 的问题,不是网络的问题,是 JVM 自己转不动了。顺带说一句 ALB 的 504 是怎么来的------连接阶段 10 秒没建起来,或者连接建起来了但空闲超时(默认 60 秒)前没响应,两种都记 504。

接着问 JVM 在干什么:

bash 复制代码
top -H -b -n1 -p <PID> | head -20

热线程全是 ParGC Thread#0/1/2/3。四个并行 GC 的工作线程在满负荷跑,一个应用线程都没进前二十。CPU 是 GC 吃掉的,不是业务吃掉的。

再补一刀:

bash 复制代码
jstat -gcutil -t <PID> 1000 10

老年代使用率(O)贴着 100%,Full GC 之后不下降,FGC 一直涨。

FGCT 那个数字,把「泄漏」这个假设否掉了

常规剧本到这里就该去找内存泄漏了。作者换了个算法,先看总账:

bash 复制代码
ps -o etimes= -p <PID>                       # 实例跑了多久
grep -m1 -n 'OutOfMemoryError' crx-quickstart/logs/error.log
  • 实例运行时长:约 21 小时。
  • FGCT(Full GC 累计耗时):11,178 秒,折合约 3.1 小时。
  • 首个 OOM 出现的时间点,跟这 3.1 小时几乎精确吻合。

FGCT 是墙钟暂停时间,不是 CPU 时间。Parallel 收集器做 Full GC 时会把所有应用线程停住(STW),所以这 3.1 小时就是「整个 JVM 什么都不干、只在回收」的时间。而它在第一个 OOM 之前是健康的。

一个跑了 21 小时的实例,前 18 小时正常,后 3 小时全在 GC------这是急性事件,不是慢性泄漏。方向换掉:别找谁在慢慢涨内存,去找 3 小时前那个时刻发生了什么。

这个判断的价值在于省时间。找泄漏要抓堆、要比对直方图、要等复现;找急性事件只要读一个时间点附近的日志。

jstack 打不进去,只能让 JVM 自己吐

按套路该抓线程栈了,两条命令全失败:

bash 复制代码
jstack -l <PID>
jcmd <PID> GC.heap_dump /tmp/heap.hprof

报的是 AttachNotSupportedException: Unable to open socket file ... target process doesn't respond within 10500ms。

attach 的机制是:工具创建一个 .attach_pid 触发文件并给目标发信号,JVM 的 Attach Listener 线程应该在临时目录里建一个 UNIX socket 出来。10.5 秒等不到 socket,就是 JVM 没空理你。

作者列了五种常见原因------用户不匹配、临时目录被清理器删了、JDK 版本不一致、启动时加了 -XX:+DisableAttachMechanism、以及 JVM 根本到不了 safepoint。这次是第五种:线程 dump 和堆 dump 都要在 safepoint 执行,一个连续 Full GC 的 JVM 到不了 safepoint。

jstat 为什么还能用?因为它读的是 HotSpot 写在 /tmp/hsperfdata_<user>/ 下的共享内存文件,不需要 JVM 配合。GC 卡死的时候,这是少数还活着的观测手段。

剩下的办法是 kill -3 <PID>。发 SIGQUIT,JVM 会把完整线程 dump 打到 stdout(不是 error.log),然后继续运行。这点很容易踩空------很多人去 error.log 里翻,翻不到就以为没生成。GC 卡死状态下,dump 可能要等好几分钟才出现,因为要等下一个 safepoint。

线程名里带着 URL,这一条直接锁定了现场

AEM 有个少见的特性:Sling 处理请求时会把线程改名为「客户端地址 + 毫秒时间戳 + HTTP 方法 + URI + 协议」。所以线程 dump 里能直接读到是哪个页面把线程卡住的。

bash 复制代码
grep -oE '^"[^"]* \[[0-9]+\] [A-Z]+ [^ "]+' threaddump.txt \
  | sed -E 's/^"[^ ]* \[[0-9]+\] //' | sort | uniq -c | sort -rn

9 次 dump,每次都是 4 个线程,卡在同一个 editable template 的 structure.html 上,10 分钟内毫无变化。

到这里问题定位到页面了。接下来是日志。

bash 复制代码
grep -cE '^[0-9]{2}\.[0-9]{2}\.20' error.log      # 13,061 条
grep -cvE '^[0-9]{2}\.[0-9]{2}\.20' error.log     # 3,090,535 行栈

858 MB 的 error.log,前一天是 12 MB,涨了约 71 倍。1.3 万条日志,平均每条带 237 行堆栈。

出现最频繁的一条是 WCMDeveloperModeFilter Error during include of SlingRequestPathInfo,重复了 2,000 次,路径长这样:

bash 复制代码
/conf/<site>/settings/wcm/templates/<template>/structure/jcr:content/header/header/header/...

一条路径里 header 重复出现 40 次。

递归保护为什么没兜住

根因是一个组件通过 HTL 的 data-sly-resource 渲染了同类型的子资源,子资源再渲染同类型子节点,一路嵌套下去。触发路径有两条:组件代码本身就写错了,或者布局容器的 policy 允许放「同类型的容器组件」------后者意味着编辑在界面上拖一下就能造出来,不需要任何代码变更。

Sling 本来有保护:sling.max.inclusions 默认 50 层,sling.max.calls 默认 1000 次。到顶抛 RecursionTooDeepException。

保护生效了,但代价太高。三层放大:

第一层,每层嵌套都占着内存。 request wrapper、response wrapper、缓冲输出、组件上下文,全都要等深层 include 返回才能释放。40 层压着,等于 40 份请求上下文同时活着。

第二层,失败本身很贵。 异常栈本身已经几千帧深,HTL 在每一层再包一次,单次失败产出的异常链约 2,000 行。而 Author 上默认开着的 WCMDeveloperModeFilter 会把每次组件异常完整记下来,页面还继续渲染。

第三层,编辑器会重试。 模板编辑器一刷新就再请求一次 structure 页面。几千条日志乘以两千行栈,加上几次并发渲染,7 GB 堆就满了。

堆满之后进入死亡螺旋:回收不出空间 → 继续 Full GC → 应用线程全停 → 请求排队 → 更多请求超时。

对照:慢泄漏和急性事件,两条完全相反的路

这两种故障在监控上长得很像,处置顺序却是反的。这篇复盘最值得抄的就是这张对照:

判断维度 慢泄漏 急性事件(本例)
老年代曲线 阶梯式上涨,Full GC 后能回落一部分 一次冲顶,Full GC 后不回落
FGCT 与 uptime FGCT 远小于 uptime,占比缓慢升高 FGCT ≈ 首个 OOM 之后的全部时间
线程栈能否抓到 通常能,jstack 正常 抓不到,attach 超时
该看的证据 堆转储、对象直方图 首个 OOM 附近的日志、线程名里的 URI
处置 修代码、调堆、等复现 立刻止血 + 定位触发源
重启的代价 重启能缓解,还能再抓一次 重启销毁唯一一份现场

最后一行是关键。重启会清掉那个 7 GB 堆、清掉卡住的线程、清掉还没轮转的日志。所以作者宁可让机器继续 300% 烧着,也先把 kill -3 和日志抓下来。

加固清单里,这三条最值得照抄

原作者给了八条,三条最实用。

告警要告「因」不告「症」。 老年代使用率超 90% 持续 5 分钟、每分钟 Full GC 耗时、健康检查的延迟 (不只是状态码)、ALB 的 HTTPCode_ELB_504_Count 和 TargetResponseTime、日志日增长超过平日 4 倍。还有一条容易被忽略:把「指标缺失」也当成违约。死亡螺旋的时候 JVM 可能完全停止上报,监控上看到的是「没有数据」,不是「一切正常」。

部署后对模板做冒烟测试。 记下 error.log 当前行数,用 Query Builder 查 /conf 下所有 cq:Template,逐个请求 $tpl/structure.html,60 秒超时,非 200 就算失败,再检查新增日志里有没有 RecursionTooDeepException。

日志检查不能省。 因为 Author 上的 WCMDeveloperModeFilter 会捕掉组件异常、照样渲染页面------一个坏掉的 include 是可以返回 200 的。只看状态码会漏。

谁该改:开发、运维、编辑各有一件事

写组件的人 别在 HTL 里用 data-sly-resource 递归渲染同类型资源。真要树形结构,就在模型层算好层级、传一个深度参数进去,到阈值直接不渲染。递归在模板层是没有出口的,只有 Sling 那个 50 层的兜底。

做运维的人把「堆转储 + GC 日志」当默认配置,别等出事再开。这两样东西平时占几十 MB,出事那天值三小时。

管内容的人------这次真正的入口。模板策略允许拖同类型容器,等于把「制造无限递归」的权限给了编辑。策略收紧一次,比写十条注释有用。

加堆不是答案,收集器也不是

最容易走偏的一步是把堆从 7 GB 加到 16 GB,然后宣布问题解决。这次的现象是「活跃数据超过了堆」,加堆只能推迟下一次,因为递归本身没修。

真正要改的两处:把那个自包含的组件修掉,把布局容器的 policy 收紧,禁止往容器里拖同类型的容器。前者治代码,后者治人------编辑在界面上能做出来的结构,代码审查是看不见的。

收集器的选择也别乱动。AEM 6.5 配 Java 11 时,Adobe 指定的是 -XX:+UseParallelGC,也就是这次 top -H 里看到的那四个 ParGC Thread。6.5 LTS 配 Java 17/21 时换成 -XX:+UseG1GC。任何收集器在活跃数据大于堆的时候都会连续 Full GC,换成 G1 只是把停顿切碎,不会变出空间。

Parallel 有个默认开启的 GC overhead limit:GC 时间占比超过 98%、回收量不足 2% 时抛 OOM。注意它抛完之后 JVM 不会自杀,还会继续转------这正好解释了为什么机器上 CPU 一直是 300%,进程却还在。有些团队把 -XX:-UseGCOverheadLimit 关掉,结果只是把 OOM 推迟,机器烧得更久。

还有两个参数必开,这次的现场就是因为没开才这么贵:

bash 复制代码
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/aem/dumps/java_pid%p.hprof
-Xlog:gc*:file=/var/aem/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50M

最后一行是 Java 9 之后的新语法,-XX:+PrintGCDetails 那一套已经废了。GC 日志按 10 个文件 × 50 MB 轮转,出事后能回看死亡螺旋开始前那几分钟的回收节奏------这次没有,只能靠 FGCT 反推。

顺带一个没人愿意踩的坑:如果现场用了 kill -9 强杀,AEM 的 TarMK 段存储可能留下未提交的段,重启后报 SegmentNotFoundException,那时候就得停实例、备份 segmentstore、用与实例 Oak 版本一致的 oak-run 跑 check --bin=-1。这条命令不能对运行中的实例执行。所以第一步是「先取证再重启」,第二步就是「别用 kill -9」。

边界:这次没查清的部分

几处信息缺口,得标出来。

没有 P99 延迟数据。 复盘只提到 504 来自 ALB 的 10 秒连接超时和 60 秒空闲超时,没给请求延迟曲线,也没给从修复到恢复的耗时。真要算业务影响,这两个数才是分母。

「OOM 抛在肇事线程」是例外,不是常态。 作者自己提醒:抛 OOM 的请求通常只是受害者,OOM 会抛在当时恰好申请内存的任意线程里。这次核实下来,OOM 确实落在那条递归调用栈里,属于运气好。

递归深度为什么是 40 不是 50,有解释但没验证。 说法是页面和 layout container 上层的 include 也占配额,所以实际嵌套到 40 就到顶了。这个推断合理,但没见到配额计数的实测。

sling.max.inclusions 调小能不能提前拦住,没讨论。 直觉上把 50 改成 20 会更早失败、单次异常更浅,但代价是正常的多层 include 会被误伤。这条需要按自己站点的实际嵌套深度定,没有通用值。

堆怎么配,取决于机器不是取决于应用。 Adobe 的建议是 16 GB 主机配 8--10 GB 堆,TarMK 的段文件走 mmap 在堆外,所以要加物理内存而不是无脑加堆。这条在云上按实例规格算,不能照搬。

收尾:下次先看两个数

再遇到「Author 全线 504、CPU 打满」,别急着重启,先敲两条:ps -o etimes= 和 jstat -gcutil 的 FGCT。

FGCT 如果接近「首个 OOM 至今」的时长,直接按急性事件查:kill -3 拿线程栈,看线程名里的 URI。FGCT 如果只占 uptime 的一小截,再回去找泄漏。

判断错了方向,三小时就白花了。反过来,先花三十秒算一次 FGCT 和 uptime 的比值,能省掉后面大多数弯路。这次运气好,有 AEM 那个「线程名带 URL」的特性兜底;换个不带这特性的框架,同样的现场可能就抓不到触发页面。


来源:

相关推荐
用户432935928051 小时前
CoAP还是MQTT:1万只Cat.1水表三个月带宽续航实测
java
知守观1 小时前
多模块架构演进:横向分层 vs 纵向领域,13 个真实项目的切分对照
java·后端·微服务
Java成神之路-1 小时前
Java String 为什么不可变?和 HashMap 到底有什么关系?
java
paopaokaka_luck1 小时前
基于springboot+vue的益暖微光公益服务平台(DeepSeek公益助手、善款信息追溯、志愿服务积分闭环、ECharts运营分析、活动状态自动流转)
java·前端·javascript·spring boot·mysql·架构·echarts
文丑颜不良啊2 小时前
LeetCode1401~1420题目解析
java·leetcode
Nuanyt2 小时前
LeetCode Hot 100 链表专题2 3 题 (Java)
java·笔记·算法·leetcode·链表
文丑颜不良啊2 小时前
LeetCode1341~1360题目解析
java·leetcode
QianhangQianping2 小时前
撤销重做不是「存快照」:一个可视化编辑器的Operation 设计
java·前端·数据库·typescript·编辑器·前端架构·撤销重做
海绵宝宝转agent2 小时前
LeetCodehot100题解
java·笔记·学习·算法·leetcode·深度优先