Actuator 可以告诉平台应用当前是否健康,但健康状态本身无法解释一次请求为什么失败。生产问题通常需要回答:请求什么时候到达、经过哪个实例、使用了什么业务条件、在哪一层失败,以及哪些日志属于同一次调用。
本章先使用 Spring Boot 默认日志体系记录分级事件,再为 HTTP 请求增加 requestId,最后启用 Spring Boot 4.1 原生结构化日志。这里的请求标识只关联当前服务内的一次请求;跨服务 Trace 会在下一篇可观测性中继续讲解。
1. 日志链路
使用 Spring Boot Starter 时,应用通常通过 SLF4J API 写日志,由默认 Logback 实现决定格式和输出位置:
1业务代码2-> SLF4J API3-> Logback 实现4-> 控制台或文件5-> 日志采集系统
业务类只面向 SLF4J,不直接依赖 Logback 类:
1import org.slf4j.Logger;2import org.slf4j.LoggerFactory;34private static final Logger log = LoggerFactory.getLogger(HealthService.class);
LoggerFactory.getLogger(HealthService.class) 使用类名作为 Logger 名称。生产环境可以按包或类调整级别,而不修改源码。
不要使用 System.out.println() 代替应用日志。标准输出本身可以继续作为最终输出目标,但日志框架还能提供级别、时间、线程、Logger 名称、异常调用栈和结构化字段。
容器环境通常让应用写标准输出,由平台负责采集、保留和轮转。直接把日志长期写进容器内部文件,容器替换后文件可能一起消失。
2. 日志级别
常用级别从严重到详细依次是:
| 级别 | 当前项目中的用途 |
|---|---|
ERROR | 请求无法完成,并且需要立即关注的未预期故障 |
WARN | 已降级或出现异常条件,但应用仍能继续工作 |
INFO | 启动、停止、重要业务状态变化等低频事件 |
DEBUG | 本地排查所需的业务路径与参数 |
TRACE | 框架或协议级的极细粒度诊断 |
在 application.yaml 中按包设置级别:
1logging:2level:3root: INFO4cn.usehook.spring: DEBUG
生产 Profile 通常保持较保守的级别:
1logging:2level:3root: INFO4cn.usehook.spring: INFO
级别是最低输出阈值。Logger 配置为 INFO 时会输出 INFO、WARN 和 ERROR,不会输出 DEBUG 与 TRACE。
不要长期把 org.hibernate.orm.jdbc.bind 等参数日志设为 TRACE。它可能包含业务数据,日志量也会随着 SQL 调用快速增长。局部排查完成后应恢复级别。
3. 参数与上下文
记录心跳检查完成事件时使用参数化日志:
1log.info(2"Health check completed: service={}, status={}",3savedStatus.service(),4savedStatus.status()5);
{} 是 SLF4J 占位符。不要先使用字符串拼接:
1log.debug("Loaded status: " + expensiveDescription(savedStatus));
拼接会在调用 Logger 前计算消息,即使 DEBUG 被关闭也可能产生开销。参数化日志可以在级别未启用时避免不必要的字符串构造。
Spring Boot 4.1 的结构化日志还能读取 SLF4J Fluent API 中的键值:
1log.atInfo()2.addKeyValue("healthService", savedStatus.service())3.addKeyValue("healthStatus", savedStatus.status())4.log("Health check completed");
消息用于人阅读,healthService 和 healthStatus 则成为可检索字段。这里没有使用 ECS 已占用的顶层 service 名称,避免自定义字符串与框架生成的服务对象冲突。字段名称应保持稳定,不要同一含义一会儿写 healthService,一会儿写 serviceName。
记录异常时把异常对象作为最后一个参数:
1log.error("Unexpected health check failure", exception);
只记录 exception.getMessage() 会丢失异常类型、调用栈和原因链。已知业务异常如果已经转换为预期 400 或 404,也不必全部按 ERROR 重复记录。
4. 请求标识
一次 HTTP 请求可能经过 Controller、Service、Repository 和异常处理器。可以使用 Servlet Filter 在进入 Spring MVC 前建立请求标识,并通过 MDC 让同一线程上的日志自动携带该字段:
01package cn.usehook.spring.web;0203import jakarta.servlet.FilterChain;04import jakarta.servlet.ServletException;05import jakarta.servlet.http.HttpServletRequest;06import jakarta.servlet.http.HttpServletResponse;07import java.io.IOException;08import java.util.UUID;09import org.slf4j.MDC;10import org.springframework.stereotype.Component;11import org.springframework.web.filter.OncePerRequestFilter;1213@Component14public class RequestIdFilter extends OncePerRequestFilter {1516private static final String HEADER = "X-Request-Id";1718@Override19protected void doFilterInternal(20HttpServletRequest request,21HttpServletResponse response,22FilterChain filterChain23) throws ServletException, IOException {24String requestId = validRequestId(request.getHeader(HEADER))25? request.getHeader(HEADER)26: UUID.randomUUID().toString();2728response.setHeader(HEADER, requestId);2930try (MDC.MDCCloseable ignored = MDC.putCloseable("requestId", requestId)) {31filterChain.doFilter(request, response);32}33}3435private boolean validRequestId(String requestId) {36return requestId != null37&& requestId.matches("[A-Za-z0-9-]{1,64}");38}39}
客户端提供符合规则的 X-Request-Id 时继续使用,否则生成 UUID。限制字符和长度可以避免任意请求头污染日志格式或制造超大字段。响应头把最终采用的标识返回客户端,报错时可以据此检索服务端日志。
MDC.putCloseable() 在进入调用链前设置值,并在 try 结束时清理。Servlet 线程会被线程池复用,如果只调用 MDC.put() 却不清理,后续请求可能错误继承上一个请求的标识。
MDC 通常依赖当前线程。把工作提交到另一个线程、异步执行器或消息队列后,值不会自动正确传播,需要使用框架提供的上下文传播机制。不要手动把一个可变 MDC Map 长期共享给多个线程。
5. 结构化输出
Spring Boot 4.1 原生支持 ECS、GELF 和 Logstash JSON 格式。容器生产环境可以启用 ECS 控制台日志:
1logging:2structured:3format:4console: ecs
启用后,每条日志占一行 JSON,时间、级别、Logger、线程、消息和 MDC 字段都有固定结构:
01{02"@timestamp": "2026-09-05T10:30:00.000Z",03"log": {04"level": "INFO"05},06"service": {07"name": "spring"08},09"message": "Health check completed",10"requestId": "9ae8d7be-862f-4c58-8838-945baf104b64"11}
实际日志仍然是一行紧凑 JSON,这里换行只是为了阅读。spring.application.name 会作为默认服务名称进入 ECS 字段,MDC 中的 requestId 也会成为 JSON 属性。
开发环境继续使用默认可读文本即可,不必为了格式一致牺牲本地调试体验。结构化输出的目标是让采集平台稳定解析,不是让业务代码手写 JSON 字符串。
6. 生产边界
日志内容要在可诊断性与数据安全之间取得边界:
- 不记录密码、访问令牌、数据库连接串中的凭据和完整认证头;
- 不默认记录完整请求体,DTO 中可能出现个人或敏感数据;
- 对用户输入做长度控制,不让单条日志无限膨胀;
- 区分业务失败与系统故障,避免所有 4xx 都成为
ERROR; - 保持事件名称和结构化字段稳定,便于仪表盘与告警查询;
- 明确保留期限、访问权限与脱敏规则。
使用同一个请求 ID 调用接口并检查响应头:
1curl -i \2-H 'X-Request-Id: tutorial-001' \3http://localhost:8080/api/v1/health
如果代码在同一请求线程中记录日志,结构化结果应包含 requestId=tutorial-001。它能串起当前服务内的事件,却没有描述请求调用其他服务后的父子关系和耗时跨度。
下一篇进入指标与可观测性:使用 Micrometer 记录可聚合的次数与耗时,再使用 Trace ID 连接跨组件调用。日志回答「发生了什么」,指标回答「整体趋势怎样」,链路追踪回答「一次调用经过了哪里」。