ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Logback日志分离实战:不同业务场景写入独立文件的最佳实践

Logback日志分离实战:不同业务场景写入独立文件的最佳实践

1. 需求拆解与方案选型:为什么日志必须“分家”

先说个最直观的现象:在很多项目里,往往只有一个logback.xml从头配到尾,所有业务日志和框架日志都往同一个app.log里面塞。系统小的时候没什么感觉,但当业务量上去、模块多了之后,这种“大锅饭”式日志管理会让人非常痛苦。

我遇到过的情况是这样的:某电商系统的订单模块和支付模块共用一个日志文件,线上排查问题时,用grep按关键词去过滤订单号、用户ID、支付流水号,日志量大到一个文件十几个GB,命令执行起来慢得没法忍。更要命的是,不同业务场景的日志混在一起之后,日志级别、保留策略、排查路径互相干扰:订单日志想保留30天,支付流水想保留180天,日志切割周期也要分开,一个滚动策略没法同时满足两边的需求。

所以标题里说的“不同业务场景日志写入不同单独日志文件”,本质上是日志治理的刚需。拆开看,这个需求背后通常有这几类诉求:

  • 排查效率:按业务线隔离日志后,定位问题只需要看对应文件,不用在混排日志里大海捞针。
  • 保留策略差异化:合规审计类日志(比如支付、操作记录)要求长时间留档,调试类日志可以只留三五天。
  • 按场景配置级别:某个业务模块可以独立调成 DEBUG 级别,而不影响生产环境的 INFO 级别全局设置。
  • 便于监控与告警:独立文件可以单独给日志采集器(如 Filebeat、Promtail)配置采集路径,后续做指标分析、异常告警更干净。

从技术选型角度来看,Logback 本身支持多Logger+ 多Appender的机制。我们既可以静态配置固定的业务模块Logger,也可以借助 MDC(Mapped Diagnostic Context,映射诊断上下文)按全动态的区分维度来路由日志。方案没有绝对的好坏,关键看业务是“稳定分模块”还是“动态多维度”。

我在动笔之前先把这个思路整理清楚:核心不是“怎么配”,而是“根据什么维度分、怎么方便地维护这些配置”。分文件的维度如果拍脑袋定下来,配置完成了后面维护成本会很高。所以下文先讲设计,再讲配置,最后说踩坑。

2. 核心思路拆解:从 Logger 命名到 Appender 路由

要理解 Logback 这套日志分离机制,先要把它最底层的几条概念捋顺:Logger、Appender、Layout、Filter。用生活化的类比来说,Logger 是水管,Appender 是水龙头,Layout 是决定水流形态的模具,Filter 是阀门。日志事件从业务代码里发出之后,顺着 Logger 这根水管流出去,流到哪个水龙头(Appender)是由 Logger 的配置和 Filter 共同决定的,最终以某种格式写入目标文件。

2.1 静态绑定:按业务模块指定 Logger

这种方式最简单粗暴。比如我们把项目里的业务模块拆成ORDER、PAYMENT、RISK三个领域,那么就去定义三个独立的Logger:

  • com.demo.order
  • com.demo.payment
  • com.demo.risk

然后在logback.xml里给每个 Logger 各自绑定一个专属 Appender,分别指向logs/order.log、logs/pay.log、logs/risk.log。业务代码里按照类所处包路径使用对应 Logger,日志就自然流到各自文件里。

这种方案的优点是配置直观、代码改动小,只要包命名规整,理解成本极低。缺点则是维度固定,如果后续业务要从“按模块分”改成“按用户ID分”或“按订单状态分”,静态 Logger 就没法搞了,必须上动态路由方案。

2.2 动态路由:借助 MDC 实现请求维度拆分

另一种思路是不强制 Logger 实例分开,而是借助 MDC 在运行时把某个“业务标识”塞进日志上下文,然后让 Appender 根据这个标识动态决定写往哪个文件。Logback 原生支持这一能力的关键就是SiftingAppender,它在内部会根据 discriminator(判别器)的值动态生成不同的子 Appender。

