Exception: xử lý lỗi thanh toán mà không nuốt mất lỗi
Tiếp đi: Exception trong Java.
Trong shop-backend, PaymentService gọi cổng thanh toán, đọc file đối soát, lưu Order. Chỗ nào cũng có thể lỗi. Bài này trả lời: lỗi nào nên ném, ném loại gì, bắt ở đâu.
1. Bài toán: catch xong rồi… thôi
public void pay(Order order) {
try {
gateway.charge(order.getId(), order.getTotal());
order.markPaid();
} catch (Exception e) {
e.printStackTrace(); // in ra console rồi đi tiếp
}
orderRepo.save(order); // đơn vẫn được lưu như chưa có gì xảy ra
}
2. Vấn đề
- Lỗi bị nuốt: caller tưởng thanh toán thành công, khách không biết, log production thì không có (printStackTrace ra stderr).
catch (Exception e)bắt cảNullPointerExceptionlà bug của mình, trộn với lỗi nghiệp vụ.- Không phân biệt "hết tiền" (báo khách) với "cổng timeout" (retry) với "bug" (fix code).
3. Giải pháp: hiểu cây exception rồi chọn đúng loại
Throwable
/ \
Error Exception
(OutOfMemoryError, / \
StackOverflowError) IOException RuntimeException
→ không bắt SQLException (NullPointer, IllegalArgument,
→ CHECKED IllegalState, ...)
→ UNCHECKED
- Checked (con của
Exceptionnhưng không phảiRuntimeException): compiler bắt phảicatchhoặc khai báothrows. Dành cho lỗi bên ngoài có thể phục hồi: file, mạng. - Unchecked (
RuntimeException): không bắt buộc xử lý. Thường là bug (NPE) hoặc vi phạm nghiệp vụ. - Error: JVM hỏng, không nên catch.
Spring và hầu hết code hiện đại dùng unchecked cho lỗi nghiệp vụ, rồi xử lý tập trung ở một chỗ.
4. Code từng bước
Bước 1: Custom business exception
public abstract class BusinessException extends RuntimeException {
private final String code;
protected BusinessException(String code, String message) {
super(message);
this.code = code;
}
protected BusinessException(String code, String message, Throwable cause) {
super(message, cause); // giữ lỗi gốc
this.code = code;
}
public String getCode() { return code; }
}
public class InsufficientBalanceException extends BusinessException {
public InsufficientBalanceException(String orderId) {
super("PAYMENT_INSUFFICIENT", "Not enough balance for order " + orderId);
}
}
public class PaymentGatewayException extends BusinessException {
public PaymentGatewayException(String orderId, Throwable cause) {
super("PAYMENT_GATEWAY_ERROR", "Gateway failed for order " + orderId, cause);
}
}
Bước 2: Wrap lỗi kỹ thuật, giữ cause
public void pay(Order order) {
try {
gateway.charge(order.getId(), order.getTotal()); // ném IOException
} catch (IOException e) {
throw new PaymentGatewayException(order.getId(), e); // ✅ có cause
// ❌ throw new PaymentGatewayException(order.getId(), null); mất stack trace gốc
}
order.markPaid();
orderRepo.save(order);
}
Bước 3: try-with-resources khi đọc file đối soát
public List<String> readSettlement(Path file) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(file)) { // tự close()
return reader.lines().toList();
}
}
Mọi class implement AutoCloseable đều dùng được. close() chạy cả khi có exception; nếu close() cũng lỗi thì lỗi đó được gắn vào getSuppressed() của lỗi chính, không đè mất.
Bước 4: Xử lý tập trung bằng @RestControllerAdvice
@RestControllerAdvice
public class ApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(InsufficientBalanceException.class)
ProblemDetail balance(InsufficientBalanceException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
pd.setProperty("code", e.getCode());
return pd;
}
@ExceptionHandler(PaymentGatewayException.class)
ProblemDetail gateway(PaymentGatewayException e) {
log.error("Gateway error", e); // log cả stack trace + cause
return ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY);
}
}
curl -X POST localhost:8080/orders/42/pay
# {"status":422,"detail":"Not enough balance for order 42","code":"PAYMENT_INSUFFICIENT"}
5. Cách hoạt động
Controller → OrderService → PaymentService → gateway.charge()
│ IOException
wrap ←─────────┘
PaymentGatewayException(cause=IOException)
↑ bay ngược lên call stack, không ai catch
DispatcherServlet → @RestControllerAdvice → 502 + log đầy đủ
6. So sánh
| Checked | Unchecked | |
|---|---|---|
| Kế thừa | Exception | RuntimeException |
| Compiler bắt xử lý | Có | Không |
| Ví dụ | IOException, SQLException | NPE, IllegalArgumentException |
| Dùng khi | Caller thật sự phục hồi được ngay | Lỗi nghiệp vụ, bug, lỗi xử lý tập trung |
| Với lambda/stream | Khó dùng (phải bọc) | Dùng thoải mái |
7. Khi nào nên dùng / không nên dùng
- Nên: ném exception khi không thể làm đúng hợp đồng của method; bắt ở nơi biết xử lý (retry, trả HTTP code).
- Không nên: catch rồi bỏ qua, catch
Throwable, dùng exception để điều khiển luồng bình thường (ví dụ dò "không tìm thấy" trong vòng lặp). - Không nên: log rồi ném lại ở mọi tầng, một lỗi bị log 5 lần.
PaymentService có @Transactional. Ném checked exception thì transaction có rollback không?Mình để @Transactional trên pay(), ném IOException mà đơn vẫn được lưu vào DB. Sao không rollback?
Đúng rồi, đây là hành vi mặc định của Spring chứ không phải bug: chỉ rollback với unchecked exception và Error. Checked exception thì commit.
@Transactional
public void pay(Order order) throws IOException {
orderRepo.save(order);
gateway.charge(...); // ném IOException → vẫn COMMIT
}
@Transactional(rollbackFor = Exception.class) // ✅ cách 1
public void pay(Order order) throws IOException { ... }
// ✅ cách 2 (hay dùng hơn): wrap thành BusinessException (unchecked) như ở trên
Lý do lịch sử: Spring coi checked là "lỗi dự kiến, caller xử lý được", giống quy ước EJB. Một bẫy khác: catch exception bên trong method rồi không ném lại thì proxy không thấy lỗi, cũng commit. Chi tiết về rollbackFor, noRollbackFor và propagation xem Propagation & rollback.
Ném exception có chậm không? Mình thấy có người bảo trả về null hoặc mã lỗi cho nhanh.
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: phần tốn kém chủ yếu là tạo exception (gọi fillInStackTrace() để chụp call stack), không phải throw/catch. Call stack càng sâu (Spring thường 100+ frame) càng tốn.
new RuntimeException() → chụp stack trace: vài micro giây, sâu thì hơn
throw / catch → rẻ
lỗi hiếm (1/1000 request) → không đáng kể so với 1 query DB (ms)
- Lỗi hiếm (thanh toán thất bại): cứ dùng exception, code rõ hơn nhiều so với mã lỗi.
- Trường hợp bình thường, lặp liên tục (kiểm tra voucher có tồn tại không trong vòng lặp 1 triệu lần): dùng
Optional/boolean, đừng dùng exception làm luồng điều khiển. - Nếu thật sự cần exception "nhẹ": constructor
super(msg, cause, false, false)tắt stack trace. Hiếm khi cần.
finally thì sao, có lúc nào nó không chạy không?Hai câu hỏi nhỏ:
finallycó luôn chạy không?- Trong
finallymàreturnthì chuyện gì xảy ra?
Đúng là finally gần như luôn chạy: sau return, sau exception, sau break. Ngoại lệ: System.exit(), JVM crash, thread bị kill.
int f() {
try { throw new IllegalStateException("payment failed"); }
finally { return 1; } // ❌ exception BIẾN MẤT, f() trả về 1
}
return (hoặc throw) trong finally đè lên kết quả của try, kể cả nuốt exception. Quy tắc: finally chỉ để dọn dẹp, không return. Với resource thì dùng try-with-resources thay cho finally { close(); }.
Phỏng vấn hỏi exception handling trong Java thì trả lời sao?
Câu 1: Checked và unchecked exception khác nhau gì?
🇻🇳 Tiếng Việt:
Checked kế thừa
Exception, compiler bắt phải catch hoặc khai báothrows, dùng cho lỗi bên ngoài có thể phục hồi nhưIOException. Unchecked kế thừaRuntimeException, không bắt buộc xử lý, thường là bug hoặc vi phạm nghiệp vụ. Trong dự án Spring em dùng unchecked custom exception cho lỗi nghiệp vụ và xử lý tập trung ở@RestControllerAdvice.
🇬🇧 English:
Checked exceptions extend
Exceptionand the compiler forces you to catch or declare them; they fit recoverable external failures likeIOException. Unchecked ones extendRuntimeExceptionand are not enforced; they usually mean bugs or business rule violations. In Spring projects I use unchecked custom exceptions for business errors and handle them centrally in a@RestControllerAdvice.
Câu 2: Best practice khi xử lý exception?
🇻🇳 Tiếng Việt:
Không nuốt lỗi; catch ở nơi thật sự xử lý được; khi wrap thì luôn truyền cause; dùng try-with-resources cho resource; không return trong finally; log một lần ở tầng biên; không dùng exception cho luồng bình thường vì tạo stack trace tốn kém.
🇬🇧 English:
Never swallow exceptions; catch only where you can actually handle them; always pass the cause when wrapping; use try-with-resources; never return from finally; log once at the boundary; and don't use exceptions for normal control flow because capturing the stack trace is expensive.
Câu 3: @Transactional rollback với exception nào?
🇻🇳 Tiếng Việt:
Mặc định chỉ rollback với
RuntimeExceptionvàError; checked exception sẽ commit. Muốn khác thì dùngrollbackFor. Nếu catch exception bên trong method mà không ném lại thì proxy không biết, transaction cũng commit.
🇬🇧 English:
By default it rolls back only on
RuntimeExceptionandError; checked exceptions commit. UserollbackForto change that. If you catch the exception inside the method and don't rethrow, the proxy never sees it and the transaction commits.
Cách nhớ ngắn gọn
- Checked = compiler ép, unchecked = RuntimeException, Error = đừng bắt.
- Wrap luôn kèm cause, không nuốt lỗi.
- try-with-resources thay finally close; không return trong finally.
- Spring: rollback mặc định chỉ với unchecked.
- Tốn kém nằm ở stack trace, không dùng exception làm luồng điều khiển.
Tự kiểm tra
Vì sao catch (Exception e) { e.printStackTrace(); } là xấu?
Lỗi bị nuốt, caller tưởng thành công; bắt lẫn cả bug như NPE; log ra stderr không vào hệ thống log.
Wrap exception mà không truyền cause thì mất gì?
Mất stack trace và message của lỗi gốc, debug production chỉ thấy lỗi bọc ngoài.
try-with-resources xử lý lỗi trong close() thế nào?
Nếu thân try đã lỗi, lỗi của close() được gắn vào getSuppressed() của lỗi chính chứ không đè mất.
Method @Transactional ném IOException có rollback không?
Không, checked exception mặc định commit. Cần rollbackFor hoặc wrap sang unchecked.
Phần nào của exception tốn kém nhất?
Tạo exception, cụ thể là fillInStackTrace() chụp call stack.