Transaction trong Spring: @Transactional và các bẫy hay gặp
1. Tổng quan
@Transactional là annotation được dùng nhiều nhất trong Spring Boot. Nó cũng là nơi phát sinh nhiều bug khó thấy: dữ liệu không rollback, transaction “biến mất”, hoặc UnexpectedRollbackException xuất hiện mà không rõ lý do.
Bài này giải thích cách @Transactional hoạt động qua proxy. Sau đó, chúng ta đi qua từng bẫy hay gặp, mỗi bẫy có code minh họa và cách sửa. Đọc xong, bạn sẽ biết khi nào transaction thực sự được mở và khi nào nó rollback.
2. Thiết lập
Chúng ta dùng Spring Data JPA với H2 in-memory:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Bật log của transaction interceptor để thấy khi nào transaction được mở và đóng:
logging.level.org.springframework.transaction.interceptor=TRACE
Entity và repository dùng trong bài:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue
private Long id;
private String customerName;
private BigDecimal amount;
protected Order() {
}
public Order(String customerName, BigDecimal amount) {
this.customerName = customerName;
this.amount = amount;
}
public Long getId() { return id; }
public String getCustomerName() { return customerName; }
public BigDecimal getAmount() { return amount; }
}
public interface OrderRepository extends JpaRepository<Order, Long> {
}
Bảng được đặt tên orders vì order là từ khóa SQL.
3. @Transactional hoạt động thế nào
3.1. Ví dụ cơ bản
@Service
public class PaymentService {
public void charge(String customer, BigDecimal amount) {
if (amount.compareTo(new BigDecimal("1000")) > 0) {
throw new IllegalStateException("Vượt hạn mức thanh toán");
}
}
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
@Transactional
public Order placeOrder(String customer, BigDecimal amount) {
Order order = orderRepository.save(new Order(customer, amount));
paymentService.charge(customer, amount);
return order;
}
}
Khi gọi placeOrder("An", new BigDecimal("5000")), đơn hàng được save trước. Sau đó charge ném IllegalStateException. Vì đây là RuntimeException, Spring rollback toàn bộ, nên bảng orders vẫn trống.
3.2. Proxy là trung tâm
Spring không sửa bytecode của OrderService. Nó tạo một proxy (mặc định là CGLIB subclass trong Spring Boot) bọc quanh bean. Bean khác inject OrderService thực chất nhận được proxy này. Mỗi lời gọi đi qua TransactionInterceptor:
caller → proxy → [mở transaction] → OrderService.placeOrder() → [commit/rollback] → caller
Log TRACE cho thấy rõ điều này:
TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: Vượt hạn mức thanh toán
Hầu hết các bẫy bên dưới đều xuất phát từ một nguyên tắc: lời gọi không đi qua proxy thì không có transaction.
4. Bẫy 1: Self-invocation
@Service
public class OrderService {
// ... constructor như trên
public void placeOrders(List<String> customers) {
for (String customer : customers) {
placeOrder(customer, new BigDecimal("100")); // gọi qua this
}
}
@Transactional
public Order placeOrder(String customer, BigDecimal amount) {
System.out.println("Transaction active: "
+ TransactionSynchronizationManager.isActualTransactionActive());
Order order = orderRepository.save(new Order(customer, amount));
paymentService.charge(customer, amount);
return order;
}
}
Gọi orderService.placeOrders(List.of("An", "Bình")) từ controller sẽ in ra:
Transaction active: false
Transaction active: false
placeOrders không có @Transactional, nên proxy chuyển thẳng lời gọi vào object thật. Bên trong, placeOrder(...) tương đương this.placeOrder(...), mà this là object gốc chứ không phải proxy. Annotation bị bỏ qua hoàn toàn. Mỗi save chạy trong transaction riêng của SimpleJpaRepository và commit ngay. Nếu đơn thứ hai lỗi, đơn thứ nhất vẫn nằm trong DB.
Có ba cách sửa, xếp theo thứ tự nên ưu tiên:
- Tách
placeOrdersang một bean khác, ví dụOrderPlacementService, rồi inject vào. - Đặt
@Transactionallên chínhplaceOrders, nếu bạn muốn cả lô là một transaction. - Self-inject qua
@Lazy. Cách này chạy được nhưng khó đọc, chỉ nên dùng khi không thể tách bean.
@Service
public class OrderBatchService {
private final OrderService orderService; // đây là proxy
public OrderBatchService(OrderService orderService) {
this.orderService = orderService;
}
public void placeOrders(List<String> customers) {
customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
}
}
Lúc này mỗi lời gọi đi qua proxy, và log in ra Transaction active: true.
5. Bẫy 2: Method private hoặc final
Từ Spring Framework 6.0, khi dùng class-based proxy (CGLIB), @Transactional hoạt động trên cả method public, protected và package-private. Tuy nhiên vẫn còn hai trường hợp không hoạt động:
- Method
private: subclass không override được, nên proxy không chặn được lời gọi. - Method
final: CGLIB cũng không override được. Lời gọi chạy thẳng vào code gốc, không có transaction.
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // KHÔNG có transaction
return orderRepository.save(new Order(customer, amount));
}
Spring không ném lỗi trong trường hợp này. Bạn chỉ thấy một dòng log ở mức INFO hoặc DEBUG, rất dễ bỏ qua.
Lưu ý: Kotlin mặc định đóng (
final) mọi class và method. Hãy dùng pluginkotlin-spring(all-open) để các bean Spring được mở tự động.
6. Bẫy 3: Checked exception không gây rollback
Mặc định, Spring chỉ rollback khi gặp RuntimeException hoặc Error. Checked exception sẽ để transaction commit.
public class PaymentDeclinedException extends Exception {
public PaymentDeclinedException(String message) {
super(message);
}
}
@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
Order order = orderRepository.save(new Order(customer, amount));
if (amount.signum() <= 0) {
throw new PaymentDeclinedException("Số tiền không hợp lệ");
}
return order;
}
Gọi với amount = -5 thì exception vẫn ném ra cho caller, nhưng đơn hàng âm đã được lưu. Cách sửa là khai báo rõ:
@Transactional(rollbackFor = PaymentDeclinedException.class)
Nhiều team quy ước dùng rollbackFor = Exception.class cho toàn bộ service. Cách đơn giản hơn là thiết kế exception nghiệp vụ kế thừa RuntimeException. Điều ngược lại cũng làm được: noRollbackFor = SomeRuntimeException.class giữ commit dù có lỗi.
7. Bẫy 4: Nuốt exception và UnexpectedRollbackException
Đây là bẫy khó hiểu nhất. Xét một service giữ hàng trong kho:
@Service
public class InventoryService {
@Transactional // propagation mặc định: REQUIRED
public void reserve(String sku) {
throw new IllegalStateException("Hết hàng: " + sku);
}
}
Và OrderService gọi nó, rồi bắt lỗi với ý định “giữ hàng thất bại thì vẫn tạo đơn”:
@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
try {
inventoryService.reserve(sku);
} catch (IllegalStateException ex) {
System.out.println("Bỏ qua lỗi kho: " + ex.getMessage());
}
return order;
}
Kết quả:
Bỏ qua lỗi kho: Hết hàng: SKU-01
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
Chuyện xảy ra như sau. reserve tham gia cùng transaction vật lý với placeOrderWithInventory. Khi exception đi qua proxy của InventoryService, Spring đánh dấu transaction là rollback-only. Việc bạn catch ở ngoài không gỡ được dấu này. Đến lúc commit, Spring phát hiện dấu, rollback, rồi ném UnexpectedRollbackException để báo rằng caller tưởng commit nhưng thực tế không.
Cách sửa tùy vào ý định của bạn:
- Nếu lỗi kho phải hủy cả đơn, đừng
catch. Hãy để exception đi lên. - Nếu
reservelà việc độc lập, cho nó transaction riêng bằngREQUIRES_NEW(xem mục 8). - Nếu
reservekhông cần ghi DB, bỏ@Transactionalkhỏi nó.
Lưu ý: Nuốt exception bên trong cùng một method thì transaction không bị đánh dấu. Dấu chỉ được đặt khi exception đi xuyên qua một proxy
@Transactional.
8. Propagation REQUIRES_NEW: hữu ích nhưng đắt
Một trường hợp hợp lý cho REQUIRES_NEW là ghi audit log. Log cần được lưu kể cả khi nghiệp vụ chính rollback:
@Service
public class AuditService {
private final AuditLogRepository auditLogRepository;
public AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action) {
auditLogRepository.save(new AuditLog(action));
}
}
Spring tạm treo transaction ngoài và mở một transaction mới trên một connection khác. Khi record kết thúc, transaction này commit độc lập.
Cái giá phải trả:
- Mỗi request giữ cùng lúc hai connection. Khi tải cao, pool (HikariCP mặc định có 10 connection) có thể cạn và gây deadlock ở tầng pool: mọi thread đều giữ một connection và chờ connection thứ hai.
- Transaction mới không nhìn thấy dữ liệu chưa commit của transaction ngoài. Nếu
AuditLogcó foreign key tớiOrdervừa tạo, bạn sẽ gặp lỗi hoặc bị lock chờ.
Chỉ dùng REQUIRES_NEW khi thực sự cần commit độc lập, không dùng như cách “chữa cháy” cho mọi lỗi rollback-only.
9. readOnly và transaction quá dài
@Transactional(readOnly = true) báo cho Hibernate đặt flush mode MANUAL và bỏ qua dirty checking. Với truy vấn trả về nhiều entity, điều này giảm đáng kể bộ nhớ và CPU:
@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
return orderRepository.findAll().stream()
.filter(o -> o.getCustomerName().equals(customer))
.toList();
}
Có hai điều cần nhớ:
readOnlylà một gợi ý tối ưu, không phải cơ chế bảo vệ. Tùy driver và database, một câuUPDATEnative vẫn có thể chạy.- Đừng gọi HTTP API bên ngoài, gửi email hay
Thread.sleepbên trong transaction. Connection bị giữ suốt thời gian đó, và lock trên các row đã ghi cũng vậy. Hãy lấy dữ liệu từ bên ngoài trước, sau đó mới mở transaction ngắn để ghi.
10. Kiểm thử hành vi rollback
Một bẫy nữa nằm ở test. Nếu bạn đặt @Transactional lên test class, mọi thứ chạy trong transaction của test và bị rollback ở cuối. Điều đó che mất việc service có commit hay rollback đúng hay không. Với test về transaction, hãy bỏ annotation này và tự dọn dữ liệu:
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@AfterEach
void cleanUp() {
orderRepository.deleteAll();
}
@Test
void whenPaymentFails_thenOrderIsRolledBack() {
assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
.isInstanceOf(IllegalStateException.class);
assertThat(orderRepository.count()).isZero();
}
@Test
void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
.isInstanceOf(UnexpectedRollbackException.class);
assertThat(orderRepository.count()).isZero();
}
@Test
void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
.isInstanceOf(PaymentDeclinedException.class);
assertThat(orderRepository.count()).isEqualTo(1);
}
}
Test thứ ba “khóa” lại hành vi mặc định với checked exception. Khi bạn thêm rollbackFor, test này sẽ đỏ, và bạn đổi kỳ vọng thành isZero(). Đây là cách tốt để tài liệu hóa quyết định rollback ngay trong code.
11. Kết luận
@Transactional trông đơn giản, nhưng mọi hành vi của nó đều phụ thuộc vào proxy. Tóm tắt các bẫy chính:
- Self-invocation: gọi qua
thisthì bỏ qua proxy. Hãy tách bean. - Method
private/final: không bị intercept, và Spring không báo lỗi. - Checked exception: mặc định commit. Dùng
rollbackForhoặc dùngRuntimeException. - Rollback-only: exception đi qua proxy bên trong sẽ đánh dấu cả transaction,
catchở ngoài không cứu được. REQUIRES_NEW: tốn thêm connection, chỉ dùng khi cần commit độc lập.- Test có
@Transactional: che mất hành vi commit/rollback thật.
Khi nghi ngờ, hãy bật log TransactionInterceptor ở mức TRACE và kiểm tra TransactionSynchronizationManager.isActualTransactionActive(). Hai công cụ này cho bạn biết chính xác transaction có thực sự tồn tại hay không.