简单来说,MDC 相当于一个线程级的“口袋”,你可以MDC.put("bizScene", "order")塞一个键值对进去。Logback 在打印日志时通过判别器读取这个键值,再用SiftingAppender给它安排一个专属文件输出。

这个方案灵活得多:今天按场景分,明天按用户维度分,都只需要调整 MDC 的 key 和 value,不用改 Logback 配置文件的结构。

2.3 两种方案的取舍判断

根据我的实际经验,做技术决策时要问自己几个问题:

  1. 业务维度是否长期稳定?如果确定半年甚至一年内就是固定那么几个模块,静态 Logger 方案完全够用,而且排查配置要容易很多。
  2. 文件数量上限是否能控住?如果 SiftingAppender 按用户ID拆分,一个高并发平台会产生几十万个动态文件,文件句柄、磁盘 inode 都会被拖垮,这个坑必须要提前评估。
  3. 运维同学是否能接受?静态多文件方案虽然不够炫,但巡检、转储、监控都简单。动态文件方案如果缺少配套的清理机制,很容易把磁盘搞满。

从我这个老开发的角度看,大多数项目选择静态 Logger * 多 Appender 的组合其实是最稳的,但确实也存在一些对动态能力有硬性要求的场景,比如既要按业务场景分文件,又要保留某个用户颗粒度的完整操作链路,那 MDC+SiftingAppender 就是绕不开的选择。下面把两种方案的完整配置方式都写出来,实际操作时按需选择即可。

3. 关键配置逐项解析:核心套路与避坑点

在贴完整配置之前,有必要把配置里的核心“零件”逐个拆开讲清楚,因为后面所有操作都是在这些零件的基础上拼装。

3.1 Appender 的组成与选型

Appender是 Logback 里负责“把日志写到哪”的组件,在分文件场景里主要是RollingFileAppender。它还有两个必须配的子件:

  • encoder:负责把日志事件格式化成字符串。日常建议用PatternLayoutEncoder,并明确指定字符集,避免Windows服务器上默认字符集导致中文乱码。
  • rollingPolicy:负责切割文件,常用的是TimeBasedRollingPolicy(按时间切)和SizeAndTimeBasedRollingPolicy(时间 + 大小双重条件切)。

对于TTL(日志保留时长),用maxHistory参数控制,比如maxHistory为30就只保留最近30天的历史文件,超过的会自动异步删除。这个参数很多人忽略,漏掉之后日志文件会无限累积,眼睁睁看着磁盘被写满。

3.2 Filter:决定日志事件的“去留”

光给 Logger 指定 Appender 还不够,因为 Logback 默认情况下additivity是开启的,子 Logger 的日志事件会继续向上层 Logger 传播,造成同一事件被上游 Appender 重复接收。控制“去留”的组件就是 Filter。

Logback 里最常用的两个 Filter:

  • LevelFilter:按日志级别过滤,比如这个 Appender 只收 ERROR。
  • EvaluatorFilter:配合表达式,用来做复杂匹配,比如按日志内容关键词过滤。这个通常配合 Janino 依赖使用。

在分场景分文件的配置里,通常用 Logger 隔离 +LevelFilter控制级别,用additivity="false"阻断传播,组合起来就能实现比较干净的隔离效果。

3.3 异步写入:性能与故障排查之间的平衡

生产环境里大量业务日志直接走同步 IO,线程阻塞在磁盘写入上,对接口响应时间的影响不能忽视。Logback 官方提供了AsyncAppender,原理是内部维护一个阻塞队列,业务线程把日志事件塞进队列即刻返回,由后台线程批量刷盘。

这个方案在提升吞吐的同时也带来一个问题:如果丢日志,排查链路数据不完整。所以对日志可靠性要求极高的场景,建议同步落盘或采用“先异步、失败落盘”的折中策略。关于这个我会在第5部分写一个比较完整的配置模板,方便直接抄。

