
日志这东西不碰上问题的时候没人想动它。Spring Boot 项目默认就是 logback开发阶段打打控制台完全够用一上线、一压测、一出诡异问题log4j2 的价值就出来了。这篇帖子就是一份 Spring Boot 整合 log4j2 日志配置的完整实操记录从依赖替换、配置文件写法、滚动策略、异步日志到全链路 TraceId把能踩的坑和该抄的作业都整理出来。适合刚把 Spring Boot 项目搭起来、准备认真搞日志的同学也适合项目里 Python 日志乱成一团想重构的老手。1. 换掉 logback 之前先搞清楚 log4j2 到底强在哪1.1 默认的 logback 真有那么差吗说句公道话logback 在中小项目里并不差配置简单、文档多、和 Spring Boot 无缝集成。但现实中你早晚会遇到这么几个破事第一接口请求量一上来日志同步写盘的性能瓶颈会直接反馈到接口 TPS 上。我试过一个订单服务在压测到 800 QPS 的时候日志写入成了明显的短板线程大量阻塞在日志输出的 IO 上。logback 虽然也有 AsyncAppender但底层用的是 ArrayBlockingQueue入队和出队是有锁竞争的并发高了以后吞吐量和 log4j2 的差距会用数据说话。第二线上排查问题的时候日志文件全是滚动切分和归档策略不够灵活导致的早该清理却没清理、磁盘被打满的情况。logback 的 TimeBasedRollingPolicy 配合 SizeAndTimeBasedRollingPolicy 其实也能用但触发条件的组合和删除策略的粒度都不如 log4j2 来得顺手。第三也是最多人忽略的logback 和 log4j2 同样基于 SLF4J 门面但 log4j2 的异步 Logger 实现是真·无锁基于 LMAX Disruptor。说得直白点一个是排队叫号一个是多窗口自助取餐。你换到 log4j2 以后日志系统的吞吐上限一下子就高出一大截。1.2 log4j2 真正打动我的是这三个能力先给结论log4j2 用来替换 logback 主要有三个我用下来离不了的能力。第一个是异步日志性能。通过 Disruptor 无锁队列实现高并发场景下日志损失从 logback 的百分之十几可以降到个位数。这个不是玄学是你压测时能用 jmeter 和 JFR 看到真实变化的。第二个是滚动归档和删除策略足够灵活。logback 的 RollingFile 滚动规则你和它掰扯半天log4j2 这边直接用 Policies 加 DefaultRolloverStrategy时间触发、大小触发、Cron 触发都能组合还能用 Delete 直接按文件名和修改时间清理过期日志。磁盘管理这件事配置好以后基本不用再管。第三个是配置热更新。log4j2 支持 monitorInterval你在不重启应用的情况下修改日志级别和 Appender过个几十秒就自动生效。生产环境遇到问题临时开 DEBUG排查完改回 INFO整个过程不需要发版这个体验太重要了。2. 项目整合实操依赖替换和第一份日志配置2.1 必须处理好的依赖冲突Spring Boot 的 spring-boot-starter-web 默认带的是 spring-boot-starter-logging底层实现就是 logback。你要换 log4j2首先就得把这个默认的日志 starter 从依赖里排除掉再把 spring-boot-starter-log4j2 引进来。以 Maven 为例最干净的做法是在每个引入了 spring-boot-starter-web 的模块里排除dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-logging/artifactId /exclusion /exclusions /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-log4j2/artifactId /dependency如果你的项目里模块很多每个 starter 都手动排除很啰嗦我一般会在根 pom 的 dependencyManagement 里统一管理或者干脆在父 pom 里把 spring-boot-starter-logging 全局排除。但有一点要注意MyBatis、Druid 这类第三方组件有时会间接引入 logback-classic光排除 Spring Boot 的 starter 还不够最后用 mvn dependency:tree 查一下看到 logback-classic 就必须干掉。引入 spring-boot-starter-log4j2 后它会自动带上 log4j-api、log4j-core以及 slf4j 到 log4j2 的桥接包 log4j-slf4j-impl。项目里用 org.slf4j.Logger 写的代码不用改一行不用动。2.2 Spring Boot 3.x 和 4.x 版本的几个小坑如果你用的是 Spring Boot 3.x 或更新的版本整合逻辑基本一致starter 坐标没变但有几个细节要留意。第一Jakarta 命名空间。Spring Boot 3 的 servlet 相关 API 已经从 javax 换成 jakarta如果你要在过滤器里做 TraceId 之类的逻辑导入的包要写 jakarta.servlet.Filter别套老代码。第二log4j2 的版本。Spring Boot 3.0 到 3.2 默认管理的 log4j2 版本是 2.x 的较新版本如果你自己额外依赖了旧版 log4j2会出现一些兼容问题。简单做法是不要手动指定 log4j2 版本让 spring-boot-starter 统一管理。遇到过 Spring Boot 3 项目里因为 log4j2 版本过低导致 JSONTemplateLayout 不生效的情况升级到 2.20 就正常了。第三Spring Boot 4.0 的动态配置变化。Spring Boot 3 之后对自动配置类的暴露机制一直在调整DataSourceAutoConfiguration 这类配置的定位方式也变了。和日志配置有关的影响是如果你在自定义 Starter 里依赖了 LoggingSystem要注意新版里日志系统初始化顺序更严格。一般我们做纯 log4j2 配置不需要碰这一层知道有这个变化就行免得以后排查奇怪问题的时候没方向。2.3 一份能直接抄的基础 log4j2.xml依赖配好以后在 src/main/resources 下创建 log4j2.xml。下面这份配置我拿来做基础模板线上项目大部分需求都能覆盖?xml version1.0 encodingUTF-8? Configuration statusWARN monitorInterval30 Properties Property nameLOG_HOME/data/logs/your-app/Property Property nameLOG_PATTERN%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/Property /Properties Appenders Console nameConsoleAppender targetSYSTEM_OUT PatternLayout pattern${LOG_PATTERN}/ /Console RollingFile nameRollingFileAppender fileName${LOG_HOME}/app.log filePattern${LOG_HOME}/app-%d{yyyy-MM-dd}-%i.log.gz PatternLayout pattern${LOG_PATTERN}/ Policies TimeBasedTriggeringPolicy interval1 modulatetrue/ SizeBasedTriggeringPolicy size200 MB/ /Policies DefaultRolloverStrategy max30/ /RollingFile /Appenders Loggers Root levelinfo AppenderRef refConsoleAppender/ AppenderRef refRollingFileAppender/ /Root Logger namecom.example.mapper leveldebug additivityfalse AppenderRef refConsoleAppender/ /Logger /Loggers /Configuration这里几个点稍微解释一下。Configuration 标签里的 monitorInterval30 就是前面的热更新开关30 秒扫描一次配置文件。PatternLayout 是日志输出格式里面 %d、%thread、%-5level、%logger 这些占位符后面详细讲。RollingFile 的 filePattern 决定了归档文件的命名规则%d 按日期分文件%i 在同一天内文件被大小策略再次触发时做序号递增。最后那个 Logger 配置是给 MyBatis 的 mapper 接口开 debug 用的注意 additivity 设为 false否则 SQL 日志会重复打印两遍。这个细节以前坑过我日志翻半天发现同一条 SQL 出现两次就是因为子 Logger 打印完后事件又冒泡到了 Root。2.4 多环境配置怎么处理log4j2 本身支持通过系统属性来选择配置文件比如启动参数里指定 -Dlog4j2.configurationFileclasspath:log4j2-prod.xml。但更常见的做法是只维护一份 log4j2.xml用环境变量来控制日志级别和日志目录。比如前面配置里的 LOG_HOME可以在 Properties 里改成Property nameLOG_HOME${env:LOG_HOME:-/data/logs/your-app}/Property这样本地环境不配环境变量就落到默认路径生产环境通过容器环境变量指定目录。日志级别同理可以用 ${sys:log.level:-info} 配合启动参数 -Dlog.leveldebug 灵活调整。一套配置走天下不用维护多个 XML 副本省事很多。我见过一些团队把 log4j2-dev.xml、log4j2-prod.xml 好几个文件堆在 resources 下配置文件之间内容大量重复改个格式要同步好几个文件很容易漏。用环境变量开关字段比复制文件健康得多。3. 核心配置项深度拆解Logger、Appender、Layout 和滚动策略3.1 Logger、Appender、Layout 三者到底是什么关系这套概念很多人用了一年 log4j2 也没理清楚但理清楚以后写配置基本就自由了。Logger 是应用中通过 LoggerFactory.getLogger() 拿到的记录器它决定了哪条日志要输出、以什么级别输出。Appender 是日志的目的地控制台、文件、数据库都是 Appender。Layout 则是每条日志最终长成什么样纯文本、JSON 都是 Layout 的事。一条日志从产生到落盘流程是业务代码调用 logger.info()日志事件进入 Logger 链Logger 根据级别判断是否放行然后分发给绑定的 AppenderAppender 再套上 Layout 的格式写到目标位置。这里最容易被绕晕的是多层 Logger 时的 additivity 属性。默认情况下additivity 为 true意味着日志事件在用完当前 Logger 的 Appender 之后还会继续传给上一层 Logger一般是 Root一直冒泡到最顶层。如果你在子 Logger 里挂了文件 AppenderRoot 里又挂了同一个文件 Appender日志就会被写两份。配置里看到重复日志十有八九是这个原因。正确姿势是全局统一在 Root 里挂 File Appender 和 Console Appender子 Logger 只调整级别需要单独挂 Appender 时把 additivity 关掉避免重复。3.2 RollingFile 滚动触发策略和归档删除策略磁盘不会爆的关键RollingFile 是生产环境最重要的 Appender。它包含三个核心部分fileName 是当前正在写的文件filePattern 是归档文件的名字模板Policies 和 DefaultRolloverStrategy 则决定了什么时候滚动、保留多少份。先看 Policies也就是触发条件。TimeBasedTriggeringPolicy 表示按时间触发interval 默认是 1表示每天滚动一次配合 filePattern 里的 %d{yyyy-MM-dd}凌晨 0 点自动把前一天的日志归档。modulate 设为 true意思是 0 点滚动从日初开始计算而不是从应用启动时间开始算。SizeBasedTriggeringPolicy 是按大小触发。比如 size200 MB当前日志文件到了 200MB即使没到午夜也会触发滚动因为 filePattern 里有 %i所以同一天内可能切出 app-2024-05-01-1.log.gz、app-2024-05-01-2.log.gz 这样的多个归档文件。再看 DefaultRolloverStrategy 里的 max30它限制的是同一天同一规则下最多保留 30 个归档文件。这里有个局限max 只控制文件数量上限不会按天数清理历史文件。真正要清理过期日志得用 Delete 配合 AgeDefaultRolloverStrategy max30 Delete basePath${LOG_HOME} maxDepth1 IfFileName glob*/app-*.log.gz / IfLastModified age30d / /Delete /DefaultRolloverStrategybasePath 指定扫描目录IfFileName 限定只删 app- 开头的 gz 压缩包IfLastModified age30d 表示只删除修改时间超过 30 天的文件。这样线上日志文件只保留最近 30 天磁盘基本不会爆。对了归档用 .gz 后缀很重要。log4j2 在滚动时会自动把旧文件压缩成 gz一个 200MB 的日志压缩完可能只有 20MB 左右。我曾经见过不压缩的滚动目录一周就占了 60 多个 G压缩以后不到 10G。日志清理这件事别指望人肉盯把滚动和删除策略配好才是治本。3.3 PatternLayout 格式模板看懂每个占位符PatternLayout 是纯文本日志最常用的 Layout。上面配置里的 LOG_PATTERN 一行我拆开解释一下%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n%d 是时间戳后面花括号里是日期格式我统一用这个格式精确到毫秒方便排查时对时间线。%thread 是当前线程名异步线程和请求线程一对比能快速发现日志是不是在预期线程里打的。%-5level 是日志级别level 字符串左对齐、占 5 位宽度这样 INFO 和 ERROR 在视觉上对齐日志文件看起来整齐得多。%logger{36} 是 Logger 名称也就是类名36 表示如果类名太长只保留完整的前 36 个字符避免一行日志被超长的全限定类名撑爆。%msg 是消息正文%n 是换行。这些占位符组合起来每一行日志的长相就是2024-05-01 12:00:00.123 [http-nio-8080-exec-1] INFO com.example.order.service.OrderService - 创建订单成功, orderId10001一眼能看清时间、线程、级别、来源类、具体信息排查问题的时候效率高非常多。除了这些基础占位符还有一个 %X{traceId} 用来输出 MDC 里的值全链路追踪会用到下一章讲。3.4 日志中打印请求参数和 SQL 的技巧开发阶段想在日志里看 MyBatis 执行的 SQL除了前面在 Logger 里按 mapper 包开 debug还可以直接针对 mapper 接口所在的包配置级别。比如 mapper 接口在 com.example.order.mapper那就Logger namecom.example.order.mapper leveldebug additivityfalse/这样只打印这一层的 SQL 日志不会像全局 debug 那样把框架内部日志全带出来效率影响小很多。还有一个常见的需求是打印 HTTP 请求参数。直接在业务代码里 logger.info(params: {}, JSON.toJSONString(request)) 是很多人会干的事但务必注意千万别把密码、token、身份证号这些敏感字段打进日志。日志系统一旦落盘清理比登天还难合规问题和安全风险都藏在这种“图省事”里。4. 全链路追踪落地TraceId 在 log4j2 中的正确姿势4.1 用 MDC 在入口处生成 TraceId线上排查问题最快的方式就是一条请求从网关到下游服务所有日志共享同一个请求 ID。Log4j2 的 MDCMapped Diagnostic Context就是干这个的SLF4J 提供了 org.slf4j.MDC 工具类log4j2 适配之后可以直接用。最基础的做法是在 Spring Boot 里加一个 Filter在请求进入时生成 TraceId放入 MDC请求结束后移除Component public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId UUID.randomUUID().toString().replace(-, ); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }然后在 log4j2.xml 的 PatternLayout 里加上 %X{traceId} 输出Property nameLOG_PATTERN%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n/Property这样日志里每一行都会带上 traceId。注意用 finally 移除非常重要否则线程池复用线程时上一次请求的 traceId 会串到下一次请求里查问题会查到怀疑人生。Servlet 容器默认每个请求用一个线程执行还是有可能复用的我见过线上日志里同一个 traceId 出现在完全不相干的接口下就是因为移除逻辑没做好。4.2 异步线程里怎么保证 TraceId 不丢Filter 里的 MDC 只在当前线程有效。如果业务代码里用了线程池异步执行子线程默认拿不到父线程的 MDC 内容TraceId 就断了。解决办法是给线程池加上 TaskDecorator在任务提交时拷贝父线程的 MDC 上下文执行前恢复执行后清理Bean public Executor asyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setTaskDecorator(runnable - { MapString, String context MDC.getCopyOfContextMap(); return () - { MDC.setContextMap(context); try { runnable.run(); } finally { MDC.clear(); } }; }); executor.initialize(); return executor; }这个方案本质上是把父线程的 MDC 内容“快递”给子线程子线程用完再清掉避免污染。如果是 Spring 的 Async 注解继承自 AsyncConfigurer 时也可以重写 getAsyncExecutor 返回上面这个 executor效果一样。这一步不做日志主链路有 TraceId异步链路全断等于全链路追踪只追了一半。4.3 调用远程服务时 TraceId 怎么传服务和服务之间通过 HTTP 或 RPC 调用的时候TraceId 光在本地线程里传不够还要通过请求头透传给下游。常规做法是定义请求头名称比如 X-Trace-Id在调用方生成或透传接收方在 Filter 里优先从请求头取取不到再自己生成。String traceId request.getHeader(X-Trace-Id); if (traceId null || traceId.isEmpty()) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(traceId, traceId);配置 RestTemplate 或 OpenFeign 的拦截器把当前 MDC 里的 traceId 放到出站请求头里。这样整条调用链的日志就能串起来。很多公司内部会直接用 SkyWalking 这类 APM 工具做全链路追踪但轻量场景下用 MDC 加请求头的方式成本低、见效快也非常值得一用。5. 异步日志调优AsyncLogger 与 AsyncAppender 别选错5.1 两种异步方式到底有什么区别log4j2 的异步有两种用法。第一种是 AsyncAppender它是在普通 Appender 外面包一层队列日志先进队列后台线程再批量写入实际 Appender。第二种是 AsyncLogger它是让 Logger 本身异步直接在日志事件产生时放进 Disruptor 无锁环形队列再被后台线程消费。如果你追求写日志对业务线程影响最小选 AsyncLogger。因为它连日志级别判断、Layout 格式化这些逻辑都放在消费者线程里执行业务线程只负责把事件丢进队列就走了。AsyncAppender 则更简单直接适合只想把 IO 操作从业务线程挪到后台的场景。如果只是从 logback 切过来想快速提升日志吞吐用 AsyncAppender 包一层成本最低。但压测数据摆在那里——AsyncLogger 配合 Disruptor在 8 核机器上能把日志丢数据平均影响降到极低水平AsyncAppender 还是有队列竞争极限吞吐不如 AsyncLogger。5.2 Disruptor 队列与等待策略怎么调如果选择全异步模式需要在 log4j2.component.properties 里配置Log4jContextSelectororg.apache.logging.log4j.core.async.AsyncLoggerContextSelector然后 Disruptor 的默认队列大小是 8192对应系统属性 log4j2.asyncQueueFullPolicy。当队列满的时候默认策略是阻塞。如果不想因为日志导致业务延时的波动可以把默认的丢弃策略保留但在生产环境我更推荐设置 waitStrategy 来换取更稳定的写入延迟。Disruptor 的 WaitStrategy 有 Block、Sleep、Yield 等几种。Block 策略 CPU 空转少但延迟高Sleep 策略折中Yield 策略低延迟但 CPU 占用高。我在项目里一般默认不改除非压测发现日志影响明显才会尝试换成 Sleep 或 TimedWait观察 GC 和接口延迟再定。这里要提醒一下全异步模式启动时如果你的系统里同时存在 log4j2-slf4j-impl 和 logback-classic启动会直接告警甚至失败。所以这类配置一定在依赖冲突全部解决后再上。5.3 日志丢失到底是谁的锅很多人骂异步日志丢日志其实绝大多数情况是配置问题。最常见的是 AsyncAppender 设置的 blockingfalse队列满时新日志直接被丢弃。生产环境如果丢弃策略开得太大关键错误日志可能在排查时根本找不到所以一般建议保留一部分日志量把队列设置大一点并在启动时用 JVM 参数 log4j2.asyncQueueFullPolicyDiscard 时至少在配置里留一个 error 级别的同步兜底日志。我自己在线上项目的做法是业务日志走 AsyncLogger RollingFile Appender而 ERROR 级别日志额外挂一个独立的同步 File Appender只记录错误明细。这样一来异步队列满丢弃的只是普通日志最关键的报错信息无论如何都不会丢排查事故的时候就有保底数据。这个设计用一句话概括就是“异步为主同步兜底”。6. 生产环境的高频问题排查和联动方案6.1 用 Actuator 动态调整日志级别Spring Boot Actuator 提供了 /actuator/loggers 端点可以动态查看和修改日志级别这个功能和 log4j2 的能力结合起来非常实用。查看所有 Logger 级别curl http://localhost:8080/actuator/loggers临时把 com.example.order.service.OrderService 调到 DEBUGcurl -X POST http://localhost:8080/actuator/loggers/com.example.order.service.OrderService \ -H Content-Type: application/json \ -d {configuredLevel:DEBUG}这个操作不用重启、不用发版排查问题的时候非常爽。但要强调一点这个端点在生产环境绝对要加权限控制否则就是安全漏洞。比如 spring security 配置里只允许管理员角色访问或通过 management.endpoints.web.exposure.include 精细控制暴露范围别把所有端点裸奔在公网上。未授权访问 actuator 导致的日志泄漏和信息泄露在行业里不是新鲜事。6.2 容器化和 Filebeat 联动时的注意事项现在项目基本都容器化部署。日志和 Docker、Filebeat 这些采集组件联动时最容易出问题的是标准输出和日志文件的重复采集。我们的经验是在容器内 log4j2 只保留 Console Appender把日志打到 stdout由容器运行时统一收集然后 Filebeat 以容器日志为标准采集。如果同时保留 RollingFile又让 Filebeat 去采集宿主机挂载目录就会造成日志重复入库。如果坚持用文件方式输出那么在 Dockerfile 里要把日志目录声明成 Volume并确保 Filebeat 容器能读取到宿主机对应目录。这里有个细节log4j2.xml 里的 LOG_HOME 在生产环境通过环境变量注入容器中建议统一写到 /data/logs/。另外 Filebeat 的 multiline 配置要针对 Java 异常的堆栈做处理否则一条异常被拆成多行Kibana 里看起来非常痛苦。典型的 filebeat.yml 片段multiline.pattern: ^\d{4}-\d{2}-\d{2} multiline.negate: true multiline.match: after意思是从非日期开头的行为前一行异常的延续。这个配置在 Rocky 系服务器上部署 Filebeat 时同样适用。6.3 常见问题速查表问题现象可能原因解决方案日志还是 logback 格式spring-boot-starter-logging 未排除干净mvn dependency:tree 排查排除 logback 依赖同一条日志打印两次子 Logger additivity 未关设置 additivityfalse异步队列后 ERROR 日志消失blockingfalse 队列满丢弃ERROR 单独挂同步 Appender 兜底TraceId 有时有有时没有Filter 的 MDC.remove 未在 finally 执行确保 finally 中移除线程池加 TaskDecorator磁盘被日志打满滚动策略或删除策略缺失配 DefaultRolloverStrategy Delete gz 压缩文件按天没有滚动filePattern 缺少 %d 规则检查 filePattern 是否包含日期模板日志时区错乱容器时区不是 UTC 或其他容器设置 TZ 环境变量SQL 日志看不到mapper 包未单独开 debug增加 Logger name 指向 mapper 包并设 debug速查表里这八条是我在几个项目里帮同事排查日志问题时的高频命中原由。对照这个表你可以先自查一遍自己的项目大概率能省掉一次折腾。日志配置这件事做完不是终点它需要持续跟着项目演进。项目上线后多观察日志文件大小、滚动频率、磁盘占用业务量涨了原来的 200MB 触发策略可能第二天就滚了几十份文件归档保留 30 天可能半个月就把磁盘干满。我个人习惯每个月看一眼日志目录的占用和归档情况根据实际流量把滚动和删除策略再调一调。你如果刚开始从 logback 换到 log4j2建议先在测试环境压一把看看异步日志的吞吐变化和归档节奏再推到生产。日志系统不会帮你写业务代码但它能在出问题的时候帮你省下几个小时的定位时间。