Bài này về ngoại lệ mà tôi thấy nhiều người gặp nhất mà không hiểu vì sao.
Bắt ngoại lệ vẫn không cứu được
@Transactional
public void ngoaiBatLoi() {
repo.save(muc("ngoai"));
try { con.nemTrongCungGiaoDich(); } // bean khác, REQUIRED
catch (Exception e) { log("đã bắt: " + e); }
}
Trông rất hợp lý: gọi việc phụ, nó hỏng thì bỏ qua, việc chính vẫn giữ.
đã bắt: IllegalStateException
NÉM RA NGOÀI: UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
số bản ghi +0
Ngoại lệ đã bắt được, nhưng phương thức vẫn nổ ở lúc kết thúc, và bản ghi "ngoai" không được lưu.
REQUIRED nghĩa là "tham gia giao dịch đang có" — nên phương thức con chạy trong chính giao dịch của bạn, không phải giao dịch riêng. Khi nó ném, Spring đánh dấu giao dịch là rollback-only. Bạn bắt được ngoại lệ Java, nhưng cái dấu đó vẫn còn, và lúc commit Spring thấy nó rồi ném UnexpectedRollbackException.
Điểm cần nhớ: bắt ngoại lệ không gỡ được dấu rollback-only. Một khi giao dịch đã bị đánh dấu, nó sẽ rollback, không có cách nào cứu.
REQUIRES_NEW thì khác
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void nemTrongGiaoDichMoi() { repo.save(muc("requires-new")); throw new IllegalStateException(); }
đã bắt: IllegalStateException
(REQUIRES_NEW) ngoài chạy xong bình thường
(REQUIRES_NEW) số bản ghi +1
Lần này đúng như mong đợi: giao dịch con rollback (bản ghi "requires-new" biến mất), giao dịch ngoài commit bình thường (bản ghi "ngoai2" được lưu, nên +1).
REQUIRES_NEW tạm dừng giao dịch hiện tại và mở một giao dịch hoàn toàn riêng, trên một kết nối riêng. Con hỏng không ảnh hưởng cha.
Ba cái giá:
Tốn thêm một kết nối. Trong lúc con chạy, cha vẫn giữ kết nối của nó. Gọi REQUIRES_NEW lồng nhau trong vòng lặp là cách làm cạn pool.
Có thể tự khoá chính mình. Cha đã khoá một dòng, con mở giao dịch mới rồi cũng muốn dòng đó — nó chờ cha, cha chờ nó, và cả hai treo tới khi hết hạn.
Con không thấy dữ liệu chưa commit của cha. Đó là điểm chính, nhưng nó cũng nghĩa là con đọc ra trạng thái cũ.
Dùng khi nào: ghi log kiểm toán, ghi nhận sự kiện, gửi thông báo — những việc phải giữ lại kể cả khi việc chính hỏng.
Bảy mức lan truyền
| Mức | Đang có giao dịch | Không có giao dịch |
|---|---|---|
REQUIRED (mặc định) |
tham gia | tạo mới |
REQUIRES_NEW |
tạm dừng, tạo mới | tạo mới |
SUPPORTS |
tham gia | chạy không giao dịch |
NOT_SUPPORTED |
tạm dừng | chạy không giao dịch |
MANDATORY |
tham gia | ném ngoại lệ |
NEVER |
ném ngoại lệ | chạy không giao dịch |
NESTED |
điểm lưu (savepoint) | tạo mới |
Thực tế bạn dùng hai dòng đầu ở 95% trường hợp.
MANDATORY đáng biết: nó là cách khai rằng "phương thức này phải được gọi trong giao dịch" và bắt lỗi ngay thay vì âm thầm chạy ngoài giao dịch.
NESTED dùng điểm lưu của JDBC — rollback được phần con mà không mất phần cha, trên cùng một kết nối. Nghe lý tưởng, nhưng nhiều tổ hợp driver và CSDL không hỗ trợ, và Hibernate xử lý nó không hoàn toàn suôn sẻ. Tôi hiếm khi dùng.
Vì sao REQUIRES_NEW đôi khi không cô lập thật
Đây là bài học đã ghi trong CLAUDE.md của chính blog này, và nó tốn của tôi khá nhiều thời gian:
Khi request không có giao dịch mà open-session-in-view đang bật, REQUIRES_NEW có thể mượn lại chính EntityManager mà OSIV đang giữ. Kết quả: một lần rollback làm hỏng cả trang, dù bạn tưởng nó đã cô lập.
Cần cô lập thật sự thì tạo EntityManager riêng từ EntityManagerFactory:
var em = emf.createEntityManager();
try {
em.getTransaction().begin();
// ...
em.getTransaction().commit();
} finally { em.close(); }
Blog này dùng cách đó cho việc ghi lượt xem — nó phải không bao giờ làm hỏng việc đọc bài.
Và kèm theo: đừng truyền entity đang thuộc session này sang giao dịch khác, chỉ truyền id.
Mức cô lập
Tham số thứ hai, ít dùng hơn nhưng cần biết:
@Transactional(isolation = Isolation.REPEATABLE_READ)
| Mức | Chặn được |
|---|---|
READ_UNCOMMITTED |
không gì |
READ_COMMITTED |
đọc bẩn |
REPEATABLE_READ |
thêm: đọc không lặp lại |
SERIALIZABLE |
thêm: đọc ảo |
PostgreSQL mặc định READ_COMMITTED, MySQL InnoDB mặc định REPEATABLE_READ.
Với phần lớn ứng dụng, mặc định của CSDL là đúng, và vấn đề đồng thời nên giải bằng khoá lạc quan — bài ngày mai. Nâng mức cô lập là công cụ thô: nó làm chậm mọi thứ để chữa một chỗ.
Ba quy tắc
Đừng bắt ngoại lệ rồi tưởng đã xử lý xong. Với REQUIRED, giao dịch đã hỏng từ lúc con ném.
Việc phụ phải hỏng độc lập thì dùng REQUIRES_NEW — hoặc tốt hơn, đưa nó ra ngoài giao dịch hẳn bằng @TransactionalEventListener(AFTER_COMMIT).
Thấy UnexpectedRollbackException thì tìm chỗ bắt ngoại lệ, đừng tìm chỗ ném. Thông điệp "silently rolled back" nói đúng chuyện đã xảy ra, nhưng nó chỉ ra chỗ phát hiện, không phải chỗ gây ra.
Thử ba mươi giây
grep -rn -B2 -A2 "catch" --include='*.java' src/main/java/**/service/ | grep -A4 "@Transactional"
Mỗi chỗ bắt ngoại lệ bên trong một phương thức @Transactional mà ngoại lệ đó đến từ một bean khác cũng có @Transactional — đó là một UnexpectedRollbackException đang chờ dữ liệu thật để lộ ra.
Ngày mai: khoá lạc quan và bi quan — hai người cùng sửa một bản ghi thì ai thắng.