Log là hộp đen của máy bay: chẳng ai ngó tới cho đến lúc rơi. Với phần mềm, "lúc rơi" thường là hai giờ sáng, trên máy chủ bạn không được phép dừng, không có debugger nào để cắm vào. Lúc đó một dòng log tốt đáng bằng cả buổi ngồi đoán — còn một dòng log tồi thì tệ hơn không có, vì nó ghi sai độ cao và đẩy đội điều tra đi lạc hướng. Bài này về cách viết loại thứ nhất.

SLF4J là mặt tiền, không phải bộ ghi log

Trong dự án Java hiện đại, bạn viết mã dựa trên SLF4J — một interface — còn việc ghi thật do Logback hoặc Log4j2 đảm nhiệm ở lúc chạy.

private static final Logger log = LoggerFactory.getLogger(Log.class);

Kiểu Logger này là của SLF4J. Đổi từ Logback sang Log4j2 chỉ cần đổi phụ thuộc trong pom.xml, không sửa một dòng mã nào. Đây là ví dụ sạch sẽ của nguyên tắc D trong SOLID — phụ thuộc vào trừu tượng.

Khai private static final là quy ước: một logger cho mỗi lớp, tạo một lần, và tên logger chính là tên lớp — nhờ đó bạn bật tắt log theo từng gói.

Năm mức, và tiêu chí chọn

INFO  vd.Log - INFO: sự kiện đáng ghi nhận
WARN  vd.Log - WARN: bất thường nhưng vẫn chạy được
ERROR vd.Log - ERROR: hỏng việc, cần người xem

Chú ý là TRACE và DEBUG không xuất hiện — cấu hình đang đặt mức INFO, nên chúng bị bỏ qua. Đây là điểm mấu chốt của phần tiếp theo.

Tiêu chí tôi dùng:

Mức Khi nào Ai đọc
ERROR Việc thất bại, cần người can thiệp Người trực
WARN Bất thường nhưng đã tự xử lý được Người trực, khi rà soát
INFO Sự kiện nghiệp vụ đáng ghi nhận Người vận hành
DEBUG Chi tiết để gỡ lỗi Lập trình viên
TRACE Từng bước một Lập trình viên, hiếm khi bật

Câu hỏi để phân biệt ERROR và WARN: có ai phải làm gì đó vì dòng log này không? Nếu không, nó là WARN. Log ERROR cho những thứ không cần hành động là cách nhanh nhất để người trực học cách bỏ qua cảnh báo.

Và INFO nên ghi sự kiện nghiệp vụ, không phải bước kỹ thuật. "Đã tạo đơn DH-001" là INFO. "Bắt đầu vòng lặp thứ 3" là DEBUG.

Dấu ngoặc nhọn: không phải chuyện thẩm mỹ

Hai cách viết cùng một dòng log:

log.debug("xử lý đơn " + i + " của khách " + i);   // nối chuỗi
log.debug("xử lý đơn {} của khách {}", i, i);       // tham số hoá

Với mức DEBUG đang tắt, 200.000 lần gọi (đo trên máy tôi):

  nối chuỗi   : 10 ms
  tham số hoá :  5 ms

Bản nối chuỗi tốn gấp đôi, dù không một dòng log nào được ghi ra.

Lý do: tham số của một lời gọi phương thức luôn được tính trước khi vào phương thức. Với cách nối chuỗi, Java phải ghép chuỗi xong rồi mới đưa cho log.debug() — và debug() nhìn thấy mức đang tắt, vứt luôn chuỗi vừa ghép. Như nấu xong cả bữa rồi mới biết bếp đã đóng cửa.

Với cách tham số hoá, chuỗi mẫu là hằng số, hai số nguyên được đóng hộp, và việc ghép chỉ xảy ra nếu mức log đang bật.

Chênh lệch ở đây khiêm tốn vì tôi chỉ ghép hai số. Với một đối tượng có toString nặng — một danh sách, một bản ghi lớn — khác biệt thành hàng chục lần. Và trong mã thật, những dòng log.debug nằm trong vòng lặp nóng chính là chỗ hay gặp nhất.

Nếu việc dựng tham số thật sự tốn kém, hãy kiểm tra trước:

if (log.isDebugEnabled()) {
    log.debug("chi tiết: {}", tinhToanRatTonKem());
}

Chỉ dùng khi cần — bản thân phép kiểm tra cũng có chi phí, và trong 95% trường hợp thì tham số hoá là đủ.

Ghi ngoại lệ cho đúng

catch (NumberFormatException e) {
    log.error("Không đọc được số lượng từ '{}'", "abc", e);
}
ERROR vd.Log - Không đọc được số lượng từ 'abc'
java.lang.NumberFormatException: For input string: "abc"
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at vd.Log.main(Log.java:32)
	...

Chú ý: ngoại lệ là tham số cuối cùng và không có {} tương ứng. SLF4J nhận ra điều đó và in cả stack trace. Đây là cú pháp mà nhiều người không biết, nên hay viết:

log.error("lỗi: " + e.getMessage());       // MẤT stack trace
log.error("lỗi", e.getMessage());          // cũng mất, vì truyền chuỗi chứ không phải ngoại lệ

Cả hai đều vứt đi thứ quý nhất. Stack trace cho biết lỗi xảy ra ở dòng nào, qua những hàm nào — thông tin mà không câu chữ nào thay thế được.

MDC: gắn ngữ cảnh vào mọi dòng

Đây là công cụ bị dùng ít nhất so với giá trị nó mang lại.

MDC.put("maDon", "DH-001");
log.info("bắt đầu xử lý");
log.info("hoàn tất");
MDC.clear();

Với mẫu log có %X{maDon}:

INFO vd.Log DH-001 - bắt đầu xử lý
INFO vd.Log DH-001 - hoàn tất

Mã đơn xuất hiện ở mọi dòng mà không phải viết vào từng câu. Khi hệ thống xử lý hàng nghìn đơn đồng thời, đây là thứ duy nhất cho phép bạn lọc ra đúng một đơn từ đống log lẫn lộn.

Trong ứng dụng web, người ta thường đặt MDC ở một filter đầu vào: mã yêu cầu, id người dùng, mã phiên. Mọi dòng log phát sinh trong yêu cầu đó tự động mang theo.

MDC gắn với luồng, nên phải xoá sau khi dùng. Trong môi trường có thread pool, luồng được dùng lại cho yêu cầu khác — không xoá thì đơn hàng của người này dính vào log của người kia. Đặt MDC.clear() trong finally, hoặc dùng try (var c = MDC.putCloseable("maDon", ma)) để nó tự xoá.

Và một chi tiết tôi vừa vấp phải khi viết bài này: MDC có hoạt động hay không phụ thuộc vào mẫu log. Nếu pattern không có %X, mọi thứ bạn đặt vào MDC sẽ không hiện ra và bạn tưởng nó hỏng. Lần đầu tôi chạy ví dụ trên, hai dòng đó in ra không có mã đơn — chỉ vì thiếu một chuỗi bảy ký tự trong file cấu hình.

Ba thói quen làm log vô dụng

Log rồi ném lại.

catch (SQLException e) {
    log.error("lỗi truy vấn", e);
    throw new LoiTruyCapDuLieu("không đọc được", e);   // sẽ log lại ở tầng trên
}

Cùng một lỗi xuất hiện hai lần trong log, với hai stack trace gần giống nhau. Người đọc tưởng có hai sự cố. Nguyên tắc: hoặc xử lý, hoặc ném lên — đừng làm cả hai. Log ở nơi bạn thật sự xử lý lỗi, thường là tầng ngoài cùng.

Log trong vòng lặp. Một dòng cho mỗi phần tử, với mười nghìn phần tử, là mười nghìn dòng chôn vùi mọi thứ khác. Hãy log tổng kết: "đã xử lý 10.000 bản ghi, 3 lỗi".

Log thông tin nhạy cảm. Mật khẩu, token, số thẻ, thông tin cá nhân. Log được gom về hệ thống tập trung, giữ nhiều tháng, và nhiều người đọc được hơn bạn nghĩ. Một dòng log.debug("request: {}", body) tưởng vô hại có thể ghi lại toàn bộ mật khẩu người dùng gửi lên.

Log có cấu trúc

Với hệ thống chạy thật, log dạng JSON đáng cân nhắc hơn dạng văn bản:

{"time":"2026-05-29T03:12:43Z","level":"INFO","logger":"vd.Log",
 "maDon":"DH-001","msg":"Đã tạo đơn với 3 mặt hàng"}

Nó khó đọc bằng mắt hơn, nhưng cho phép truy vấn — lọc theo maDon, đếm theo level, dựng biểu đồ. Logback có logstash-logback-encoder làm việc này, và mọi trường MDC tự thành trường JSON.

Nguyên tắc chọn: log của môi trường phát triển thì dạng văn bản; log của sản xuất, nơi có công cụ thu thập, thì dạng JSON.

Muốn tự sửa hai thứ trong ba mươi giây: tìm một dòng log.debug có nối chuỗi bằng dấu cộng, đổi sang {}; rồi tìm một khối catch có log.error(... + e.getMessage()) và đổi thành truyền thẳng e làm tham số cuối. Chạy lại, và lần này bạn sẽ thấy stack trace — thứ mà trước đó đã bị vứt đi mỗi lần lỗi xảy ra.

Mẫu số chung

Cái hộp đen ghi lại chuyến bay không phải chuyện riêng của Java, và bốn nguyên tắc trên hoá ra là trí khôn chung của mọi hệ sinh thái ghi log. Go từ 1.21 có slog trong thư viện chuẩn: có mức, có cấu trúc key-value (đúng vai MDC), và ghi lười qua LogValuer để không phải dựng thông điệp mình sẽ vứt đi. Python thì mang y nguyên cái bẫy dấu-cộng: logger.debug("x %s", val) là lười, còn logger.debug(f"x {val}") thì f-string luôn được tính dù DEBUG tắt — đúng khác biệt nối-chuỗi so với tham-số-hoá ở trên. JavaScript có pino và winston ghi thẳng JSON, cùng khái niệm mức và trường ngữ cảnh. Còn cái ý "viết mã dựa trên interface, chọn bộ ghi thật lúc chạy" thì logging của Python vốn chính là một interface như thế, và .NET có ILogger cùng vai với SLF4J.

Điểm chung, và là thứ đáng mang theo, có bốn ý mà ngôn ngữ nào cũng lặp lại. Phân mức để lọc tín hiệu khỏi nhiễu — và ERROR chỉ dành cho thứ cần ai đó hành động, nếu không người trực sẽ học cách phớt lờ. Ghi lười — đừng bỏ công dựng một thông điệp bạn sẽ không in ra; tham số hoá thay cho nối chuỗi, ở đâu cũng vậy. Ngữ cảnh đi kèm — gắn mã yêu cầu vào mọi dòng (MDC, slog attrs) là thứ duy nhất cứu bạn khi hàng nghìn luồng ghi lẫn vào nhau. Cấu trúc hoá — log để máy truy vấn được, không chỉ để mắt người đọc. Và bao trùm tất cả là một câu hỏi giản dị: người đọc dòng này lúc hai giờ sáng có hiểu ngay chuyện gì đã xảy ra không? Nếu không, dòng log đó chưa làm xong việc của nó.

Ngày mai: Optional — công cụ được kỳ vọng xoá sổ NullPointerException, và những cách dùng khiến nó chỉ đổi chỗ vấn đề.