>**Spring 系列 · 第 4 篇**
> 面向 Java 初学者和有一定 Spring 使用经验的开发者。
> 技术环境:Java 17+ · Spring Boot 3.4.x(兼容 3.x / 4.x)· SLF4J · Logback
> 示例项目:[spring-logging-demo](./spring-logging-demo/)**(订单 API + 完整日志配置)
系列文章目录
>系列阅读建议:第 1、2 篇帮你搭好 Spring 骨架;本文聚焦**日志**——线上出问题 80% 靠它定位。建议 clone 示例项目,边看边跑。
提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 系列文章目录
- - [导语](#导语)
- [一、日志到底是什么](#一日志到底是什么)
- [1.1 一句话结论](#11-一句话结论)
- [1.2 日志解决了什么问题](#12-日志解决了什么问题)
- [1.3 日志 vs System.out.println()](#13-日志-vs-systemoutprintln)
- [1.4 为什么生产环境不能只依赖控制台](#14-为什么生产环境不能只依赖控制台)
- [二、Spring 日志体系的核心组件](#二spring-日志体系的核心组件)
- [2.1 一句话结论](#21-一句话结论)
- [2.2 生活类比:统一插座与具体电器](#22-生活类比统一插座与具体电器)
- [2.3 组件关系类图](#23-组件关系类图)
- [2.4 Spring 本身如何使用日志](#24-spring-本身如何使用日志)
- [2.5 常见错误](#25-常见错误)
- [三、Spring Boot 默认日志机制](#三spring-boot-默认日志机制)
- [3.1 默认实现与级别](#31-默认实现与级别)
- [3.2 默认日志格式拆解](#32-默认日志格式拆解)
- [3.3 用 application.yml 配置](#33-用-applicationyml-配置)
- [3.4 多环境不同级别](#34-多环境不同级别)
- [四、如何在 Java 代码中正确打印日志](#四如何在-java-代码中正确打印日志)
- [4.1 推荐的 Logger 定义方式](#41-推荐的-logger-定义方式)
- [4.2 日志级别「阶梯」](#42-日志级别阶梯)
- [4.3 参数化日志 vs 字符串拼接](#43-参数化日志-vs-字符串拼接)
- [4.4 异常日志:保留堆栈](#44-异常日志保留堆栈)
- [4.5 一次 HTTP 请求的日志对象图](#45-一次-http-请求的日志对象图)
- [五、Spring Boot 日志配置实战](#五spring-boot-日志配置实战)
- [5.1 示例项目结构](#51-示例项目结构)
- [5.2 logback-spring.xml 关键配置](#52-logback-springxml-关键配置)
- [5.3 配置优先级(由高到低)](#53-配置优先级由高到低)
- [六、常见问题排查](#六常见问题排查)
- [6.1 日志完全没有输出](#61-日志完全没有输出)
- [6.2 级别配置不生效](#62-级别配置不生效)
- [6.3 同一条日志重复打印 2~3 次](#63-同一条日志重复打印-23-次)
- [6.4 其他高频坑](#64-其他高频坑)
- [七、日志使用最佳实践](#七日志使用最佳实践)
- [7.1 什么该记、什么不该记](#71-什么该记什么不该记)
- [7.2 生产环境设计要点](#72-生产环境设计要点)
- [八、MDC 与链路追踪简介](#八mdc-与链路追踪简介)
- [8.1 MDC 是什么](#81-mdc-是什么)
- [8.2 它解决什么问题](#82-它解决什么问题)
- [8.3 示例:TraceIdFilter](#83-示例traceidfilter)
- [8.4 进阶概念(点到为止)](#84-进阶概念点到为止)
- [总结](#总结)
- [常见面试题 / 读者自测题](#常见面试题--读者自测题)
导语
凌晨两点,生产环境接口超时。你打开服务器,面对满屏 `System.out.println("here1")`、`System.out.println("here2")`——不知道哪条对应哪个请求,更不知道异常发生在哪一层。
日志不是「打印调试信息」那么简单。它是应用的黑匣子:记录谁在什么时候做了什么、成功还是失败、出了什么错。会写业务代码 ≠ 会设计日志;后者直接决定你排查问题的效率。
本文用费曼学习法讲清 Spring 日志体系:先给结论,再讲原理,最后给可运行代码。读完后你将能够:
1. 说清楚日志与 `println` 的本质区别;
2. 理解 SLF4J、Logback、Spring Boot Logging 的关系;
3. 在 Java 代码中正确选择日志级别、记录异常;
4. 独立完成控制台 + 文件 + 滚动 + 多环境的日志配置;
5. 排查「日志不输出、重复打印、级别不生效」等常见问题;
6. 了解 MDC 链路追踪的基本用法。
提示:以下是本篇文章正文内容,下面案例可供参考
一、日志到底是什么
1.1 一句话结论
日志是应用在运行过程中,按时间顺序、分级别、结构化地记录事件与状态地机制。
1.2 日志解决了什么问题
1.3 日志 vs `System.out.println()`
package org.example.loggingdemo; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest @Slf4j public class test { @Test void test1(String password, long userId){ // 不推荐:生产环境地大坑 System.out.println("用户登录成功,密码="+password); // 推荐 log.info("用户登录成功,userId=",userId); } }1.4 为什么生产环境不能只依赖控制台
1.容器重启即丢失:Docker / K8s 中Pod 重建后,控制台输出Gone。
2.无法长期留存:磁盘有限,需要滚动归档策略。
3.多实例难关联:10台机器地控制台,我们怎么拼成一次请求地完整链路?
4.无法分级过滤:调试信息淹没关键ERROR,真正地问题被淹没。
用大白话总结:`println`像在白板上随手写字,擦了就没了;日志系统像带时间戳、分类归档地飞行记录仪,专门给【事后复盘】用的。
二、Spring日志体系的核心组件
2.1 一句话结论
Spring Boot 采用【日志门面 + 日志实现】分层设计:代码只依赖门面(SLF4J),底层实现(默认Logback)可随时替换。
2.2 生活类比:同一插座与具体电器
-SLF4J:Simple Logging Facade for Java,门面,只提供 API,不干活。
-Logback:Logback 是 SLF4J 的原生实现,Spring Boot默认使用它。
-Log4j2:Apache 的高性能实现,需排除默认 Logback 后手动引入。
-Spring Boot Logging:Spring Boot 对 Logback 的自动配置封装,读 `application.yml` 和 `logback-spring.xml`。
2.3 组件关系类图
2.4 Spring 本身如何使用日志
SpringFramework 内部也使用SLF4J 打日志。启动时我们会看到大量`org.springframework.*`的INFO日志—— 那就是框架在报告【我正在初始化Bean、扫描组件……】。
依赖链(SpringBoot Web项目):
2.5 常见错误
>用大白话总结:我们写代码时只认 SLF4J 这个「国标插座」;Spring Boot 帮你默认接好了 Logback 这台「电器」。想换 Log4j2?换插座后面的机器就行,代码一行不改。
三、SpringBoot 默认日志机制
3.1 默认实现与级别
-实现:Logback
-默认级别: root=INFO (INFO/WARN/ERROR会输出,DEBUG/ TRACE不会)
-默认输出:控制台(彩色)
3.2 默认日志格式拆解
启动Spring Boot后,典型一行日志:
3.3 用application.yml配置
示例项目【application.yml】
spring: application: name: spring-logging-demo profiles: active: dev server: port: 8080 logging: level: root: info com.example.loggingdemo: debug file: name: logs/spring-logging-demo.log logback: rollingpolicy: max-file-size: 10MB max-history: 14 total-size-cap: 500MB file-name-pattern: logs/archive/spring-logging-demo.%d{yyyy-MM-dd}.%i.log等价的`application.properties`:
logging.level.root=info logging.level.com.example.loggingdemo=debug logging.file.name=logs/spring-logging-demo.log logging.logback.rollingpolicy.max-file-size=10MB logging.logback.rollingpolicy.max-history=14包级别会覆盖 root:`com.example.loggingdemo=debug` 只对这个包生效,其他包仍按 `root: info`。
3.4 多环境不同级别
【`application-dev.yml`】开发环境更啰嗦;
logging: level: root: debug com.example.loggingdemo: trace org.springframework.web: debug【`application-prod.yml`】生产环境只留关键信息。
logging: level: root: warn com.example.loggingdemo: info org.springframework: warn启动指定Profile:
mvn spring-boot:run -Dspring-boot.run.profiles=prod用大白话总结:不配任何东西也能跑,因为Spring Boot 已经帮我们接好了Logback;想细调,改
`application.yml`就行,不用碰XML。
四、如何在Java代码中正确打印日志
4.1 推荐的Logger 定义方式
package org.example.loggingdemo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.RestController; @RestController public class OrderController { // 每个类一个static final Logger,用当前类做名称 private static final Logger log = LoggerFactory.getLogger(OrderController.class); }4.2 日志级别的【阶梯】
4.3 参数化日志vs 字符串拼接
@Test void test2(long orderId){ //不好:即使DEBUG 关闭,字符串拼接仍会执行 log.debug("orderId="+orderId +",cost="+calculateCost(orderId)); // 好:级别不够时,calculateCost不会被调用 log.debug("orderId={},cost={}"+orderId +",cost="+calculateCost(orderId)); }4.4 异常日志:保留堆栈
@Test void test2(long orderId){ try { riskOperation(); }catch (Exception e){ // 丢失堆栈 log.error("操作失败:"+e.getMessage()); // 把异常当普通字符串参数 log.error("操作失败:{}",e); // 正确:Throwable 放最后一个参数 log.error("操作失败,orderId={}",orderId,e); }示例项目中的全局异常处理:
@ExceptionHandler(Exception.class) public ResponseEntity<Map<String , Object>> handleUnexpected(Exception ex){ log.error("系统异常",ex);//完整堆栈 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code",500,"message","internal server error")); }4.5 一次HTTP 请求的日志对象图
以 `POST /api/orders` 为例:
用大白话总结:Logger 按类建一个就行;业务关键节点用 INFO,调试用 DEBUG;打异常一定要把 `e` 传进去,别只打 `getMessage()`。
五、Spring Boot 日志配置实战
5.1 示例项目结构
Java代码
控制器类
package org.example.loggingdemo.controller; import org.example.loggingdemo.dto.response.OrderResponse; import org.example.loggingdemo.dto.response.request.CreateOrderRequest; import org.example.loggingdemo.service.OrderService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/orders") public class OrderController { // 每个类一个static final Logger,用当前类做名称 private static final Logger log = LoggerFactory.getLogger(OrderController.class); @Autowired private OrderService orderService; @PostMapping("/create") public OrderResponse create(@RequestBody CreateOrderRequest request){ log.info("收到常见订单请求,productId={},quantity={}",request.productId(),request.quantity()); return orderService.create(request); } @GetMapping("/{id}") public OrderResponse getById(@PathVariable Long id){ log.debug("查询订单,orderId={}",id); return orderService.getById(id); } }DTO类
package org.example.loggingdemo.dto.response.request; public record CreateOrderRequest(Long productId,Integer quantity) { }package org.example.loggingdemo.dto.response; public record OrderResponse(Long id, Long productId,Integer quantity,String status) { }异常类
package org.example.loggingdemo.exception; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.Map; @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<Map<String,Object>> handleNotFound(OrderNotFoundException ex){ log.warn("业务异常:{}",ex .getCause()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code",404,"message","internal server error")); } @ExceptionHandler(Exception.class) public ResponseEntity<Map<String,Object>> handleUnexpected(Exception e){ // 正确做法:把 Throwable 作为最后一个参数,保留完整堆栈 log.error("系统异常",e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of("code",500,"message","internal server error")); } }package org.example.loggingdemo.exception; public class OrderNotFoundException extends RuntimeException { public OrderNotFoundException(Long orderId) { super("Order not found"+orderId); } }filter类
package org.example.loggingdemo.filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.UUID; @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class TraceIdFilter extends OncePerRequestFilter { private static final String TRACE_ID_KEY = "traceId"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isBlank()){ traceId = UUID.randomUUID().toString().replace("-","").substring(0,16); } MDC.put(TRACE_ID_KEY,traceId); response.setHeader("X-Trace-Id",traceId); try{ filterChain.doFilter(request,response); }finally { MDC.remove(TRACE_ID_KEY); } } }Service类
package org.example.loggingdemo.service; import org.example.loggingdemo.dto.response.OrderResponse; import org.example.loggingdemo.dto.response.request.CreateOrderRequest; public interface OrderService { OrderResponse getById(Long id); OrderResponse create(CreateOrderRequest request); }package org.example.loggingdemo.service.impl; import lombok.extern.slf4j.Slf4j; import org.example.loggingdemo.dto.response.OrderResponse; import org.example.loggingdemo.dto.response.request.CreateOrderRequest; import org.example.loggingdemo.exception.OrderNotFoundException; import org.example.loggingdemo.service.OrderService; import org.springframework.stereotype.Service; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @Service @Slf4j public class OrderServiceImpl implements OrderService { private final AtomicLong idGenerator = new AtomicLong(1000); private final Map<Long ,OrderResponse> store = new ConcurrentHashMap<>(); @Override public OrderResponse getById(Long id) { OrderResponse order = store.get(id); if (order == null){ log.warn("订单不存在,orderId={}",id); throw new OrderNotFoundException(id); } return order; } @Override public OrderResponse create(CreateOrderRequest request) { if (request.quantity()<=0){ log.warn("订单数量非法,productId={},quantity={}",request.productId(),request.quantity()); throw new IllegalArgumentException("quantity must be positive"); } Long orderId =idGenerator.incrementAndGet(); OrderResponse order = new OrderResponse(orderId,request.productId(),request.quantity(),"CREATED"); store.put(orderId,order); log.info("订单创建成功,orderId={}",orderId); return order; } }启动类
package org.example.loggingdemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class LoggingdemoApplication { public static void main(String[] args) { SpringApplication.run(LoggingdemoApplication.class, args); } }测试:
http://localhost:8081/orders/create5.2 logback-spring.xml 关键配置
> 文件名必须是 `logback-spring.xml`(带 `-spring`),才能使用 `<springProfile>` 等 Spring 扩展。
<?xml version="1.0" encoding="UTF-8"?> <configuration scan="true" scanPeriod="30 seconds"> <springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="spring-logging-demo"/> <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-NA}] %-5level %logger{36} - %msg%n"/> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>${LOG_PATTERN}</pattern> <charset>UTF-8</charset> </encoder> </appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/${APP_NAME}.log</file> <encoder> <pattern>${LOG_PATTERN}</pattern> <charset>UTF-8</charset> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>10MB</maxFileSize> <maxHistory>14</maxHistory> <totalSizeCap>500MB</totalSizeCap> </rollingPolicy> </appender> <springProfile name="dev"> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root> <logger name="com.example.loggingdemo" level="DEBUG"/> </springProfile> <springProfile name="prod"> <root level="WARN"> <appender-ref ref="FILE"/> </root> <logger name="com.example.loggingdemo" level="INFO"/> </springProfile> <springProfile name="!prod"> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root> </springProfile> </configuration>5.4 配置优先级(由高到低)
>用大白话总结:简单需求用 `application.yml`;要分环境、自定义格式、异步写入,上 `logback-spring.xml`。
六、常见问题排查
6.1日志完全没有输出
6.2 级别配置不生效
1.Profile 没激活:`application-dev.yml` 里的配置只在 `dev` Profile 下生效。
2.logback-spring.xml 硬编码了 level:XML 中 `<root level="WARN">` 优先级高于 yml(部分场景)。
3.Logger 名称不对:应使用完整类名或包名,不是 Spring Bean 名称。
6.3 同一条日志重复打印2~3次
原因:Classpath 中存在多个 SLF4J 绑定(如同时有 `logback-classic` 和 `slf4j-simple`)。
解决:排除多余依赖:
<dependency> <groupId>some.library</groupId> <artifactId>some-artifact</artifactId> <exclusions> <exclusion> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> </exclusion> </exclusions> </dependency>6.4 其他高频坑
用大白话总结
:日志不出来,先查【级别】和【包名】;重复打印,查依赖冲突;生产环境,永远假设日志会被别人看到——别写秘密。
七、日志使用最佳实践
7.1 什么该记、什么不该记
应该记
- 请求入口:关键参数(脱敏后)、TraceId
- 业务里程碑:订单创建、支付回调、状态变更
- 异常:完整堆栈 + 业务上下文(orderId、userId)
-外部调用:下游HTTP/MQ 失败原因与耗时
不应该记
- 密码、Token、身份证号、银行卡号
- 超大JSON Body(截断或哈希)
- 无意义的【进入方法/离开方法】(除非 TRACE调式)
7.2 生产环境要点
1.按环境分级:见示例 `application-dev.yml` / `application-prod.yml`。
2.文件滚动必备:`max-history` + `total-size-cap`。
3. 敏感字段脱敏:手机号 `138****5678`。
4.统一格式:便于日志平台解析;MDC 带 traceId。
5.异步日志(高并发):Logback `AsyncAppender`,减少 I/O 阻塞业务线程——进阶场景再深入。
>用大白话总结:日志是写给自己和运维同事看的「案情记录」——够查案、不含机密、别刷屏。
八、MDC与链路追踪简介
8.1 MDC是什么
MDC(Mapped Diagnostic Context)是 SLF4J 提供的线程级字典:你在 Filter 里 `MDC.put("traceId", id)`,同一线程后续所有 `log.info()` 都能自动带上这个值。
8.2 它解决什么问题
微服务/多线程下,100条日志交织在一起,无法区分哪条属于同一请求。MDC给每个请求贴标签,日志里统一输出【traceId】。
8.3 示例:TraceIdFilter
package org.example.loggingdemo.filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.UUID; @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class TraceIdFilter extends OncePerRequestFilter { private static final String TRACE_ID_KEY = "traceId"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isBlank()){ traceId = UUID.randomUUID().toString().replace("-","").substring(0,16); } MDC.put(TRACE_ID_KEY,traceId); response.setHeader("X-Trace-Id",traceId); try{ filterChain.doFilter(request,response); }finally { MDC.remove(TRACE_ID_KEY); } } }Logback 格式中加入 `%X{traceId}`:
<pattern>%d{HH:mm:ss.SSS} [%X{traceId:-NA}] %-5level %logger{36} - %msg%n</pattern>8.4 进阶概念(点到为止)
>用大白话总结:MDC 像给每个请求发一张「工牌号」,之后这个请求经过的所有代码打日志都会自动带上工牌,排障时一搜就齐。
总结
Spring Boot 日志体系并不神秘:
1.代码层只依赖 SLF4J 门面;
2.默认实现是 Logback,由 `spring-boot-starter-logging` 自动引入;
3.简单配置用 `application.yml`,复杂配置用 `logback-spring.xml`;
4.正确使用参数化日志、合理选级别、异常带堆栈;
5.生产环境必须考虑文件滚动、级别分环境、敏感信息脱敏;
6.MDC是请求级链路追踪的入门钥匙。
把本文的spring-logging-demo跑一遍,观察控制台、日志文件和 traceId,比背十遍概念都管用。
常见面试题 / 读者自测题
1. SLF4J 和 Logback 分别是什么角色?为什么要分门面和实现?
2. Spring Boot 默认日志级别是什么?如何只把 `com.example` 包设为 DEBUG?
3. `log.debug("id=" + id)` 和 `log.debug("id={}", id)` 有何性能差异?
4. 记录异常时,`log.error("msg", e)` 和 `log.error("msg: " + e.getMessage())` 差别在哪?
5. 日志重复打印 3 次,最可能的原因是什么?
6. MDC 中的值存在哪里?为什么必须在 `finally` 里 `MDC.remove()`?
7. `logback.xml` 和 `logback-spring.xml` 有什么区别?
1. SLF4J 是 API 门面;Logback 是具体实现。分离后换实现不改业务代码。
2. 默认 root=INFO;`logging.level.com.example=debug`。
3. 前者无论级别是否开启都会拼接字符串;后者级别不够时不格式化。
4. 前者输出完整堆栈;后者只有一行 message,丢失调用链。
5. Classpath 存在多个 SLF4J 绑定。
6. ThreadLocal Map;线程池复用时不清除会串数据。
7. `-spring` 版支持 Spring Profile 等扩展,应优先使用。