Backend

99 Ngày Java — Ngày 49: Debug & đọc lỗi hiệu quả

SSite Admin
14 tháng 09, 2026 7 phút đọc 0 lượt xem
99 Ngày Java — Ngày 49: Debug & đọc lỗi hiệu quả

Hôm qua ta học cách ghi lại chuyện đã xảy ra. Hôm nay là kỹ năng đi kèm: đọc nó. Đây là thứ phân biệt rõ nhất người mới với người có kinh nghiệm — không phải biết nhiều API hơn, mà là mất năm phút thay vì năm tiếng để tìm ra một bug. Ba thứ hôm nay: đọc stack trace đúng thứ tự, đọc chuỗi Caused by từ dưới lên, và ba tính năng của debugger mà phần lớn người dùng nó chưa bao giờ bật.

Sketchnote Ngày 49: debug và đọc lỗi — đọc hai dòng đầu stack trace rồi dừng ở package của bạn, Caused by cuối cùng là nguyên nhân gốc, breakpoint có điều kiện và theo ngoại lệ, evaluate expression

Đọc stack trace đúng thứ tự

Phản xạ của người mới khi thấy ba mươi dòng đỏ là hoảng và cuộn xuống. Thật ra chỉ cần hai dòng đầu: dòng một nói lỗi gì, dòng hai nói ở đâu. Phần còn lại là đường đi tới đó — và với Spring thì chín phần mười là khung, không phải code bạn.

// ĐỌC STACK TRACE — hầu hết mọi người đọc SAI THỨ TỰ.

Exception in thread "main" java.lang.NullPointerException:
        Cannot invoke "String.length()" because "ten" is null      ← ① ĐỌC DÒNG NÀY TRƯỚC
    at vn.motdev.DonHang.tinhPhi(DonHang.java:42)                  ← ② RỒI DÒNG NÀY
    at vn.motdev.DonHangService.xuLy(DonHangService.java:88)
    at vn.motdev.DonHangController.tao(DonHangController.java:31)
    at java.base/jdk.internal.reflect...                           ← ③ BỎ QUA hết phần này
    at org.springframework.web.servlet...                          ←   (khung, không phải code bạn)

// ⭐ HAI DÒNG ĐẦU nói gần hết câu chuyện: LỖI GÌ và Ở ĐÂU.
//    Phần dưới là đường đi tới đó, và với Spring thì 90% là khung — lướt qua.

// ⭐ MẸO: quét từ trên xuống, DỪNG ở dòng ĐẦU TIÊN có package của BẠN.
//    Đó gần như luôn là nơi cần đặt breakpoint đầu tiên.

// ❗ Từ Java 14, thông điệp NullPointerException nói RÕ cái gì null —
//    "because \"ten\" is null". Trước đó bạn chỉ có mỗi số dòng.
//    Đây là một trong những cải tiến đời thường đáng giá nhất của Java hiện đại.
  • Quét từ trên xuống, dừng ở dòng đầu tiên có package của bạn — đó gần như luôn là nơi đặt breakpoint đầu tiên.

  • Từ Java 14, NullPointerException nói rõ cái gì null chứ không chỉ số dòng — một trong những cải tiến đời thường đáng giá nhất.

  • Các dòng java.base/jdk.internalorg.springframework thì lướt qua; chúng chỉ cho biết ai gọi tới, không cho biết vì sao hỏng.

Caused by — đọc từ dưới lên

Đây là chỗ tốn thời gian nhất nếu đọc sai. Một lỗi đi qua bốn tầng thì mỗi tầng bọc lại và ném tiếp, nên bạn nhận về một chuỗi. Cái cuối cùng mới là nguyên nhân gốc — đọc dòng đầu rồi đi tìm bug trong Spring ORM là mất cả buổi.

// CHUỖI "Caused by" — và câu hỏi: đọc cái NÀO?

org.springframework.dao.DataIntegrityViolationException: could not execute statement
    at org.springframework.orm.jpa...
Caused by: org.hibernate.exception.ConstraintViolationException: could not execute
    at org.hibernate.engine.jdbc...
Caused by: org.postgresql.util.PSQLException:                         ⭐ ĐỌC CÁI CUỐI CÙNG
        ERROR: duplicate key value violates unique constraint "uk_email"
    at org.postgresql.core.v3...

// ⭐ QUY TẮC: "Caused by" CUỐI CÙNG là NGUYÊN NHÂN GỐC. Đọc từ DƯỚI LÊN.
//    Ở đây: email bị trùng. Ba dòng trên chỉ là các tầng gói lại rồi ném tiếp.
//    Ai đọc dòng đầu rồi đi tìm bug trong Spring ORM là mất cả buổi.

// ❗ Muốn giữ được chuỗi này thì phải TRUYỀN NGUYÊN NHÂN khi bọc lại (Ngày 43):
throw new AppException("Không lưu được đơn", e);   // ✅ giữ chuỗi
throw new AppException("Không lưu được đơn");      // ❌ CẮT ĐỨT — mất nguyên nhân gốc
// Lỗi thứ hai là cách chắc chắn nhất để biến một bug 5 phút thành một bug 5 giờ.

// ❗ "... 47 more" ở cuối không phải bị mất — chỉ là các dòng TRÙNG với trace phía trên.

