@Transactional
Xem thửMột hệ thống ngân hàng nhỏ ghi một dòng kiểm toán "có người cố chuyển tiền" trước mỗi lần chuyển, để điều tra gian lận. Lập trình viên đặt cả hai thao tác trong cùng một giao dịch. Một đêm, kẻ tấn công thử hàng nghìn lần chuyển vượt số dư; mỗi lần ràng buộc kiểm tra số dư thất bại, giao dịch rollback, và dòng kiểm toán biến mất cùng với nó. Sáng hôm sau đội bảo mật không có dấu vết nào của cuộc tấn công. Tuần sau, một lập trình viên khác bắt ngoại lệ của một dịch vụ con để "không làm hỏng yêu cầu chính" và gặp UnexpectedRollbackException ở cuối yêu cầu mà không hiểu vì sao giao dịch đã bị hủy dù ngoại lệ đã được bắt. Và đội thanh toán nhận ra email xác nhận đã gửi cho những đơn hàng sau đó bị rollback.
@Transactional là một trong những chú thích được dùng nhiều và hiểu sai nhiều nhất của Spring. Bài này dạy cơ chế lan truyền giao dịch (propagation), mức cô lập (isolation), chế độ chỉ đọc, thời gian chờ và sự kiện gắn với giao dịch, mỗi điều được chứng minh bằng thí nghiệm với PostgreSQL 16 thật trên Spring Boot 4.1.1: dòng kiểm toán sống sót qua rollback nhờ REQUIRES_NEW, giao dịch bị đánh dấu chỉ-rollback khi ngoại lệ được bắt, NESTED chỉ chạy với điều kiện nhất định, dị thường đọc không lặp lại, và việc chỉ gửi email sau khi commit.
Sau bài này, bạn có thể
- Chọn đúng mức lan truyền (
REQUIRED,REQUIRES_NEW,NESTED,MANDATORY,NEVER) cho từng tình huống và biết kết quả đo của mỗi loại. - Giải thích
UnexpectedRollbackExceptionvà cơ chế chỉ-rollback (rollback-only). - Biết
NESTEDcần gì và vì sao không chạy với JPA mặc định. - Chọn mức cô lập và chứng minh dị thường đọc không lặp lại bằng thí nghiệm.
- Dùng
readOnly,timeoutvà@TransactionalEventListenerđúng cách.
Các mức lan truyền
Cách đọc hình
Khi một phương thức @Transactional được gọi từ trong một giao dịch đang chạy, thuộc tính propagation quyết định nó làm gì với giao dịch đó. REQUIRED (mặc định): tham gia giao dịch hiện có (hoặc tạo mới nếu chưa có), nên mọi thứ commit hoặc rollback cùng nhau. REQUIRES_NEW: tạm treo giao dịch ngoài và mở một giao dịch hoàn toàn mới trên một kết nối khác, commit hoặc rollback độc lập. NESTED: chạy trong giao dịch hiện có nhưng đặt một điểm lưu (savepoint) để có thể hoàn tác riêng phần nhỏ.
Lab dựng hai bảng (accounts có ràng buộc balance >= 0 và audit_log) cùng các dịch vụ để quan sát từng mức.
Cách chọn mức lan truyền khi gọi một phương thức con:
Đang tải sơ đồ…
REQUIRES_NEW: kiểm toán sống sót qua rollback
Đang tải Animation…
Thử và quan sát
Dự đoán trước: giao dịch chuyển tiền rollback; dòng kiểm toán ghi trước đó còn không nếu nó chạy trong cùng giao dịch, và nếu nó chạy trong giao dịch riêng? Chạy cả hai biến thể và nhìn trạng thái cuối.
@Service
public class AuditService {
private final JdbcClient jdbc;
AuditService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logIndependently(String message) {
jdbc.sql("INSERT INTO audit_log (message) VALUES (:m)").param("m", message).update();
}
@Transactional
public void logJoining(String message) {
jdbc.sql("INSERT INTO audit_log (message) VALUES (:m)").param("m", message).update();
}
}
@Service
public class TransferService {
private final JdbcClient jdbc;
private final AuditService audit;
TransferService(JdbcClient jdbc, AuditService audit) {
this.jdbc = jdbc;
this.audit = audit;
}
private void move(long from, long to, long amount) {
jdbc.sql("UPDATE accounts SET balance = balance - :a WHERE id = :id").param("a", amount).param("id", from).update();
jdbc.sql("UPDATE accounts SET balance = balance + :a WHERE id = :id").param("a", amount).param("id", to).update();
}
@Transactional
public void transferWithIndependentAudit(long from, long to, long amount) {
audit.logIndependently("attempt " + from + "->" + to + " amount " + amount);
move(from, to, amount);
}
@Transactional
public void transferWithJoiningAudit(long from, long to, long amount) {
audit.logJoining("attempt " + from + "->" + to + " amount " + amount);
move(from, to, amount);
}
}
Tài khoản 1 có 100 và lệnh chuyển 500 làm vi phạm ràng buộc CHECK. Kết quả:
| Cách ghi kiểm toán | Số dư sau cùng | Bảng audit_log |
|---|---|---|
REQUIRES_NEW | [100, 0] (chuyển tiền bị hoàn tác) | [attempt 1->2 amount 500] |
REQUIRED (cùng giao dịch) | [100, 0] | [] (rỗng) |
Với REQUIRES_NEW, dòng kiểm toán đã commit trong giao dịch riêng của nó nên còn lại dù giao dịch chuyển tiền rollback: đúng thứ đội bảo mật cần. Với REQUIRED, dòng kiểm toán nằm cùng giao dịch và biến mất theo. Dùng REQUIRES_NEW cho thao tác cần tồn tại độc lập với kết quả của giao dịch chính: kiểm toán, ghi nhật ký tiến độ, tạo mã tuần tự. Có hai cái giá: nó dùng một kết nối cơ sở dữ liệu thứ hai trong lúc kết nối thứ nhất vẫn bị giữ (trong pool nhỏ có thể gây bế tắc khi mọi kết nối đều đang chờ kết nối thứ hai), và vì giao dịch mới không thấy các thay đổi chưa commit của giao dịch ngoài nên dễ gây khóa chờ nếu cùng chạm một hàng.
Chỉ-rollback: bắt ngoại lệ không cứu được giao dịch
Phương thức con tham gia (REQUIRED) ném ngoại lệ chạy xuyên qua proxy giao dịch của nó. Proxy thấy ngoại lệ không kiểm soát và đánh dấu giao dịch chung là chỉ-rollback trước khi ngoại lệ lan tiếp. Nếu phương thức ngoài bắt ngoại lệ và tiếp tục, giao dịch đã bị đánh dấu và không thể commit.
@Transactional
public void outerCatchesInnerFailure() {
try {
audit.failAfterWriting("inner-in-same-transaction");
} catch (IllegalStateException ignored) {
}
jdbc.sql("INSERT INTO audit_log (message) VALUES ('outer-after-catch')").update();
}
PROP outer catches inner REQUIRED failure: UnexpectedRollbackException ::
Transaction silently rolled back because it has been marked as rollback-only
balances=[100, 0] audit=[]
Ngoại lệ gốc đã bị bắt nên không ai thấy, nhưng khi phương thức ngoài xong và cố commit, Spring ném UnexpectedRollbackException, và cả hai ghi chú (inner-in-same-transaction và outer-after-catch) đều biến mất. Đây là cái bẫy bắt ngoại lệ để "không làm hỏng yêu cầu chính". Cách chữa tùy ý định: nếu phần con thật sự độc lập, cho nó REQUIRES_NEW (hỏng thì chỉ giao dịch của nó rollback); nếu cần hoàn tác riêng phần con trong cùng giao dịch, dùng NESTED (phần sau); hoặc đừng bắt ngoại lệ ở đó.
NESTED: cần điều kiện mà JPA mặc định không có
NESTED đặt một điểm lưu (savepoint) JDBC, và khi phần con lỗi chỉ hoàn tác tới điểm lưu đó. Nó cần cả nestedTransactionAllowed bật và trình quản lý giao dịch hỗ trợ điểm lưu. Lab thử ba cấu hình:
| Cấu hình | Kết quả |
|---|---|
JpaTransactionManager mặc định | NestedTransactionNotSupportedException: Transaction manager does not allow nested transactions by default - specify 'nestedTransactionAllowed' property with value 'true' |
JpaTransactionManager với setNestedTransactionAllowed(true) | NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities |
DataSourceTransactionManager (chỉ JDBC) với nestedTransactionAllowed | Thành công: phần ngoài commit, nested-work bị hoàn tác |
Trong cấu hình thành công, bảng audit_log sau cùng là [outer-before-nested, outer-after-nested] còn nested-work mất: đúng ý nghĩa của điểm lưu. Bật qua Spring Boot bằng bean TransactionManagerCustomizer:
@Bean
TransactionManagerCustomizer<DataSourceTransactionManager> allowNestedJdbc() {
return manager -> manager.setNestedTransactionAllowed(true);
}
Hệ quả: với JPA, đừng dựa vào NESTED; dùng REQUIRES_NEW cho phần độc lập, hoặc cấu trúc lại để không cần hoàn tác riêng phần. NESTED chỉ dùng được ở ứng dụng JDBC thuần.
MANDATORY và NEVER: biến giả định thành kiểm tra
MANDATORY bắt buộc phải có giao dịch sẵn (lỗi nếu không); NEVER bắt buộc không có giao dịch. Lab:
PROP MANDATORY without transaction: IllegalTransactionStateException :: No existing transaction found for transaction marked with propagation 'mandatory'
PROP MANDATORY inside transaction: ok
PROP NEVER inside transaction: IllegalTransactionStateException :: Existing transaction found for transaction marked with propagation 'never'
Dùng MANDATORY cho phương thức tầng thấp chỉ đúng khi nằm trong một đơn vị công việc do tầng trên mở (ví dụ thao tác ghi cần cùng giao dịch với phần khác), và NEVER cho thao tác không được chạy trong giao dịch (gọi dịch vụ ngoài chậm).
Mức cô lập: dị thường đọc không lặp lại
PostgreSQL mặc định READ COMMITTED: mỗi câu lệnh thấy dữ liệu đã commit tại thời điểm câu lệnh bắt đầu. Lab cho một giao dịch đọc số dư hai lần, giữa hai lần một giao dịch khác commit cập nhật:
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setIsolationLevel(isolation);
new TransactionTemplate(txm, def).execute(s -> {
Long first = jdbc.queryForObject("SELECT balance FROM accounts WHERE id = 1", Long.class);
firstRead.countDown();
updated.await();
Long second = jdbc.queryForObject("SELECT balance FROM accounts WHERE id = 1", Long.class);
return first + " then " + second;
});
ISOLATION READ_COMMITTED reads twice: 100 then 150
ISOLATION REPEATABLE_READ reads twice: 100 then 100
Với READ_COMMITTED, hai lần đọc trong cùng giao dịch cho hai giá trị khác nhau (đọc không lặp lại); với REPEATABLE_READ, giao dịch thấy một bức ảnh nhất quán từ lúc nó bắt đầu. Chọn mức cô lập là chọn đánh đổi: mức cao hơn cho nhất quán đọc nhưng làm tăng khả năng xung đột ghi (PostgreSQL ném lỗi tuần tự hóa phải thử lại, bài JDBC đã nói về CannotAcquireLockException). Quy tắc thực hành: dùng mặc định cho đa số logic; nâng lên khi một báo cáo trong một giao dịch cần đọc nhất quán nhiều lần; viết logic kiểm tra rồi cập nhật bằng khóa hoặc UPDATE có điều kiện thay vì trông vào cô lập.
readOnly và timeout
@Transactional(readOnly = true) không chỉ là gợi ý cho Hibernate (bài trước thấy nó tắt flush): Spring còn đặt chế độ chỉ đọc lên kết nối JDBC, và PostgreSQL thi hành nó. Lab chèn một dòng từ phương thức chỉ đọc:
READONLY UncategorizedSQLException :: ... SQL state [25006]; ERROR: cannot execute INSERT in a read-only transaction
Tức là cố ghi trong giao dịch chỉ đọc bị cơ sở dữ liệu từ chối (mã 25006). Dùng nó làm lưới an toàn cho đường đọc và cho phép chuyển truy vấn sang bản sao chỉ đọc. Còn timeout (giây) đặt giới hạn thời gian cho giao dịch. Lab đặt @Transactional(timeout = 1) với một câu pg_sleep(2):
TIMEOUT QueryTimeoutException after 1011 ms :: ... ERROR: canceling statement due to user request
Thời gian chờ giao dịch thể hiện ở mức câu lệnh: lệnh đang chạy bị hủy sau khoảng 1 giây (Spring đặt thời gian chờ của câu lệnh bằng phần thời gian còn lại của giao dịch), và ngoại lệ là QueryTimeoutException. Đặt timeout cho các giao dịch có thể kéo dài để chúng không giữ khóa và kết nối vô hạn.
Sự kiện gắn với giao dịch: chỉ gửi sau khi commit
Gửi email, đẩy sự kiện hay xóa cache trong lúc giao dịch còn mở nghĩa là bên ngoài có thể thấy kết quả của một giao dịch sau đó rollback. @TransactionalEventListener trì hoãn xử lý sự kiện tới một pha của giao dịch:
@Component
public class Events {
public record OrderPlaced(String id) {
}
private final ApplicationEventPublisher publisher;
Events(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
@Transactional
public void place(String id, boolean fail) {
publisher.publishEvent(new OrderPlaced(id));
if (fail) {
throw new IllegalStateException("rollback");
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onCommit(OrderPlaced event) {
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
void onRollback(OrderPlaced event) {
}
}
Lab gọi place("ok-1", false) rồi place("bad-1", true): trình nghe AFTER_COMMIT chỉ nhận ok-1, trình nghe AFTER_ROLLBACK chỉ nhận bad-1. Vậy việc gửi email xác nhận đặt vào trình nghe AFTER_COMMIT đảm bảo chỉ đơn đã commit mới có email. Lưu ý: sau khi commit, giao dịch gốc đã kết thúc; nếu trình nghe cần ghi cơ sở dữ liệu, nó cần giao dịch mới (REQUIRES_NEW). Và nếu trình nghe thất bại, giao dịch gốc đã commit rồi nên lỗi không hoàn tác được: kết hợp mẫu outbox (bài email) khi việc phải chắc chắn thực hiện.
Nguyên tắc thiết kế ranh giới giao dịch
- Đặt
@Transactionalở tầng dịch vụ (đơn vị công việc nghiệp vụ), không ở controller hay repository. - Giữ giao dịch ngắn: không gọi API ngoài, gửi email, đọc tệp lớn bên trong (bài kết nối đã đo hậu quả cạn pool).
- Một giao dịch một mục đích: nếu phương thức làm hai việc độc lập, tách để mỗi việc có số phận riêng.
- Quy tắc rollback mặc định chỉ cho
RuntimeExceptionvàError(bài PostgreSQL đã chứng minh); khai báorollbackForkhi dùng ngoại lệ kiểm soát. - Nhớ giới hạn của proxy: gọi nội bộ, phương thức không công khai và
finalkhông được áp dụng (bài CRUD). - Với luồng điều khiển phức tạp,
TransactionTemplatecho kiểm soát tường minh thay vì chú thích.
Quyết định thiết kế và đánh đổi
| Nhu cầu | Cách | Đánh đổi |
|---|---|---|
| Kiểm toán không mất khi rollback | REQUIRES_NEW | Kết nối thứ hai, nguy cơ khóa chờ |
| Phần con có thể hỏng mà không cứu giao dịch cha | REQUIRES_NEW (JPA) hoặc NESTED (JDBC) | NESTED không chạy với JPA |
| Bắt ngoại lệ của dịch vụ con trong cùng giao dịch | Tránh, hoặc tách giao dịch | Dễ gặp chỉ-rollback |
| Phương thức phải nằm trong giao dịch | MANDATORY | Lỗi nếu gọi sai chỗ |
| Báo cáo cần nhất quán nhiều lần đọc | REPEATABLE_READ hoặc ảnh chụp | Xung đột ghi cần thử lại |
| Đường đọc | readOnly = true | Cơ sở dữ liệu thi hành chặn ghi |
| Giao dịch có thể kéo dài | timeout | Có thể bị hủy giữa chừng |
| Tác dụng phụ ra ngoài | AFTER_COMMIT hoặc outbox | Mất nếu tiến trình chết sau commit |
Sổ tay xử lý sự cố
- Triệu chứng: dòng kiểm toán mất khi rollback. Nguyên nhân: cùng giao dịch. Sửa:
REQUIRES_NEW. - Triệu chứng:
UnexpectedRollbackExceptiondù đã bắt ngoại lệ. Nguyên nhân: giao dịch bị đánh dấu chỉ-rollback bởi phương thức con tham gia. Sửa: tách giao dịch hoặc đừng bắt. - Triệu chứng:
NestedTransactionNotSupportedException. Nguyên nhân: JPA không hỗ trợ điểm lưu. Sửa:REQUIRES_NEWhoặc JDBC thuần. - Triệu chứng: pool kết nối cạn khi dùng
REQUIRES_NEW. Nguyên nhân: mỗi giao dịch ngoài giữ một kết nối chờ kết nối con. Sửa: tăng pool có tính toán, giảm lồng nhau. - Triệu chứng: kết quả đọc thay đổi giữa hai truy vấn trong một giao dịch. Nguyên nhân:
READ_COMMITTED. Sửa:REPEATABLE_READhoặc gom truy vấn. - Triệu chứng:
cannot execute INSERT in a read-only transaction. Nguyên nhân: ghi trong phương thứcreadOnly. Sửa: bỏreadOnlyhoặc chuyển ghi sang phương thức khác. - Triệu chứng: email gửi cho đơn đã rollback. Nguyên nhân: gửi trong giao dịch. Sửa:
AFTER_COMMIThoặc outbox. - Triệu chứng:
IllegalTransactionStateExceptionvềmandatoryhoặcnever. Nguyên nhân: gọi sai ngữ cảnh. Sửa: đặt lại ranh giới giao dịch.
Hiệu năng, bảo mật và vận hành
- Theo dõi thời gian giữ kết nối (
hikaricp.connections.usage) để thấy giao dịch dài. - Đặt
statement_timeoutvàidle_in_transaction_session_timeoutở cơ sở dữ liệu làm lưới an toàn. - Ghi log lúc rollback với nguyên nhân (cấp độ
org.springframework.transactionở môi trường điều tra). - Viết kiểm thử tích hợp với PostgreSQL thật cho mọi ranh giới giao dịch quan trọng (H2 không tái hiện điểm lưu, khóa và cô lập như thật).
Bài thực hành
- Dựng
accounts,audit_log,TransferService,AuditService; chứng minh dòng kiểm toán mất vớiREQUIREDvà còn vớiREQUIRES_NEWkhi chuyển vượt số dư. - Viết phương thức ngoài bắt ngoại lệ của phương thức con
REQUIREDvà quan sátUnexpectedRollbackException. - Thử
NESTEDvới JPA (hai cấu hình) và vớiDataSourceTransactionManager; ghi lại ba kết quả. - Gọi
MANDATORYngoài và trong giao dịch,NEVERtrong giao dịch. - Dùng
TransactionTemplatevới hai mức cô lập và một cập nhật xen giữa để chứng minh đọc không lặp lại. - Ghi từ phương thức
readOnlyvàtimeout = 1vớipg_sleep(2). - Viết
@TransactionalEventListenerAFTER_COMMITvàAFTER_ROLLBACKvà xác nhận từng cái nhận đúng sự kiện.
Tiêu chí hoàn thành: bảng kiểm toán [attempt 1->2 amount 500] rồi []; UnexpectedRollbackException; ba kết quả NESTED khác nhau; 100 then 150 và 100 then 100; mã 25006; QueryTimeoutException khoảng 1 giây; afterCommit=[ok-1] và afterRollback=[bad-1]. Gợi ý nếu NESTED vẫn lỗi với JDBC: kiểm tra đã loại cấu hình JPA khỏi ngữ cảnh để Boot tạo DataSourceTransactionManager.
Tự kiểm tra
REQUIREDvàREQUIRES_NEWkhác nhau thế nào về kết nối và về số phận commit?- Vì sao
UnexpectedRollbackExceptionxuất hiện dù bạn đã bắt ngoại lệ của phương thức con? - Vì sao
NESTEDkhông chạy vớiJpaTransactionManagermặc định và cách có được điểm lưu? - Dị thường nào xảy ra ở
READ_COMMITTEDmàREPEATABLE_READngăn được? readOnly = truelàm gì ở tầng Hibernate và ở tầng cơ sở dữ liệu?timeoutcủa@Transactionalbiểu hiện thế nào khi vượt?- Vì sao gửi email nên ở trình nghe
AFTER_COMMIT, và rủi ro còn lại?
Gợi ý đáp án. (1) REQUIRED dùng chung giao dịch và kết nối, commit hoặc rollback cùng nhau; REQUIRES_NEW treo giao dịch ngoài và mở kết nối mới, commit độc lập. (2) Phương thức con tham gia đã đánh dấu giao dịch chung là chỉ-rollback khi ngoại lệ đi qua proxy; ngoài cố commit thì Spring ném lỗi. (3) JPA dialect không hỗ trợ điểm lưu (và cần bật nestedTransactionAllowed); chỉ DataSourceTransactionManager với JDBC thuần hỗ trợ. (4) Đọc không lặp lại: hai lần đọc cùng một hàng cho giá trị khác nhau. (5) Hibernate không flush dirty checking; Spring đặt kết nối JDBC chỉ đọc và PostgreSQL từ chối ghi (25006). (6) Câu lệnh đang chạy bị hủy, ngoại lệ QueryTimeoutException. (7) Để email chỉ gửi cho giao dịch đã commit; rủi ro: tiến trình chết sau commit và trước khi gửi nên phải dùng outbox khi cần chắc chắn.
Điều cần nhớ
REQUIRES_NEWcho thao tác tồn tại độc lập; nhớ cái giá về kết nối và khóa.- Bắt ngoại lệ của phương thức con tham gia không cứu giao dịch: nó đã bị đánh dấu chỉ-rollback.
NESTEDkhông dùng được với JPA; chỉ với JDBC thuần.READ_COMMITTEDcho phép đọc không lặp lại;readOnlyđược cơ sở dữ liệu thi hành;timeouthủy câu lệnh.- Tác dụng phụ ra ngoài đi sau commit, tốt nhất qua outbox.
Học liệu chọn lọc
- Spring Framework Reference: Transaction Propagation
- Spring Framework Reference: Using @Transactional
- Spring Framework Reference: Transaction-bound Events
- PostgreSQL Documentation: Transaction Isolation
- PostgreSQL Documentation: SET TRANSACTION (READ ONLY)
- The Java Tutorials (Oracle): Using Transactions (JDBC), Savepoints
- Spring Boot Reference: Transaction Management (TransactionManagerCustomizer)
- Sách: High-Performance Java Persistence (Vlad Mihalcea), chương về giao dịch và cô lập; Designing Data-Intensive Applications (Martin Kleppmann), chương về giao dịch.