3.4 占位符模式:别为了省事把排查体验毁掉

PatternLayout 的格式直接影响日志检索效率。我见过有人把日志格式写成%msg%n,只输出消息正文,完全没有时间戳和线程信息,排查多线程问题时根本无法定位时序。比较好的实践是至少包含这些信息:

  • %d{yyyy-MM-dd HH:mm:ss.SSS}:精确到毫秒的时间
  • %thread:线程名,排查线程池问题时必备
  • %level:日志级别,方便按级别过滤
  • %logger{36}:Logger 名称,用于区分模块
  • %msg%n:消息体及换行

如果有 traceId(全链路追踪ID),强烈建议把它也放进格式里,配上 MDC 取值,比如%X{traceId}。这样日志里能直接串起一次完整的请求链路,排障效率翻倍。

4. 实操配置第一步:固定模块的文件分离方案

现在开始写干货。这个方案我给它取名叫“静态分模块日志”,结构清晰,最容易落地。假设我们有三个业务模块:订单(order)、支付(payment)、风控(risk),再加上一个应用主日志(app),目标是把它们写到各自独立的文件里。

4.1 代码端的 Logger 获取方式

业务代码里不要再用LoggerFactory.getLogger(SomeClass.class)这种默认形式,而是应该针对每个模块专门取 Logger。注意 Logger 的命名非常关键。推荐两种做法。

第一种,按包名区分。把订单相关代码归到com.demo.order包,代码里写:

private static final Logger logger = LoggerFactory.getLogger(OrderServiceImpl.class);

这样 Logger 名默认是类的全限定名,比如com.demo.order.service.OrderServiceImpl,天然归属com.demo.order分支下。

第二种,直接在类里定义模块级 Logger。适合不想强制约束包名的情况:

private static final Logger riskLogger = LoggerFactory.getLogger("RISK_LOGGER");

这里RISK_LOGGER是一个自定义 Logger 名称,后面配置里直接指向这个名字即可。

4.2 logback.xml 关键配置

下面是一个可直接复用的基础配置,重点看几个 Logger 的定义和additivity="false"的设置:

<?xml version="1.0" encoding="UTF-8"?> <configuration debug="false"> <!-- 应用主日志文件 --> <appender name="APP_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>10GB</totalSizeCap> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 订单专用日志文件 --> <appender name="ORDER_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/order.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/order.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>200MB</maxFileSize> <maxHistory>15</maxHistory> <totalSizeCap>5GB</totalSizeCap> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 支付专用日志文件 --> <appender name="PAY_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/pay.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/pay.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>200MB</maxFileSize> <maxHistory>90</maxHistory> <totalSizeCap>20GB</totalSizeCap> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 风控专用日志文件,一般只需要 WARN/ERROR --> <appender name="RISK_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/risk.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/risk.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>5GB</totalSizeCap> </rollingPolicy> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>WARN</level> </filter> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 根 Logger:默认所有日志先落到 app.log --> <root level="INFO"> <appender-ref ref="APP_FILE"/> </root> <!-- 订单模块 Logger:不走 root 传播,避免重复写 app.log --> <logger name="com.demo.order" level="INFO" additivity="false"> <appender-ref ref="ORDER_FILE"/> </logger> <!-- 支付模块 Logger --> <logger name="com.demo.payment" level="INFO" additivity="false"> <appender-ref ref="PAY_FILE"/> </logger> <!-- 风控模块 Logger,这里考虑接收自定义 Logger --> <logger name="RISK_LOGGER" level="WARN" additivity="false"> <appender-ref ref="RISK_FILE"/> </logger> </configuration>

