Spring REST API & Data JPA/@Transactional & AOP proxy — cơ chế begin/commit/rollback bên dưới
39/46
Bài 39 / 46~12 phútRelationships & TransactionsMiễn phí lượt xem

@Transactional & AOP proxy — cơ chế begin/commit/rollback bên dưới

@Transactional không magic — Spring tạo CGLIB proxy wrap method để chèn begin/commit/rollback. Bài này bóc đúng một concept: proxy hoạt động thế nào, rollback rule RuntimeException vs checked, và self-invocation bypass proxy — pitfall kinh điển khiến @Transactional mất tác dụng im lặng.

TL;DR: @Transactional hoạt động qua AOP proxy — Spring tạo CGLIB subclass wrap method của bạn, chèn BEGIN/COMMIT/ROLLBACK quanh mỗi lần gọi. Rollback chỉ xảy ra với RuntimeExceptionError theo mặc địnhIOException, SQLException và các checked exception khác khiến transaction commit bình thường, dẫn tới dữ liệu bị lưu một nửa mà không ai hay. Self-invocation (this.method()) đi thẳng vào object thật, bỏ qua proxy hoàn toàn, nên @Transactional trên method được gọi nội bộ không có hiệu lực. Ba cách fix: tách class, inject self bean, hoặc đẩy annotation lên entry point.

1. Vấn đề trước khi có @Transactional

Hình dung bạn đang viết OrderService.placeOrder() — method cần thực hiện 3 thao tác DB phải atomic: lưu order, trừ inventory, ghi payment. Không có transaction boundary, đoạn code JDBC truyền thống trông như thế này:

// JDBC thu cong — truoc @Transactional
Connection conn = dataSource.getConnection();
try {
    conn.setAutoCommit(false);                  // BEGIN tx thu cong

    orderRepo.save(conn, order);                // INSERT orders
    inventoryRepo.reserve(conn, items);         // UPDATE inventory
    paymentRepo.charge(conn, payment);          // INSERT payments

    conn.commit();                              // COMMIT neu tat ca ok
} catch (RuntimeException e) {
    conn.rollback();                            // ROLLBACK neu co loi
    throw e;
} finally {
    conn.setAutoCommit(true);
    conn.close();
}

Mỗi method nghiệp vụ phải tự nhớ: mở connection, tắt auto-commit, try/catch rollback, finally close. Đây là boilerplate lặp lại ở mọi service method — dễ quên, dễ sai, khó maintain.

@Transactional sinh ra để xoá toàn bộ boilerplate này. Bạn chỉ đặt annotation, Spring chèn BEGIN/COMMIT/ROLLBACK tự động. Câu hỏi đặt ra là: Spring làm điều đó bằng cách nào mà không yêu cầu bạn thay đổi code gốc?

2. Cơ chế bên dưới — AOP proxy

@Transactional thuộc về AOP (Aspect-Oriented Programming) — kỹ thuật chèn logic cắt ngang (cross-cutting concern như transaction, security, logging) vào method mà không sửa code gốc. Spring hiện thực AOP thông qua proxy.

Khi Spring scan thấy class có method annotated @Transactional, nó không sử dụng object gốc trực tiếp. Thay vào đó, nó tạo một CGLIB subclass (proxy) tại runtime, override từng method có @Transactional, rồi đăng ký proxy đó vào container thay cho object gốc. Mọi caller khi inject OrderService thực ra nhận về proxy, không phải instance gốc.

sequenceDiagram
    participant Caller
    participant Proxy as OrderService Proxy (CGLIB)
    participant TM as TransactionManager
    participant Real as OrderService (real object)
    participant DB as PostgreSQL

    Caller->>Proxy: placeOrder(req)
    Proxy->>TM: getTransaction() -> BEGIN
    TM->>DB: BEGIN
    Proxy->>Real: placeOrder(req)
    Real->>DB: INSERT order, UPDATE inventory, INSERT payment
    Real-->>Proxy: return Order
    Proxy->>TM: commit()
    TM->>DB: COMMIT
    Proxy-->>Caller: return Order