// ❗ Stack trace RỖNG (chỉ có tên ngoại lệ, không dòng nào):
//    JVM đã tối ưu một ngoại lệ lặp đi lặp lại nhiều lần ở chỗ nóng.
//    Chạy lại với -XX:-OmitStackTraceInFastThrow để lấy lại trace đầy đủ.
  • Muốn giữ được chuỗi thì phải truyền nguyên nhân khi bọc lại (Ngày 43) — quên tham số e là cắt đứt và mất hẳn nguyên nhân gốc.

  • ... 47 more không phải bị mất — đó là các dòng trùng với trace phía trên, framework rút gọn cho đỡ dài.

  • Stack trace rỗng là JVM đã tối ưu một ngoại lệ ném lặp lại ở chỗ nóng; chạy lại với -XX:-OmitStackTraceInFastThrow để lấy lại.

Ba tính năng debugger đáng bật

Phần lớn người dùng debugger chỉ biết đặt breakpoint rồi bấm F8. Ba thứ dưới đây mới là chỗ nó tiết kiệm hàng giờ — đặc biệt là breakpoint có điều kiện, thứ biến việc dò một đơn hàng sai trong năm mươi nghìn đơn thành một cú chuột phải.

// DEBUGGER — ba thứ biến nó từ "chạy từng dòng" thành công cụ thật.

// ① BREAKPOINT CÓ ĐIỀU KIỆN — thứ tiết kiệm nhiều thời gian nhất.
for (DonHang d : tatCaDonHang) {      // 50.000 đơn, chỉ một đơn sai
    xuLy(d);                          // bấm F9 50.000 lần? Không.
}
// Chuột phải vào breakpoint → Condition: d.getId() == 12345
// Chương trình chạy full tốc độ và chỉ dừng ĐÚNG lần bạn cần.

// ② BREAKPOINT THEO NGOẠI LỆ — khi bạn KHÔNG BIẾT dừng ở đâu.
//    IntelliJ: Run → View Breakpoints → Java Exception Breakpoints → NullPointerException
//    Chương trình dừng ngay KHOẢNH KHẮC ngoại lệ được ném, với TOÀN BỘ biến còn sống.
//    Cực hợp khi lỗi chỉ xảy ra thi thoảng, hoặc nằm trong code bạn không sửa được.

// ③ EVALUATE EXPRESSION — chạy code BẤT KỲ tại điểm dừng, không cần build lại.
//    Gõ  ds.stream().filter(x -> x.getGia() < 0).toList()  ngay lúc đang dừng.
//    Thay được cả một vòng lặp thêm log rồi chạy lại từ đầu.

// ❗ Debugger CHỈ dùng được khi lỗi TÁI HIỆN ĐƯỢC trên máy bạn.
//    Lỗi chỉ xảy ra trên production thì công cụ là LOG (Ngày 48) — đó là lý do
//    hai bài này đi liền nhau. Debugger cho lỗi tái hiện được;
//    log có traceId cho lỗi bạn không nhìn thấy tận mắt.

// ❗ Còn một công cụ nữa cho lỗi "chỉ xảy ra lúc 2 giờ sáng": THREAD DUMP.
jcmd <pid> Thread.print        // ứng dụng treo → xem luồng nào đang kẹt ở đâu
jcmd <pid> GC.heap_info        // nghi rò rỉ bộ nhớ → nhìn heap trước khi đoán
  • Breakpoint theo ngoại lệ dùng khi bạn không biết dừng ở đâu — chương trình dừng ngay khoảnh khắc ngoại lệ được ném, với toàn bộ biến còn sống.

  • Evaluate Expression chạy code bất kỳ tại điểm dừng — thay được cả một vòng "thêm log rồi build lại".

  • Debugger chỉ dùng được khi lỗi tái hiện được; lỗi chỉ xảy ra trên production thì công cụ là log có traceId của Ngày 48.

  • Ứng dụng treo hay nghi rò rỉ bộ nhớ thì dùng jcmd lấy thread dump và heap info — nhìn trước khi đoán.

Bài tập nhỏ

  • Lấy một stack trace dài trong dự án của bạn và tìm dòng đầu tiên thuộc package của mình — đo xem mất bao lâu.

  • Bọc một SQLException hai lần, một lần có truyền e một lần không, rồi so hai stack trace.

  • Đặt breakpoint có điều kiện trong vòng lặp mười nghìn phần tử để dừng đúng phần tử thứ 7777.

  • Bật exception breakpoint cho NullPointerException rồi chạy một chương trình có lỗi ngẫu nhiên.

  • Chạy jcmd <pid> Thread.print trên ứng dụng đang chạy và tìm xem luồng nào đang WAITING.

Kết lại

Bốn ý gói lại hôm nay: stack trace thì hai dòng đầu nói gần hết câu chuyện, còn lại là lướt tới dòng đầu tiên thuộc package của bạn; chuỗi Caused by phải đọc từ dưới lên vì cái cuối cùng mới là nguyên nhân gốc — và muốn giữ được chuỗi thì phải truyền nguyên nhân khi bọc lại; breakpoint có điều kiện và breakpoint theo ngoại lệ là hai thứ tiết kiệm hàng giờ mà phần lớn người dùng debugger chưa bao giờ bật; và debugger chỉ hợp với lỗi tái hiện được, còn lỗi trên production thì công cụ vẫn là log của hôm qua. Ngày 50checkpoint: gộp mười bài vừa rồi thành một mini project đọc CSV và JSON, thống kê bằng collections, xử lý lỗi tử tế. 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 48: Logging đúng cách

Vì sao System.out.println không phải logging, SLF4J là cái cầu chứ không phải bộ ghi log, mức log là câu hỏi ai sẽ đọc dòng này, dấu {} là chuyện hiệu năng — và những thứ tuyệt đối không được log.

13 thg 9, 20268 phút3
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