Đọc stack trace và cause chain — giữ dấu vết lỗi qua mọi layer
Cách đọc stack trace production, cơ chế fillInStackTrace của JVM, kỹ thuật giữ nguyên nhân gốc qua cause chain và suppressed exception.
TL;DR: Stack trace là nhật ký ghi lại hành trình lan truyền của ngoại lệ qua Call Stack. JVM tạo dựng stack trace ngay tại thời điểm new Exception() thông qua hàm gốc native fillInStackTrace(). Khi bắt ngoại lệ ở tầng thấp và ném ngoại lệ mới ở tầng cao, bắt buộc phải truyền exception gốc vào tham số cause để bảo toàn chuỗi nguyên nhân (Cause Chain). Cơ chế Suppressed Exception (Java 7+) ngăn ngừa việc ngoại lệ đóng tài nguyên ghi đè mất ngoại lệ nghiệp vụ chính.
Ở bài trước, bạn đã nắm vững cơ chế Stack Unwinding khi một ngoại lệ được ném ra và leo ngược qua các tầng stack frame. Bài học này sẽ giúp bạn giải mã chính xác bức tranh dấu vết mà JVM để lại trong log hệ thống khi quá trình này diễn ra.
Khi kiểm tra nhật ký vận hành (production log), bạn thường xuyên bắt gặp đoạn thông báo dạng:
LibraryServiceException: Khong the thuc hien muon an pham ID: B-102
at com.olhub.library.LibraryService.checkout(LibraryService.java:45)
at com.olhub.library.LoanController.handleLoan(LoanController.java:28)
Caused by: java.sql.SQLException: connection refused: database-cluster:5432
at org.postgresql.Driver.connect(Driver.java:456)
Chỉ mất chưa đầy 10 giây, một kỹ sư có kinh nghiệm sẽ nắm bắt trọn vẹn: lỗi nghiệp vụ xảy ra ở đâu (LibraryService.java:45), bắt nguồn từ tầng nào (LoanController), và quan trọng nhất: nguyên nhân kỹ thuật gốc rễ là máy chủ PostgreSQL từ chối kết nối (connection refused). Nếu không có Stack Trace và Cause Chain, việc chẩn đoán lỗi trong môi trường thực tế sẽ biến thành bài toán phỏng đoán tốn kém.
1. Stack trace hoạt động như thế nào và cách đọc nhanh trong thực tế?
Stack trace là bản ghi tuần tự các khung stack (stack frames) tại thời điểm ngoại lệ được khởi tạo:
public class TraceDemo {
public static void main(String[] args) {
stepOne();
}
static void stepOne() { stepTwo(); }
static void stepTwo() { stepThree(); }
static void stepThree() {
throw new RuntimeException("Su co tai stepThree");
}
}
Khi thực thi, máy ảo in ra:
Exception in thread "main" java.lang.RuntimeException: Su co tai stepThree
at TraceDemo.stepThree(TraceDemo.java:9)
at TraceDemo.stepTwo(TraceDemo.java:7)
at TraceDemo.stepOne(TraceDemo.java:6)
at TraceDemo.main(TraceDemo.java:4)
Quy tắc đọc hiểu stack trace:
- Dòng tiêu đề:
java.lang.RuntimeException: Su co tai stepThreecho biết Kiểu ngoại lệ và Thông điệp chi tiết. - Dòng đầu tiên trong danh sách (
at ...): Là điểm ném ngoại lệ chính xác (tại phương thứcstepThree, fileTraceDemo.java, dòng số 9). - Các dòng tiếp theo: Chuỗi các hàm gọi ngược từ gần nhất tới xa nhất (
stepTwo->stepOne->main).
Chiến lược 4 bước đọc log production trong 10 giây
- Đọc lướt dòng đầu tiên để phân loại vấn đề (Lỗi logic dữ liệu, NullPointer, hay mạng I/O).
- Quét nhanh xuống 3-5 dòng
atđầu tiên thuộc package dự án của bạn (bỏ qua các package trung gian của framework như Spring, Tomcat, Netty). - Tìm kiếm từ khóa
Caused by:nằm ở cuối log. Nếu có, đây chính là nguyên nhân cốt lõi cần khắc phục trước tiên.
2. JVM tạo dựng Stack trace bằng cách nào trong bộ nhớ?
Một hiểu lầm rất phổ biến: Nhiều người nghĩ stack trace được tạo ra khi lệnh throw thực thi. Thực tế, JVM tạo dựng stack trace ngay tại thời điểm gọi hàm khởi tạo new RuntimeException(...).
Trong constructor của Throwable, máy ảo gọi hàm native:
public synchronized Throwable fillInStackTrace() {
if (stackTrace != null || backtrace != null) {
fillInStackTrace(0);
stackTrace = UNASSIGNED_STACK;
}
return this;
}
JVM sẽ quét ngược Call Stack của luồng hiện tại, trích xuất metadata từ bytecode:
- Tên lớp (Class name) và Tên phương thức (Method name).
- Tên file nguồn (lấy từ thuộc tính
SourceFiletrong bytecode class). - Dòng lệnh mã nguồn (lấy từ bảng
LineNumberTablekhi biên dịch với cờ-g).
Mỗi khung stack được lưu thành một đối tượng StackTraceElement. Thao tác duyệt stack này tốn từ 1 đến 10 microsecond cho mỗi ngoại lệ. Đối với luồng chạy bình thường, chi phí này hoàn toàn không đáng kể; nhưng đối với các vòng lặp xử lý hàng triệu phần tử mỗi giây, việc lạm dụng khởi tạo ngoại lệ sẽ trở thành điểm nghẽn hiệu năng.
3. Cause Chain: Giữ gìn nguyên nhân gốc rễ qua các tầng kiến trúc
Trong kiến trúc phân tầng (Controller -> Service -> Repository), mỗi tầng có một phạm vi trừu tượng riêng. Khi tầng cơ sở dữ liệu gặp sự cố SQLException, tầng LibraryService không nên ném thẳng SQLException ra ngoài mà cần bọc lại thành LibraryStorageException.
Tuy nhiên, bạn bắt buộc phải truyền ngoại lệ gốc vào tham số cause:
public class LibraryRepository {
public LibraryItem findById(String id) {
try {
return queryDatabase(id);
} catch (SQLException e) {
// Chuan muc: Truyen ca thong diep domain va ngoai le goc e vao cause
throw new LibraryStorageException("Khong the truy van an pham: " + id, e);
}
}
}
Khi ghi log, JVM sẽ tự động xuất ra chuỗi nguyên nhân liên hoàn:
LibraryStorageException: Khong the truy van an pham: B-102
at LibraryRepository.findById(LibraryRepository.java:25)
at LibraryService.checkout(LibraryService.java:12)
Caused by: java.sql.SQLException: connection timeout to primary DB
at org.postgresql.Driver.connect(Driver.java:456)
Sai lầm nghiêm trọng: Bỏ quên tham số Cause khi Wrap
Quan sát sự khác biệt khi lập trình viên quên truyền ngoại lệ gốc:
// SAI LAM NGHIEP DU: Mat hoan toan dau vet loi goc
catch (SQLException e) {
throw new LibraryStorageException("Khong the truy van an pham: " + id);
}