Luồng chi tiết khi @Transactional method được gọi:

  1. Caller gọi proxy — Spring đã inject proxy vào caller qua @Autowired.
  2. Proxy interceptor kích hoạtTransactionInterceptor kiểm tra @Transactional metadata của method.
  3. Begin transactionTransactionManager gọi DataSource.getConnection(), tắt autoCommit, thực hiện BEGIN.
  4. Gọi method thật — proxy delegate xuống object gốc với connection đã mở.
  5. Method thật chạy — mọi repo call dùng connection trong TransactionSynchronizationManager (thread-local binding).
  6. Method return → proxy commit; ném RuntimeException → proxy rollback.
Vì sao chọn proxy thay vì bytecode instrumentation?

Spring có thể dùng AspectJ weaving (sửa trực tiếp bytecode lúc compile/load) — nhưng proxy runtime đơn giản hơn, không cần agent JVM, không cần build plugin. Trade-off: proxy chỉ intercept được call từ ngoài class. Self-call bypass proxy — đây là hệ quả thiết kế quan trọng nhất (phần 4).

Cơ chế proxy tương tự được dùng cho @PreAuthorize trong Spring Security — xem AOP proxy JDK vs CGLIB để hiểu chi tiết tại sao Spring chọn CGLIB thay vì JDK dynamic proxy cho @Transactional.

3. Rollback rule — RuntimeException vs checked exception

Đây là pitfall gây silent data corruption nhiều nhất trong production Spring.

Default rule: Spring rollback transaction chỉ khi method ném RuntimeException hoặc Error. Checked exception không rollback — transaction commit bình thường.

@Transactional
public void doWork() {
    repo.save(partialData);             // INSERT chay
    throw new RuntimeException("loi"); // -> ROLLBACK, partialData mat
}

@Transactional
public void doWork2() throws IOException {
    repo.save(partialData);             // INSERT chay
    throw new IOException("loi");       // -> COMMIT! partialData ton tai trong DB
}

Trong doWork2, dữ liệu đã được lưu dù method throw exception. Caller nhận exception, tưởng không có gì được ghi — nhưng DB đã có record.

Tại sao checked exception không rollback?

Quy tắc này không ngẫu nhiên — nó kế thừa từ spec EJB (Enterprise JavaBeans), tiền thân Spring muốn thay thế. EJB phân loại:

  • Application exception (checked): lỗi nghiệp vụ có thể phục hồi, caller biết cách handle → transaction nên commit, caller quyết định tiếp theo.
  • System exception (runtime): lỗi hệ thống không lường trước → transaction phải rollback.

Spring giữ nguyên convention này để tương thích, nhưng trong thực tế Java hiện đại (2004 trở đi), exception hierarchy checked/unchecked không ánh xạ rõ ràng với "business/system" nữa — nhiều business exception là RuntimeException (vd IllegalArgumentException, EntityNotFoundException).

Hệ quả thực tế: nếu bạn dùng checked exception cho business logic, transaction vẫn commit khi exception xảy ra:

// NGUY HIEM — checked exception commit tx
public class BusinessException extends Exception { }   // checked

@Transactional
public void transferMoney(Long from, Long to, BigDecimal amount)
        throws BusinessException {
    accountRepo.debit(from, amount);   // UPDATE — chay
    if (accountRepo.balance(to) < 0) {
        throw new BusinessException("insufficient funds"); // COMMIT! debit da ghi
    }
    accountRepo.credit(to, amount);    // chua chay
}
// Ket qua: tai khoan "from" bi tru, tai khoan "to" KHONG duoc cong

Custom rollback rule

// Rollback khi gap checked exception cu the
@Transactional(rollbackFor = IOException.class)
public void importFile() throws IOException { ... }

// Rollback tat ca exception (ke ca checked)
@Transactional(rollbackFor = Exception.class)
public void criticalWork() throws Exception { ... }

// Khong rollback voi exception nay du la RuntimeException
@Transactional(noRollbackFor = ValidationWarningException.class)
public void doWork() { ... }

Khuyến nghị 2026: business exception nên extend RuntimeException. Toàn bộ Spring Framework, Hibernate, JPA đều dùng unchecked exception. Domain layer sạch hơn khi không có throws clause ở mọi method.

// DUNG — unchecked business exception
public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(BigDecimal requested, BigDecimal available) {
        super("Requested " + requested + " but only " + available + " available");
    }
}

// @Transactional rollback tu dong, khong can rollbackFor
@Transactional
public void transferMoney(Long from, Long to, BigDecimal amount) {
    accountRepo.debit(from, amount);
    if (accountRepo.balance(to) < 0) {
        throw new InsufficientFundsException(amount, accountRepo.balance(to));
    }
    accountRepo.credit(to, amount);
}

