@Transactional và 5 cái bẫy hay gặp
Tiếp bài @Transactional nhé.
Phần này liên quan trực tiếp đến race condition, deadlock và system design. Đây là chỗ phỏng vấn Senior hay đào sâu nhất.
1. Bài toán: đặt hàng trong shop-app
Một khách hàng đặt hàng, hệ thống phải:
- Kiểm tra Product và trừ stock.
- Tạo Order.
- Tạo OrderItem.
Nếu bước 3 lỗi, bước 1 và 2 không được commit. Tất cả hoặc không gì cả.
2. Vấn đề
Không có transaction, mỗi câu SQL tự commit (auto-commit). Bước 3 lỗi thì stock đã bị trừ, Order "mồ côi" vẫn nằm trong DB. Dữ liệu sai lệch.
Nhưng thêm @Transactional rồi vẫn có những trường hợp transaction không được tạo hoặc không rollback mà không hề báo lỗi. Đó là các bẫy của bài này.
3. Giải pháp: @Transactional qua AOP proxy
Spring không sửa code của bạn. Nó tạo một proxy bọc bean, và chỉ lời gọi đi qua proxy mới có transaction.
Controller
|
v
+--------------------------------+
| OrderService$$Proxy |
| 1. getTransaction() → BEGIN |
| 2. gọi target.createOrder() | ---> OrderService (object thật)
| 3. không lỗi → flush, COMMIT |
| RuntimeException/Error |
| → ROLLBACK |
+--------------------------------+
Từ hình này suy ra được gần hết các bẫy.
4. Code từng bước
Bước 1: Dependency. spring-boot-starter-data-jpa đã có JpaTransactionManager, không cần thêm gì.
Bước 2: Config. Log transaction để nhìn thấy BEGIN/COMMIT/ROLLBACK:
logging:
level:
org.springframework.orm.jpa.JpaTransactionManager: debug
org.hibernate.SQL: debug
Bước 3: Code Java. Phiên bản đúng:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
public OrderService(OrderRepository orderRepository,
ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}
@Transactional
public Order createOrder(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Product not found"));
if (product.getStock() < quantity) {
throw new IllegalStateException("Insufficient stock");
}
product.setStock(product.getStock() - quantity); // dirty checking
Order order = new Order();
OrderItem item = new OrderItem();
item.setProduct(product);
item.setQuantity(quantity);
order.addItem(item);
return orderRepository.save(order);
}
}
Code này minh họa transaction, nhưng chưa an toàn trước concurrent request: hai transaction cùng đọc stock = 10 và cùng trừ (lost update). Cách xử lý (@Version, SELECT ... FOR UPDATE, atomic UPDATE) nằm ở bài locking.
Bẫy 1: Self-invocation
@Service
public class PaymentService {
public void process() {
updatePayment(); // this.updatePayment() → KHÔNG qua proxy
}
@Transactional
public void updatePayment() {
// database operations
}
}
Gọi process() từ ngoài: proxy thấy process() không có @Transactional, gọi thẳng target. Bên trong this.updatePayment() là lời gọi Java thường, annotation bị bỏ qua. Sửa: tách sang bean khác.
@Service
public class PaymentService {
private final PaymentWriter writer;
public PaymentService(PaymentWriter writer) { this.writer = writer; }
public void process() {
writer.updatePayment(); // đi qua proxy của PaymentWriter
}
}
@Service
public class PaymentWriter {
@Transactional
public void updatePayment() { /* ... */ }
}
Bẫy 2: Method private / final
@Transactional
private void saveInternal() { ... } // proxy không override được
Proxy CGLIB chỉ override được method non-private, non-final. Từ Spring 6, method protected/package-private được hỗ trợ với class-based proxy, nhưng private thì không bao giờ. Quy ước an toàn: đặt @Transactional trên method public và gọi từ bean khác.
Bẫy 3: Checked exception không rollback
@Transactional
public void process() throws Exception {
orderRepository.save(order);
throw new Exception("Failed"); // checked → mặc định COMMIT!
}
Quy tắc mặc định: RuntimeException và Error → rollback; checked exception → commit. Sửa:
@Transactional(rollbackFor = Exception.class)
public void process() throws Exception { ... }
Một biến thể: tự try/catch nuốt exception bên trong method transactional. Proxy không thấy lỗi nên vẫn commit.
Bẫy 4: readOnly = true hiểu sai
@Transactional(readOnly = true)
public List<ProductDto> listProducts() { ... }
- Với Hibernate: đặt FlushMode MANUAL, bỏ snapshot dirty checking → nhẹ hơn.
- Driver PostgreSQL nhận
SET TRANSACTION READ ONLY; có thể route sang read replica nếu cấu hình routing datasource. - Nó không phải cơ chế bảo mật. Gọi
@Modifyingquery hoặc methodREQUIREDbên trong vẫn tham gia transaction read-only này, và hành vi phụ thuộc DB/driver. Đừng ghi dữ liệu trong method readOnly.
Bẫy 5: Transaction dài vì gọi HTTP bên trong
@Transactional
public void checkout(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
PaymentResult r = paymentClient.charge(order); // HTTP 5 giây!
order.setStatus(r.success() ? PAID : FAILED);
}
- Connection DB bị giữ suốt 5 giây. HikariCP mặc định 10 connection → 10 request chậm là pool cạn, cả app treo.
- Nếu đã lock row (
FOR UPDATE), các transaction khác chờ theo, tăng nguy cơ deadlock/timeout. - Payment trừ tiền thành công nhưng commit DB lỗi → rollback local không hoàn tiền được.
Sửa: tách thành các transaction ngắn, gọi HTTP ở giữa:
public void checkout(Long orderId) { // không @Transactional
orderTx.markPending(orderId); // Tx 1: ngắn
PaymentResult r = paymentClient.charge(...); // ngoài transaction
orderTx.markResult(orderId, r); // Tx 2: ngắn
}
Với hệ thống thật cần thêm idempotency key, trạng thái UNKNOWN khi timeout, Outbox/Saga. Một @Transactional local không thể rollback giao dịch ở ngân hàng.
Bước 4: Test bằng curl
curl -X POST "http://localhost:8080/orders?productId=1&quantity=999"
# 409 Insufficient stock
# Log: Creating new transaction ... OrderService.createOrder
# Initiating transaction rollback
# Kiểm tra stock không đổi
curl http://localhost:8080/products/1
5. Cách hoạt động: luồng commit/rollback
Controller gọi orderService.createOrder()
|
v
Spring Transaction Proxy
| TransactionInterceptor
v
BEGIN (lấy connection từ HikariCP, autocommit=false)
|
v
SELECT product
|
v
UPDATE product + INSERT order + INSERT order_item (khi flush)
|
+-- không lỗi ---------------> FLUSH → COMMIT → trả connection
|
+-- RuntimeException/Error -> ROLLBACK → trả connection
|
+-- checked exception ------> COMMIT (!) trừ khi rollbackFor
6. So sánh: 5 bẫy
| Bẫy | Triệu chứng | Cách sửa |
|---|---|---|
| Self-invocation | Không có transaction, không lỗi | Tách bean, hoặc inject self / TransactionTemplate |
| Private method | Annotation bị bỏ qua | Dùng method public, gọi từ bean khác |
| Checked exception | Dữ liệu dở dang vẫn commit | rollbackFor = Exception.class hoặc ném RuntimeException |
| Nuốt exception | Commit dù lỗi | Ném lại, hoặc setRollbackOnly() |
| readOnly hiểu sai | Ghi trong read-only, hành vi khó đoán | Chỉ dùng cho truy vấn thuần |
| Transaction dài + HTTP | Pool cạn, lock lâu, deadlock | Transaction ngắn, gọi HTTP ngoài transaction |
7. Khi nào nên dùng / không nên dùng
- Nên đặt
@Transactionalở tầng Service, mỗi use case một transaction. - Nên dùng
readOnly = truecho query thuần. - Không nên đặt ở Controller hoặc Repository cho logic nghiệp vụ nhiều bước.
- Không nên gọi HTTP, gửi email, sleep bên trong transaction.
- Cần kiểm soát chi tiết (vòng lặp, batch) thì dùng
TransactionTemplate.
@Transactional), B dùng chung transaction hay mở mới? B lỗi thì A có bị rollback không? Đó là propagation.Self-invocation thì inject chính nó vào (self) có được không?
Được, chạy đúng vì self là proxy:
@Service
public class PaymentService {
private final PaymentService self;
public PaymentService(@Lazy PaymentService self) { this.self = self; }
public void process() {
self.updatePayment(); // qua proxy → có transaction
}
@Transactional
public void updatePayment() { ... }
}
Nhưng đây là dấu hiệu class đang làm hai việc. Tách bean thường rõ ràng hơn. Ngoài ra còn hai cách: TransactionTemplate (lập trình), hoặc AspectJ weaving (sửa bytecode, không cần proxy) nhưng ít team dùng.
Transaction dài thì sao biết được? Có cách đo không?
Có. Bạn hiểu đúng hướng: phải đo, không đoán.
- HikariCP metrics qua Actuator/Micrometer:
hikaricp.connections.active,hikaricp.connections.pending,hikaricp.connections.usage. Pending tăng là có transaction giữ connection lâu. spring.datasource.hikari.leak-detection-threshold=5000: log stacktrace nếu một connection bị giữ quá 5 giây.- PostgreSQL:
SELECT pid, now() - xact_start, state, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY 2 DESC; - Tracing (Micrometer Tracing): nhìn span HTTP nằm bên trong span transaction.
Phỏng vấn hỏi @Transactional và các bẫy thì trả lời sao?
Q1. @Transactional hoạt động thế nào?
🇻🇳 Tiếng Việt:
@Transactional định nghĩa ranh giới transaction cho method. Spring tạo AOP proxy bọc bean; proxy mở transaction qua TransactionManager trước khi gọi method, sau đó commit, hoặc rollback theo rollback rules. Chỉ lời gọi đi qua proxy mới được áp dụng.
🇬🇧 English:
@Transactional defines a transaction boundary for a method. Spring wraps the bean in an AOP proxy that starts a transaction through the transaction manager before the call and commits or rolls back afterwards according to the rollback rules. Only calls that go through the proxy are transactional.
Q2. Vì sao @Transactional không chạy khi gọi nội bộ?
🇻🇳 Tiếng Việt:
Ở chế độ proxy mặc định, lời gọi
this.method()bên trong class đi thẳng vào object thật, bỏ qua proxy, nên advice transaction không được áp dụng. Cách sửa là tách method sang bean khác, dùng TransactionTemplate hoặc AspectJ.
🇬🇧 English:
In Spring's default proxy-based mode, internal method calls bypass the transactional proxy, so the called method's transaction advice is not applied. The fix is to move the method to another bean, use TransactionTemplate, or use AspectJ weaving.
Q3. Checked exception có rollback không?
🇻🇳 Tiếng Việt:
Mặc định không. Spring chỉ rollback với RuntimeException và Error; checked exception sẽ commit. Muốn rollback thì khai báo
rollbackFor = Exception.classhoặc bọc thành RuntimeException.
🇬🇧 English:
Not by default. Spring rolls back on RuntimeException and Error only; a checked exception leads to a commit. To roll back, declare
rollbackFor = Exception.classor wrap it in a runtime exception.
Q4. Vì sao không nên gọi HTTP trong transaction?
🇻🇳 Tiếng Việt:
Transaction giữ connection và có thể giữ lock trong suốt lời gọi HTTP chậm, làm cạn connection pool và tăng lock contention, deadlock. Hơn nữa rollback DB không hoàn tác được hệ thống bên ngoài. Em tách thành các transaction ngắn, gọi HTTP ở giữa, kèm idempotency và Outbox/Saga nếu cần.
🇬🇧 English:
The transaction holds a database connection, and possibly row locks, for the whole slow HTTP call, which exhausts the connection pool and increases lock contention and deadlocks. A database rollback also cannot undo the external system. I split the work into short transactions with the HTTP call in between, plus idempotency and an Outbox or Saga when needed.
Cách nhớ ngắn gọn
- Transaction = proxy. Không qua proxy thì không có transaction (self-call, private).
- Rollback mặc định: unchecked + Error. Checked →
rollbackFor. - readOnly = tối ưu, không phải bảo mật.
- Transaction ngắn; không HTTP, không sleep bên trong.
Tự kiểm tra
process() gọi this.updatePayment() có @Transactional. Có transaction không?
Không, vì lời gọi nội bộ không đi qua proxy.
Method ném IOException trong @Transactional mặc định: commit hay rollback?
Commit. Checked exception không rollback trừ khi có rollbackFor.
Bắt exception bằng try/catch rồi log, không ném lại. Kết quả?
Proxy không thấy lỗi nên commit. Phải ném lại hoặc gọi TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
readOnly = true giúp gì với Hibernate?
FlushMode MANUAL, bỏ dirty checking/snapshot, DB có thể tối ưu; có thể route sang replica.
Gọi payment API 5 giây trong transaction gây hậu quả gì?
Giữ connection và lock lâu, cạn HikariCP pool, tăng deadlock/timeout; rollback local không hoàn tiền được.