从 `println` 到生产级日志:Spring Boot 日志体系完全指南

> **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 示例项目,边看边跑。


提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


导语

凌晨两点,生产环境接口超时。你打开服务器,面对满屏 `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()`

java 复制代码
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】

java 复制代码
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`:

java 复制代码
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`】开发环境更啰嗦;

java 复制代码
logging:
  level:
    root: debug
    com.example.loggingdemo: trace
    org.springframework.web: debug

【`application-prod.yml`】生产环境只留关键信息。

java 复制代码
logging:
  level:
    root: warn
    com.example.loggingdemo: info
    org.springframework: warn

启动指定Profile:

bash 复制代码
mvn spring-boot:run -Dspring-boot.run.profiles=prod

**用大白话总结:**不配任何东西也能跑,因为Spring Boot 已经帮我们接好了Logback;想细调,改

`application.yml`就行,不用碰XML。

四、如何在Java代码中正确打印日志

4.1 推荐的Logger 定义方式

java 复制代码
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 字符串拼接

java 复制代码
    @Test
    void test2(long orderId){
        //不好:即使DEBUG 关闭,字符串拼接仍会执行
        log.debug("orderId="+orderId +",cost="+calculateCost(orderId));
//        好:级别不够时,calculateCost不会被调用
        log.debug("orderId={},cost={}"+orderId +",cost="+calculateCost(orderId));
    }

4.4 异常日志:保留堆栈

java 复制代码
@Test
    void test2(long orderId){
        try {
            riskOperation();
        }catch (Exception e){
//            丢失堆栈
            log.error("操作失败:"+e.getMessage());
//            把异常当普通字符串参数
            log.error("操作失败:{}",e);
//            正确:Throwable 放最后一个参数
            log.error("操作失败,orderId={}",orderId,e);
        }

示例项目中的全局异常处理:

java 复制代码
    @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代码

控制器类

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类

java 复制代码
package org.example.loggingdemo.dto.response.request;

public record CreateOrderRequest(Long productId,Integer quantity) {
}
java 复制代码
package org.example.loggingdemo.dto.response;

public record OrderResponse(Long id, Long productId,Integer quantity,String status) {
}

异常类

java 复制代码
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"));
    }
}
java 复制代码
package org.example.loggingdemo.exception;

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long orderId) {
        super("Order not found"+orderId);
    }
}

filter类

java 复制代码
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类

java 复制代码
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);
}
java 复制代码
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;
    }
}

启动类

java 复制代码
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);
    }

}

测试:

bash 复制代码
http://localhost:8081/orders/create

5.2 logback-spring.xml 关键配置

> 文件名必须是 `logback-spring.xml`(带 `-spring`),才能使用 `<springProfile>` 等 Spring 扩展。

XML 复制代码
<?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(部分场景)。

  1. Logger 名称不对 :应使用完整类名或包名,不是 Spring Bean 名称。

6.3 同一条日志重复打印2~3次

原因:Classpath 中存在多个 SLF4J 绑定(如同时有 `logback-classic` 和 `slf4j-simple`)。

解决:排除多余依赖:

XML 复制代码
<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`。

  1. 文件滚动必备 :`max-history` + `total-size-cap`。

3**. 敏感字段脱敏** :手机号 `138****5678`。

4.统一格式 :便于日志平台解析;MDC 带 traceId。

  1. 异步日志(高并发):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

java 复制代码
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}`:

XML 复制代码
<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` 自动引入;

  1. 简单配置 用 `application.yml`,复杂配置 用 `logback-spring.xml`;

4.正确使用 参数化日志、合理选级别、异常带堆栈;

5.生产环境 必须考虑文件滚动、级别分环境、敏感信息脱敏;

  1. 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` 有什么区别?

  8. SLF4J 是 API 门面;Logback 是具体实现。分离后换实现不改业务代码。

  9. 默认 root=INFO;`logging.level.com.example=debug`。

  10. 前者无论级别是否开启都会拼接字符串;后者级别不够时不格式化。

  11. 前者输出完整堆栈;后者只有一行 message,丢失调用链。

  12. Classpath 存在多个 SLF4J 绑定。

  13. ThreadLocal Map;线程池复用时不清除会串数据。

  14. `-spring` 版支持 Spring Profile 等扩展,应优先使用。

相关推荐
脉动数据行情1 小时前
Java OkHttp 对接国际期货批量行情 SpringBoot 定时落库实践
java·spring boot·okhttp
星夜离尘561 小时前
MATLAB 定点量化误差实验 半个 LSB 的界限为什么挡不住削顶失真
开发语言·matlab
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 百度人脸识别:介绍
java·开发语言·交友
障碍的枫子1 小时前
我的第一个Agent
开发语言·python·langchain
打工仔折腾 AI1 小时前
260+异构同步链路实战:如何用语义化自动翻译破解CDC DDL同步痛点
人工智能·后端·python·自然语言处理·性能优化·langchain·机器翻译
Pniubi1 小时前
区分agent中长期记忆和偏好记忆
开发语言·ai·智能体
松就是我902982 小时前
Agent工具调用实现原理
后端
xian_wwq2 小时前
【学习笔记】Milvus 是什么?为什么 RAG 系统经常使用它?-14
笔记·学习·milvus
一枕槐安4872 小时前
大模型应用后端设计:上下文管理、缓存、降级策略的工程方案总结
java·spring·缓存