@Transactional trong Spring: cơ chế, propagation và những cái bẫy
On this page
Chỉ một annotation, nhưng là nguồn gốc của vô số bug production: dữ liệu không rollback,
UnexpectedRollbackExceptionbí ẩn,LazyInitializationException, connection pool cạn kiệt. Bài này đi từ cơ chế bên dưới đến các tình huống thực tế.
TL;DR#
@Transactionalhoạt động nhờ AOP proxy: proxy mở transaction trước khi gọi method, commit khi thành công, rollback khi có exception phù hợp.- Transaction gắn với thread hiện tại (qua
ThreadLocaltrongTransactionSynchronizationManager). - Rollback mặc định chỉ với
RuntimeExceptionvàError. Checked exception → commit! DùngrollbackFornếu cần. - Propagation quyết định hành vi khi method transactional gọi method transactional khác. Phải nắm:
REQUIRED(mặc định),REQUIRES_NEW,NESTED. - Các trường hợp không hoạt động: self-invocation, method
private, exception bị catch và nuốt, checked exception, chạy ở thread khác, bean không do Spring quản lý. readOnly = truelà gợi ý tối ưu, không phải cơ chế bảo vệ tuyệt đối.- Giữ transaction ngắn: không gọi HTTP API bên ngoài, không gửi email trong transaction.
flowchart LR
C[Caller] --> P["Proxy: TransactionInterceptor"]
P -->|"1. begin"| TM[PlatformTransactionManager]
P -->|"2. invoke"| T[Method thật]
T -->|"3. return / throw"| P
P -->|"4. commit / rollback"| TM
1. Transaction là gì — nhắc nhanh#
Transaction là một đơn vị công việc tuân thủ ACID:
- Atomicity: tất cả hoặc không gì cả.
- Consistency: dữ liệu đi từ trạng thái hợp lệ sang trạng thái hợp lệ.
- Isolation: các transaction đồng thời không “giẫm chân” nhau (xem thêm 4 isolation level).
- Durability: đã commit thì không mất.
Không có Spring, bạn sẽ viết thủ công:
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false);
// ... nhiều câu SQL ...
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.close();
}
@Transactional là cách declarative để Spring làm đoạn boilerplate này thay bạn.
2. Cơ chế hoạt động bên dưới#
2.1. Các thành phần#
| Thành phần | Vai trò |
|---|---|
| Proxy (AOP) | Bọc bean, chặn lời gọi method |
TransactionInterceptor |
Advice đọc cấu hình @Transactional và điều phối |
PlatformTransactionManager |
Abstraction thực hiện begin/commit/rollback. Implementation: JpaTransactionManager, DataSourceTransactionManager, JtaTransactionManager… |
TransactionSynchronizationManager |
Lưu connection/EntityManager và trạng thái transaction theo ThreadLocal |
2.2. Luồng chi tiết#
sequenceDiagram
participant C as Caller
participant P as Proxy
participant TI as TransactionInterceptor
participant TM as JpaTransactionManager
participant TL as ThreadLocal
participant S as Service thật
participant R as Repository
C->>P: placeOrder()
P->>TI: invoke
TI->>TM: getTransaction(definition)
TM->>TL: bind EntityManager + Connection vào thread
TI->>S: placeOrder()
S->>R: save(order)
R->>TL: lấy EntityManager đã bind sẵn
S-->>TI: return
TI->>TM: commit()
TM->>TL: unbind, trả connection về pool
TI-->>C: kết quả
Điểm mấu chốt: repository không nhận connection qua tham số — nó lấy từ ThreadLocal. Vì vậy mọi thao tác trên cùng thread trong phạm vi method đều dùng chung một transaction. Và cũng vì vậy, code chạy ở thread khác sẽ không thấy transaction này.
2.3. Đặt @Transactional ở đâu?#
- Trên method hoặc class (áp dụng cho mọi method public của class). Annotation ở method ghi đè annotation ở class.
- Đặt ở service layer — nơi định nghĩa một use case hoàn chỉnh. Không đặt ở controller (transaction quá rộng), cũng không chỉ trông cậy vào repository (mỗi lần gọi repository là một transaction riêng).
- Nên dùng
org.springframework.transaction.annotation.Transactionalthay vìjakarta.transaction.Transactional: bản của Spring có đủ thuộc tính (propagation,isolation,readOnly,timeout…). - Spring Boot tự bật transaction management; không cần
@EnableTransactionManagement.
3. Rollback rules — bẫy số 1#
Mặc định: rollback khi gặp unchecked exception (RuntimeException và subclass) và Error. Checked exception → vẫn COMMIT.
flowchart TB
E{Method throw gì?} -->|RuntimeException| R[ROLLBACK]
E -->|Error| R
E -->|"Checked Exception (IOException, custom extends Exception)"| CM[COMMIT]
E -->|Không throw| CM
Lý do lịch sử: Spring kế thừa quan điểm của EJB — checked exception được xem là “business exception có thể xử lý được”.
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) throws InsufficientFundsException {
accountRepo.debit(from, amount);
if (balanceTooLow(from)) {
throw new InsufficientFundsException(); // extends Exception → debit vẫn COMMIT!
}
accountRepo.credit(to, amount);
}
Cách khắc phục:
@Transactional(rollbackFor = Exception.class) // rollback mọi exception
@Transactional(rollbackFor = InsufficientFundsException.class) // chỉ định cụ thể
@Transactional(noRollbackFor = NotificationFailedException.class) // ngược lại: không rollback
Hoặc thiết kế business exception kế thừa RuntimeException — cách được nhiều team chọn.
4. Propagation — khi transaction gặp transaction#
Propagation trả lời câu hỏi: “Method này được gọi khi đã có (hoặc chưa có) transaction thì làm gì?”
| Propagation | Đã có transaction | Chưa có transaction |
|---|---|---|
REQUIRED (mặc định) |
Tham gia vào | Tạo mới |
REQUIRES_NEW |
Tạm dừng cái cũ, tạo mới độc lập | Tạo mới |
NESTED |
Tạo savepoint trong cái cũ | Tạo mới |
SUPPORTS |
Tham gia vào | Chạy không transaction |
MANDATORY |
Tham gia vào | Throw exception |
NOT_SUPPORTED |
Tạm dừng cái cũ, chạy không transaction | Chạy không transaction |
NEVER |
Throw exception | Chạy không transaction |
4.1. REQUIRED — một transaction vật lý chung#
flowchart LR
subgraph TX["Transaction vật lý duy nhất"]
A["OrderService.placeOrder() - REQUIRED"] --> B["InventoryService.reserve() - REQUIRED"]
A --> C["PaymentService.charge() - REQUIRED"]
end
Bất kỳ method nào thất bại → toàn bộ rollback. Đây là hành vi mong muốn trong đa số trường hợp.
Bẫy UnexpectedRollbackException:
@Service
class OrderService {
@Transactional
public void placeOrder() {
orderRepo.save(order);
try {
loyaltyService.addPoints(userId); // REQUIRED, throw RuntimeException
} catch (Exception e) {
log.warn("Không cộng được điểm, bỏ qua"); // tưởng là đã xử lý xong...
}
} // commit → UnexpectedRollbackException!
}
sequenceDiagram
participant O as placeOrder - outer
participant L as addPoints - inner, REQUIRED
participant TX as Transaction chung
O->>TX: begin
O->>L: addPoints()
L-->>TX: RuntimeException → đánh dấu rollback-only
L-->>O: throw
Note over O: catch và nuốt exception
O->>TX: commit()
TX-->>O: UnexpectedRollbackException
Khi exception bay qua ranh giới proxy của method inner, transaction chung bị đánh dấu rollback-only. Outer không thể “cứu” nó bằng cách catch. Nếu thực sự muốn lỗi ở addPoints không ảnh hưởng order → dùng REQUIRES_NEW hoặc NESTED cho addPoints, hoặc chuyển sang xử lý sau commit.
4.2. REQUIRES_NEW — transaction độc lập#
sequenceDiagram
participant O as Outer TX1
participant A as AuditService - REQUIRES_NEW
participant DB
O->>DB: begin TX1, UPDATE account
O->>A: log()
Note over O: TX1 bị tạm dừng
A->>DB: begin TX2, INSERT audit_log
A->>DB: commit TX2
Note over O: TX1 tiếp tục
O->>DB: rollback TX1 do lỗi
Note over DB: account rollback, audit_log VẪN CÒN
Use case: audit log, log lỗi, cấp số thứ tự — những thứ phải được lưu dù transaction chính thất bại.
Lưu ý:
- Mỗi
REQUIRES_NEWchiếm thêm một connection trong khi connection của outer vẫn đang bị giữ. Dùng nhiều, hoặc gọi trong vòng lặp dưới tải cao → cạn connection pool, thậm chí deadlock pool. - TX2 không nhìn thấy dữ liệu chưa commit của TX1. Nếu TX2 cố update cùng row mà TX1 đang lock → tự deadlock.
- Nếu TX2 throw exception và exception lan ra outer mà không được catch, TX1 cũng rollback.
4.3. NESTED — savepoint#
Inner chạy trong cùng transaction vật lý nhưng có savepoint. Inner lỗi → rollback về savepoint, outer có thể tiếp tục. Outer rollback → inner cũng mất.
REQUIRES_NEW |
NESTED |
|
|---|---|---|
| Transaction vật lý | 2 transaction riêng | 1 transaction + savepoint |
| Connection | 2 | 1 |
| Inner commit độc lập khi outer rollback? | ✅ Có | ❌ Không |
| Inner rollback mà outer vẫn commit được? | ✅ | ✅ |
| Hỗ trợ | Mọi transaction manager | DataSourceTransactionManager (JDBC). Không hỗ trợ với JPA/Hibernate thông thường |
5. Isolation, readOnly, timeout#
@Transactional(
isolation = Isolation.REPEATABLE_READ,
readOnly = true,
timeout = 5 // giây
)
isolation: mặc địnhIsolation.DEFAULT= dùng default của database (PostgreSQL: Read Committed, MySQL InnoDB: Repeatable Read). Chỉ có hiệu lực khi bắt đầu transaction mới — nếu method tham gia transaction có sẵn (REQUIRED), isolation của nó bị bỏ qua.readOnly = true:- Hibernate đặt flush mode
MANUALvà bỏ snapshot cho dirty checking → tiết kiệm memory và CPU với query lớn. - JDBC driver nhận
Connection.setReadOnly(true); một số DB/driver tối ưu thêm. - Có thể dùng để route sang read replica (với
AbstractRoutingDataSource+LazyConnectionDataSourceProxy). - Không phải cơ chế chặn ghi đáng tin cậy — hành vi khi cố ghi phụ thuộc DB và driver.
- Hibernate đặt flush mode
timeout: transaction chạy quá thời gian sẽ bị đánh dấu rollback. Chỉ áp dụng cho transaction mới.
Pattern phổ biến:
@Service
@Transactional(readOnly = true) // mặc định cho cả class: đọc
public class ProductService {
public Product findById(Long id) { ... }
@Transactional // ghi đè cho method ghi
public Product update(Long id, UpdateRequest req) { ... }
}
6. Khi @Transactional KHÔNG hoạt động#
6.1. Self-invocation#
@Service
public class UserService {
public void importUsers(List<UserDto> dtos) {
dtos.forEach(this::createUser); // gọi qua this → KHÔNG qua proxy
}
@Transactional
public void createUser(UserDto dto) { ... } // không có transaction
}
Proxy chỉ chặn lời gọi từ bên ngoài. this là object thật. Cách xử lý: tách sang bean khác (khuyến nghị), self-injection qua @Lazy, hoặc dùng TransactionTemplate.
6.2. Visibility của method#
- Method
private→ không bao giờ hoạt động. - Trước Spring 6.0: chỉ method public được hỗ trợ.
- Từ Spring Framework 6.0 (Boot 3.x): với class-based proxy (CGLIB), method
protectedvà package-private cũng được hỗ trợ. - Method
finalhoặcstatic→ không hoạt động với CGLIB.
6.3. Exception bị nuốt#
@Transactional
public void process() {
try {
repo.save(a);
repo.save(b); // lỗi
} catch (Exception e) {
log.error("lỗi", e); // không rethrow → proxy thấy method return bình thường → COMMIT a
}
}
Nếu buộc phải catch mà vẫn muốn rollback: rethrow, hoặc gọi TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
6.4. Checked exception#
Đã nói ở phần 3 — commit mặc định.
6.5. Chạy ở thread khác#
@Transactional
public void process() {
orderRepo.save(order);
CompletableFuture.runAsync(() -> inventoryRepo.decrease(...)); // thread khác, KHÔNG thuộc transaction
}
Transaction gắn với ThreadLocal của thread gọi. Tương tự với @Async, parallelStream(), và reactive code (WebFlux cần ReactiveTransactionManager riêng).
6.6. Bean không do Spring quản lý, hoặc thiếu transaction manager phù hợp#
new UserService()→ không có proxy.- Ứng dụng có nhiều
DataSourcemà không chỉ định transaction manager → transaction chạy trên datasource A trong khi code ghi vào datasource B. Chỉ định bằng@Transactional(transactionManager = "orderTxManager"). - Bảng MySQL dùng engine MyISAM → không hỗ trợ transaction.
Tóm tắt nhanh#
flowchart TB
Q1{"Gọi từ bên ngoài bean, qua proxy?"} -- Không --> X[Không có transaction]
Q1 -- Có --> Q2{"Method private / final / static?"}
Q2 -- Có --> X
Q2 -- Không --> Q3{"Cùng thread?"}
Q3 -- Không --> X
Q3 -- Có --> Q4{"Có exception thoát ra khỏi method?"}
Q4 -- Không --> CM[COMMIT]
Q4 -- Có --> Q5{"RuntimeException / Error hoặc khớp rollbackFor?"}
Q5 -- Có --> RB[ROLLBACK]
Q5 -- Không --> CM
7. Transaction và JPA: những điều liên quan#
- Dirty checking: trong transaction, entity managed bị sửa sẽ tự động
UPDATEkhi commit — không cần gọisave(). Đây là “tác dụng phụ” hay làm người mới bất ngờ. LazyInitializationException: truy cập lazy association sau khi transaction (và persistence context) đã đóng — thường ở controller hoặc khi serialize JSON. Giải pháp: fetch đủ dữ liệu trong transaction (JOIN FETCH,@EntityGraph) và trả về DTO.- Open Session In View (
spring.jpa.open-in-view, mặc địnhtrue): giữEntityManagermở suốt request nên che giấu lỗi lazy loading, nhưng làm connection bị giữ lâu hơn cần thiết. Nhiều team đặtfalse. - Flush trước khi query: Hibernate có thể flush các thay đổi pending trước một JPQL query (flush mode
AUTO) để query thấy dữ liệu mới nhất. - Constraint violation phát hiện muộn: lỗi unique constraint có thể chỉ xuất hiện lúc commit/flush, tức là sau khi method của bạn đã return — try/catch trong method sẽ không bắt được. Dùng
saveAndFlush()nếu cần bắt lỗi ngay.
8. Transaction ngắn — nguyên tắc vàng#
Transaction dài giữ connection và lock lâu, làm giảm throughput và tăng khả năng deadlock.
// ❌ Không nên
@Transactional
public void checkout(Cart cart) {
Order order = orderRepo.save(toOrder(cart));
paymentGateway.charge(order); // HTTP call 2-5 giây, đang giữ connection DB
emailService.sendConfirmation(order); // gửi email trong transaction, nếu sau đó rollback thì email đã đi
}
Cách tốt hơn:
- Gọi dịch vụ bên ngoài trước hoặc sau transaction.
- Dùng
@TransactionalEventListener(phase = AFTER_COMMIT)để thực hiện side effect chỉ khi transaction đã commit thành công:
@Transactional
public void checkout(Cart cart) {
Order order = orderRepo.save(toOrder(cart));
eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
}
@Component
class OrderEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent e) {
emailService.sendConfirmation(e.orderId());
}
}
- Với việc publish message sang Kafka/RabbitMQ cần đảm bảo nhất quán tuyệt đối → dùng Outbox pattern.
9. Programmatic transaction#
Khi cần kiểm soát chi tiết (một phần method trong transaction, retry quanh transaction, tránh self-invocation):
@Service
@RequiredArgsConstructor
public class ImportService {
private final TransactionTemplate transactionTemplate;
public void importBatch(List<Row> rows) {
for (List<Row> chunk : partition(rows, 500)) {
transactionTemplate.executeWithoutResult(status -> {
chunk.forEach(this::saveRow); // mỗi chunk một transaction
});
}
}
}
Cấu hình propagation/isolation/timeout trên TransactionTemplate giống annotation.
10. Testing#
@DataJpaTestvà các test có@Transactionalsẽ tự rollback sau mỗi test — tiện, nhưng có thể che giấu lỗi chỉ xuất hiện lúc commit (constraint,LazyInitializationException). Với integration test quan trọng, cân nhắc không dùng rollback tự động và tự dọn dữ liệu.- Kiểm tra có đang trong transaction:
TransactionSynchronizationManager.isActualTransactionActive(). - Bật log để quan sát:
logging.level.org.springframework.transaction=DEBUGvàlogging.level.org.springframework.orm.jpa=DEBUG.
11. Câu hỏi phỏng vấn thường gặp#
@Transactionalhoạt động thế nào? → AOP proxy +TransactionInterceptor+PlatformTransactionManager, trạng thái lưu trongThreadLocal.- Mặc định rollback với exception nào? →
RuntimeExceptionvàError; checked exception thì commit. - Liệt kê các trường hợp annotation không có tác dụng. → Self-invocation, private/final, exception bị nuốt, checked exception, thread khác, không phải Spring bean.
REQUIREDkhácREQUIRES_NEW? → Tham gia chung vs tạo transaction độc lập; use case audit log; rủi ro cạn connection pool.REQUIRES_NEWkhácNESTED? → Hai transaction vật lý vs savepoint;NESTEDkhông hỗ trợ JPA.UnexpectedRollbackExceptionxảy ra khi nào? → Inner (REQUIRED) throw làm transaction rollback-only, outer catch rồi cố commit.readOnly = truecó tác dụng gì? → Tối ưu Hibernate flush/dirty checking, gợi ý cho driver, route replica.- Làm sao gửi email chỉ khi transaction commit thành công? →
@TransactionalEventListener(AFTER_COMMIT)hoặc Outbox.