注意我在风控这个 Appender 里加了ThresholdFilter,也就是说即使业务方不小心打了 INFO 或 DEBUG 日志,这个文件里也不会写那些低级别的信息,只保留 WARN 以上内容,这能有效控制风控日志的体量。但这里有个附加问题:如果只定义ThresholdFilter为 WARN,那么 ERROR 依然是输出的。如果你想只保留 WARN 和 ERROR,并且后续要做统计,这个写法是正确的;如果你只想要 ERROR,就要换LevelFilter,下面第6节会细说。

4.3 配置里几个容易被忽略的细节

  • 每个 Logger 都要显式考虑additivity。忘记写additivity="false"的话,日志事件会继续冒泡到根 Logger,结果就是:order 日志同时写进了order.log和app.log,你以为是分开了,实际上业务日志在 app.log 里照样增长。这个坑极其常见,我早期就吃过亏。
  • maxFileSize和maxHistory要按模块单独评估。订单日志一天500MB很正常,支付日志可能一天才几十MB,统一用一个切割策略会带来不必要的 IO 和文件碎片。
  • 如果开启 gzip 压缩历史日志(fileNamePattern末尾加.gz),会占用一定 CPU,但对磁盘空间很友好。对“需要经常回溯查询最近日志”的历史文件,不建议压缩,因为解压有损耗;对超过7天以上的冷数据,压缩存储是比较划算的。

这套配置下来,模块文件隔离已经完成,适合绝大多数业务系统。但如果你有更复杂的诉求,比如“按租户”“按用户ID”“按渠道来源”动态拆分,那就要看下面的进阶方案。

5. 进阶实操:用 MDC 实现全动态日志文件路由

静态配置写好后,很多同事会来问:“我们现在要根据”下单渠道“来拆分日志,比如APP渠道、小程序渠道、PC渠道,各写各的文件。但如果渠道数量还在涨,难道每个渠道都要去改一遍 logback.xml 吗?”

答案显然是不能靠手工静态改配置。这时候要上 Logback 的SiftingAppender。

5.1 工作原理简述

SiftingAppender做的事情可以这样理解:它本身不直接写文件,内部有一个“管家”,管家根据一个 key 的值动态创建子 Appender。比如 MDC 里有一个 key 叫channel,当第一个请求带channel=app进来时,管家创建一个专门写logs/channel-app.log的 RollingFileAppender;另一个请求带channel=pc时,再创建写logs/channel-pc.log的 Appender。后续相同 key 的日志直接复用已创建的实例。

从实现角度看,SiftingAppender底层通过Discriminator读取 MDC 或者其他上下文数据,然后从缓存里取对应子 Appender。

5.2 业务代码里往 MDC 塞值

关键在于业务入口处设置 MDC,在业务结束时清除 MDC。最常用的做法是用过滤器或者 AOP 统一处理。底层是 SLF4J 的 MDC,必须成对调用put和remove。

一个典型的 Filter 代码如下:

