Skip to content

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

10 min read

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#

  • @Transactional hoạt động nhờ AOP proxy: proxy mở transaction trước khi gọi method, commit khi thành công, rollback khi có exception phù hợp.
  • Transaction gắn với thread hiện tại (qua ThreadLocal trong TransactionSynchronizationManager).
  • Rollback mặc định chỉ với RuntimeException và Error. Checked exception → commit! Dùng rollbackFor nếu cần.
  • Propagation quyết định hành vi khi method transactional gọi method transactional khác. Phải nắm: REQUIRED (mặc định), REQUIRES_NEW, NESTED.
  • Các trường hợp không hoạt động: self-invocation, method private, exception bị catch và nuốt, checked exception, chạy ở thread khác, bean không do Spring quản lý.
  • readOnly = true là gợi ý tối ưu, không phải cơ chế bảo vệ tuyệt đối.
  • Giữ transaction ngắn: không gọi HTTP API bên ngoài, không gửi email trong transaction.
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:

  • Atomicity: tất cả hoặc không gì cả.
  • Consistency: dữ liệu đi từ trạng thái hợp lệ sang trạng thái hợp lệ.
  • Isolation: các transaction đồng thời không “giẫm chân” nhau (xem thêm 4 isolation level).
  • Durability: đã commit thì không mất.

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?#

  • Trên method hoặc class (áp dụng cho mọi method public của class). Annotation ở method ghi đè annotation ở class.
  • Đặt ở service layer — nơi định nghĩa một use case hoàn chỉnh. Không đặt ở controller (transaction quá rộng), cũng không chỉ trông cậy vào repository (mỗi lần gọi repository là một transaction riêng).
  • Nên dùng org.springframework.transaction.annotation.Transactional thay vì jakarta.transaction.Transactional: bản của Spring có đủ thuộc tính (propagation, isolation, readOnly, timeout…).
  • Spring Boot tự bật transaction management; không cần @EnableTransactionManagement.

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

  • Mỗi REQUIRES_NEW chiếm thêm một connection trong khi connection của outer vẫn đang bị giữ. Dùng nhiều, hoặc gọi trong vòng lặp dưới tải cao → cạn connection pool, thậm chí deadlock pool.
  • TX2 không nhìn thấy dữ liệu chưa commit của TX1. Nếu TX2 cố update cùng row mà TX1 đang lock → tự deadlock.
  • Nếu TX2 throw exception và exception lan ra outer mà không được catch, TX1 cũng rollback.

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
)
  • isolation: mặc định Isolation.DEFAULT = dùng default của database (PostgreSQL: Read Committed, MySQL InnoDB: Repeatable Read). Chỉ có hiệu lực khi bắt đầu transaction mới — nếu method tham gia transaction có sẵn (REQUIRED), isolation của nó bị bỏ qua.
  • readOnly = true:
    • Hibernate đặt flush mode MANUAL và bỏ snapshot cho dirty checking → tiết kiệm memory và CPU với query lớn.
    • JDBC driver nhận Connection.setReadOnly(true); một số DB/driver tối ưu thêm.
    • Có thể dùng để route sang read replica (với AbstractRoutingDataSource + LazyConnectionDataSourceProxy).
    • Không phải cơ chế chặn ghi đáng tin cậy — hành vi khi cố ghi phụ thuộc DB và driver.
  • timeout: transaction chạy quá thời gian sẽ bị đánh dấu rollback. Chỉ áp dụng cho transaction mới.

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#

  • Method private → không bao giờ hoạt động.
  • Trước Spring 6.0: chỉ method public được hỗ trợ.
  • Từ Spring Framework 6.0 (Boot 3.x): với class-based proxy (CGLIB), method protected và package-private cũng được hỗ trợ.
  • Method final hoặc static → không hoạt động với CGLIB.

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#

  • new UserService() → không có proxy.
  • Ứng dụng có nhiều DataSource mà không chỉ định transaction manager → transaction chạy trên datasource A trong khi code ghi vào datasource B. Chỉ định bằng @Transactional(transactionManager = "orderTxManager").
  • Bảng MySQL dùng engine MyISAM → không hỗ trợ transaction.

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#

  • Dirty checking: trong transaction, entity managed bị sửa sẽ tự động UPDATE khi commit — không cần gọi save(). Đây là “tác dụng phụ” hay làm người mới bất ngờ.
  • LazyInitializationException: truy cập lazy association sau khi transaction (và persistence context) đã đóng — thường ở controller hoặc khi serialize JSON. Giải pháp: fetch đủ dữ liệu trong transaction (JOIN FETCH, @EntityGraph) và trả về DTO.
  • Open Session In View (spring.jpa.open-in-view, mặc định true): giữ EntityManager mở suốt request nên che giấu lỗi lazy loading, nhưng làm connection bị giữ lâu hơn cần thiết. Nhiều team đặt false.
  • Flush trước khi query: Hibernate có thể flush các thay đổi pending trước một JPQL query (flush mode AUTO) để query thấy dữ liệu mới nhất.
  • Constraint violation phát hiện muộn: lỗi unique constraint có thể chỉ xuất hiện lúc commit/flush, tức là sau khi method của bạn đã return — try/catch trong method sẽ không bắt được. Dùng saveAndFlush() nếu cần bắt lỗi ngay.

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:

  • Gọi dịch vụ bên ngoài trước hoặc sau transaction.
  • Dùng @TransactionalEventListener(phase = AFTER_COMMIT) để thực hiện side effect chỉ khi transaction đã commit thành công:
@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());
    }
}
  • Với việc publish message sang Kafka/RabbitMQ cần đảm bảo nhất quán tuyệt đối → dùng Outbox pattern.

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#

  • @DataJpaTest và các test có @Transactional sẽ tự rollback sau mỗi test — tiện, nhưng có thể che giấu lỗi chỉ xuất hiện lúc commit (constraint, LazyInitializationException). Với integration test quan trọng, cân nhắc không dùng rollback tự động và tự dọn dữ liệu.
  • Kiểm tra có đang trong transaction: TransactionSynchronizationManager.isActualTransactionActive().
  • Bật log để quan sát: logging.level.org.springframework.transaction=DEBUG và logging.level.org.springframework.orm.jpa=DEBUG.

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.