@Transactional và 5 cái bẫy hay gặp

4 câu hỏi~25 phútTừ khóa: @Transactional, AOP proxy, self-invocation, private method, checked exception, rollbackFor, readOnly, long transaction, connection pool

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:

  1. Kiểm tra Product và trừ stock.
  2. Tạo Order.
  3. 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() { ... }

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);
}

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ẫyTriệu chứngCách sửa
Self-invocationKhông có transaction, không lỗiTách bean, hoặc inject self / TransactionTemplate
Private methodAnnotation bị bỏ quaDùng method public, gọi từ bean khác
Checked exceptionDữ liệu dở dang vẫn commitrollbackFor = Exception.class hoặc ném RuntimeException
Nuốt exceptionCommit dù lỗiNém lại, hoặc setRollbackOnly()
readOnly hiểu saiGhi trong read-only, hành vi khó đoánChỉ dùng cho truy vấn thuần
Transaction dài + HTTPPool cạn, lock lâu, deadlockTransaction ngắn, gọi HTTP ngoài transaction

7. Khi nào nên dùng / không nên dùng

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.

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.class hoặ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.class or 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


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.