在项目开发中,一个请求往往要经过多个层次的处理:
java
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id) {
log.info("查询订单, orderId={}", id); // Controller
return orderService.getById(id);
}
@Service
public class OrderService {
public Order getById(Long id) {
log.info("开始查询, id={}", id); // Service
return orderMapper.selectById(id);
}
}
当线上出现问题时,需要从日志中找到这个请求的完整执行链路。但如果日志中没有统一的标识,只能通过时间、orderId 等信息去匹配,效率很低。
更复杂的情况是,请求处理过程中会启动异步任务、使用线程池、调用下游服务,这些日志如何关联起来?
基本思路
在日志中引入统一的上下文信息,最关键的是 traceId(追踪 ID)。一个请求的所有日志都带上相同的 traceId,就能通过 grep 或日志系统快速定位问题。
SLF4J 提供了 MDC(Mapped Diagnostic Context)机制来解决这个问题:
java
MDC.put("traceId", "123456");
log.info("这条日志会自动带上 traceId");
MDC.clear();
配置 logback 输出格式:
xml
<pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
输出效果:
ini
12:34:56.789 [http-nio-8080-exec-1] [123456] INFO c.e.OrderController - 查询订单, orderId=1
12:34:56.890 [http-nio-8080-exec-1] [123456] INFO c.e.OrderService - 开始查询, id=1
但直接在每个方法里写 MDC.put/clear 不现实,需要统一处理。
设计上下文数据结构
先定义上下文要包含哪些信息:
java
@Getter
@Setter
public class LogContext {
private String traceId;
private String userId;
private String clientId;
private String uri;
public static LogContext create() {
LogContext context = new LogContext();
context.setTraceId(UUID.randomUUID().toString().replace("-", ""));
return context;
}
public static LogContext from(String traceId) {
LogContext context = new LogContext();
context.setTraceId(traceId);
return context;
}
}
用 ThreadLocal 存储上下文:
java
public class LogContextHolder {
private static final ThreadLocal<LogContext> CONTEXT = new ThreadLocal<>();
public static void setContext(LogContext context) {
CONTEXT.set(context);
updateMDC(context);
}
public static LogContext getContext() {
return CONTEXT.get();
}
public static String getTraceId() {
LogContext context = CONTEXT.get();
return context != null ? context.getTraceId() : null;
}
public static void clear() {
CONTEXT.remove();
MDC.clear();
}
private static void updateMDC(LogContext context) {
if (context != null) {
MDC.put("traceId", context.getTraceId());
MDC.put("userId", context.getUserId());
MDC.put("clientId", context.getClientId());
}
}
}
Web 请求统一处理
通过 Filter 在请求入口生成 traceId,请求结束时清理:
java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class LogContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
try {
// 从请求头获取 traceId,没有则生成新的
String traceId = req.getHeader("traceId");
LogContext context = StringUtils.hasText(traceId)
? LogContext.from(traceId)
: LogContext.create();
// 设置其他上下文信息
context.setUri(req.getRequestURI());
String userId = getUserIdFromRequest(req);
if (userId != null) {
context.setUserId(userId);
}
LogContextHolder.setContext(context);
// 将 traceId 写入响应头,方便前端追踪
res.setHeader("traceId", context.getTraceId());
chain.doFilter(request, response);
} finally {
LogContextHolder.clear();
}
}
private String getUserIdFromRequest(HttpServletRequest request) {
// 从 token 或 session 中获取 userId
// ...
return null;
}
}
这样,Controller、Service、Repository 层的所有日志都会自动带上 traceId,无需手动处理。
跨线程场景
普通的 ThreadLocal 在子线程中无法获取父线程的值,需要特殊处理。
更多ThreadLocal父子线程传值方案可参考:mp.weixin.qq.com/s/Aw9UPkY6M...
线程池装饰器
使用阿里巴巴的 TransmittableThreadLocal(TTL):
xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.3</version>
</dependency>
改造 LogContextHolder:
java
public class LogContextHolder {
// 改用 TransmittableThreadLocal
private static final TransmittableThreadLocal<LogContext> CONTEXT =
new TransmittableThreadLocal<>();
// 其他方法不变
}
创建 TTL 装饰的线程池:
java
@Configuration
public class ExecutorConfig {
@Bean("ttlExecutor")
public Executor ttlExecutor() {
TtlExecutors.getTtlExecutor(new ThreadPoolExecutor(
10, 20, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadFactoryBuilder().setNameFormat("ttl-pool-%d").build()
));
}
}
使用装饰后的线程池:
java
@Service
public class OrderService {
@Autowired
@Qualifier("ttlExecutor")
private Executor ttlExecutor;
public void processAsync(Long orderId) {
ttlExecutor.execute(() -> {
// 这里的日志会自动带上父线程的 traceId
log.info("异步处理订单, orderId={}", orderId);
});
}
}
CompletableFuture
CompletableFuture 需要用 TTL 包装:
java
public CompletableFuture<Order> getOrderAsync(Long id) {
return TtlCompletable.get(() ->
CompletableFuture.supplyAsync(() -> {
log.info("异步查询订单, id={}", id); // 有 traceId
return orderMapper.selectById(id);
}, ttlExecutor)
);
}
@Async 异步任务
Spring 的 @Async 需要配置线程池装饰器:
java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return TtlExecutors.getTtlExecutor(new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100)
));
}
}
使用:
java
@Service
public class OrderService {
@Async
public void sendNotification(Long orderId) {
// 这里的日志会自动带上主线程的 traceId
log.info("发送订单通知, orderId={}", orderId);
}
}
HTTP 调用传递 traceId
调用下游服务时,需要把 traceId 传递过去,形成完整的调用链。
RestTemplate
java
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
RestTemplate template = new RestTemplate();
template.setInterceptors(Collections.singletonList(new TraceIdInterceptor()));
return template;
}
}
public class TraceIdInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) {
String traceId = LogContextHolder.getTraceId();
if (traceId != null) {
request.getHeaders().set("traceId", traceId);
}
return execution.execute(request, body);
}
}
WebClient
java
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder()
.filter((request, next) -> {
String traceId = LogContextHolder.getTraceId();
if (traceId != null) {
request.headers().set("traceId", traceId);
}
return next.exchange(request);
})
.build();
}
}
Feign
java
@Configuration
public class FeignConfig {
@Bean
public RequestInterceptor traceIdInterceptor() {
return template -> {
String traceId = LogContextHolder.getTraceId();
if (traceId != null) {
template.header("traceId", traceId);
}
};
}
}
完整示例
Controller:
java
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
log.info("接收查询请求, orderId={}", id); // 有 traceId
return orderService.getById(id);
}
@PostMapping
public Order create(@RequestBody OrderCreateDTO dto) {
log.info("接收创建请求, dto={}", dto); // 有 traceId
return orderService.create(dto);
}
}
Service:
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
@Qualifier("ttlExecutor")
private Executor executor;
public Order getById(Long id) {
log.info("开始查询, id={}", id); // 有 traceId
Order order = orderMapper.selectById(id);
// 异步记录日志
executor.execute(() -> {
log.info("记录查询日志, orderId={}", order.getId()); // 有 traceId
});
return order;
}
}
日志输出:
ini
2025-01-08 12:34:56.789 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.OrderController - 接收查询请求, orderId=1
2025-01-08 12:34:56.890 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO c.e.OrderService - 开始查询, id=1
2025-01-08 12:34:56.990 [ttl-pool-1] [a1b2c3d4e5f6] INFO c.e.OrderService - 记录查询日志, orderId=1
三条日志的 traceId 都是 a1b2c3d4e5f6,可以快速关联。
注意事项
1. 内存泄漏
ThreadLocal 必须在请求结束时清理,否则可能会导致内存泄漏。Filter 中的 finally 块很重要:
java
try {
// 处理请求
} finally {
LogContextHolder.clear(); // 必须清理
}
2. 线程池命名
给线程池设置有意义的名字,方便从日志中识别:
java
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build()
3. MDC 的性能
MDC 读写有少量开销,但对性能影响很小。如果是超高并发场景,可以考虑按需开启。
4. traceId 生成策略
几种常见方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 简单、无需协调 | 较长、无序 |
| 雪花算法 | 性能高、趋势递增 | 需要机器 ID 配置 |
| Redis incr | 全局唯一、递增 | 依赖 Redis |
| 请求入口自增 | 简单 | 单机内唯一 |
大多数场景用 UUID 就够了。
5. 分布式追踪
如果是微服务架构,可以考虑接入 SkyWalking、Zipkin 等分布式追踪系统,它们提供更强大的链路追踪能力,包括调用关系图、耗时分析等。
但接入成本较高,中小项目用 MDC + traceId 通常就够了。
总结
统一日志上下文的核心是:
- 1. 入口生成:Filter/Interceptor 拦截请求,生成 traceId
- 2. 线程传递:用 TTL 解决跨线程问题
- 3. 服务传递:HTTP 调用通过请求头传递 traceId
- 4. 出口清理:请求结束时清理 ThreadLocal
这套方案不依赖第三方框架,实现简单,适合大多数单体应用或中小型微服务项目。
如果项目规模较大,对链路追踪要求高,可以考虑接入专业的 APM 系统。