跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

Actuator 可以告诉平台应用当前是否健康,但健康状态本身无法解释一次请求为什么失败。生产问题通常需要回答:请求什么时候到达、经过哪个实例、使用了什么业务条件、在哪一层失败,以及哪些日志属于同一次调用。

本章先使用 Spring Boot 默认日志体系记录分级事件,再为 HTTP 请求增加 requestId,最后启用 Spring Boot 4.1 原生结构化日志。这里的请求标识只关联当前服务内的一次请求;跨服务 Trace 会在下一篇可观测性中继续讲解。

1. 日志链路

使用 Spring Boot Starter 时,应用通常通过 SLF4J API 写日志,由默认 Logback 实现决定格式和输出位置:

logging-stack.txt
1
业务代码
2
-> SLF4J API
3
-> Logback 实现
4
-> 控制台或文件
5
-> 日志采集系统

业务类只面向 SLF4J,不直接依赖 Logback 类:

src/main/java/cn/usehook/spring/health/HealthService.java
1
import org.slf4j.Logger;
2
import org.slf4j.LoggerFactory;
3
4
private 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 中按包设置级别:

src/main/resources/application.yaml
1
logging:
2
level:
3
root: INFO
4
cn.usehook.spring: DEBUG

生产 Profile 通常保持较保守的级别:

src/main/resources/application-prod.yaml
1
logging:
2
level:
3
root: INFO
4
cn.usehook.spring: INFO

级别是最低输出阈值。Logger 配置为 INFO 时会输出 INFO、WARN 和 ERROR,不会输出 DEBUG 与 TRACE。

不要长期把 org.hibernate.orm.jdbc.bind 等参数日志设为 TRACE。它可能包含业务数据,日志量也会随着 SQL 调用快速增长。局部排查完成后应恢复级别。

3. 参数与上下文

记录心跳检查完成事件时使用参数化日志:

HealthService.java
1
log.info(
2
"Health check completed: service={}, status={}",
3
savedStatus.service(),
4
savedStatus.status()
5
);

{} 是 SLF4J 占位符。不要先使用字符串拼接:

eager-message.java
1
log.debug("Loaded status: " + expensiveDescription(savedStatus));

拼接会在调用 Logger 前计算消息,即使 DEBUG 被关闭也可能产生开销。参数化日志可以在级别未启用时避免不必要的字符串构造。

Spring Boot 4.1 的结构化日志还能读取 SLF4J Fluent API 中的键值:

structured-event.java
1
log.atInfo()
2
.addKeyValue("healthService", savedStatus.service())
3
.addKeyValue("healthStatus", savedStatus.status())
4
.log("Health check completed");

消息用于人阅读,healthService 和 healthStatus 则成为可检索字段。这里没有使用 ECS 已占用的顶层 service 名称,避免自定义字符串与框架生成的服务对象冲突。字段名称应保持稳定,不要同一含义一会儿写 healthService,一会儿写 serviceName。

记录异常时把异常对象作为最后一个参数:

exception-log.java
1
log.error("Unexpected health check failure", exception);

只记录 exception.getMessage() 会丢失异常类型、调用栈和原因链。已知业务异常如果已经转换为预期 400 或 404,也不必全部按 ERROR 重复记录。

4. 请求标识

一次 HTTP 请求可能经过 Controller、Service、Repository 和异常处理器。可以使用 Servlet Filter 在进入 Spring MVC 前建立请求标识,并通过 MDC 让同一线程上的日志自动携带该字段:

src/main/java/cn/usehook/spring/web/RequestIdFilter.java
01
package cn.usehook.spring.web;
02
03
import jakarta.servlet.FilterChain;
04
import jakarta.servlet.ServletException;
05
import jakarta.servlet.http.HttpServletRequest;
06
import jakarta.servlet.http.HttpServletResponse;
07
import java.io.IOException;
08
import java.util.UUID;
09
import org.slf4j.MDC;
10
import org.springframework.stereotype.Component;
11
import org.springframework.web.filter.OncePerRequestFilter;
12
13
@Component
14
public class RequestIdFilter extends OncePerRequestFilter {
15
16
private static final String HEADER = "X-Request-Id";
17
18
@Override
19
protected void doFilterInternal(
20
HttpServletRequest request,
21
HttpServletResponse response,
22
FilterChain filterChain
23
) throws ServletException, IOException {
24
String requestId = validRequestId(request.getHeader(HEADER))
25
? request.getHeader(HEADER)
26
: UUID.randomUUID().toString();
27
28
response.setHeader(HEADER, requestId);
29
30
try (MDC.MDCCloseable ignored = MDC.putCloseable("requestId", requestId)) {
31
filterChain.doFilter(request, response);
32
}
33
}
34
35
private boolean validRequestId(String requestId) {
36
return requestId != null
37
&& 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 控制台日志:

src/main/resources/application-prod.yaml
1
logging:
2
structured:
3
format:
4
console: ecs

启用后,每条日志占一行 JSON,时间、级别、Logger、线程、消息和 MDC 字段都有固定结构:

application.log
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 调用接口并检查响应头:

terminal.bash
1
curl -i \
2
-H 'X-Request-Id: tutorial-001' \
3
http://localhost:8080/api/v1/health

如果代码在同一请求线程中记录日志,结构化结果应包含 requestId=tutorial-001。它能串起当前服务内的事件,却没有描述请求调用其他服务后的父子关系和耗时跨度。

下一篇进入指标与可观测性:使用 Micrometer 记录可聚合的次数与耗时,再使用 Trace ID 连接跨组件调用。日志回答「发生了什么」,指标回答「整体趋势怎样」,链路追踪回答「一次调用经过了哪里」。