Hình dung một giao dịch như một bản nháp viết bằng bút chì. Bạn viết dần, và chỉ tô mực (commit) ở phút cuối nếu không có gì trục trặc; có bất kỳ lỗi nào thì cả trang bị tẩy sạch — tất-cả-hoặc-không-gì. Và có một con dấu "HUỶ TRANG NÀY" mà bất kỳ ai đang viết chung bản nháp đều đóng được, một khi đã đóng thì không gỡ ra được. 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, và nó xoay quanh đúng con dấu đó.
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, viết lên cùng một bản nháp. Khi nó ném, Spring đóng con dấu rollback-only lên trang. 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 con dấu rollback-only. Nó giống như chộp tay người kia sau khi họ đã đóng dấu — đã muộn. 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 — việc con lấy một bản nháp riêng với con dấu huỷ của riêng nó. 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.
Nếu chỉ soi một thứ trong ba mươi giây, tìm những chỗ bắt ngoại lệ trong một giao dịch:
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.
Mẫu số chung
Cái gốc của mọi chuyện trong bài: giao dịch không ghép lại với nhau một cách miễn phí. Một giao dịch phẳng thì sạch sẽ — tất-cả-hoặc-không-gì. Nhưng "một phương thức giao dịch gọi một phương thức giao dịch khác" thì không có nghĩa tự nhiên nào, nên khung nào cũng phải phát minh ra các mức lan truyền, và chúng giống nhau ở khắp nơi: TransactionScope của .NET có Required/RequiresNew đúng từng chữ; Rails mặc định cho giao dịch lồng tham gia giao dịch ngoài (phải requires_new: true mới thành savepoint) — và dính đúng cái bẫy nổi tiếng y hệt: rollback ở trong không cứu được nếu nó chỉ tham gia; Django có atomic với savepoint. Cùng một cái trap: một thao tác con tham gia giao dịch của người gọi có thể làm hỏng toàn bộ, còn độc lập thật thì tốn một kết nối/giao dịch riêng.
Điều thứ hai, sâu hơn: commit là một pha tách rời khỏi đoạn mã đã chạy, nên một giao dịch đã hỏng chỉ lộ ra ở ranh giới — lúc commit — chứ không phải ở chỗ bạn bắt lỗi. Bắt ngoại lệ không bao giờ gỡ được cái án đó (rollback-only của Spring, rollback-bị-tham-gia của Rails) — đúng cái sự thật "lỗi nổ ở ranh giới commit/flush, không phải ở dòng bạn bọc try/catch" mà bài về đóng tệp đã nói. Sợi chỉ chung: với mỗi thao tác con, quyết định rõ nó sống chết cùng cha (tham gia — mặc định) hay sống độc lập (giao dịch riêng, trả giá một kết nối); đừng bao giờ tin try/catch cứu được một giao dịch mà hàm con đã kết án; và đẩy các việc phụ dễ-hỏng-nhưng-độc-lập ra sau commit thay vì lồng chúng vào trong.
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.