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.

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.

Sáu bước này nằm gọn trong hộp proxy — sơ đồ ở phần 4 vẽ đúng cái hộp đó, và cho thấy chuyện gì xảy ra khi một lời gọi không đi ngang qua nó.

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.

Ba câu hỏi mà proxy xét lúc method kết thúc, và nhánh đáy là nhánh không để lại dấu vết nào:

Cây quyết định lúc method @Transactional kết thúc: không ném exception thì COMMIT như mong đợi, ném RuntimeException hoặc Error thì ROLLBACK, ném checked exception mà có khai rollbackFor thì cũng ROLLBACK, còn checked exception không khai rollbackFor thì Spring COMMIT và phần dữ liệu đã ghi ở lại trong DB

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.

Hai đường gọi tới cùng một method: đường dọc từ caller ngoài class đi xuyên hộp OrderService PROXY nên có BEGIN rồi COMMIT hoặc ROLLBACK bọc quanh, còn đường ngang từ importOrders đang chạy trong chính bean gốc gọi thẳng processOne nên không mở transaction nào và mỗi save tự commit riêng

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
0/5 câu đã trả lời
  1. 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);
      }
    }
  2. 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());
      }
    }
  3. 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ì?
  4. 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?
  5. 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ì?

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Ôn phỏng vấn

Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.

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

Bài tiếp theo

Propagation, Isolation & readOnly — kiểm soát transaction boundary