@Transactional trong Spring: cơ chế, propagation và những cái bẫy

Chỉ một annotation, nhưng là nguồn gốc của vô số bug production: dữ liệu không rollback, UnexpectedRollbackException bí ẩ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

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:

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?


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 ý:

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
)

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

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

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


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:

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

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


11. Câu hỏi phỏng vấn thường gặp

  1. @Transactional hoạt động thế nào? → AOP proxy + TransactionInterceptor + PlatformTransactionManager, trạng thái lưu trong ThreadLocal.
  2. Mặc định rollback với exception nào? → RuntimeException và Error; checked exception thì commit.
  3. 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.
  4. REQUIRED khác REQUIRES_NEW? → Tham gia chung vs tạo transaction độc lập; use case audit log; rủi ro cạn connection pool.
  5. REQUIRES_NEW khác NESTED? → Hai transaction vật lý vs savepoint; NESTED không hỗ trợ JPA.
  6. UnexpectedRollbackException xảy ra khi nào? → Inner (REQUIRED) throw làm transaction rollback-only, outer catch rồi cố commit.
  7. readOnly = true có tác dụng gì? → Tối ưu Hibernate flush/dirty checking, gợi ý cho driver, route replica.
  8. Làm sao gửi email chỉ khi transaction commit thành công? → @TransactionalEventListener(AFTER_COMMIT) hoặc Outbox.
Back
0%