4. Self-invocation bypass proxy — pitfall kinh điển

Đây là pitfall phổ biến nhất với @Transactional — code trông đúng, compile pass, test đơn giản pass, nhưng transaction không hoạt động ở production.

Kịch bản: importOrders() không cần transaction (chỉ loop), processOne() cần transaction cho mỗi order. Bạn viết:

@Service
public class OrderService {

    // Method nay khong co @Transactional -- chi la vong lap
    public void importOrders(List<OrderRequest> requests) {
        requests.forEach(req -> this.processOne(req));  // self-call!
    }

    @Transactional
    public void processOne(OrderRequest req) {
        orderRepo.save(new Order(req));         // @Transactional -- phai co hieu luc
        inventoryRepo.reserve(req.items());
    }
}

Kết quả: @Transactional trên processOne không có hiệu lực. Nếu inventoryRepo.reserve() throw exception, orderRepo.save() vẫn được commit.

Tại sao?this.processOne(req) gọi trực tiếp vào object thật — bỏ qua proxy. Proxy chỉ được kích hoạt khi call đến từ ngoài class.

flowchart TB
  subgraph EXT["External call -- qua proxy, @Transactional active"]
    direction LR
    C["Caller ngoai class<br/>ex: Controller"] -->|"orderService.processOne(req)"| P["OrderService Proxy<br/>(CGLIB subclass)"]
    P -->|"BEGIN tx"| R["OrderService (real)"]
    R --> OK["@Transactional<br/>co hieu luc"]
  end
  subgraph SELF["Self-call -- bypass proxy, @Transactional bi bo qua"]
    direction LR
    M1["importOrders() ben trong class"] -->|"this.processOne(req)"| M2["processOne() goi thang<br/>khong qua proxy"]
    M2 --> NO["@Transactional<br/>KHONG co hieu luc"]
  end

Fix 1 — Tách class (khuyến nghị)

Tách processOne ra class riêng, inject vào OrderService. Call từ importOrders đến bean khác luôn đi qua proxy.

@Service
public class OrderImportService {

    private final OrderProcessingService processingService;

    public void importOrders(List<OrderRequest> requests) {
        requests.forEach(processingService::processOne);  // goi qua proxy cua bean khac
    }
}

@Service
public class OrderProcessingService {

    @Transactional
    public void processOne(OrderRequest req) {
        orderRepo.save(new Order(req));
        inventoryRepo.reserve(req.items());
    }
}

Fix 2 — Inject self bean

Spring cho phép bean inject chính nó — khi đó bạn giữ tham chiếu tới proxy của chính mình.

@Service
public class OrderService {

    @Autowired
    @Lazy                               // tranh circular dependency khi khoi dong
    private OrderService self;

    public void importOrders(List<OrderRequest> requests) {
        requests.forEach(self::processOne);  // goi qua proxy cua chinh minh
    }

    @Transactional
    public void processOne(OrderRequest req) {
        orderRepo.save(new Order(req));
        inventoryRepo.reserve(req.items());
    }
}

Fix 3 — Đẩy annotation lên entry point

Nếu toàn bộ importOrders nên atomic, đặt @Transactional trên method đó thay vì processOne.

@Service
public class OrderService {

    @Transactional                      // wrap toan bo vong lap
    public void importOrders(List<OrderRequest> requests) {
        requests.forEach(req -> this.processOne(req));  // self-call ok vi tx da active
    }

    public void processOne(OrderRequest req) {          // khong can @Transactional rieng
        orderRepo.save(new Order(req));
        inventoryRepo.reserve(req.items());
    }
}

Lưu ý: fix 3 chỉ phù hợp khi toàn bộ import là một transaction duy nhất. Nếu muốn mỗi order là transaction độc lập (fail một order không ảnh hưởng order khác), bắt buộc dùng fix 1 hoặc fix 2.

Private method cũng bypass proxy

@Transactional trên private method không bao giờ có hiệu lực — Spring AOP proxy chỉ override được public method. Compiler không báo lỗi, annotation bị bỏ qua hoàn toàn. Đặt @Transactional chỉ trên public method.

5. Tại sao @Transactional chọn declarative (annotation) thay vì programmatic?

