ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Logback异步日志队列积压引发内存溢出:定位与优化实践

Logback异步日志队列积压引发内存溢出:定位与优化实践 1. 深夜告警一次由日志引发的内存危机1.1 故障现象从一条告警短信开始那天晚上刚过零点手机连续响了七八条告警监控平台显示订单服务堆内存使用率已经飙到95%以上Full GC次数在十分钟内从个位数涨到了四十多次。我登上去看的时候老年代几乎被占满GC日志里全是连续的长暂停服务响应时间从几十毫秒拉到三秒以上紧接着一个OOM实例直接挂掉又被容器平台自动拉起。这种场景在Java后端并不少见但最让人头疼的是这个服务运行了几个月一直很稳定流量也没有突增代码近期也没上线过大的改动。容器重启后大约三个小时同样的曲线又爬了一遍说明它不是一个偶发问题而是有确定性的内存泄漏或者高内存消耗路径在持续运行。当时直觉告诉我这个问题的根子一定藏在一个平时不太被重点观察的组件里。1.2 第一轮排查常规手段全部落空我先按老套路走了一遍用 jstat 观察 Eden、Survivor、老年代的变化曲线发现Young区GC之后对象逃逸比例特别高用 arthas 的 dashboard 看各线程CPU和内存情况发现业务线程普遍卡在日志输出相关调用上检查本地和Redis缓存没有膨胀检查数据库连接池、HTTP连接池数量正常检查定时任务和大对象缓存也没有异常。但有一个细节引起了我的注意线上服务配置了两个日志输出端一个是按天滚动切分的文件另一个是异步输出到集中日志平台。而业务代码里对关键接口的入参出参、SQL执行耗时、外部调用结果都做了很详尽的信息级别日志打印。也就是说这个服务平时单个请求产生的日志量本来就比别人大在错误或慢请求场景下还会额外输出异常堆栈。于是我把怀疑方向从“业务代码内存泄漏”调整成了“日志框架栈内存异常增长”。接下来就是核心操作导出heap dump让数据说话。2. 定位真凶heap dump里的Logback对象2.1 导出堆转储这一步需要冷静要确认内存到底被什么东西占了最直接的办法就是heap dump。我在实例OOM之前抢着导了一次也用启动参数兜了底# 手工导出一份当前堆的快照注意线上操作会让应用短暂卡顿 jmap -dump:live,formatb,file/data/dump/heap_$(date %Y%m%d%H%M%S).bin pid启动参数上建议提前加好-XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump/这里有两个经验第一heap dump会触发一次Full GC而且文件可能达到堆大小的1.5到3倍一个4GB的堆会产出6GB以上的dump文件导出时应用会明显卡顿所以我通常会选择在低峰期或者直接对备用实例操作第二dump文件一定要预留足够的磁盘空间否则导出到一半失败前功尽弃。2.2 MAT分析一堆LoggingEvent浮出水面拿到dump之后我用Eclipse MAT打开先看Leak Suspects报告。结果非常清楚内存中有大量的 ch.qos.logback.classic.spi.LoggingEvent 对象占据了将近2GB的保留堆内存。我顺着引用链往下看发现这些LoggingEvent并不是散落在各个线程里的临时对象而是全部被一个东西串起来了AsyncAppender内部的BlockingQueue。队列里积压了几万个日志事件每个事件又持有格式化后的消息字符串、参数数组、线程名、时间戳、MDC map以及可选的ThrowableProxy。其中ThrowableProxy还引用了完整的StackTraceElement数组。这意味着什么日志框架自己不产生业务数据但它像一辆失控的卡车把一堆本来应该快速写入磁盘并被GC回收的日志对象全部堆积在内存队列里等着异步消费。如果消费端处理速度跟不上生产端队列就会越积越多内存自然跟着爆。为了验证这不是个例我还用MAT的OQL做了一次统计select count(*) from ch.qos.logback.classic.spi.LoggingEvent结果数量是三万多个单个事件平均大小几十KB其中有几个异常日志事件居然超过了500KB。这时候我已经基本确定问题就出在Logback的异步处理机制上。3. LOGBACK内存危机背后的机制3.1 LoggingEvent到底占了多少内存要理解为什么一个LoggingEvent能占这么大的内存得打开它的内部结构来看。Logback在一次日志调用时会创建LoggingEvent对象里面有这些东西logger名称、日志级别、线程名、时间戳这些是基础元信息占不了多少空间消息模板和参数数组比如 log.info(订单创建成功, order{}, orderDTO)这个orderDTO如果是一个包含几十个字段的大对象作为参数数组被holding在事件里格式化后的完整消息字符串在异步Appender场景下消息的格式化会在消费端执行但如果是同步输出格式化结果会生成一个完整的大字符串ThrowableProxy如果打印了异常整个异常链和全部堆栈帧都会被包装进来这是最容易吃掉内存的部分MDC快照LoggingEvent会引用MDC map如果MDC中被塞入了业务对象同样会被事件长链路引用导致无法回收。我这次在dump里看到的一个极端的LoggingEvent是因为业务代码在catch块里做了一件很“常见”但又很糟糕的事情把整个响应体result对象拼进了error日志。这个对象是一个几百KB的嵌套JSON结构toString之后还要再复制一份字符串结果一个日志事件就占了半MB。这里有个很容易被忽略的机制同步Appender打印完日志后LoggingEvent通常可以随栈帧一起被回收问题不大但一旦用了AsyncAppender事件会进入一个队列等待异步线程处理。在这个等待窗口里所有被LoggingEvent引用的对象都不能被GC回收。窗口越大事件越多内存膨胀就越厉害。3.2 队列积压错误日志的滚雪球效应AsyncAppender默认使用ArrayBlockingQueue容量是256。生产线程每打一条日志就往队列里offer一次消费端只有一个Worker线程在另一头poll并交给实际的Appender去输出。如果实际Appender的写入目标变慢——比如磁盘IO抖动、网络拥堵、日志平台接口超时——消费速度就会下降队列会很快占满。队列满时会发生什么AsyncAppender有两个关键配置discardingThreshold 和 neverBlock。当队列剩余容量低于discardingThreshold时Appender会直接丢弃TRACE、DEBUG、INFO级别的事件只保留WARN和ERROR。这个设计的初衷是保证重要日志不被丢弃但同时也埋了一个雷一旦系统出现故障业务代码通常会疯狂输出ERROR日志而这些ERROR日志永远会被保留并塞进队列。积压的日志会让内存持续上涨而上涨又加剧了GC压力GC变慢又让日志消费端更慢最终形成恶性循环。neverBlock这个参数也有讲究。默认情况下队列满后新的日志事件会被阻塞丢弃还是会阻塞业务线程取决于具体实现。如果设置了neverBlocktrueoffer会立即返回false并丢弃事件业务线程永远不会被日志阻塞但代价就是日志丢失率变高。这次出问题服务的配置里queueSize被调成了5万初衷是“避免高峰期日志丢失”结果成了内存爆炸的加速器。3.3 被忽略的MDC引用链除了队列积压还有一个更隐蔽的问题MDC的引用链。很多团队会在拦截器或者过滤器里给请求打上traceId用MDC.put(traceId, xxx)的方式让所有日志都带上链路追踪ID。这本是好习惯。但如果有人像下面这样写MDC.put(userInfo, userDetailService.getUserDetail(userId));然后把一个很大的Java对象放进了MDC问题就来了。LoggingEvent在创建时会获取当前线程的MDC map虽然不同版本的处理方式略有差异但核心机制是一样的事件持有MDC map的引用链。只要这个事件还在异步队列里等待处理MDC里的userInfo对象就不会被回收。更隐蔽的是如果业务代码把对象put进MDC后没有remove它一直存在当前线程的ThreadLocal里。如果这个线程刚好是Tomcat线程池中的线程线程长期存活那么MDC里的对象就会成为线程生命周期级别的强引用彻底堵死GC回收路径。这种问题即使LoggingEvent被消费完了MDC里的对象依然无法释放。排查这类问题时光看heap dump还不够还要注意线程栈里的ThreadLocal引用。我处理过一次线上案例服务没有任何异常日志但堆内存一直缓慢增长最后发现就是某个切面把用户对象放进了MDC案例持续了几个月才被定位。那次之后我对MDC的使用就多了一条铁律要么只存轻量级字符串要么在使用完成后必须try-finally remove。4. 修复方案从配置到代码的全链路优化4.1 logback.xml的重新设计既然根因是AsyncAppender队列积压第一步就是把日志配置重做一遍。这里给出一个生产环境可用的logback.xml核心配置?xml version1.0 encodingUTF-8? configuration !-- 文件输出端按天滚动保留7天 -- appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file/data/logs/order-service.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/data/logs/order-service.%d{yyyy-MM-dd}.log.gz/fileNamePattern maxHistory7/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n/pattern /encoder /appender !-- 异步输出端队列容量1024保证业务线程不被阻塞 -- appender nameASYNC_FILE classch.qos.logback.classic.AsyncAppender queueSize1024/queueSize discardingThreshold0/discardingThreshold neverBlocktrue/neverBlock appender-ref refFILE/ /appender !-- 生产环境下只开必要日志 -- logger nameorg.springframework levelWARN/ logger nameorg.apache.http levelWARN/ logger namecom.alibaba.druid levelWARN/ root levelINFO appender-ref refASYNC_FILE/ /root /configuration这里几个配置点我逐个解释一下。queueSize设成1024不是随意拍脑袋。按照每个日志事件平均5KB到10KB算1024个事件大约占5MB到10MB内存即使出现极端异常栈撑死多占几十MB在堆内存里完全可控。如果你非要在高并发大日志量下继续用大队列那就要提前算出它能吃多少内存别让队列成为第二个堆。discardingThreshold设为0意味着当队列剩余容量低于阈值后所有级别日志都会被丢弃。这里有一层权衡默认的丢弃逻辑会保护WARN/ERROR日志但保住了错误日志的同时也会让内存继续被这些“重要日志”填满直到把堆撑爆。我的选择是在系统内存安全和日志完整之间优先保证可用性极端情况下宁可丢日志也不让服务因为日志挂掉。neverBlocktrue是为了保证日志永远不阻塞业务线程。这是异步日志的核心价值日志是副作用不能反过来拖垮主流程。另外我把生产环境里的ConsoleAppender去掉了。控制台输出在高并发下会严重拖慢应用而且容器环境里控制台日志通常没有持久化价值属于纯浪费。如果确实需要实时看日志建议走文件或集中平台而不是ConsoleAppender。4.2 配合Maven和Spring Profile控制SQL日志项目热词里有一条“maven项目logback配置文件 查看控制台输出的sql”这说明很多同学习惯在本地开发时通过日志看SQL。这个需求本身没问题但要注意如果把这套配置直接带到生产问题就大了。Mapper接口的SQL日志一般由日志级别控制。在MyBatis/MyBatis-Plus项目里mapper包的日志级别设为DEBUG就能把每条SQL打印出来。但SQL日志往往附带参数值一条复杂的批量插入SQL可能包含几百行参数单条日志轻松超过几十KB。生产环境如果开DEBUG总量是非常可观的。正确做法是通过Spring Profile做环境隔离。Spring Boot项目里用logback-spring.xml可以这样写springProfile namedev logger namecom.example.mapper levelDEBUG/ /springProfile springProfile nameprod logger namecom.example.mapper levelINFO/ /springProfile本地开发时用dev配置手滑SQL也能直接命令行看生产环境用prod配置保证看不到无用的底层SQL。如果生产上确实需要排查慢SQL可以在数据库层开启慢查询日志或者临时人工降级某个实例的mapper为DEBUG而不是全量开着。同时要注意Maven项目打包时过滤配置的问题。我见过不少项目把logback.xml放在src/main/resources根目录但打包时被Maven的resources插件重复过滤导致配置失效或变量替换异常。这里的建议是Spring Boot项目优先使用logback-spring.xml因为它支持springProfile标签也避免了Logback原生配置加载顺序问题。4.3 接Loki时的额外内存考量loki-logback-appender这次排查还牵扯出一个相关组件Loki日志采集端。很多团队现在不用传统的ELK而是用Loki做集中日志Java侧通常通过loki-logback-appender直接把日志推给Loki。而这个appender同时引入了另一条内存消耗路径。loki-logback-appender本身也带有异步发送机制它内部维护一个批次队列按batchSize和batchTimeoutMs两个维度批量推送。如果batchSize配得太大内存里会滞留大量日志事件等待打包如果Loki服务端出现网络不可用或接口超时appender会按maxRetries重试重试期间所有日志都会积压。推荐的生产配置大致是appender nameLOKI classcom.github.loki4j.logback.Loki4jAppender http urlhttp://loki-gateway:3100/loki/api/v1/push/url /http batch batchSize512/batchSize batchTimeoutMs5000/batchTimeoutMs /batch format label patternapporder-service,level%level/pattern /label message pattern%m%n/pattern /message /format maxRetries2/maxRetries /appenderbatchSize在512到1024之间是一个比较稳妥的范围。batchTimeoutMs设成5秒保证长时间低流量下日志也能被及时推送出去不至于一直攒着不发送。maxRetries不要设太大默认3次以内就好多次重试非但解决不了远端服务问题反而会把自己的内存/网络占用拖垮。如果服务对日志可靠性要求很高建议在Loki前面加一层消息队列或文件采集器用Filebeat/Promtail去读文件推送而不是让应用进程直接做网络推送。应用进程的本职工作是业务逻辑日志推送不该占用它太多资源。4.4 业务代码层面的日志治理配置优化只能控制内存使用的上限真正要让内存平稳下来还得从日志产出源头治起。我对这次涉及的业务代码做了三类调整第一控制异常日志的堆栈输出。有些错误信息本来只需要一行摘要代码里却把完整堆栈打了出来而框架层日志又打一遍一条逻辑错误最终产生了几十条日志。对业务可预期的异常我建议只记录关键上下文比如订单号、错误码、失败原因不打印完整堆栈只有真正需要排查的异常比如网络超时、反序列化失败才保留堆栈。第二减少大对象的日志化。日志里打印业务对象时尽量只打印JSON序列化后的摘要信息或者手动截断长度。之前提到的一条日志半MB的例子就是直接把整个响应体塞了进去。后来我给那个类加了一个toSummary方法只输出订单ID、状态、耗时等十来个字段日志瞬间从几百KB降到了几百字节。第三统一日志切面的输出格式。项目里如果有全局请求日志切面建议关注一下是否把request body和response body都完整打出来了。对于HTTP接口生产环境记录URI、请求方法、状态码、耗时、主键参数就够了body内容最多保留前几百个字符或者干脆不记录。完整body让开发测试环境打生产环境关了就是。还有一个实用小技巧在日志格式里增加一个JSON字段用于在日志检索时快速过滤大日志。比如在logstash encoder的customFields里加一个logSize字段写入日志对象的大小。这样在Loki或者ELK里可以快速查出哪些接口在持续输出大日志直接定位到代码位置。5. 常见问题与排查技巧实录5.1 这套排查思路怎么复制到其他服务排查日志内存问题思路是通用的。这里整理一份可以直接复用的排查清单后面再遇到类似告警就不用手忙脚乱。第一步打开GC日志看老年代和Full GC趋势。如果老年代持续上升且Full GC后回收不彻底优先怀疑堆里存在长生命周期对象引用第二步导出heap dump用MAT看Dominator Tree和Leak Suspects。重点看类名里带 logback、kafka、httpclient、druid 这些框架类的实例它们往往是对象的“收纳盒”第三步用OQL统计几个关键类。比如 ch.qos.logback.classic.spi.LoggingEvent、ch.qos.logback.core.spi.DeferredProcessingAware看数量和保留堆大小第四步配合线程栈检查AsyncAppender$Worker 所在线程的状态如果它长期处于WAITING、TIMED_WAITING或IO阻塞说明消费端出现瓶颈第五步查看日志输出目标的状态。磁盘空间、网络延迟、远程日志接口的P99耗时任何一个异常都会导致消费速度骤降第六步修复后观察至少两到三个Full GC周期确认老年代使用率是否出现稳定回落而不是持续爬升。这套清单不只适用于Logback也适用于任何使用异步队列消费日志/消息/事件的场景。核心逻辑永远是先明确对象引用链再找队列瓶颈最后治理生产源头。5.2 logback内存隐患速查表下面把这次的常见问题整理成一张表方便后续排查时对照现象可能原因解决方案堆内存缓慢上升老年代持续增长AsyncAppender队列积压大量LoggingEvent调小queueSize优化输出端速度减少日志产出Full GC后老年代回收效果差MDC map中持有大对象或业务对象只向MDC放字符串用完必须remove错误日志打印特别多时内存暴涨异常堆栈过大、错误信息拼接了大对象限制堆栈输出打印摘要而不是完整对象应用偶发卡顿日志线程长期阻塞同步Appender写入磁盘/网络慢改异步Appender生产去掉ConsoleAppender日志莫名丢失neverBlocktrue和discardingThreshold配置过强根据业务容忍度平衡可靠性和内存不要同时极限配置接入Loki后内存增加明显batchSize过大或远端不可用重试堆积控制batchSizemaxRetries设小增加消息队列缓冲5.3 一个容易忽略的坑异步线程池数量最后补一个不太起眼但容易踩的坑有些项目会在logback配置里给AsyncAppender设置自定义线程池比如通过一个自定义的ExecutorService来做消费。初衷是想提升消费并行度但如果多个AsyncAppender共用一个线程池或者线程池的排队策略是无界队列那日志积压的问题就会从Appender的BlockingQueue转移到线程池的任务队列里内存照样会被打爆。我一般不建议对AsyncAppender做自定义线程池扩展。Logback默认的单消费线程其实够用瓶颈通常不在消费并发度上而在实际输出目标上。与其增加线程数不如把文件写到本地SSD、把远程推送放进缓冲区、把日志量降下来这样效果更直接。还有一个细节如果项目从Logback 1.2.x升级到1.3.xAsyncAppender的队列类型和行为有一些调整建议升级后重新评估queueSize和丢弃策略不要沿用旧配置直接上生产。写在实际调整之后这次内存告警从发现到定位花了大概三个小时真正修复配置不到半小时。事后复盘最值钱的不是改了几行配置而是建立了对日志框架内存模型的敏感度。现在我对任何Java服务都有两个习惯第一日志配置必须包含队列容量和丢弃策略的明确说明不允许默默用默认值第二每半年会主动导一次heap dump做体检重点看LoggingEvent、异步队列和MDC引用这三类对象。最后分享一个小技巧如果团队已经有Prometheus监控可以给应用加一个指标统计每分钟产生的日志事件数量和当前日志队列剩余容量配合Grafana做成一条曲线。一旦日志量出现异常增长曲线会比你的告警短信更早告诉你问题要来了。毕竟日志是应用最诚实的表达方式也是最容易被低估的内存消耗源。
返回列表