尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Logback异步日志引发老年代内存告警:从GC曲线到Heap Dump的完整排查与根治

Logback异步日志引发老年代内存告警:从GC曲线到Heap Dump的完整排查与根治 凌晨快一点告警群弹出来一条消息pod内存使用率超过95%正在触发重启策略。我下意识打开监控面板看了一眼老年代GC曲线像爬坡一样往上冲每次Full GC之后只掉下来一点点然后又继续涨。这种曲线我看过太多次了典型的“有东西占着堆不放手”。可当时怎么也没想到最后的元凶会是日志框架——LOGBACK。这个事儿复盘下来其实特别有意思。日志框架平时人畜无害大家默认它就是写写文件、打打控制台能出什么问题可一旦业务量上来、日志量上来logback可能是JVM堆里最大的一坨驻留对象之一。尤其是当你用了异步Appender、网络Appender、随手往MDC里塞东西这些“高级玩法”之后它完全有能力把老年代吃到告警线。这篇文章就把整个排查链路、内存模型和修复方案一次讲透希望对被类似告警折磨过的同学有点帮助。1. 告警曲线背后怎么锁定“日志框架”这个嫌疑1.1 内存告警的典型特征先说现象。服务内存告警不是一上来就OOM通常有一个渐进过程。你会发现老年代使用率稳步爬升YGC越来越频繁单次GC时间越来越长最后老年代满了触发Full GC。Full GC之后如果内存能回落到低位那大概率是流量高峰带来的压力问题如果回落幅度很小甚至完全不回落那就倾向于是有对象被长期引用、无法回收。我们这次的情况是Full GC之后老年代从97%掉到78%过十几分钟又涨回95%以上。这种“降一点、再涨回去”的节奏说明有对象在持续产生并且持续被引用。这时候第一反应是查代码里的缓存、线程池、连接池但查了一圈都没问题。1.2 快速排查动作先别急着dump很多同学一遇到内存告警就想抓堆dump但dump之前有几个动作更划算能帮你节省大量分析时间用jstat -gcutil pid 1000 30看GC趋势确认老年代增长节奏。用jcmd pid Thread.print看线程状态检查是不是有线程大面积blocked。回看监控系统里这几个指标磁盘IO util、日志采集端的消费延迟、应用QPS/TPS。我们在线程栈里发现了一批线程集体卡在socketWrite和FileOutputStream.write上。再一看日志采集端Loki的写入延迟飙到了几十秒。到这里日志链路的嫌疑已经很大了——日志写不出去事件在JVM里堆积内存自然被吃掉。1.3 怎么把嫌疑范围继续缩小日志框架导致的内存问题有一个非常明显的特征内存曲线和日志量曲线强相关。你把日志量与内存使用率叠在一张图上看如果涨跌节奏一致基本可以锁定方向。还有一种快速验证法临时把root logger级别从INFO调高到WARN或者直接摘掉某个日志Appender观察老年代曲线是否掉头。如果内存增长立刻放缓那不用等dump分析就能实锤是日志链路的问题。这个操作需要走配置中心或者运维通道但它是性价比最高的止血手段。这里也顺带说一句客户端场景如Flutter、Android里的日志内存治理思路其实和JVM侧殊途同归——本质都是“日志事件被缓冲住了没及时消费”。不过严格来说后台服务的内存告警更凶因为堆可能直接被日志事件塞满所以本文还是聚焦在logback这套JVM生态上。2. 异步队列的账一个LoggingEvent到底占了多少内存2.1 Logback异步模型回顾Logback的异步日志模型并不复杂业务代码调logger.info()时事件不是直接写文件或发网络而是被丢进一个ArrayBlockingQueue由后台Worker线程从队列里取出来再交给真正的Appender去处理。这样做的目的是把I/O开销从业务线程里剥离出去降低调用延迟。这个模型本身没错但它引入了一个新问题队列里的日志事件全都在堆里躺着。同步模式下日志写完就释放异步模式下事件从入队到被消费之间的时间窗口内都占用着堆内存。2.2 一个LoggingEvent携带了哪些东西很多人以为一个日志事件就是一行字符串撑死几百字节。真扒开Logback的LoggingEvent类看你会发现它携带的东西远比你想象得多成员说明内存占比时间戳、线程名、LoggerName、Level基础元信息小FormattedMessage / message日志正文取决于内容argumentArray占位符参数引用数组引用着业务对象可能极大MDC副本异步输出时会为每条事件复制一份当前线程的MDC Map与MDC内容正相关CallerData如果开了%class/%method/%line会记录调用栈快照StackTraceElement[]每条几十个元素每个约100~200字节ThrowableProxy异常堆栈信息如果打印异常几条就顶几KB一个普通INFO事件大概1~2KB带异常堆栈的事件轻松上10KB。要是打印体里带了个大集合、大对象的toString()单条事件干到几百KB也不是没可能。2.3 queueSize不是越大越好出了事之后大家习惯性会把queueSize调大觉得“队列大点不容易丢日志”。这个概念对了一半但代价极大。logback的AsyncAppender队列默认容量是256ArrayBlockingQueue(256)。很多人觉得太小改成8192、65536。我们来算一笔账假设峰值每秒产生2000条日志每条平均2KB一秒就是4MB。如果采集端抖动30秒队列就需要容纳60000条按2KB每条算就是120MB堆内存被日志占着。这还只是单实例你要是多副本部署每一个副本都来这么一坨GC压力立刻爆炸。用队列的原因是要扛住消费端的瞬时抖动而不是用堆内存给日志做无限缓冲。合理的容量应该是“峰值速率 × 你能容忍的最大积压秒数”然后把积压秒数控制在一个很小的范围比如3到5秒就够了。剩下的靠丢弃策略兜底而不是靠大队列硬扛。2.4 队列满之后的连锁反应还有一个被人忽略的参数叫neverBlock。它的默认值是false意味着队列满时业务线程调用logger.info()会阻塞等待入队。这是个很隐蔽的雪崩点日志量突增 → 队列满 → 业务线程写日志被阻塞 → 请求处理变慢 → 积压更多请求 → 产生更多日志。整个服务不是挂在OOM上而是挂在“日志阻塞了业务线程”上但监控看起来仍然是内存告警、GC飙升非常容易误判。反过来如果把neverBlock设为true队列满时日志会被直接丢弃业务线程不受影响。这对保护核心业务是好事但有一个副作用排查问题时日志会有缺口。所以这个参数一定要结合业务对日志完整性的诉求来权衡不能无脑开。3. 两种隐蔽的变形MDC不清与超大日志体3.1 MDC的Map为什么越攒越大MDCMapped Diagnostic Context是logback里非常常用的功能很多团队用它往日志里塞traceId、userId、订单号这些上下文信息方便按关键字检索日志。搜索词里那个“logback 添加关键字”说的就是这件事。但MDC的清理是个大坑。它的底层是ThreadLocalMapString, String线程池里的线程是长期复用的。如果你在Filter或者拦截器里调了MDC.put(traceId, xxx)但没有在请求结束时MDC.remove()或MDC.clear()那这个线程下一次处理请求时Map还是满的。再put一个keyMap又多一项。日积月累线程池里的常驻线程每人挂着一个越来越大的HashMap里面塞满了历史请求的数据。在异步场景下这个坑还会加倍。Logback的异步Appender在入队时会拷贝当前线程的MDC副本到LoggingEvent里所以即使你后来清了MDC已经入队的日志事件仍然持有那批快照直到事件被消费。如果MDC里put的是一个对象而这个对象内部又引用着大集合、数据库连接这类资源它会被这个事件引用住GC完全收不动。正确姿势是在finally块里清理并且尽量用MDC.clear()而不是一个个remove()public class TraceFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { MDC.put(traceId, UUID.randomUUID().toString().replace(-, )); MDC.put(uri, request.getRequestURI()); chain.doFilter(request, response); } finally { MDC.clear(); } } }注意如果你在业务代码里手动MDC.put了自定义字段也必须在对应的finally里清掉。只靠Filter清理一旦遇到异步线程、MQ消费者线程照样漏。3.2 一条日志里藏着一头大象另一种常见问题纯粹是日志内容写得太肥。我见过最夸张的案例是批量处理接口直接logger.info(batch result: {}, list)那个list里有两万个对象toString()展开后是一条几百KB的字符串。这条日志事件在堆里会发生多次复制参数数组持有的对象引用、格式化后的字符串、异步队列里的存储拷贝、最终写入文件或发送到网络时的字节数组。一次逗号拼接堆里凭空多出好几个MB的临时对象。日志打印SQL是另一个重灾区。搜索词里有“maven项目logback配置文件 查看控制台输出的sql”这是本地调试的刚需。但有的项目把SQL日志级别在生产也调到DEBUG批量INSERT打印完整参数列表一条SQL日志顶得上别人一百条。更要命的是MYBATIS打印SQL时如果没加参数长度限制CLOB/BLOB字段的toString会被完整打出来几MB的文本直接进堆。基本原则是日志里只放摘要信息不要放大对象。大对象的集合输出场景可以打印数量、关键ID列表、耗时等轻量信息。批量结果要展示抽样数据走专门的审计日志不要塞业务日志里。4. 日志归集链路Loki/TCP Appender把问题放大了4.1 网络Appender为什么容易积压如果你的日志输出目标是文件系统那么消费端再慢磁盘也能缓冲一下。但如果日志直接通过网络Appender外发问题就完全不一样了。很多使用Loki做日志归集的团队会在应用里直接接loki-logback-appendercom.github.loki4j:loki-logback-appender。这类Appender的原理是在内存里攒一批日志事件凑够batchSize或达到batchTimeoutMs后通过HTTP POST推给Loki。批处理本身是正常的问题出在Loki不可达或写入延迟高的时候——客户端内存里会一直缓存未发送成功的批次。我们这次出问题的时候监控显示Loki的alerts数量在涨distributor队列堆积。应用侧的Loki appender发了HTTP请求收不到响应又没配超时请求线程挂在SocketOutputStream.write()上事件在内存里越积越多。线程栈里那批socketWrite阻塞线程就是它的实锤。4.2 网络Appender的守护参数使用Loki/Logstash这类网络Appender有四个参数必须盯紧参数作用建议batchSize每批日志条数上限不要贪大500以内比较稳batchTimeoutMs批次最长等待时间500~1000ms连接超时/读取超时避免请求长时间挂起连接3s、读取5s以内重试策略失败后重试机制限制重试次数避免无限重试叠加积压如果项目里用的是logstash-logback-encoder的LogstashTcpSocketAppender同理——TCP断线重连期间内部队列会持续堆积事件。没有配置maxQueueSize之类的保护叠加上游网络抖动内存告警只是时间问题。4.3 架构层面更稳的方案聊完参数说个更根本的方案尽量别让应用JVM直接外发日志到日志平台。比较稳的架构是应用只写本地文件由Filebeat、Promtail这类轻量agent采集文件再转发到Loki或ES。本地文件是天然的解耦缓冲不占JVM堆内存采集端挂了最多就是日志文件在磁盘上多躺一会JVM内存不受影响应用稳定性有保障。如果团队配置有限必须走网络Appender至少要做到两条一是队列有上限、有丢弃策略二是在故障期间能通过配置中心快速关掉网络Appender让日志降级到本地文件等服务稳定了再恢复。提前做好这个降级开关再遇到Loki抖动你只需要点一下配置内存曲线就能稳住。5. heap dump实操用MAT把源头钉死在Logback上5.1 抓dump的时机和工具选择如果你已经通过前面的排查把嫌疑锁定到日志链路接下来就是用堆dump拿到铁证。抓dump有几个注意点时机等老年代涨到告警阈值附近再抓太早看不出问题太晚可能服务OOM重启什么都抓不到。准备dump本身有STWStop The World影响线上操作要找低峰期并申请变更窗口。命令Java 8用jmap -dump:formatb,file/tmp/heap.hprof pidJava 11更推荐jcmd pid GC.heap_dump /tmp/heap.hprof。另外强烈建议启动参数里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/opt/logs/真的OOM的时候留一份现场后面复盘会轻松很多。埋个细节抓dump之前如果有条件先手工触发一次Full GCjcmd pid GC.run将堆里的瞬态垃圾清一遍再让业务跑一两分钟。这样dump文件里的对象基本都是“赖着不走”的驻留对象分析起来干扰少很多。5.2 MAT里看什么、怎么看dump文件拿到手用Eclipse MAT打开我一般按下面的顺序看Overview → Biggest Objects先看大对象有哪些如果排名靠前的是logback相关的类方向就对了。Dominator Tree搜ArrayBlockingQueue、AsyncAppender$Worker、LoggingEvent这些类名。重点看队列里的元素数量和Retained Heap。Path To GC Roots选一个队列元素右键查GC根路径。如果链路是LoggerContext → AsyncAppender → ArrayBlockingQueue → Object[] → LoggingEvent说明这些事件是被日志框架的单例引用着属于典型的“队列积压型”问题。按类查看实例数搜ch.qos.logback.classic.LoggingEvent看实例总数和Shallow Heap/Retained Heap。如果事件数量没多少但Retained Heap巨大说明单个事件内部携带了大对象那你得继续往下看事件里的argumentArray和MDC副本引用了谁。有一个真实参考数据当时那台机器堆内存配置2G老年代占用1.8Glogback相关对象Retained Heap约800MB占了老年代的44%。队列里的日志事件只有3000多条但平均每条超过260KB——因为有一条业务日志把整个聚合结果集打出来了。这个数据一摆出来谁再跟我说“日志不占内存”我不信。5.3 区分主次队列积压和MDC泄漏要分开处理在MAT里还有一个非常容易混淆的点AsyncAppender队列里的事件积压和MDC泄漏最终都表现为logback相关对象占用堆内存高但处理方式完全不同。队列积压型的特征是事件数量大、队列接近满、往往伴随网络或磁盘I/O阻塞。解法是压缩日志量、调小队列、修采集端问题。MDC泄漏型的特征是事件数量和队列深度正常但有一批线程的ThreadLocal里挂着巨大的HashMap这些Map被LogbackMDCAdapter引用持有大量历史请求数据。解法是修代码清理逻辑。这两个问题可能同时存在所以分析dump时一定要看GC根路径搞清每个对象是被谁引用着再对症下药。6. 止血与根治参数、代码和日志治理一起上6.1 一份可落地的logback配置模板排查完、事故复盘完最重要的是把配置改回去。下面这份配置是我目前用下来比较平衡的一套核心思路是队列有上限、内容不带调用栈、丢日志不阻塞业务configuration property nameLOG_HOME value/data/logs/ appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern${LOG_HOME}/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory7/maxHistory totalSizeCap10GB/totalSizeCap /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%t] %c{1} - %m%n/pattern charsetUTF-8/charset /encoder /appender appender nameASYNC classch.qos.logback.core.AsyncAppender queueSize2048/queueSize discardingThreshold0/discardingThreshold neverBlocktrue/neverBlock includeCallerDatafalse/includeCallerData maxFlushTime5000/maxFlushTime appender-ref refFILE/ /appender root levelINFO appender-ref refASYNC/ /root /configuration几个参数逐个说清楚queueSize2048按我们服务峰值每秒几百条日志的量级2048足以扛过采集端几秒钟的抖动。具体数值按第二节里的公式算别照抄。discardingThreshold0队列剩余容量低于这个阈值时异步Appender会丢弃TRACE/DEBUG/INFO事件只保留WARN和ERROR。设为0表示不丢弃INFO适合需要日志完整性的场景。默认值是队列容量的20%如果发现告警时缺了INFO日志多半是这个参数在起作用。neverBlocktrue队列满时丢弃事件保护业务线程。这里要明确日志可以丢业务不能挂。includeCallerDatafalse强烈建议关掉。日志pattern里如果没有%class、%method、%line完全不需要计算调用者堆栈这个开关省下的堆内存非常可观。maxFlushTime5000JVM退出时Worker线程最多花5秒把队列里的日志刷完避免丢日志的同时也不会拖慢停机时间。6.2 代码层的日志瘦身清单配置改完之后代码里这两类问题必须修一类是大对象入日志。凡是list、map、JSON对象、字节数组这类可能很大的东西不要直接做日志参数。改成打印list.size()、orderId这类摘要。如果确实要输出明细截断处理只打前N条。另一类是MDC清理。之前给过一个Filter的示例核心是finally里MDC.clear()。如果你在MQ消费者、异步线程池、定时任务里也手动put了MDC同样要在对应的结束位置清理。这里唯一的例外是如果你确认某个线程是一次性的、用完即销毁那不清也问题不大但线程池的线程是复用的必须清。我习惯在代码评审时把“日志里塞大对象”和“MDC.put后没有清理”列为必查项。这两类问题不涉及复杂算法就是习惯问题但线上内存被吃掉的案例十有八九和它们有关。6.3 给日志链路加监控和降级预案修复之后还有一件重要的事情——让下次故障能更早暴露。目前比较顺手的方式是给应用接入micrometer的LogbackMetrics它会暴露logback的日志事件计数和队列深度指标配合采集端在Grafana里画一条线阈值告警一挂日志链路出问题的时候不用等内存告警。另外两个指标强烈建议监控每分钟日志条数正常情况下是个平稳曲线一旦突增10倍说明代码里有人打循环日志或者日志级别被人为调低。日志量突增是内存告警的前置信号。Loki/采集端写入延迟这个指标在业务侧可能拿不到但如果团队有可观测平台让监控团队把采集端的写入延迟暴露出来内存告警出现时可以直接看这里。降级预案也要提前设计。最实用的一个是通过配置中心动态调整logback的root level遇到紧急情况先调到WARN止血再慢慢定位问题。配合远程配置中心这个操作在告警群里点一下就生效比临时改配置发版快得多。6.4 通告与复盘把这个认知传下去最后想多说一句这次事故之后我让团队把logback配置文件纳入代码评审范围任何队列参数、Appender变更都必须在评审里说明。同时把“日志框架是内存大户”这个认知写进了团队的排障手册——以后再有人遇到内存告警排查列表里不会再漏掉日志框架这一项。我个人的体会是日志导致的内存问题排查起来不难难在第一反应想不到它。大多数人对日志框架的认知停留在“开关日志、看看输出的层面”但它在大流量下的内存行为、队列参数、MDC生命周期这些细节才是真正决定线上稳定性的关键。如果你也在做日志框架相关的工作无论服务端用logback还是在Flutter、Android这类端侧搞内存优化一旦发现“内存持续上涨又找不到明显泄漏点”强烈建议先看一眼日志链路——把logback队列调小、把MDC清理干净、把网络Appender的兜底策略做好你能省下后面一大堆排查时间。
返回列表