Trước khi Spring phổ biến, transaction management programmatic như phần 1 là chuẩn mực. @Transactional giải quyết ba vấn đề:

  1. Tách biệt concern — business logic không lẫn transaction plumbing. OrderService chỉ chứa logic nghiệp vụ, không biết connection pool hay transaction manager tồn tại.
  2. Không lặp boilerplate — 10 service method chỉ cần 10 annotation thay vì 10 lần try/catch/finally với connection management.
  3. Testability — object gốc có thể test không cần Spring container (unit test), proxy chỉ bật khi chạy tích hợp.

Đánh đổi của declarative là proxy mechanism — self-invocation không hoạt động (phần 4), private method không được intercept. Đây là giá phải trả cho sự đơn giản của annotation-based approach. Nếu cần transaction trên private method hoặc self-call, dùng AspectJ weaving (compile-time hoặc load-time) thay CGLIB proxy — nhưng cách đó phức tạp hơn nhiều và hiếm khi cần thiết.

Liên hệ các bài khác

  • AOP proxy JDK vs CGLIB: bài này giải thích tại sao Spring dùng CGLIB subclass thay JDK InvocationHandler cho @Transactional, và hệ quả đối với class không implement interface — đọc để hiểu rõ hơn tại sao self-invocation là giới hạn cứng của proxy-based AOP.
  • Propagation & isolation: khi bạn hiểu proxy mechanism ở bài này, bài tiếp theo đào sâu propagation (REQUIRED, REQUIRES_NEW, NESTED) và isolation (READ_COMMITTED, REPEATABLE_READ) — hai thuộc tính quyết định transaction tương tác với nhau và với DB như thế nào.

Tóm tắt

  • @Transactional hoạt động qua CGLIB proxy — Spring tạo subclass override method, chèn BEGIN/COMMIT/ROLLBACK quanh mỗi lần gọi từ bên ngoài.
  • Rollback mặc định chỉ với RuntimeExceptionError. Checked exception commit bình thường — dùng rollbackFor hoặc chuyển sang RuntimeException hierarchy.
  • Self-invocation bypass proxythis.method() không qua proxy, @Transactional mất hiệu lực. Fix: tách class, inject self bean, hoặc đẩy annotation lên entry point.
  • @Transactional trên private method không có hiệu lực — proxy chỉ override public method.
  • Declarative transaction đánh đổi proxy constraints để đổi lấy separation of concern và loại bỏ boilerplate.

Tự kiểm tra

Tự kiểm tra
Q1
Giải thích tại sao đoạn code sau có thể gây lưu dữ liệu không nhất quán vào DB, dù method throw exception:
@Transactional
public void importData(Path file) throws IOException {
  List<Record> records = fileParser.parse(file); // co the throw IOException
  for (Record r : records) {
      repo.save(r);
  }
}

IOExceptionchecked exception. Spring mặc định chỉ rollback transaction khi gặp RuntimeException hoặc Error.

Nếu fileParser.parse(file) throw IOException sau khi một số repo.save(r) đã chạy, transaction sẽ commit — các record đã lưu trước đó tồn tại trong DB, phần còn lại không được lưu.

Caller nhận exception nên có thể không biết dữ liệu đã được lưu một phần. Đây là silent data corruption.

Fix: dùng @Transactional(rollbackFor = IOException.class), hoặc wrap IOException trong RuntimeException tại tầng gọi fileParser.parse(), hoặc di chuyển file parsing ra ngoài transaction boundary.

Q2
Đoạn code sau có bug không? Nếu có, @Transactional có active không khi notifyAll() gọi sendNotification()?
@Service
public class NotificationService {

  public void notifyAll(List<User> users) {
      users.forEach(u -> this.sendNotification(u));
  }

  @Transactional
  public void sendNotification(User user) {
      notificationRepo.save(new Notification(user));
      emailQueue.push(user.email());
  }
}

Có bug. this.sendNotification(u)self-invocation — gọi trực tiếp vào object thật, không qua CGLIB proxy mà Spring đã tạo.

Khi notifyAll() được gọi từ bên ngoài, caller nhận proxy của NotificationService. Nhưng khi proxy delegate vào object thật để chạy notifyAll(), các lời gọi this.sendNotification(u) bên trong method đó đi thẳng vào object thật — bỏ qua proxy hoàn toàn.

