1. 日誌紀錄的執行緒安全
在單執行緒程式中一切很簡單:只有一個執行緒寫日誌,沒人干擾它。但在真實的應用——Web 服務、微服務——往往同時運轉著數十甚至上百個執行緒。想像一下,如果好幾個人同時在筆記本的同一行用筆書寫——結果可以說是非常難以辨認。
執行緒安全(thread safety)是指,即使有 100500 個執行緒同時寫日誌,訊息也不會混淆、不會黏在一起、也不會遺失。
在各個庫中如何實作?
現代的日誌庫(Log4j 2、Logback、java.util.logging)一開始就以執行緒安全為目標來設計。這表示:
- 每個執行緒都可以安全地呼叫 logger 的方法。
- 庫內部透過同步與佇列,讓訊息彼此不會干擾。
- 即使多個執行緒同時寫入同一個檔案,日誌也不會彼此混在一起。
重要:logger 本身(例如 SLF4J 或 Log4j 的 Logger 物件)可以在任何類別中作為 static final 欄位使用——這不會導致執行緒問題。
範例
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MultiThreadedLoggerExample {
private static final Logger logger = LoggerFactory.getLogger(MultiThreadedLoggerExample.class);
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 5; i++) {
logger.info("執行緒 {} 正在寫入訊息 {}", Thread.currentThread().getName(), i);
}
};
Thread t1 = new Thread(task, "第一");
Thread t2 = new Thread(task, "第二");
t1.start();
t2.start();
}
}
在日誌中你會看到兩個執行緒的訊息井然有序——不會糊成一團或互相覆蓋。
2. 日誌上下文:MDC(Mapped Diagnostic Context)
想像你的應用同時處理上百個請求,每個各自在自己的執行緒中。日誌訊息飛來飛去,但看不出屬於哪個請求。你不只想知道「發生了什麼」,還想知道是對哪個請求、與誰、在什麼情境中發生。
MDC(Mapped Diagnostic Context)是一種機制,可將與目前執行緒相關的額外資訊「附加」到日誌上。該執行緒寫出的每條訊息都會自動攜帶這些額外資料。
範例:記錄請求識別碼
在 Web 應用中可為每個請求指派唯一 ID(例如 UUID)。透過 MDC,這個 ID 會自動加到處理該請求之執行緒所寫出的所有日誌。
程式碼長這樣(SLF4J + Logback):
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import java.util.UUID;
public class MdcExample {
private static final Logger logger = LoggerFactory.getLogger(MdcExample.class);
public static void main(String[] args) {
Runnable task = () -> {
// 產生請求的唯一識別碼
String requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId); // 加入到 MDC
logger.info("正在處理請求");
doSomeWork();
logger.info("處理完成");
MDC.clear(); // 結束後務必清除!
};
Thread t1 = new Thread(task, "執行緒-1");
Thread t2 = new Thread(task, "執行緒-2");
t1.start();
t2.start();
}
static void doSomeWork() {
logger.debug("正在執行工作...");
}
}
設定日誌格式(例如,logback.xml):
<encoder>
<pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} [requestId=%X{requestId}] - %msg%n</pattern>
</encoder>
結果:
12:01:23 [執行緒-1] INFO MdcExample [requestId=ad8d...f3] - 正在處理請求
12:01:23 [執行緒-1] DEBUG MdcExample [requestId=ad8d...f3] - 正在執行工作...
12:01:23 [執行緒-1] INFO MdcExample [requestId=ad8d...f3] - 處理完成
重點!
- MDC 只在單一執行緒範圍內運作。如果你把工作交給另一個執行緒(例如透過執行緒池),需要手動傳遞 MDC 的值(或使用能自動處理的專用庫)。
- 別忘了清除 MDC!若未清除,資料可能「外洩」到同一執行緒處理的下一個請求(例如 Web 伺服器的執行緒池)。請在 finally 區塊呼叫 MDC.clear()。
3. Web 應用中的日誌紀錄
Web 應用不只是啟動後就跑的程式,而是一條真正的生產線:請求進來、被處理、回應送出,而且是同時、大量進行。在這裡,日誌不是奢侈品,而是必要工具!
在 Web 應用中要記錄什麼?
- HTTP 請求與回應:方法、URL、參數、回應狀態、處理時間。
- 錯誤與例外:所有非預期失敗與 stack trace。
- 業務事件:註冊、登入、下單、付款等。
- 技術細節:與資料庫、外部服務的互動,操作耗時等。
核心原則:請用能讓你在一個月後凌晨三點出事時,仍能看懂到底發生什麼的方式來記錄日誌。
範例:HTTP 請求日誌(Spring Boot)
最簡單的做法是使用過濾器或切面,為每個進來的請求做日誌。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;
@Component
public class RequestLoggingFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId);
HttpServletRequest httpRequest = (HttpServletRequest) request;
logger.info("請求:{} {}", httpRequest.getMethod(), httpRequest.getRequestURI());
long start = System.currentTimeMillis();
try {
chain.doFilter(request, response); // 交給後續鏈(到控制器)
} finally {
long duration = System.currentTimeMillis() - start;
logger.info("回應已送出,處理時間:{} 毫秒", duration);
MDC.clear();
}
}
}
錯誤與例外的日誌
在 Web 框架(例如 Spring)中,通常會使用專門的錯誤處理器(@ExceptionHandler)來優雅地記錄所有非預期的故障。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public String handleException(Exception ex) {
logger.error("發生錯誤:", ex); // 記錄完整的 stack trace!
return "error"; // 回傳錯誤頁面
}
}
與 Web 框架的整合
幾乎所有現代 Web 框架(Spring、Jakarta EE、Micronaut 等)都能「開箱即用」地與 logger 整合。通常只要把 SLF4J/Logback 依賴加入專案——啟動、請求處理、錯誤等標準訊息就會自動被記錄。
4. 實作:在多執行緒任務中的日誌範例
讓我們在教學應用(例如訂單處理服務)中加入多執行緒處理,看看日誌如何幫忙不致手忙腳亂。
範例:使用多執行緒與 MDC 處理訂單
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class OrderProcessingApp {
private static final Logger logger = LoggerFactory.getLogger(OrderProcessingApp.class);
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 5; i++) {
final int orderId = i;
executor.submit(() -> {
String requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId);
try {
logger.info("開始處理訂單 {}", orderId);
processOrder(orderId);
logger.info("訂單 {} 處理成功", orderId);
} catch (Exception ex) {
logger.error("處理訂單時發生錯誤 " + orderId, ex);
} finally {
MDC.clear();
}
});
}
executor.shutdown();
}
static void processOrder(int orderId) throws InterruptedException {
if (orderId % 2 == 0) {
throw new RuntimeException("對偶數訂單的錯誤模擬");
}
Thread.sleep(500); // 模擬工作
}
}
發生了什麼:
- 每筆訂單在獨立的執行緒中處理。
- 每個執行緒都會建立唯一的 requestId(透過 MDC)。
- 可以用這個識別碼把同一筆訂單的所有日誌串在一起查找。
- 錯誤會連同完整堆疊一起記錄。
日誌格式已設定為顯示 requestId。
5. 重要細節與注意事項
- 執行緒、執行緒池與 MDC。 如果使用執行緒池(你很可能會用),請記住:池中的執行緒會被重複利用!若忘了清除 MDC,一個請求的資料可能會流入另一個請求的日誌。總是在工作結束時呼叫 MDC.clear()。
- MDC 與非同步任務。 在非同步的 Web 框架(例如 Spring WebFlux)中,MDC 不一定能「開箱即用」,因為請求處理可能在不同執行緒間跳動。可使用專用的擴充或轉接器。
- 微服務中的日誌。 在微服務架構中,通常不只記錄本地請求識別碼,還會記錄全域(traceId),並在服務之間傳遞。這能追蹤請求在整個系統中的路徑(分散式追蹤)。常見系統有 Zipkin、Jaeger、OpenTelemetry。
6. 示範:System.out.println 與日誌的差異
System.out.println 只是把字串印到主控台。在多執行緒環境中:
- 訊息可能彼此交錯。
- 沒有時間、執行緒、層級、上下文等資訊。
- 無法設定輸出到檔案、格式、或依層級過濾。
Logger 會輸出結構化訊息,考量執行緒、層級、格式,並支援輸出到不同目的地(檔案、主控台、網路)。
比較範例
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PrintVsLogger {
private static final Logger logger = LoggerFactory.getLogger(PrintVsLogger.class);
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 3; i++) {
System.out.println("System.out: " + Thread.currentThread().getName() + " 步驟 " + i);
logger.info("Logger: 步驟 {}", i);
}
};
new Thread(task, "T1").start();
new Thread(task, "T2").start();
}
}
結論:
- System.out —— 訊息可能交錯,沒有時間與層級。
- Logger —— 每條訊息都包含時間、執行緒、層級,可過濾並快速定位。
7. 多執行緒與 Web 應用中日誌的常見錯誤
常見錯誤 1:使用 System.out.println 取代 logger。 在多執行緒環境會造成主控台「一團亂」,無法依層級過濾,也缺乏上下文資訊。
常見錯誤 2:忽略或錯用 MDC。 若不使用 MDC 傳遞請求/使用者識別碼,日誌會失去意義——無法判斷錯誤屬於哪個請求。若忘了清除 MDC,資料可能「外洩」到其他請求。
常見錯誤 3:把 logger 建成區域變數。 請使用 private static final 的 Logger——這樣每個類別只建立一次,不浪費記憶體,也更不易出錯。
常見錯誤 4:記錄敏感資料。 密碼、信用卡、個資等都不應該寫入日誌——這是資安問題!
常見錯誤 5:只用「INFO」或只用「ERROR」。 請用合適層級:DEBUG 用於除錯、INFO 用於業務事件、ERROR 用於錯誤。不要全部寫成同一層級,否則日誌會失去價值。
常見錯誤 6:未記錄例外的 stack trace。 如果只寫 logger.error("錯誤: " + ex.getMessage()),就會遺失錯誤原因的關鍵資訊。請務必記錄完整例外:logger.error("錯誤", ex)。
常見錯誤 7:自製但非執行緒安全的 logger。 若有人「自己寫 logger」但沒有做好同步——在多執行緒環境幾乎可保證會遺失或破壞日誌。
GO TO FULL VERSION