Toàn bộ thông tin về SQLException: connection timeout cùng dòng code chính xác trong thư viện JDBC bị xoá sạch hoàn toàn khỏi bộ nhớ log. Lập trình viên trực ca lúc đó sẽ không có cách nào biết hệ thống bị lỗi mạng, lỗi sai mật khẩu hay lỗi cú pháp SQL.
Quy tắc xây dựng Custom Exception hỗ trợ Cause Chain
Mọi lớp custom exception bạn tạo ra phải luôn cung cấp ít nhất 4 constructor chuẩn mực sau:
public class LibraryStorageException extends RuntimeException {
public LibraryStorageException(String message) {
super(message);
}
public LibraryStorageException(String message, Throwable cause) {
super(message, cause); // Truyen cause vao Throwable cha
}
public LibraryStorageException(Throwable cause) {
super(cause);
}
}
4. Suppressed Exceptions: Xử lý sự cố phụ khi dọn dẹp tài nguyên
Trước Java 7, nếu một ngoại lệ phát sinh trong khối try và sau đó một ngoại lệ khác tiếp tục phát sinh trong khối finally khi đóng tài nguyên:
try {
throw new LoanProcessingException("The ban doc khong hop le");
} finally {
resource.close(); // Gia su close() nem IOException
}
Ngoại lệ LoanProcessingException sẽ bị biến mất hoàn toàn và bị ghi đè bởi IOException từ finally.
Java 7 bổ sung tính năng Suppressed Exception (Ngoại lệ bị nén). Cú pháp try-with-resources tự động ghim ngoại lệ phát sinh lúc đóng vào danh sách suppressed của ngoại lệ chính:
LoanProcessingException: The ban doc khong hop le
at LoanService.process(LoanService.java:42)
Suppressed: java.io.IOException: Failed to release file lock
at CatalogFile.close(CatalogFile.java:88)
Bạn có thể truy xuất danh sách này bằng mã nguồn thông qua phương thức e.getSuppressed().
5. Những cạm bẫy thường gặp khi xử lý Stack trace
❌ Bẫy 1: Gọi printStackTrace() bừa bãi thay vì dùng Logger
catch (Exception e) {
e.printStackTrace(); // Anti-pattern
}
Lệnh này in ra System.err. Trong môi trường container hoặc cloud, luồng System.err thường không được định dạng JSON, không có timestamp, và không gửi được về hệ thống giám sát tập trung (như ElasticSearch hoặc CloudWatch). Luôn dùng: logger.error("Thao tac that bai", e).
❌ Bẫy 2: Ném ngoại lệ mới mà bỏ quên biến gốc
Luôn nhớ nguyên tắc: Khi viết throw new MyException(...) bên trong khối catch (OtherException e), vị trí tham số cuối cùng của constructor luôn phải là biến e.
❌ Bẫy 3: Ghi log lỗi lặp đi lặp lại qua nhiều tầng (Log and Rethrow)
// TANG REPOSITORY
catch (SQLException e) {
logger.error("Error in repo", e);
throw new ServiceException(e);
}
// TANG SERVICE
catch (ServiceException e) {
logger.error("Error in service", e);
throw e;
}
Mỗi lỗi bị nhân bản log thành 3-4 lần cho cùng một sự cố, làm tràn ngập màn hình theo dõi và gây nhiễu cho đội ngũ vận hành. Chỉ nên ghi log tại tầng biên giới xử lý cuối cùng (Boundary Layer), còn các tầng trung gian chỉ nên wrap và propagate.
6. Deep Dive Oracle & OpenJDK References
- Throwable Javadoc: Đặc tả kỹ thuật về các phương thức
fillInStackTrace(),getCause(),initCause(), vàaddSuppressed(). - JLS §11.3 (Run-Time Handling of an Exception): Cơ chế dịch chuyển điều khiển và khởi tạo bảng vết tích ngoại lệ của máy ảo Java.
- Effective Java (Joshua Bloch) Item 75: "Include failure-capture information in detail messages" (Đưa toàn bộ thông tin ngữ cảnh dữ liệu lỗi vào thông điệp ngoại lệ).
7. Tự kiểm tra kiến thức
- Q1Tại sao nói ngoại lệ Java ghi nhận vị trí Call Stack tại thời điểm gọi lệnh new thay vì thời điểm gọi lệnh throw?
- Q2Hậu quả nghiêm trọng nhất của việc quên truyền ngoại lệ gốc (cause) khi tạo ngoại lệ bọc mới là gì?
- Q3Làm thế nào để lấy được ngoại lệ ban đầu (Root Cause) từ một đối tượng ngoại lệ bị bọc qua nhiều tầng kiến trúc?
- Q4Cơ chế Suppressed Exception được kích hoạt tự động trong tình huống nào?
- Q5Tại sao anti-pattern 'Log and Rethrow' (vừa ghi log vừa ném tiếp) ở các tầng trung gian lại bị coi là thực hành xấu?
Bài tiếp theo: Exception table và cost của exception — throw rẻ, tạo đắt
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
try, catch, finally — cây Throwable và cơ chế throw
Bài tiếpException table và cost của exception — throw rẻ, tạo đắt
Lộ trình Junior Java Backend Developer
Chặng 4/21 · Exception handling — bài 2/7 của chặng
Thảo luận & góp ý
Hỏi đáp về bài này