engineering

Vì sao @Transactional không rollback dù đã đặt đúng chỗ?

@Transactional nằm trên method mà dữ liệu vẫn lưu một nửa? Vì nó là AOP proxy: self-invocation bỏ qua proxy, còn checked exception mặc định không rollback.

OLHub Team2 tháng 7, 2026 · 6 phút đọc

Cậu junior mới vào team ngồi cách tôi hai bàn, và tôi nghe tiếng thở dài của một người đang bí. Cậu ấy phụ trách batch cuối ngày — cái job quét các giao dịch chờ rồi ghi vào sổ cái. Mỗi lần chuyển tiền là một bút toán kép: ghi Nợ tài khoản người gửi, ghi Có tài khoản người nhận, hai vế phải khớp tuyệt đối. Sáng nay đối soát lệch — vài tài khoản bị ghi Nợ nhưng không hề có vế Có tương ứng. Tiền rời khỏi tài khoản mà không tới đâu cả.

"Em soi cả tiếng rồi anh ạ. Method ghi sổ có @Transactional hẳn hoi, mà nó vẫn ghi Nợ xong mới lỗi ở vế Có — đáng lẽ phải hoàn tác cả hai chứ." Cậu ấy nói đúng đến từng chữ. Hàm ghi sổ gói hai thao tác Nợ và Có trong một method gắn @Transactional ngay trên đầu; log cho thấy vế Nợ đã commit độc lập, còn vế Có thì văng exception. Hai thao tác lẽ ra không thể tách rời lại được ghi tách rời.

Tôi kéo ghế sang ngồi cạnh. Câu đầu tiên tôi hỏi không phải về code mà về triệu chứng: nó hỏng mọi lúc, hay chỉ khi chạy batch? Cậu ấy nghĩ một lát — giao dịch lẻ khách bấm trên app thì rollback đúng như sách, chỉ mấy đêm batch chạy cả xấp giao dịch mới lệch. Với một người đã dính đúng cái bẫy này vài lần như tôi, câu trả lời đó gần như đã là lời giải.

Cậu ấy thì vẫn đang đi hướng khác, nghi loại exception — định đổi vế Có sang ném RuntimeException cho chắc. Tôi bảo khoan, rồi kéo cho cậu ấy xem lại hàm batch: nó lặp qua danh sách giao dịch rồi gọi thẳng this.post(t) — một method @Transactional được gọi từ chính bên trong cùng class. Cái annotation ấy không sai một chữ nào; nó vô hiệu vì cách @Transactional thực sự chạy — thứ chẳng bao giờ lộ ra khi chỉ nhìn code.

Tiết lộ. @Transactional không hề nằm trong method của bạn — nó là một AOP proxy bọc quanh bean để chèn BEGIN/COMMIT/ROLLBACK. Và vì là proxy, nó có một điểm mù chí mạng: khi method được gọi từ chính bên trong class (this.method()), lời gọi đi thẳng vào object gốc, không qua proxy — nên không có transaction nào được mở, @Transactional trở thành vô nghĩa dù nằm chình ình ngay trên method. Đó chính là ca của cậu junior. Và nó không phải cách duy nhất annotation này lặng lẽ vô hiệu — cái bẫy thứ hai đợi ở cuối bài.

Cơ chế bên dưới: @Transactional là một proxy, không phải phép thuật

Hình dung thế này: proxy như một người gác cổng đứng ở cửa toà nhà. Ai từ ngoài đi vào cũng phải qua tay ông ấy đóng dấu — đó chính là chỗ Spring lặng lẽ chèn mở/đóng transaction. Nhưng nếu bạn đã ở bên trong toà nhà rồi bước sang phòng bên cạnh, bạn không đi qua cổng, và người gác cổng không hề biết bạn vừa di chuyển. Giữ lấy hình ảnh đó, giờ ta xem Spring dựng "người gác cổng" ấy bằng gì.

Khi Spring quét thấy một class có method gắn @Transactional, nó không đưa object gốc của bạn vào container. Thay vào đó, nó tạo một proxy tại runtime bọc lấy bean. Trên Spring Boot, mặc định proxy là một CGLIB subclass kế thừa class gốc — kể cả khi bean có implement interface, vì Boot đặt sẵn proxy-target-class=true. Proxy đó override từng method có annotation, và mọi chỗ @Autowired LedgerService thực ra nhận về cái proxy này, không phải instance bạn viết.

Proxy đó làm đúng một việc quanh mỗi lần gọi: mở connection và BEGIN trước khi vào logic thật, COMMIT nếu method return êm, ROLLBACK nếu method ném RuntimeException (hoặc Error). Toàn bộ cơ chế transaction nằm ở lớp vỏ này — object gốc của bạn không biết gì về transaction cả.

Đây chính là chỗ hở của proxy: nó chỉ chặn được lời gọi đi từ bên ngoài class vào. Một lời gọi this.method() từ bên trong cùng class đi thẳng vào object gốc, hoàn toàn không chạm tới proxy — đúng như bước sang phòng bên cạnh mà không qua cổng, nên lớp BEGIN/COMMIT/ROLLBACK không bao giờ được kích hoạt.

