Backend

99 Ngày Java — Ngày 48: Logging đúng cách

SSite Admin
13 tháng 09, 2026 8 phút đọc 3 lượt xem
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.

Sketchnote Ngày 48: logging đúng cách — vì sao System.out không phải logging, SLF4J là cái cầu, năm mức log và ai đọc chúng, log có tham số với dấu ngoặc nhọn, và những thứ không bao giờ đượ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.out còn chặn luồng vì có synchronized bê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ở DEBUG cho đú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ới DEBUG đ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.

  • MDC gắn traceId vào mọi dòng của một request; nhớ clear() trong finally vì luồng được tái sử dụng.

Bài tập nhỏ

  • Đổi logging.level của một gói sang DEBUG rồ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ới toString() có in ra màn hình, tắt DEBUG rồ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 MDC với traceId rồ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!

S

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.

Bài viết liên quan

99 Ngày Spring — Ngày 49: Resource server

Một dòng issuer-uri là đủ, JWKS cho phép hai mươi service verify token mà không service nào giữ bí mật, vì sao bỏ kiểm iss là lỗ hổng chết người — và scope khác role ở chỗ nào.

14 thg 9, 20267 phút0
99 Ngày Java — Ngày 49: Debug & đọc lỗi hiệu quả

Hai dòng đầu stack trace nói gần hết câu chuyện, chuỗi Caused by phải đọc từ dưới lên, breakpoint có điều kiện và theo ngoại lệ — cùng ranh giới giữa lúc dùng debugger và lúc chỉ còn log.

14 thg 9, 20267 phút0
99 Ngày Spring — Ngày 48: OAuth2 login

Luồng authorization code năm bước và vì sao phải qua code thay vì trả token thẳng, tham số state chống CSRF, cấu hình oauth2Login gọn tới bất ngờ — và vì sao khóa định danh phải là cặp nhà cung cấp cộng id.

13 thg 9, 20267 phút5