Propagation và rollback nâng cao: REQUIRED, REQUIRES_NEW, NESTED
Trình bày thêm về @Transactional và cơ chế rollback nâng cao đi.
Bài trước là "một method, một transaction". Thực tế service gọi service. Câu hỏi là: transaction lan truyền (propagate) thế nào qua các lời gọi đó.
1. Bài toán: ghi audit log khi đặt hàng
Trong shop-app, OrderService.createOrder() (có @Transactional) gọi:
InventoryService.decreaseStock(): phải cùng sống chết với Order.AuditLogService.log(): ghi lại "user X cố đặt hàng", kể cả khi đặt hàng thất bại.
2. Vấn đề
Mặc định mọi @Transactional là REQUIRED: tham gia transaction đang có. Vậy:
createOrder() [Tx A]
├─ decreaseStock() → join Tx A ✔ đúng ý
├─ log() → join Tx A ✘ Order lỗi → audit log cũng bị rollback
└─ throw RuntimeException → ROLLBACK Tx A
Audit log mất, không còn dấu vết. Thêm một bẫy: nếu decreaseStock() ném exception và createOrder() catch để "bỏ qua", lúc commit sẽ nổ UnexpectedRollbackException.
3. Giải pháp: chọn propagation
| Propagation | Đã có transaction | Chưa có transaction |
|---|---|---|
| REQUIRED (mặc định) | Tham gia | Tạo mới |
| REQUIRES_NEW | Tạm dừng Tx ngoài, tạo Tx mới độc lập (connection mới) | Tạo mới |
| NESTED | Tạo savepoint trong Tx ngoài | Tạo mới (như REQUIRED) |
| SUPPORTS | Tham gia | Chạy không transaction |
| NOT_SUPPORTED | Tạm dừng Tx ngoài, chạy không transaction | Không transaction |
| MANDATORY | Tham gia | Ném exception |
| NEVER | Ném exception | Không transaction |
Ba cái cần nắm chắc: REQUIRED, REQUIRES_NEW, NESTED.
4. Code từng bước
Bước 1: Dependency. Như các bài trước.
Bước 2: Config. Bật log transaction manager để thấy "Suspending current transaction", "Creating nested transaction":
logging:
level:
org.springframework.orm.jpa.JpaTransactionManager: debug
org.springframework.transaction: debug
Bước 3: Code Java
@Entity
@Table(name = "audit_logs")
public class AuditLog {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String action;
private LocalDateTime createdAt = LocalDateTime.now();
protected AuditLog() {}
public AuditLog(String action) { this.action = action; }
}
@Service
public class AuditLogService {
private final AuditLogRepository repository;
public AuditLogService(AuditLogRepository repository) { this.repository = repository; }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String action) {
repository.save(new AuditLog(action));
} // COMMIT riêng, ngay tại đây
}
@Service
public class OrderService {
private final InventoryService inventoryService;
private final AuditLogService auditLogService;
private final OrderRepository orderRepository;
public OrderService(InventoryService inventoryService,
AuditLogService auditLogService,
OrderRepository orderRepository) {
this.inventoryService = inventoryService;
this.auditLogService = auditLogService;
this.orderRepository = orderRepository;
}
@Transactional
public Order createOrder(Long userId, Long productId, int qty) {
auditLogService.log("user " + userId + " create order"); // Tx B, commit luôn
inventoryService.decreaseStock(productId, qty); // join Tx A
Order order = new Order();
// ...
return orderRepository.save(order);
}
}
Chú ý: AuditLogService là bean riêng. Nếu để log() trong OrderService và gọi this.log() thì REQUIRES_NEW bị bỏ qua (self-invocation, bài 4.3.3).
Bẫy UnexpectedRollbackException
@Transactional
public void createOrder(...) {
try {
inventoryService.decreaseStock(productId, qty); // REQUIRED, ném RuntimeException
} catch (IllegalStateException e) {
log.warn("Hết hàng, vẫn tạo order chờ hàng"); // nuốt lỗi
}
orderRepository.save(order);
} // commit → UnexpectedRollbackException!
Khi exception bay qua proxy của decreaseStock(), Spring đã đánh dấu Tx A là rollback-only (vì đó là transaction dùng chung). Catch ở ngoài không xóa được dấu đó. Lúc commit, Spring rollback và ném UnexpectedRollbackException để báo "bạn tưởng commit nhưng thực ra rollback".
Cách sửa, tùy ý nghĩa nghiệp vụ:
- Kiểm tra trước (
hasStock()) thay vì dùng exception điều khiển luồng. - Cho
decreaseStock()chạyREQUIRES_NEWhoặcNESTEDnếu thật sự muốn phần đó thất bại độc lập. @Transactional(noRollbackFor = IllegalStateException.class)trêndecreaseStock()nếu lỗi đó không phải lỗi dữ liệu.
NESTED: savepoint
@Transactional(propagation = Propagation.NESTED)
public void applyVoucher(Long orderId, String code) { ... }
Lỗi trong applyVoucher() chỉ rollback về savepoint, Order vẫn commit được. Lưu ý: NESTED cần JDBC savepoint. JpaTransactionManager với Hibernate dùng được savepoint, nhưng rollback về savepoint không rollback Persistence Context: các Entity đã sửa trong bộ nhớ vẫn mang giá trị cũ-mới lẫn lộn và có thể bị flush lại. Vì vậy NESTED hợp nhất với JDBC thuần (DataSourceTransactionManager); với JPA nhiều team dùng REQUIRES_NEW hoặc kiểm tra trước thay thế.
Bước 4: Test bằng curl
curl -X POST "http://localhost:8080/orders?userId=10&productId=1&qty=999"
# 409 Insufficient stock
psql -c "select action from audit_logs order by id desc limit 1;"
# user 10 create order ← vẫn còn nhờ REQUIRES_NEW
psql -c "select count(*) from orders;" # không tăng
5. Cách hoạt động
REQUIRES_NEW
createOrder() Tx A (connection #1) ──────────────── ROLLBACK
|
+── log() Tx A SUSPENDED
Tx B (connection #2) ── COMMIT
Tx A RESUMED
NESTED
createOrder() Tx A ──────── SAVEPOINT sp1 ────────── COMMIT
|
applyVoucher() lỗi
|
ROLLBACK TO SAVEPOINT sp1
(phần trước sp1 vẫn giữ)
REQUIRED + catch
createOrder() Tx A ── decreaseStock() ném lỗi ── mark rollback-only
── catch, tiếp tục
── commit? → ROLLBACK + UnexpectedRollbackException
6. So sánh: REQUIRES_NEW vs NESTED
| REQUIRES_NEW | NESTED | |
|---|---|---|
| Transaction vật lý | Tx mới, connection mới | Cùng Tx, thêm savepoint |
| Inner commit | Commit ngay, độc lập | Chỉ commit khi Tx ngoài commit |
| Outer rollback | Inner vẫn giữ | Inner cũng mất |
| Inner rollback | Outer không ảnh hưởng (nếu catch exception) | Về savepoint, outer tiếp tục (nếu catch) |
| Rủi ro | Cần 2 connection: pool nhỏ + tải cao dễ deadlock pool; inner không thấy dữ liệu chưa commit của outer | Phụ thuộc hỗ trợ savepoint |
| Dùng cho | Audit log, outbox ghi lỗi, sinh số thứ tự | Bước tùy chọn có thể bỏ qua |
7. Khi nào nên dùng / không nên dùng
- REQUIRED: 95% trường hợp.
- REQUIRES_NEW: dữ liệu phải sống dù nghiệp vụ chính thất bại (audit, log lỗi). Không lạm dụng: mỗi lần tốn thêm một connection, và gọi lồng nhiều tầng dưới tải cao có thể làm pool cạn (thread giữ connection #1 chờ connection #2).
- NESTED: bước phụ có thể thất bại riêng, khi transaction manager hỗ trợ savepoint.
- MANDATORY: method chỉ được gọi trong transaction có sẵn (ví dụ repository custom), giúp phát hiện gọi sai.
- Đừng dùng exception để điều khiển luồng nghiệp vụ qua ranh giới transaction.
mappedBy, cascade, orphanRemoval nghĩa là gì?REQUIRES_NEW thì transaction trong có nhìn thấy Order vừa insert ở transaction ngoài không?
Không. Đây là điểm hay bị hỏi. Tx B là transaction độc lập, connection khác. Order của Tx A chưa commit nên với isolation READ COMMITTED (mặc định của PostgreSQL), Tx B không thấy.
Tệ hơn: nếu Tx A đã UPDATE products ... WHERE id=1 (giữ row lock) và Tx B cũng UPDATE dòng đó, Tx B chờ Tx A, còn Tx A đang chờ Tx B kết thúc. Đây là deadlock trong cùng một thread mà database không phát hiện được (vì Tx A không chờ lock DB, nó chờ Java). Chỉ có lock timeout cứu. Vì vậy REQUIRES_NEW nên đụng vào bảng khác (audit_logs), không đụng dòng mà Tx ngoài đang sửa.
rollbackFor và noRollbackFor dùng khi nào cho đúng?
Đúng rồi, đó là hai công cụ điều chỉnh rollback rules:
// Checked exception nghiệp vụ cũng phải rollback
@Transactional(rollbackFor = PaymentDeclinedException.class)
// Lỗi "mềm" không làm hỏng dữ liệu, vẫn muốn commit phần đã làm
@Transactional(noRollbackFor = NotificationFailedException.class)
Nhiều team đặt quy ước: exception nghiệp vụ đều extends RuntimeException, nên không cần rollbackFor. Nếu dùng checked exception, cấu hình rollbackFor = Exception.class một lần cho nhất quán. Lưu ý rule áp dụng tại proxy nơi exception bay qua, kể cả proxy của method con REQUIRED: nó vẫn đánh rollback-only cho transaction chung.
Phỏng vấn hỏi propagation thì trả lời sao?
Q1. REQUIRED và REQUIRES_NEW khác nhau thế nào?
🇻🇳 Tiếng Việt:
REQUIRED tham gia transaction hiện có, không có thì tạo mới; mọi thứ commit hoặc rollback cùng nhau. REQUIRES_NEW tạm dừng transaction ngoài, mở transaction vật lý mới với connection riêng, commit hoặc rollback độc lập. Em dùng REQUIRES_NEW cho audit log cần giữ lại khi nghiệp vụ chính lỗi.
🇬🇧 English:
REQUIRED joins the existing transaction or creates one if none exists, so everything commits or rolls back together. REQUIRES_NEW suspends the outer transaction and starts a new physical transaction with its own connection that commits or rolls back independently. I use it for audit logs that must survive a failed business operation.
Q2. NESTED khác REQUIRES_NEW thế nào?
🇻🇳 Tiếng Việt:
NESTED vẫn nằm trong transaction ngoài, chỉ tạo savepoint. Lỗi bên trong rollback về savepoint; nhưng nếu transaction ngoài rollback thì phần bên trong cũng mất. REQUIRES_NEW là transaction riêng, commit trước và không bị ảnh hưởng khi ngoài rollback. NESTED cần hỗ trợ savepoint của JDBC.
🇬🇧 English:
NESTED runs inside the outer transaction using a savepoint. A failure inside rolls back to the savepoint, but if the outer transaction rolls back, the nested work is lost too. REQUIRES_NEW is a separate transaction that commits on its own and survives an outer rollback. NESTED requires JDBC savepoint support.
Q3. UnexpectedRollbackException xảy ra khi nào?
🇻🇳 Tiếng Việt:
Khi method con dùng REQUIRED ném exception qua proxy, Spring đánh dấu transaction chung là rollback-only. Method ngoài catch exception rồi cố commit, Spring phát hiện dấu rollback-only, rollback và ném UnexpectedRollbackException. Sửa bằng cách không nuốt lỗi, kiểm tra trước, hoặc dùng REQUIRES_NEW/NESTED cho phần được phép thất bại.
🇬🇧 English:
It happens when an inner REQUIRED method throws an exception through its proxy, so Spring marks the shared transaction as rollback-only. The outer method catches the exception and tries to commit; Spring sees the rollback-only flag, rolls back and throws UnexpectedRollbackException. The fix is to not swallow the error, validate up front, or use REQUIRES_NEW or NESTED for the part that is allowed to fail.
Cách nhớ ngắn gọn
- REQUIRED = đi chung xe. REQUIRES_NEW = gọi xe riêng (connection riêng). NESTED = điểm dừng (savepoint) trên cùng xe.
- Exception bay qua proxy con REQUIRED → cả xe bị đánh dấu rollback-only.
- Catch rồi commit → UnexpectedRollbackException.
- REQUIRES_NEW tốn connection, không thấy dữ liệu chưa commit của Tx ngoài.
Tự kiểm tra
Propagation mặc định của @Transactional là gì?
REQUIRED.
Audit log dùng REQUIRES_NEW, sau đó createOrder rollback. Audit log còn không?
Còn, vì Tx của audit log đã commit độc lập.
Cùng tình huống nhưng dùng NESTED thì sao?
Mất, vì NESTED chỉ là savepoint trong Tx ngoài; ngoài rollback thì toàn bộ rollback.
Vì sao catch exception của method con REQUIRED rồi commit lại nổ lỗi?
Transaction chung đã bị đánh rollback-only khi exception đi qua proxy con; commit sẽ rollback và ném UnexpectedRollbackException.
Rủi ro khi lạm dụng REQUIRES_NEW dưới tải cao?
Mỗi thread cần 2 connection; pool nhỏ có thể cạn và treo (pool deadlock); lock trên cùng dòng giữa Tx ngoài và trong gây chờ vô hạn.