flowchart TD
    Caller["Caller ben ngoai"] -->|"1. goi qua proxy"| Proxy["Proxy CGLIB"]
    Proxy -->|"2. BEGIN tx"| Real["Object goc"]
    Real -->|"3. business logic"| Real
    Proxy -->|"4. COMMIT hoac ROLLBACK"| Done["Ket qua"]
    Self["this.method() tu trong class"] -.->|"bo qua proxy: KHONG co tx"| Real

Kiểm chứng bằng code

Đây là hình dạng đã gây ra vụ lệch sổ, rút gọn lại:

@Service
public class LedgerService {

    // Method co @Transactional -- ghi No + ghi Co la mot but toan kep atomic
    @Transactional
    public void post(Transfer t) {
        accounts.debit(t.getFrom(), t.getAmount());   // ghi No
        accounts.credit(t.getTo(), t.getAmount());    // ghi Co (co the fail)
    }

    // Batch cuoi ngay -- goi post() cho tung giao dich TU BEN TRONG cung class
    public void postAll(List<Transfer> pending) {
        for (Transfer t : pending) {
            this.post(t);   // SELF-INVOCATION -> bo qua proxy -> KHONG co transaction
        }
    }
}

Khi postAll gọi this.post(t), lời gọi không qua proxy. @Transactional trên post coi như không có: accounts.debit(...) commit ngay lập tức, và nếu accounts.credit(...) ném lỗi thì tiền đã rời tài khoản gửi mà không tới tài khoản nhận — sổ lệch. Chạy một giao dịch lẻ qua API (từ ngoài vào, qua proxy) thì đúng; chạy qua batch (self-call) thì sai — nên nó lọt qua mọi test đơn lẻ.

Cách phát hiện nhanh khi nghi ngờ: in ra class thật của bean lúc chạy.

System.out.println(ledgerService.getClass().getName());
// LedgerService$$SpringCGLIB$$0  -> dang chay qua proxy (dung)
// LedgerService                  -> object goc, KHONG co proxy wrap

(Trên Spring Boot mặc định luôn là CGLIB nên tên proxy luôn dạng $$SpringCGLIB$$; chỉ khi bạn tự tắt bằng proxy-target-class=false thì bean có interface mới thành JDK proxy tên com.sun.proxy.$ProxyN.)

Ba cách sửa, đều nhằm cho lời gọi đi qua proxy trở lại: (1) tách post sang một @Service khác rồi inject vào, (2) inject chính bean này vào chính nó (self-injection) và gọi qua tham chiếu đó thay vì this, hoặc (3) đẩy ranh giới transaction lên đúng entry point — thường là cách sạch nhất.

Cái bẫy thứ hai: checked exception không rollback

Giả sử bạn đã sửa xong self-invocation, lời gọi qua proxy đàng hoàng. Vẫn còn một cách nữa khiến rollback không chạy mà chẳng ai hay — và lần này nó giáng thẳng vào chính cái LedgerService kia:

// SAI: checked exception -> mac dinh KHONG rollback
@Transactional
public void post(Transfer t) throws AuditException {   // checked
    accounts.debit(t.getFrom(), t.getAmount());   // ghi No -- da COMMIT khi loi sau bay ra
    auditTrail.write(t);                           // ghi log kiem toan ngoai -> throw checked
    accounts.credit(t.getTo(), t.getAmount());     // dong nay khong chay
}

// DUNG: khai bao rollbackFor cho checked exception
@Transactional(rollbackFor = Exception.class)
public void post(Transfer t) throws AuditException { ... }

Mặc định Spring chỉ rollback khi method ném RuntimeException hoặc Error. Một checked exception — AuditException từ auditTrail.write(...) ở trên, hay IOException, SQLException — khiến transaction commit bình thường, nên accounts.debit(...) đã ghi Nợ còn accounts.credit(...) chưa chạy: sổ lại lệch, đúng kiểu bug ban đầu nhưng từ một nguyên nhân khác hẳn. Quy ước checked-thì-commit / runtime-thì-rollback này Spring kế thừa từ spec EJB CMT (checked = lỗi nghiệp vụ có thể phục hồi nên commit; runtime = lỗi hệ thống nên rollback), nhưng ngày nay nó chủ yếu là một cái bẫy. Fix: khai báo rollbackFor, hoặc dùng RuntimeException cho lỗi nghiệp vụ.

Sổ cái khớp lại ngay chiều hôm đó. Cậu junior học được bài học đắt hơn mọi tài liệu: một annotation đặt đúng chỗ không có nghĩa là nó đang chạy — muốn chắc thì đừng tin vội, cứ in cái class thật ra mà xem.

Muốn hiểu tận cơ chế BEGIN/COMMIT/ROLLBACK cùng đủ ba cách fix, bài @Transactional & AOP proxy trong khoá Spring REST API & Data JPA bóc đúng lớp proxy đó — và nếu tò mò vì sao Spring chọn CGLIB, AOP Proxy — JDK vs CGLIB đi sâu thêm.

Sẵn sàng học sâu hơn?

Biến những gì vừa đọc thành kỹ năng thật với khoá học của OLHub, hoặc mang câu hỏi của bạn ra thảo luận cùng cộng đồng.

Đọc tiếp

Bài viết liên quan