Kết quả: @Transactional trên sendNotification() không có hiệu lực. Mỗi notification được lưu không có transaction wrapper — nếu emailQueue.push() throw exception, notificationRepo.save() vẫn đã commit.

Fix tốt nhất: tách sendNotification() ra NotificationSender class riêng, inject vào NotificationService, rồi gọi qua bean đó thay vì this.

Q3
Tại sao `@Transactional` được thiết kế để rollback RuntimeException nhưng commit checked exception? Nguồn gốc từ đâu và hệ quả là gì?

Quy tắc này xuất phát từ spec EJB (Enterprise JavaBeans) — tiền thân mà Spring ra đời để thay thế. EJB phân biệt hai loại exception:

Application exception (checked): lỗi nghiệp vụ mà caller biết cách xử lý — ví dụ OrderNotFoundException. EJB spec cho rằng transaction nên commit, để caller quyết định hành động tiếp theo.

System exception (runtime): lỗi hệ thống bất ngờ như NullPointerException, OutOfMemoryError — transaction phải rollback vì trạng thái không xác định.

Spring giữ convention này để tương thích với EJB ecosystem và legacy code.

Hệ quả: nếu business exception extend Exception (checked), transaction commit khi exception xảy ra — dữ liệu được lưu một phần mà không ai hay. Khuyến nghị hiện đại: business exception luôn extend RuntimeException. Toàn bộ Spring, Hibernate, JPA đều theo convention này.

Q4
Bạn cần implement importOrders() — loop qua danh sách order, mỗi order cần transaction độc lập (fail một order không rollback các order đã lưu). Có hai cách fix self-invocation: tách class và inject self. Cách nào nên chọn và vì sao?

Tách class là cách được khuyến nghị hơn vì các lý do sau:

Rõ ràng về dependency: khi tách thành OrderProcessingService, intent rõ ràng — đây là component xử lý từng order, được inject qua constructor như mọi dependency khác. Không có gì bất thường về structural design.

Dễ test hơn: unit test OrderProcessingService độc lập không cần quan tâm đến importOrders. Inject self bean tạo circular dependency phức tạp hơn trong test setup.

Tránh @Lazy circular dependency: inject self bean yêu cầu @Lazy để tránh Spring complain circular dependency khi khởi động. Đây là workaround code smell.

Khi nào inject self có thể dùng: khi class quá nhỏ để tách, hoặc khi refactor tốn quá nhiều công trong codebase legacy mà cần fix nhanh. Tuy nhiên, đây vẫn là giải pháp tạm thời.

Fix 3 (đẩy annotation lên entry point) không phù hợp ở đây vì nó làm toàn bộ import là một transaction — một order fail sẽ rollback tất cả order trước đó, không đáp ứng yêu cầu "transaction độc lập".

Q5
Spring dùng CGLIB proxy thay vì sửa trực tiếp bytecode (AspectJ weaving) để implement @Transactional. Lợi ích và giới hạn của lựa chọn này là gì?

Lợi ích của CGLIB proxy:

Đơn giản setup: không cần compiler plugin, không cần JVM agent, không cần bước weaving riêng trong build pipeline. Thêm Spring dependency là dùng được.

Runtime flexibility: proxy được tạo khi container khởi động — Spring có thể quyết định class nào cần proxy dựa trên bean definitions và annotation, mà không cần compile lại.

Không phụ thuộc build tool: AspectJ weaving yêu cầu cấu hình Maven/Gradle plugin hoặc Java agent (-javaagent). CGLIB chạy hoàn toàn trong Spring container.

Giới hạn cốt lõi:

Self-invocation không hoạt động: đây là hệ quả trực tiếp của proxy mechanism — proxy chỉ intercept call từ ngoài class. Gọi this.method() trong cùng class đi thẳng vào object thật.

Chỉ intercept public method: CGLIB subclass không override được private hoặc final method — @Transactional trên private method bị bỏ qua hoàn toàn mà không có cảnh báo.

Nếu hai giới hạn trên là vấn đề với use case cụ thể, Spring hỗ trợ AspectJ weaving (compile-time hoặc load-time) để bỏ qua proxy mechanism — nhưng complexity cao hơn đáng kể và hiếm khi cần thiết.

Bài tiếp theo: Propagation, isolation & readOnly

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên