Log là thứ bạn chỉ thấy giá trị khi hệ thống đã hỏng và bạn không có debugger — thường là lúc hai giờ sáng, trên máy chủ mà bạn không được phép dừng.

Lúc đó, một dòng log tốt đáng giá bằng cả buổi ngồi đoán. Và một dòng log tồi thì tệ hơn không có, vì nó khiến bạn tin vào thứ sai.

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à TRACEDEBUG 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 ERRORWARN: 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.

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:

  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.

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.

Thử ba mươi giây

Tìm trong dự án của bạn 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 catchlog.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.

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 đề.