99 Ngày Java — Ngày 48: Logging đúng cách
Có một thứ mọi hệ thống chạy thật đều dùng hằng ngày mà hầu như không ai được dạy tử tế: ghi log. Ta thường học nó bằng cách bắt chước code xung quanh, rồi tới một đêm sự cố mới phát hiện log của mình không trả lời được câu hỏi nào. Hôm nay ta chữa đúng bốn chỗ đó: vì sao System.out.println không phải logging, mức log là để cho ai đọc, vì sao {} không phải chuyện thẩm mỹ, và những thứ tuyệt đối không được log.

Vì sao System.out.println không phải là logging
Nó cũng in ra chữ, nên nhìn thì giống. Nhưng nó thiếu mọi thứ khiến một dòng log có ích lúc ba giờ sáng: không thời điểm, không mức độ, không biết lớp nào in ra, và quan trọng nhất — không tắt được nếu không sửa code rồi build lại.
// VÌ SAO System.out.println KHÔNG PHẢI LÀ LOGGING.
System.out.println("Đã xử lý đơn " + id); // ❌ trông thì cũng ra chữ...
// ...nhưng nó thiếu MỌI thứ khiến một dòng log có ích lúc 3 giờ sáng:
// · KHÔNG có thời điểm → không biết chuyện xảy ra lúc nào
// · KHÔNG có mức độ → không tách được "thông tin" với "cháy nhà"
// · KHÔNG có tên lớp / luồng → không biết ai in ra, luồng nào
// · KHÔNG TẮT ĐƯỢC → muốn im thì phải sửa code và build lại
// · Ghi thẳng ra màn hình → không xoay file, không gửi sang hệ thống gom log
// · CHẶN LUỒNG → System.out có synchronized bên trong;
// in trong vòng lặp nóng là tự tạo nút thắt cổ chai
// Cùng việc đó với một logger:
private static final Logger log = LoggerFactory.getLogger(DonHangService.class);
log.info("Đã xử lý đơn {}", id);
// → 2026-09-13 18:10:04.221 INFO [http-nio-8080-exec-3] c.m.DonHangService - Đã xử lý đơn 42
// ⭐ SLF4J là CÁI CẦU, không phải bộ ghi log.
// Code của bạn gọi SLF4J. Ai thực sự ghi ra file là Logback hoặc Log4j2,
// và đổi bộ ghi log chỉ là đổi một dependency — không sửa một dòng code nào.
// Đó là lý do THƯ VIỆN thì bắt buộc dùng SLF4J: không ép người dùng chọn theo mình.
// Spring Boot dựng sẵn SLF4J + Logback, bạn không cần cấu hình gì để bắt đầu.System.outcòn chặn luồng vì cósynchronizedbên trong — in trong vòng lặp nóng là tự tạo nút thắt cổ chai.SLF4J là cái cầu, không phải bộ ghi log: code gọi SLF4J, còn Logback hay Log4j2 mới thật sự ghi ra file.
Nhờ vậy đổi bộ ghi log chỉ là đổi một dependency — đây là lý do thư viện bắt buộc phải dùng SLF4J, để không ép người dùng chọn theo mình.
Spring Boot dựng sẵn SLF4J cộng Logback, nên bạn không cần cấu hình gì để bắt đầu.
Mức log — câu hỏi thật là ai sẽ đọc dòng này
Đừng nghĩ mức log là "mức độ to mồm". Hãy nghĩ nó là ai sẽ đọc dòng này: ERROR là có người phải hành động ngay, INFO là sự kiện nghiệp vụ đáng lưu lại, DEBUG là cho người đang gỡ lỗi. Đặt sai mức thì hoặc bạn chìm trong nhiễu, hoặc bạn bỏ lỡ đúng dòng cần thấy.
// MỨC LOG — không phải "mức độ to mồm", mà là AI SẼ ĐỌC DÒNG NÀY.
log.error("Không gửi được email cho {}", email, e); // có người phải HÀNH ĐỘNG ngay
log.warn ("Gọi lại lần {} tới {}", lan, url); // bất thường, chưa hỏng — theo dõi
log.info ("Đơn {} đã thanh toán {}đ", id, tien); // sự kiện NGHIỆP VỤ đáng ghi lại
log.debug("Truy vấn tồn kho: {}", sql); // dành cho người ĐANG gỡ lỗi
log.trace("Vòng lặp thứ {}", i); // chi tiết tới mức phiền
// Bộ lọc theo NGƯỠNG: đặt INFO thì INFO, WARN, ERROR ra — DEBUG và TRACE im.
// Chỉnh được lúc CHẠY, không cần build lại:
logging.level.root=INFO
logging.level.vn.motdev.donhang=DEBUG // mở sâu đúng MỘT gói đang nghi
// ❗ SAI LẦM HAY GẶP NHẤT — dùng ERROR cho mọi ngoại lệ:
try { ... } catch (Exception e) { log.error("Lỗi", e); } // ❌
// Người dùng gõ sai email KHÔNG phải chuyện phải gọi dậy ai lúc nửa đêm.
// Khi mọi thứ đều là ERROR thì không còn gì là ERROR nữa — cảnh báo bị lờ đi.
// Quy tắc thực dụng: ERROR là "có người phải làm gì đó NGAY".
// Còn lại là WARN hoặc INFO.
// ❗ VÀ ĐỪNG log rồi ném lại — dòng lỗi sẽ xuất hiện HAI, BA lần ở các tầng:
catch (SQLException e) {
log.error("Lỗi DB", e); // ❌ tầng trên cũng sẽ log lần nữa
throw new AppException(e);
}
// Chọn MỘT: hoặc xử lý và log, hoặc ném lên cho tầng biết cách xử lý (Ngày 41–43).Khi mọi thứ đều là ERROR thì không còn gì là ERROR — cảnh báo bị lờ đi, và đó là cách một hệ thống mất khả năng tự báo động.
Ngưỡng chỉnh được lúc chạy: mở
DEBUGcho đúng một gói đang nghi mà không cần build lại.Đừng log rồi ném lại — cùng một lỗi sẽ hiện hai ba lần ở các tầng; chọn một: hoặc xử lý và log, hoặc ném lên (Ngày 41–43).
Dấu {}, những thứ cấm log, và MDC
Ba thứ trong phần này, và thứ đáng nhớ nhất không phải cú pháp. Nối chuỗi bằng + khiến bạn trả tiền cho một dòng log không bao giờ được in; còn log nhầm mật khẩu hay token thì biến file log thành một sự cố bảo mật thật sự.
// LOG CÓ THAM SỐ — cú pháp {} không phải để cho đẹp.
log.debug("Tìm thấy " + ds.size() + " đơn của " + kh.toString()); // ❌
// Chuỗi này được GHÉP NGAY LẬP TỨC, kể cả khi mức DEBUG đang TẮT.
// Trong vòng lặp nóng, đó là hàng nghìn chuỗi rác cho bộ nhớ dọn — trả tiền cho
// một dòng log không bao giờ được in ra.
log.debug("Tìm thấy {} đơn của {}", ds.size(), kh); // ✅
// Ghép chuỗi chỉ xảy ra NẾU mức DEBUG đang bật. Không bật thì gần như miễn phí.
// NGOẠI LỆ luôn là tham số CUỐI, và KHÔNG có {} cho nó:
log.error("Không lưu được đơn {}", id, e); // ✅ in kèm nguyên stack trace
log.error("Không lưu được đơn {} {}", id, e); // ❌ e thành chuỗi, MẤT stack trace
// ❗ ĐỪNG BAO GIỜ LOG những thứ này — đây là chỗ log biến thành sự cố bảo mật:
log.info("Đăng nhập: {} / {}", email, matKhau); // ❌ mật khẩu vào file log
log.debug("Token: {}", jwt); // ❌ ai đọc log là đăng nhập được
log.info("Thẻ: {}", soThe); // ❌ vi phạm PCI-DSS
// Log thường được gom về một chỗ, giữ nhiều tháng, và nhiều người xem được
// hơn database rất nhiều. Coi file log như dữ liệu CÔNG KHAI trong nội bộ.
// MDC — thứ khiến log dùng được thật trong hệ thống nhiều request song song:
MDC.put("traceId", UUID.randomUUID().toString());
try { ... } finally { MDC.clear(); } // ❗ finally: luồng được TÁI SỬ DỤNG
// Mọi dòng log trong request đó mang cùng traceId → lọc ra là thấy trọn một câu chuyện,
// thay vì mò giữa hàng nghìn dòng của hàng trăm request đan xen nhau.{}hoãn việc ghép chuỗi tới khi biết chắc dòng đó sẽ được in — vớiDEBUGđang tắt thì gần như miễn phí.Ngoại lệ là tham số cuối và không có
{}cho nó; đặt thêm{}là mất luôn stack trace.Không bao giờ log mật khẩu, token, số thẻ — log được gom về một chỗ, giữ nhiều tháng, và nhiều người xem được hơn database.
MDCgắntraceIdvào mọi dòng của một request; nhớclear()trongfinallyvì luồng được tái sử dụng.
Bài tập nhỏ
Đổi
logging.levelcủa một gói sangDEBUGrồi chạy lại — thấy log mở ra mà không phải build lại.Viết
log.debug("x" + doiTuong)vớitoString()có in ra màn hình, tắtDEBUGrồi xem nó vẫn chạy.Log một ngoại lệ theo hai cách — có và không có
{}cho nó — rồi so xem cách nào còn stack trace.Đặt
MDCvớitraceIdrồi bắn 10 request song song và thử lọc theo một id.Tìm trong dự án hiện tại xem có chỗ nào log ra token hay mật khẩu không — thường là có.
Kết lại
Bốn ý gói lại hôm nay: System.out.println không phải logging vì thiếu thời điểm, mức độ, nguồn gốc và khả năng tắt — chưa kể nó chặn luồng; mức log là câu hỏi ai sẽ đọc dòng này, và dùng ERROR cho mọi thứ là cách nhanh nhất để hệ thống mất khả năng báo động; {} hoãn việc ghép chuỗi nên nó là chuyện hiệu năng chứ không phải thẩm mỹ; và đừng bao giờ log mật khẩu, token hay số thẻ, vì file log là dữ liệu công khai trong nội bộ. Ngày 49 ta học cách đọc lỗi và debug hiệu quả — đọc stack trace từ đâu, chuỗi caused-by nói gì, và breakpoint có điều kiện. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


