ARTICLE DETAIL

资讯详情

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

Logback进阶实践:异步日志、MDC链路追踪与过滤器配置

Logback进阶实践:异步日志、MDC链路追踪与过滤器配置 聊到logback大多数人第一反应就是logback.xml里那几个appender标签会配置个ConsoleAppender、RollingFileAppender再套个pattern格式就算会用了。但真正到了高并发压测、多模块排查、生产环境告警这些场景你会发现基础配置完全不够看。日志一多就把接口拖慢线上查问题翻半天找不到一条完整的链路该记录的隐私信息原样落盘到日志文件里——这些问题都不是改几行配置能解决的。上一期咱们梳理了logback的核心组件和配置语法属于能用。这期进阶篇我把自己在生产环境里折腾过的异步日志、MDC链路追踪、过滤器体系、自定义扩展这几个方向整理一遍还会把丢日志、级别失效、性能倒挂这些坑一起讲清楚。1. 异步日志的核心机制队列、丢弃策略与IO瓶颈1.1 同步日志为什么会在高并发下拖垮服务先想一个现象压测的时候把日志级别调到DEBUG吞吐量直接腰斩关掉日志性能又回来了。这不是logback的问题而是同步IO在执行链条里占据了大量等待时间。一条同步日志从业务线程发出后要走完格式化消息—写缓冲区—磁盘刷盘或网络传输这条完整链路才算结束。磁盘IO的耗时通常是毫秒级甚至更高而业务逻辑本身可能只需要几微秒。日志量大时业务线程会成批地堵在I/O等待上这就是吞吐量骤降的根因。异步日志解决的思路很直白把写磁盘这个慢动作交给后台线程业务线程只负责把日志事件丢进一个内存队列然后立刻返回继续干正事。1.2 AsyncAppender和AsyncLogger的真实区别logback里有两个带异步字眼的东西很多人混着用但底层逻辑不一样AsyncAppender它本身不写日志而是包装另一个具体的Appender。业务线程调用append()时只是把ILoggingEvent放进一个BlockingQueue后台线程再从队列取出事件转交给被包装的FileAppender、RollingFileAppender等真正干活的组件。AsyncLogger这是在Logger层面做异步事件产生后直接交给另一个线程处理不需要经过Appender的队列中转。它的实现依赖LMAX Disruptor的无锁环形队列在极致高并发下的吞吐和延迟表现更好。实际项目里AsyncAppender已经能满足绝大多数场景而且不需要引入额外依赖。AsyncLogger需要单独引入Disruptor依赖配置方式也不同适合那些对日志路径耗时极度敏感的中间件场景。AsyncAppender的典型配置长这样appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender appender nameASYNC classch.qos.logback.classic.AsyncAppender queueSize1024/queueSize discardingThreshold0/discardingThreshold neverBlocktrue/neverBlock maxFlushTime30000/maxFlushTime appender-ref refFILE/ /appender root levelINFO appender-ref refASYNC/ /root这套配置里有几个参数直接决定行为边界我逐一说明。1.3 queueSize、discardingThreshold、neverBlock、maxFlushTime的参数搭配逻辑queueSize队列容量默认256。队列越大抗突发能力越强但内存占用和退出时的冲刷时间也会增加。一般设1024~2048比较合理具体看单台实例每秒产生多少条日志。discardingThreshold当队列剩余容量低于这个阈值时为了优先保证队列不堆积logback会直接丢弃TRACE、DEBUG、INFO级别的事件只保留WARN和ERROR。默认值是队列容量的20%。注意很多人期望ERROR一条都不能丢结果却在极端情况下发现ERROR也消失了。原因就是discardingThreshold没调。如果业务要求关键日志不能丢直接把discardingThreshold设为0并配合neverBlockfalse。neverBlock队列满了怎么办。默认false表示队列满时业务线程会阻塞等待队列腾出空间相当于异步退化成同步但保证了日志不丢。设为true时队列满就直接丢弃新事件业务线程永远不会被阻塞但日志会丢。这个取舍要看你是日志不能缺还是接口延迟不能高。maxFlushTime应用关闭时后台线程最多等待多久把队列里的日志全部写完默认1000ms。如果队列里积压了大量日志1秒钟很可能不够JVM退出后日志就静默消失了。我的习惯是设成30000ms宁可停机慢一点也要把最后的日志落盘。从生产实践经验来看如果你只追求接口性能和吞吐量discardingThreshold20%加neverBlocktrue是常见选择如果日志要用于审计、计费、对账discardingThreshold0加neverBlockfalse才是底线配置。1.4 异步日志的适用边界不是所有项目都需要异步日志听着好但不是无脑上。如果你的服务每天日志量不到几百MB同步写盘完全没压力引入异步反而增加了排查日志时的时序困惑。只有当日志量明显拖慢了接口响应、或者GC压力来自日志对象分配时才值得改造成异步。另外有个容易被忽略的点异步日志模式下如果服务突然宕机kill -9或断电队列里未消费的日志注定丢失这是架构层面要接受的代价。对需要严格审计的场景光靠异步队列是不够的得在业务层面做可靠投递。2. MDC贯穿链路从过滤器到线程传递看日志如何带上“身份证”2.1 MDC的运行机制和一个最容易踩的坑MDCMapped Diagnostic Context是logback提供的一个线程局部变量Map可以往里面放键值对然后在布局模式里用%X{key}输出。最典型的用途就是请求唯一ID一个请求进来生成一个traceId放进MDC之后这个请求在同一个线程里产生的每一条日志都会带上traceId排查问题时一条grep就能拉出完整链路。基础用法import org.slf4j.MDC; // 请求入口 String traceId UUID.randomUUID().toString().replace(-, ); MDC.put(traceId, traceId); try { // 业务逻辑 log.info(收到订单创建请求, orderId{}, orderId); } finally { MDC.remove(traceId); }配合logback配置pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n/pattern这里有个关键点很多人在实际项目中踩过MDC.put()的键值必须成对出现用完一定要在finally里MDC.remove()。这不是洁癖而是因为Web容器和线程池会复用线程。上一次请求在ThreadLocal里留下的traceId如果不清理下一个请求复用到这个线程时日志里就会出现别人的traceId排查时直接精神分裂。2.2 异步线程里MDC为什么会丢异步日志和MDC叠加时问题就来了。当你把业务逻辑丢进线程池执行时新线程默认不会继承父线程的MDC因为MDC本身是基于ThreadLocal存储的。但logback的AsyncAppender在这点上其实做了处理它的事件对象里存放了MDC快照后台线程取事件打印时能恢复MDC所以AsyncAppender里的MDC不会丢。丢MDC的场景主要出现在你自己写的线程池、CompletableFuture、消息消费者这些跨线程执行的地方。解决方式之一是在任务提交时把父线程的MDC上下文带过去执行完再清掉。用Spring的TaskDecorator来统一处理比较优雅public class MdcTaskDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { MapString, String contextMap MDC.getCopyOfContextMap(); return () - { if (contextMap ! null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; } } ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new MdcTaskDecorator());MDC.getCopyOfContextMap()是获取当前线程MDC的副本设置到子线程后子线程内产生的日志就带上了traceId。finally里的MDC.clear()是防止线程池复用导致上下文互相污染。实际经验是在网关层或统一的Filter里生成traceId放MDC入口和出口都清理干净比在业务代码里到处手动MDC.put可靠得多。2.3 MDC值的类型和空值问题MDC的key和value都是String类型这个限制很多人会忽略。如果你往里面放了非String对象编译期不会报错但运行时会发生ClassCastException。另外MDC.put(key, null)在logback里会直接移除这个key而不是往里放一个null值。这个行为和HashMap完全不同容易产生我以为设置了null实际是删除了的困惑。如果想表示一个空值状态建议放空字符串或者约定一个特殊标记而不是直接放null。3. 过滤器体系日志输出前的三道分流闸3.1 TurboFilter和Appender Filter的分工差异logback的过滤机制分两层很多人只见过第二层TurboFilter在Logger记录事件时就被调用发生在事件进入Appender之前。它的特点是调用频繁对性能敏感同时它可以访问Logger、Level、Message等完整上下文适合做全局策略。比如DuplicateMessageFilter做重复日志抑制MDCFilter根据MDC值过滤。Appender Filter挂在某个Appender内部只对该Appender的事件流生效。这个层级的Filter又分LevelFilter、ThresholdFilter和EvaluatorFilter。实际项目中最常见的组合是用TurboFilter做全局级别开关比如压测时临时关掉某类日志用EvaluatorFilter在具体Appender上做精细化排除比如不打印健康检查接口的日志。3.2 EvaluatorFilter JaninoEventEvaluator表达式过滤实战EvaluatorFilter是功能最强大的Appender Filter它配合JaninoEventEvaluator可以写Java表达式来判断是否接受或拒绝某个事件。比如我要在文件Appender里过滤掉所有包含HEALTH的日志appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/app.log/file filter classch.qos.logback.core.filter.EvaluatorFilter evaluator classch.qos.logback.classic.booleans.JaninoEventEvaluator expressionevent.getMessage() ! null amp;amp; event.getMessage().contains(HEALTH)/expression /evaluator onMatchDENY/onMatch onMismatchNEUTRAL/onMismatch /filter encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appenderFilter链的处理规则要记牢onMatch表示表达式成立时怎么处理onMismatch表示不成立时怎么处理。取值有三类ACCEPT立即接受该日志不再进入后续Filter判断。DENY立即拒绝日志不会出现在该Appender里。NEUTRAL交给链上的下一个Filter判断如果已经是最后一个Filter默认放行。所以上面的配置意味着只要消息包含HEALTH直接拒绝其他日志正常输出。这个表达式里还支持MEC、MCE、MDC等对象比如可以用MDC值判断某个请求路径expressionmdc.get(requestURI) ! null amp;amp; mdc.get(requestURI).contains(/health)/expression这种方式很适合在线上把健康检查、心跳探测之类的噪音日志剔除出去又不用改业务代码。注意JaninoEventEvaluator依赖Janino库pom.xml里要加dependency groupIdorg.codehaus.janino/groupId artifactIdjanino/artifactId version3.1.10/version /dependency别漏了漏了启动时直接报ClassNotFoundException。3.3 用LevelFilter和ThresholdFilter做日志分级存储有时候需求很简单INFO以上的日志进一个文件ERROR以上的进另一个文件方便告警和问题回溯。这种场景用LevelFilter最直接appender nameERROR_FILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/error.log/file filter classch.qos.logback.classic.filter.LevelFilter levelERROR/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender这段配置的含义是只接受ERROR级别。INFO进不了这个文件WARN也进不了因为onMismatch直接DENY了。ThresholdFilter则不一样它是阈值概念levelWARN/level表示只接受WARN及以上级别WARN和ERRORINFO及以下全部被过滤。这两个Filter的区别一句话总结LevelFilter是精确匹配单个级别ThresholdFilter是范围匹配。4. 自定义扩展手写脱敏Converter和推送Appender4.1 自定义Converter解决日志脱敏的通用方案日志脱敏是个高频需求手机号、身份证号、银行卡号不能原样打到日志文件里。虽然可以在业务代码里手动脱敏后再输出但漏网之鱼太多。更可靠的方式是写一个自定义Converter在日志格式化阶段统一处理。实现思路是继承ClassicConverter重写convert()方法对event.getFormattedMessage()做正则替换然后注册成一个新的转换符import ch.qos.logback.classic.pattern.ClassicConverter; import ch.qos.logback.classic.spi.ILoggingEvent; import java.util.regex.Matcher; import java.util.regex.Pattern; public class MaskConverter extends ClassicConverter { private static final Pattern PHONE_PATTERN Pattern.compile(1[3-9]\\d{9}); Override public String convert(ILoggingEvent event) { String message event.getFormattedMessage(); Matcher matcher PHONE_PATTERN.matcher(message); if (matcher.find()) { return matcher.replaceAll(138****0000); } return message; } }然后在配置里声明这个转换符conversionRule conversionWordmask converterClasscom.example.log.MaskConverter/ appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/app.log/file encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %mask%n/pattern /encoder /appender之后日志里凡是匹配到手机号格式的内容都会自动替换成脱敏串。这个方法的好处是业务代码零改动新增脱敏规则也只改Converter类。实际要注意这个方案只能脱敏格式化后的最终消息如果脱敏规则与参数的位置、类型强相关比如第3个参数必须是手机号那就得在业务层做结构化处理。另外正则规则不能太宽否则会把正常的数字串误伤。我自己还会加一层对Trace ID、请求流水号这类敏感业务标识的脱敏规则。4.2 自定义Appender把日志送进告警通道有些场景日志不只往文件里写还要主动推送。比如ERROR日志触发企业微信群机器人告警或者把关键日志写入Kafka供下游消费。这些也可以靠复用来做但自己写一个Appender并不难。实现AppenderBaseILoggingEvent重写append()方法即可import ch.qos.logback.core.AppenderBase; import ch.qos.logback.classic.spi.ILoggingEvent; public class WebhookAppender extends AppenderBaseILoggingEvent { private String webhookUrl; Override protected void append(ILoggingEvent event) { // 只处理ERROR级别 if (!event.getLevel().toString().equals(ERROR)) { return; } String message event.getFormattedMessage(); // 发送到企业微信/钉钉群机器人 // 这里建议用独立的线程池异步发送不要阻塞日志线程 } public String getWebhookUrl() { return webhookUrl; } public void setWebhookUrl(String webhookUrl) { this.webhookUrl webhookUrl; } }配置里直接写appender nameWEBHOOK classcom.example.log.WebhookAppender webhookUrlhttps://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxxx/webhookUrl filter classch.qos.logback.classic.filter.LevelFilter levelERROR/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter /appender自定义Appender最容易犯的错误是在append()方法里做重操作发起HTTP请求、写数据库、同步调用外部接口。因为append()是业务线程在调用一个慢请求就会把整个日志链路和业务线程一起拖住。我的做法是append()里只做简单判断和入队真正的外部调用放到内部线程池里同时要加超时控制、熔断和失败降级否则告警机器人接口一抖动反而拖垮业务进程。4.3 通过代码动态调整日志级别配置文件里的级别是写死的但线上排查问题的时候经常需要临场把某个包的级别调到DEBUG几分钟看完了再调回去。用logback.xml改完还要reload麻烦且不可控。更推荐的方式是通过代码动态调整import ch.qos.logback.classic.Level; import ch.qos.logback.classic.LoggerContext; import org.slf4j.LoggerFactory; public void setLogLevel(String loggerName, String level) { LoggerContext context (LoggerContext) LoggerFactory.getILoggerFactory(); ch.qos.logback.classic.Logger logger context.getLogger(loggerName); logger.setLevel(Level.valueOf(level)); }对Logger.ROOT_LOGGER_NAME即root设置整体级别对具体的包路径设置局部级别。Spring Boot Actuator暴露的/loggers端点本质也是在做这件事。如果你有配置中心还可以把哪个包什么级别做成动态配置调整时实时刷新这对定位线上问题帮助非常大。5. 高频事故现场日志丢失、顺序错乱、级别失效5.1 日志丢失多半是队列参数背锅生产环境最怕关键日志没了。异步日志模式下日志丢失有三种常见原因第一种是discardingThreshold导致的丢弃。日志量突发增长队列快要满时logback会优先丢弃低级别日志。如果此时业务故障恰恰产生大量INFO日志用于排查等你想看的时候已经没了。对策是把discardingThreshold设为0或者按5.1节间歇设置。第二种是neverBlocktrue加队列满新日志直接进不了队。这本质上是容量规划问题。我见过一次线上故障错误日志每秒产生几千条队列设了512neverBlocktrue结果告警短信收到一堆队列已满日志被丢弃的提示真正的故障日志反而没记下来。第三种是应用关闭时maxFlushTime设置太短。K8s滚动更新或发布重启时队列里的日志还没来得及消费完JVM就退出了。正常现象看起来是每次重启后最后几十秒的日志消失了。把maxFlushTime调大并在应用关闭流程里预留足够时间能明显改善。5.2 日志顺序错乱先搞清楚顺序本来就不存在有人用了AsyncAppender后发现日志时间戳和实际打印顺序对不上觉得是bug。这里要泼一盆冷水多线程业务在同步模式下日志顺序也是不保证的磁盘IO调度、线程调度、锁竞争都会导致先出发的日志晚落盘。异步模式只是把这个无序性问题放大了一点。真正需要关心的是同一个线程内的业务日志顺序是否保持。AsyncAppender的BlockingQueue是FIFO的后台线程按队列顺序消费所以单线程内的事件进入队列的顺序和消费顺序一致。如果想确认某条链路内日志的先后关系建议在MDC里放一个自增序号或者依赖时间戳和thread名一起判断。5.3 日志级别失效检查additivity和继承关系级别不对除了配置本身写错还有一个隐蔽原因是additivityfalse。additivity控制的是是否把日志继续向上传递给祖先Logger。有的团队为了让某个子包只输出到独立文件把子Logger的additivity设为false并配置了独立的Appender但忘了给它设置Level。结果子Logger继承了root的输出级别而root又设置了某个Appender导致子包的日志同时打印了两份一份进独立文件一份进root的Appender看着就像级别设置没生效。排查的时候先用LoggerContext在代码里打印每个Logger的实际配置和继承关系比猜配置快得多。还有一个常见问题从配置文件里改了级别但应用没重启代码里却是用旧的LoggerContext在跑。logback在启动时加载配置之后的代码修改如果直接改配置文件而不触发reload是不会生效的。解决方式是设置configuration scantrue scanPeriod30 seconds/让logback自动监听配置文件变更并重新加载。6. 落地取舍格式化开销优化与我的默认配置6.1 最常见的性能杀手%class、%method、%linePattern里有很多转换符但开销天差地别%msg、%n、%d基本是廉价操作。%thread、%level代价很小。%logger会做包名缩写代价中等。%class、%method、%line这三个是性能杀手。因为它们要求logback生成调用栈信息StackTraceElement而生成栈信息是非常昂贵的操作在高并发下日志吞吐量会明显下降。这就是为什么很多生产配置里Pattern只保留%logger{36}而不是%class。我自己线上默认格式是%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n保留%logger{36}是为了定位代码模块去掉%line是为了性能。如果你确实需要定位到具体代码行建议只在开发、测试环境开启生产环境用Logger名MDC里的业务标识来定位。6.2 用isXxxEnabled避免无谓的格式化开销这类开发习惯的性能收益被低估了。比如某段循环里打印大量DEBUG日志但当前级别是INFO这时如果直接写log.debug(用户信息{}, user)logback仍然会执行字符串拼接——因为它要先构造参数数组和消息模板再判断级别是否允许输出。在高频调用下这种浪费是实打实的。正确的写法是先用级别判断包一层if (log.isDebugEnabled()) { log.debug(用户信息{}, user); }注意要在循环外部做判断避免每次循环都判断。另外logback的占位符{}本身有延迟求值的优化参数是引用传递的但如果参数是字符串拼接表达式依然会先执行拼接。所以传入对象引用不要传拼接好的字符串。经验法则在性能敏感的循环或高频调用路径上先判断级别再打日志普通业务代码可以放心使用占位符。6.3 配置自动加载和异步搭配方案最后给出一套我实践中比较顺手的基础模板可直接参考configuration scantrue scanPeriod30 seconds conversionRule conversionWordmask converterClasscom.example.log.MaskConverter/ appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %mask%n/pattern /encoder /appender appender nameERROR_FILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/error.log/file filter classch.qos.logback.classic.filter.LevelFilter levelERROR/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/error.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender appender nameASYNC-FILE classch.qos.logback.classic.AsyncAppender queueSize2048/queueSize discardingThreshold0/discardingThreshold neverBlockfalse/neverBlock maxFlushTime30000/maxFlushTime appender-ref refFILE/ /appender root levelINFO appender-ref refASYNC-FILE/ appender-ref refERROR_FILE/ /root /configuration这套配置的思路是全量日志走异步文件ERROR单独落一份同步文件用于告警和快速检索traceId必须在格式里脱敏Converter挂上。关于异步的那份我之所以把discardingThreshold设为0是因为在日志审计场景里不丢比不阻塞更重要如果你更在意的是接口延迟可以回调neverBlocktrue。最后再分享一个我个人的习惯每次调整完日志配置不要只盯着有没有生效而是主动去验证三件事——打几条不同级别的日志确认输出正常压一下最高日志流量看队列水位和丢失量以及观察日志文件里有没有出现脱敏不了的数据格式。日志框架这东西平时默默无闻出了问题才觉得它重要所以把配置和参数吃透省下的都是深夜排查的命。
返回列表