SpringBoot實(shí)現(xiàn)統(tǒng)一日志上下文的實(shí)戰(zhàn)指南
在項(xiàng)目開發(fā)中,一個請求往往要經(jīng)過多個層次的處理:
@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);
}
}
當(dāng)線上出現(xiàn)問題時(shí),需要從日志中找到這個請求的完整執(zhí)行鏈路。但如果日志中沒有統(tǒng)一的標(biāo)識,只能通過時(shí)間、orderId 等信息去匹配,效率很低。
更復(fù)雜的情況是,請求處理過程中會啟動異步任務(wù)、使用線程池、調(diào)用下游服務(wù),這些日志如何關(guān)聯(lián)起來?
基本思路
在日志中引入統(tǒng)一的上下文信息,最關(guān)鍵的是 traceId(追蹤 ID)。一個請求的所有日志都帶上相同的 traceId,就能通過 grep 或日志系統(tǒng)快速定位問題。
SLF4J 提供了 MDC(Mapped Diagnostic Context)機(jī)制來解決這個問題:
MDC.put("traceId", "123456");
log.info("這條日志會自動帶上 traceId");
MDC.clear();
配置 logback 輸出格式:
<pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
輸出效果:
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 不現(xiàn)實(shí),需要統(tǒng)一處理。
設(shè)計(jì)上下文數(shù)據(jù)結(jié)構(gòu)
先定義上下文要包含哪些信息:
@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 存儲上下文:
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 請求統(tǒng)一處理
通過 Filter 在請求入口生成 traceId,請求結(jié)束時(shí)清理:
@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();
// 設(shè)置其他上下文信息
context.setUri(req.getRequestURI());
String userId = getUserIdFromRequest(req);
if (userId != null) {
context.setUserId(userId);
}
LogContextHolder.setContext(context);
// 將 traceId 寫入響應(yīng)頭,方便前端追蹤
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父子線程傳值方案可參考:SpringBoot實(shí)現(xiàn)ThreadLocal父子線程傳值的幾種方式
線程池裝飾器
使用阿里巴巴的 TransmittableThreadLocal(TTL):
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.3</version>
</dependency>
改造 LogContextHolder:
public class LogContextHolder {
// 改用 TransmittableThreadLocal
private static final TransmittableThreadLocal<LogContext> CONTEXT =
new TransmittableThreadLocal<>();
// 其他方法不變
}
創(chuàng)建 TTL 裝飾的線程池:
@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()
));
}
}
使用裝飾后的線程池:
@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 包裝:
public CompletableFuture<Order> getOrderAsync(Long id) {
return TtlCompletable.get(() ->
CompletableFuture.supplyAsync(() -> {
log.info("異步查詢訂單, id={}", id); // 有 traceId
return orderMapper.selectById(id);
}, ttlExecutor)
);
}
@Async 異步任務(wù)
Spring 的 @Async 需要配置線程池裝飾器:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return TtlExecutors.getTtlExecutor(new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100)
));
}
}
使用:
@Service
public class OrderService {
@Async
public void sendNotification(Long orderId) {
// 這里的日志會自動帶上主線程的 traceId
log.info("發(fā)送訂單通知, orderId={}", orderId);
}
}
HTTP 調(diào)用傳遞 traceId
調(diào)用下游服務(wù)時(shí),需要把 traceId 傳遞過去,形成完整的調(diào)用鏈。
RestTemplate
@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
@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
@Configuration
public class FeignConfig {
@Bean
public RequestInterceptor traceIdInterceptor() {
return template -> {
String traceId = LogContextHolder.getTraceId();
if (traceId != null) {
template.header("traceId", traceId);
}
};
}
}
完整示例
Controller:
@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("接收創(chuàng)建請求, dto={}", dto); // 有 traceId
return orderService.create(dto);
}
}
Service:
@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("記錄查詢?nèi)罩? orderId={}", order.getId()); // 有 traceId
});
return order;
}
}
日志輸出:
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 - 記錄查詢?nèi)罩? orderId=1
三條日志的 traceId 都是 a1b2c3d4e5f6,可以快速關(guān)聯(lián)。
注意事項(xiàng)
1. 內(nèi)存泄漏
ThreadLocal 必須在請求結(jié)束時(shí)清理,否則可能會導(dǎo)致內(nèi)存泄漏。Filter 中的 finally 塊很重要:
try {
// 處理請求
} finally {
LogContextHolder.clear(); // 必須清理
}
2. 線程池命名
給線程池設(shè)置有意義的名字,方便從日志中識別:
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build()
3. MDC 的性能
MDC 讀寫有少量開銷,但對性能影響很小。如果是超高并發(fā)場景,可以考慮按需開啟。
4. traceId 生成策略
幾種常見方案:
| 方案 | 優(yōu)點(diǎn) | 缺點(diǎn) |
|---|---|---|
| UUID | 簡單、無需協(xié)調(diào) | 較長、無序 |
| 雪花算法 | 性能高、趨勢遞增 | 需要機(jī)器 ID 配置 |
| Redis incr | 全局唯一、遞增 | 依賴 Redis |
| 請求入口自增 | 簡單 | 單機(jī)內(nèi)唯一 |
大多數(shù)場景用 UUID 就夠了。
5. 分布式追蹤
如果是微服務(wù)架構(gòu),可以考慮接入 SkyWalking、Zipkin 等分布式追蹤系統(tǒng),它們提供更強(qiáng)大的鏈路追蹤能力,包括調(diào)用關(guān)系圖、耗時(shí)分析等。
但接入成本較高,中小項(xiàng)目用 MDC + traceId 通常就夠了。
總結(jié)
統(tǒng)一日志上下文的核心是:
- 入口生成:Filter/Interceptor 攔截請求,生成 traceId
- 線程傳遞:用 TTL 解決跨線程問題
- 服務(wù)傳遞:HTTP 調(diào)用通過請求頭傳遞 traceId
- 出口清理:請求結(jié)束時(shí)清理 ThreadLocal
這套方案不依賴第三方框架,實(shí)現(xiàn)簡單,適合大多數(shù)單體應(yīng)用或中小型微服務(wù)項(xiàng)目。
如果項(xiàng)目規(guī)模較大,對鏈路追蹤要求高,可以考慮接入專業(yè)的 APM 系統(tǒng)。
到此這篇關(guān)于SpringBoot實(shí)現(xiàn)統(tǒng)一日志上下文的實(shí)戰(zhàn)指南的文章就介紹到這了,更多相關(guān)SpringBoot統(tǒng)一日志上下文內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
ReentrantLock 非公平鎖實(shí)現(xiàn)原理詳解
這篇文章主要為大家介紹了ReentrantLock 非公平鎖實(shí)現(xiàn)原理詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12
Spring?@Configuration?proxyBeanMethods=false問題
這篇文章主要介紹了Spring?@Configuration?proxyBeanMethods=false問題,具有很好的參考價(jià)值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2025-04-04
Java優(yōu)先隊(duì)列?priority?queue
本文主要介紹了Java優(yōu)先隊(duì)列?priority?queue,優(yōu)先隊(duì)列是一種特殊的數(shù)據(jù)結(jié)構(gòu)隊(duì)列中每一個元素都被分配到一個優(yōu)先權(quán)值,出隊(duì)順序按照優(yōu)先權(quán)值來劃分。一般有兩種出隊(duì)順序高優(yōu)先權(quán)出隊(duì)或低優(yōu)先權(quán)出隊(duì),想了解具體內(nèi)容的小伙伴可以參考下文內(nèi)容,希望對你有所幫助2021-12-12