package com.demo.common.log; import org.slf4j.MDC; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import java.io.IOException; public class ChannelMdcFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String channel = extractChannel(request); if (channel != null && !channel.isEmpty()) { MDC.put("channel", channel); } try { chain.doFilter(request, response); } finally { MDC.remove("channel"); } } private String extractChannel(ServletRequest request) { // 实际项目中可能从 Header、参数等位置获取 return request.getParameter("channel"); } }

写完 Filter 后记得注册进 Web 容器。如果用的是 Spring Boot,可以简单声明一个@Component,或者FilterRegistrationBean,保证过滤器生效。

注意:MDC 本身是绑定在当前线程上的。如果业务有异步化、线程池切换,一定要在子线程里重新put上下文。否则日志打印时取不到 MDC 值,那么 SiftingAppender 就找不到正确的目标文件,所有日志全都落到默认文件去了。这是使用 MDC 方案最隐蔽的坑,我会在第6节专门讲。

5.3 SiftingAppender 配置写法

下面是一段可以直接抄的配置,不过需要说明的是:每个 SiftingAppender 都要求内部有一个timeout属性,超过多久没有日志请求,对应的子 Appender 会被关闭并移除。默认值是30分钟,但实际项目里建议设长一点,防止频繁创建销毁。

<?xml version="1.0" encoding="UTF-8"?> <configuration debug="false"> <!-- 动态渠道日志 --> <appender name="CHANNEL_FILE" class="ch.qos.logback.classic.sift.SiftingAppender"> <!-- 从 MDC 取值,这里 key 为 channel --> <discriminator> <key>channel</key> <defaultValue>unknown</defaultValue> </discriminator> <timeout>5 minutes</timeout> <maxAppenders>50</maxAppenders> <sift> <appender name="CHANNEL-${channel}" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/channel-${channel}.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/channel-${channel}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>200MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>20GB</totalSizeCap> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - traceId=%X{traceId} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> </sift> </appender> <root level="INFO"> <appender-ref ref="CHANNEL_FILE"/> </root> <!-- 如果有 traceId 生成器等组件,继续配置 --> </configuration>

5.4 动态方案的重要限制

不要以为这个配置就完事了,动态文件方案有几个现实约束,我在生产环境里都踩过:

  • maxAppenders必须设。如果不设,恶意或异常渠道值的请求会让系统创建几百个文件,最终磁盘 inode 耗尽。建议顶多不超过50个。
  • 动态文件名不能带特殊字符,比如渠道值如果是带斜杠或者冒号的,那文件路径就乱了。建议在塞 MDC 之前先做一层清洗,只保留字母、数字、下划线、中划线。
  • 每个子 Appender 都是独立的实例,对于高并发场景,大量动态 Appender 可能带来内存压力和文件句柄压力。如果某渠道量级极小且偶发,动态创建后长期不复用,这个方案就不划算,不如静态写死关键渠道。

用这个方案之后,如果要查某个渠道的日志,只需要tail -f logs/channel-app.log,按渠道排查问题效率会高很多。

6. 日志级别过滤的精细控制

前面在风控日志示例里用了ThresholdFilter,但它只能做“高于等于某级别”的粗粒度过滤。有些场景我们需要的是“精准匹配某几个级别”,而不是放行一堆相邻级别,这时候就要上EvaluatorFilter或者LevelFilter。

先看LevelFilter的用法,比如我只让 ERROR 级别进入某个文件:

<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/error.log</file> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>ERROR</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender>

这样只有 ERROR 级别才能命中这个文件,其他级别直接拒绝。适合“单独收集所有模块错误日志到 error.log”的集中监控场景。

如果只想收集 WARN 和 ERROR(但是排除 INFO、DEBUG),那LevelFilter连写两次是做不到“或”关系的,只能依赖表达式过滤。

Logback 里可以把EvaluatorFilter和Janino表达式配合起来用。写法是把<event>作为上下文变量,然后写一个基于 level 的判断表达式:

<dependency> <groupId>org.codehaus.janino</groupId> <artifactId>janino</artifactId> <version>3.1.10</version> </dependency>
<appender name="WARN_AND_ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/warn-error.log</file> <filter class="ch.qos.logback.core.filter.EvaluatorFilter"> <evaluator class="ch.qos.logback.classic.boolex.JaninoEventEvaluator"> <expression> event.getLevel().toInt() >= WARN.toInt() </expression> </evaluator> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender>

这里表达式的意思就是:WARN和WARN以上的级别全部接受,其他拒绝。用这种方式可以灵活组合多个条件。

还有一个常用条件:日志内容包含某个关键词才写入独立文件。比如支付安全团队要求抓取所有包含“支付流水号”的INFO日志落盘,表达式可以写成:

<expression> event.getFormattedMessage().contains("PAY_NO") &amp;&amp; event.getLevel().isGreaterOrEqual(INFO) </expression>

实际使用时要小心转义:在 XML 里&&要写成&amp;&amp;,不然配置文件解析直接报错。这个坑我也帮同事排查过好几次,几乎每次都是因为忘了转义。

7. 异步写入优化:高并发场景的吞吐提升方案

前文提过AsyncAppender,这里我展开讲一个完整可用的落地配置。

在分文件基础上,如果每个模块的 Appender 都是同步写盘,接口调用线程会被磁盘 IO 拖住。尤其是支付、订单这种高频模块,写入量一旦上来,线程阻塞会非常明显。解决办法是把同步 Appender 包一层异步。

Logback 官方推荐的做法是:先定义好目标 RollingFileAppender,再定义 AsyncAppender,把你的文件 Appender 作为它的子 Appender 引用。业务流程大概是:日志事件先进入 AsyncAppender 的阻塞队列,一个后台线程从队列里取数据并交给真正的 RollingFileAppender 去写文件。业务线程不需要等待文件 IO 完成。

一个典型的异步 appender 配置如下:

<appender name="ASYNC_ORDER_FILE" class="ch.qos.logback.classic.AsyncAppender"> <!-- 队列容量,这个值要结合业务量评估 --> <queueSize>8192</queueSize> <!-- 队列剩余容量低于这个值时,丢弃低级别日志,防止阻塞 --> <discardingThreshold>0</discardingThreshold> <!-- 如果 queue 满了,日志不会丢失 --> <neverBlock>true</neverBlock> <appender-ref ref="ORDER_FILE"/> </appender>

需要关注三个参数的含义和取舍:

  • queueSize:队列容量,默认256。如果并发高、日志量大,建议至少调到4096或8192。队列太小的直接后果是频繁触发丢弃策略。
  • discardingThreshold:当队列剩余容量小于该阈值时,会丢弃 DEBUG、INFO、TRACE 级别日志,只保留 WARN 和 ERROR。默认值是队列长度的20%。如果设为0,表示永远不会按这个规则丢弃日志。
  • neverBlock:默认false。如果设为false,队列满了之后业务线程会阻塞,直到有空位;如果设为true,队列满了直接丢弃日志。对于日志可靠性不敏感但又怕阻塞的场景,可以设true;对于重要的审计日志,不建议设true,宁可阻塞也要保证日志不丢。

我自己实测过:单机日志量在约1万条每秒的时候,同步写文件会偶发线程阻塞,P99延迟可能抖动到300ms以上;换成异步后,日志写入对业务线程几乎无影响,P99基本纹丝不动。

但异步方案有一个代价:如果进程突然被杀(比如kill -9),或者机器断电,队列里还没刷盘的日志会直接丢失。而且AsyncAppender默认的日志丢弃策略也可能丢 INFO 日志。所以对支付流水、审计日志这类强审计场景,我建议单独保留一个同步 Appender 写入关键业务日志,普通模块日志再走异步。

提示:不要对同一个 RollingFileAppender 既挂同步又挂异步,会导致同一个文件被多个线程同时写,有日志错乱风险。真要“一份同步一份异步”,请定义两个独立的 RollingFileAppender,分别指向不同文件。

8. 动态文件的配套:MDC 与线程池上下文传递

写日志分离配置,总会有人踩到“子线程里拿不到 MDC 值”的坑。这个坑光靠改配置解决不了,必须从代码层面配合。

背景:现代后端服务几乎都在用线程池异步处理。比如订单系统中,主线程收到请求后,把任务丢进线程池处理。主线程里做了MDC.put("orderNo", orderNo),但线程池里的工作线程并没有这个 MDC 值,日志打印出来之后 SiftingAppender 就会路由到默认文件,甚至 traceId 也丢失了,整条链路就没法串联。

我的解决思路是在提交任务到线程池时,把主线程的 MDC 上下文捕获下来,然后在任务执行前恢复,执行完后清理。

如果项目里用的是ThreadPoolExecutor,可以包装一个 Runnable:

package com.demo.common.concurrent; import org.slf4j.MDC; import java.util.Map; public class MdcRunnable implements Runnable { private final Runnable delegate; private final Map<String, String> mdcContext; public MdcRunnable(Runnable delegate) { this.delegate = delegate; this.mdcContext = MDC.getCopyOfContextMap(); } @Override public void run() { if (mdcContext != null) { MDC.setContextMap(mdcContext); } try { delegate.run(); } finally { MDC.clear(); } } }

使用方法很简单:线程池submit或者execute时,把原本的 Runnable 包一层:

executor.execute(new MdcRunnable(() -> { // 业务逻辑,这里可以正常打印日志 }));

如果项目里用的是 Spring 的@Async,可以自定义一个 TaskDecorator 来传递 MDC:

package com.demo.common.concurrent; import org.slf4j.MDC; import org.springframework.core.task.TaskDecorator; import java.util.Map; public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { if (contextMap != null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; } }

使用:

@Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setTaskDecorator(new MdcTaskDecorator()); executor.initialize(); return executor; }

这个方案我从实际项目中总结下来,是动态文件方案能否稳定落地的关键点之一。没有这一层,配置再漂亮,线上照样出现“日志跑错文件”的问题,而且极难排查。

9. 常见问题排查与避坑实录

写配置是一回事,线上跑起来之后的各种异常又是另一回事。我把这些年遇到的典型问题和排查思路整理成一张速查表,方便直接对照使用。

现象可能原因排查与解决
日志同时写入 app.log 和业务文件子 Logger 没有设置additivity="false"检查 Logger 配置,给每个业务 Logger 加additivity="false"
配置文件里改了格式,启动后不生效Spring Boot 项目中存在多个 logback 配置文件,或配置被其他启动参数覆盖启动参数里检查logging.config,确保指定到正确的文件;同时检查依赖里是否有其他日志库强行改写配置
动态文件创建了大量文件MDC 的 value 不受控,或者maxAppenders没设在业务入口清洗业务标识;在 SiftingAppender 设置maxAppenders
SiftingAppender 的日志全落到默认文件MDC 值在子线程中丢失按第8节方案在线程切换时传递 MDC 上下文
日志中文乱码encoder 没有指定字符集,Windows 默认字符集导致在 PatternLayoutEncoder 中显式配置<charset>UTF-8</charset>
SizeAndTimeBasedRollingPolicy没生效没有把maxFileSize配套好,或者 rollingPolicy 类名拼错核对类名,建议用SizeAndTimeBasedRollingPolicy而不是旧版TimeBasedRollingPolicy硬凑大小切割
历史日志没有自动清理没有配maxHistory或totalSizeCap补上这两个参数并检查路径是否正确
AsyncAppender 丢弃部分日志discardingThreshold触发,或者neverBlock=true导致排队风暴评估队列大小和discardingThreshold,关键审计日志不要用异步
启动报 “No such file or directory”日志目录不存在确认logs目录在部署机器上已创建,或在配置里使用log.path占位符由启动脚本创建
监控采集不到业务日志采集器路径和日志路径不一致确保采集路径与 Appender 的file保持一致,通常采集目录而不是单文件

再补充两个容易踩的坑。

第一个是配置里出现多个<root>标签。Logback 对重复定义<root>的处理方式是后者覆盖前者,但有些老版本会直接爆配置错误。如果你发现日志异常,先检查是不是配置里有重复 root。

第二个是跟 Spring Boot 结合时的坑。Spring Boot 1.x 默认日志框架是 Logback,但如果你引用了 Spring Boot 2.x 并声明了logback.xml,它不会自动读取旧版的logback.xml命名,而是读取logback-spring.xml。如果文件名不对,你的配置完全不生效,日志还是默认格式。这是让人最抓狂的问题之一,一定要提前确认文件名。

经验总结:排查日志配置问题时,最快的办法是启动时打开debug="true"属性或设置-Dlogback.debug=true,让 Logback 打印解析阶段的内部状态,哪些配置文件被读取了、哪些路径生效了,一目了然。定位完问题再关掉。

10. 扩展思路:多环境配置与集中日志链路

日志写进独立文件只是第一步,很多团队在部署到生产后,还会面临环境隔离和统一采集的问题。这里分享两个我比较推荐的做法。

10.1 按环境区分配置

开发、测试、生产环境对日志的要求完全不同。开发环境可能需要 DEBUG 级别,测试环境需要留更多中间过程,生产环境才讲究精简存储。Logback 支持${env}占位符和 profile 片段引入。

Spring Boot 下推荐把配置拆成多个文件:logback-spring.xml主配置,然后分别写logback-dev.xml、logback-prod.xml,通过<springProfile>标签选择加载。

或者更简单粗暴:在logback.xml里用环境变量控制日志路径和级别,比如:

<property name="LOG_PATH" value="${LOG_PATH:-logs}"/> <property name="LOG_LEVEL" value="${LOG_LEVEL:-INFO}"/>

这样通过启动参数就能动态控制日志目录与级别,不需要改配置文件本身。我在部署多套环境的经验里,这个技巧非常实用,尤其对“同构多环境”的部署方式,能少维护很多套配置。

10.2 面向集中式日志平台兼容

单独文件方案虽然解决了本地排查问题,但在微服务架构下,日志源非常多,依然需要集中采集。所以配置输出的模式要考虑到采集端的情况。

建议日志输出格式统一成 JSON,这样采集后直接做结构化解析,后续在日志平台里做查询、聚合、告警都方便。Logback 有现成的JsonEncoder,比如logstash-logback-encoder。

一个简短的 JSON 输出配置:

<encoder class="net.logstash.logback.encoder.LogstashEncoder"> <customFields>{"appname":"demo-order"}</customFields> </encoder>

配合上文讲的 MDC,还可以在 JSON 里带上所有上下文信息。这样采集端只需要解析 JSON 即可,链路分析、异常聚合、关键词检索都会更顺畅。

10.3 关于文件格式的取舍

如果团队目前没有成熟的日志采集平台,用纯文本格式依然是最直接的。但如果已经接了 ELK 或者云原生日志服务,那就强烈建议改成 JSON 格式。这里要说的是:文本格式和 JSON 格式不要同时输出,否则会把你磁盘写入量直接翻倍。

如果既希望人肉看日志方便,又希望采集端能结构化解析,我的经验是:本地排查环境用文本格式,生产环境用 JSON 格式。具体的方案是在<springProfile>里分别定义 encoder 就行。

11. 从“能分文件”到“能省运维成本”

写到这里,分文件日志已经从基础的静态方案讲到了动态方案、异步优化和集中采集,基本覆盖了日常开发中能用到的所有核心技能点。

我个人在实际操作中的体会是,日志分离配置本身并不复杂,真正的复杂度在于“业务维度到底有哪些”和“日志量有多大”。与其一上来就抄一段功能很全的配置,不如先把业务模块的日志归属划分清楚,定好 Logger 命名规范,再动手写配置。否则等到线上日志路径乱成一锅粥,再回头收拾就非常痛苦。

现有架构下比较推荐的顺序是:先用静态 Logger 方案把模块文件分好,跑一段时间观察文件量级;如果某些动态维度(渠道、用户、租户)确实需要,再引入 MDC+SiftingAppender。每一步都小步快跑,线上验证无误之后再谈异步、再谈JSON化,逐步把日志治理的能力长出来。

再分享一个我认为很值得投入的小技巧:写一个简单的 Shell 脚本,每天定时统计日志目录下各文件的增长情况,超过阈值的自动告警。这能在日志量突增时第一时间发现问题,等你真的遇到磁盘写满再处理,就已经晚了。脚本逻辑很简单,大概就是du -sh logs/*.log然后和阈值比较,配合cron调度就行。这个投入成本很低,但长期收益很高。

最后想说的是,日志不是写给别人看的,是写给未来的自己看的。一个结构清晰、路径稳定、格式统一的日志体系,关键时候能帮你省下大把排查时间,在你面对线上故障时,它就是最可靠的线索来源。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进