Backend

99 Ngày Spring — Ngày 28: Transaction & @Transactional

SSite Admin
24 tháng 08, 2026 12 phút đọc 0 lượt xem
99 Ngày Spring — Ngày 28: Transaction & @Transactional

Ba ngày về quan hệ và fetch đã dạy cách đọc dữ liệu hiệu quả — hôm nay ta chạm vào trái tim của tầng dữ liệu khi ghi: giao dịch (transaction). Một chuyển khoản là hai lệnh UPDATE — nếu lệnh thứ hai lỗi mà lệnh thứ nhất đã lưu, tiền biến mất. @Transactional tồn tại để biến nhiều thao tác thành một đơn vị tất cả-hoặc-không-gì-cả. Bài hôm nay đi qua ACID và vì sao ranh giới giao dịch nằm ở service, cơ chế proxy đứng sau annotation, propagation khi giao dịch gọi giao dịch, quy tắc rollback với bất ngờ về checked exception, và cái bẫy nổi tiếng nhất của Spring: self-invocation.

Sketchnote Ngày 28: Transaction và @Transactional — ACID, proxy và TransactionInterceptor, propagation REQUIRED vs REQUIRES_NEW, quy tắc rollback với checked exception và bẫy self-invocation

ACID & vì sao ranh giới giao dịch nằm ở service

// Chuyển khoản: hai lệnh UPDATE phải cùng thành công hoặc cùng thất bại
@Service
public class ChuyenKhoanService {

    private final TaiKhoanRepository repo;

    public ChuyenKhoanService(TaiKhoanRepository repo) { this.repo = repo; }

    @Transactional                       // org.springframework.transaction.annotation
    public void chuyen(Long tu, Long den, BigDecimal soTien) {
        TaiKhoan a = repo.findById(tu).orElseThrow();
        TaiKhoan b = repo.findById(den).orElseThrow();
        a.rut(soTien);                   // ném IllegalStateException nếu không đủ
        b.nap(soTien);
        // Không gọi save(): entity managed — flush lúc COMMIT (Ngày 25)
    }
}
// Không có @Transactional: rut() đã flush xong, nap() lỗi → tiền BIẾN MẤT.
// Có @Transactional: exception → ROLLBACK → hai tài khoản y nguyên.

// ACID — bốn chữ giao dịch cam kết:
// Atomicity   — tất cả hoặc không gì cả
// Consistency — ràng buộc (FK, CHECK, số dư ≥ 0) luôn đúng sau commit
// Isolation   — giao dịch song song không thấy dữ liệu dở dang của nhau
// Durability  — đã commit là còn — kể cả khi mất điện ngay sau đó

// Chỉ đọc: gợi ý cho Hibernate bỏ dirty-check + driver tối ưu — RẺ hơn rõ rệt
@Transactional(readOnly = true)
public List<TaiKhoan> danhSach() { return repo.findAll(); }
  • ACID là bốn cam kết: nguyên tử (tất cả hoặc không gì cả), nhất quán (ràng buộc luôn đúng sau commit), cô lập (giao dịch song song không thấy dữ liệu dở dang của nhau) và bền vững (đã commit là còn). Cơ sở dữ liệu thực hiện chúng; @Transactional chỉ là cách Spring nói với DB đâu là điểm bắt đầu và kết thúc.

  • Repository của Spring Data đã transactional (SimpleJpaRepository) nhưng mỗi lời gọi là một giao dịch riêng — hai save liên tiếp là hai lần commit, lệnh sau lỗi không cứu được lệnh trước. Vì thế ranh giới đặt ở service, gói trọn một use-case; controller không nên biết đến giao dịch.

  • readOnly = true không phải trang trí: Hibernate bỏ dirty checking (Ngày 25) và đặt FlushMode.MANUAL, driver có thể định tuyến sang bản sao đọc — với màn hình chỉ đọc, đây là tối ưu miễn phí đáng làm mặc định.

Proxy — @Transactional hoạt động thế nào

// @Transactional KHÔNG có phép màu — nó là AOP proxy (Ngày 7 & 21)

//  caller ──► [Proxy] ──► TransactionInterceptor ──► chuyen() thật
//                │  begin tx (mở EntityManager, gắn vào thread)
//                │  gọi method thật
//                │  commit — hoặc rollback nếu bay exception
//                └─ đóng EntityManager

// Spring Boot đã cấu hình sẵn: JpaTransactionManager + @EnableTransactionManagement
// Repository của Spring Data ĐÃ transactional (SimpleJpaRepository) —
// nhưng mỗi lời gọi là MỘT giao dịch riêng:
repo.save(a);      // tx 1 — commit
repo.save(b);      // tx 2 — nếu lỗi, a đã commit rồi!
// → Ranh giới giao dịch đặt ở SERVICE, gói trọn một use-case.

// Nhìn thấy nó: bật log là thấy begin/commit/rollback
// logging.level.org.springframework.transaction.interceptor=TRACE
// logging.level.org.springframework.orm.jpa=DEBUG

// Kiểm tra lúc chạy — có đang trong giao dịch không?
TransactionSynchronizationManager.isActualTransactionActive();   // true/false

// Cần giao dịch theo code (không annotation)? TransactionTemplate:
@Service
public class BaoCaoService {
    private final TransactionTemplate tx;
    public BaoCaoService(PlatformTransactionManager tm) { this.tx = new TransactionTemplate(tm); }

    public void chay() {
        tx.executeWithoutResult(status -> {
            // ... mọi thứ trong đây là MỘT giao dịch; status.setRollbackOnly() để hủy
        });
    }
}
  • Không có phép màu: Spring bọc bean bằng proxy (Ngày 7 & 21), TransactionInterceptor chặn lời gọi, mở giao dịch và gắn EntityManager vào luồng hiện tại, gọi method thật, rồi commit — hoặc rollback khi exception bay ra khỏi method. Mọi hành vi và mọi cái bẫy phía sau đều suy ra từ mô hình này.

  • Persistence context (Ngày 25) sống theo giao dịch: trong @Transactional, entity là managed nên không cần gọi save — thay đổi được flush lúc commit; ngoài giao dịch, entity detached và LazyInitializationException (Ngày 27) quay lại. Hai bài học nối vào nhau ở đúng chỗ này.

  • Muốn nhìn thấy: bật TRACE cho transaction.interceptor là thấy từng Getting transaction for/Completing transaction for; kiểm tra bằng code với TransactionSynchronizationManager.isActualTransactionActive(); và khi cần giao dịch theo code (một đoạn giữa method, không phụ thuộc proxy), TransactionTemplate là công cụ chính thống.

Propagation — khi giao dịch gọi giao dịch

// Propagation — chuyện gì xảy ra khi method @Transactional GỌI method @Transactional khác?

@Transactional                                   // = REQUIRED (mặc định)
public void datHang(DonHang don) {
    donHangRepo.save(don);
    auditService.ghiLog("Đặt hàng " + don.getId());   // gọi sang bean khác
    thongBaoService.gui(don);                           // có thể lỗi
}

// REQUIRED (mặc định): có giao dịch thì THAM GIA, chưa có thì TẠO mới
//   → ghiLog() chạy CÙNG giao dịch với datHang(): gui() lỗi → log cũng mất
@Transactional
public void ghiLog(String msg) { ... }

// REQUIRES_NEW: TẠM DỪNG giao dịch ngoài, mở giao dịch RIÊNG, commit riêng
//   → log audit LUÔN được lưu, kể cả khi đơn hàng rollback
//   Cẩn thận: chiếm THÊM một connection từ pool — lồng sâu là cạn pool
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void ghiLog(String msg) { ... }

// NESTED: savepoint trong giao dịch ngoài — rollback phần lồng mà không hủy phần ngoài
//   (JDBC hỗ trợ; JPA/Hibernate KHÔNG hỗ trợ NESTED → NestedTransactionNotSupportedException)

// MANDATORY   — bắt buộc có giao dịch sẵn, không thì ném exception (bảo vệ method nội bộ)
// SUPPORTS    — có thì dùng, không thì chạy không giao dịch
// NOT_SUPPORTED — tạm dừng giao dịch, chạy ngoài giao dịch (gọi API ngoài chậm)
// NEVER       — có giao dịch là lỗi

// BẪY REQUIRED nổi tiếng: UnexpectedRollbackException
@Transactional
public void ngoai() {
    try {
        trong.lam();          // REQUIRED, ném RuntimeException → đánh dấu rollback-only
    } catch (RuntimeException e) {
        // "bắt được rồi, chắc là commit được" — SAI
    }
}   // commit → UnexpectedRollbackException: Transaction silently rolled back
    // vì giao dịch CHUNG đã bị đánh dấu rollback-only từ bên trong
  • REQUIRED (mặc định) tham gia giao dịch sẵn có, chưa có thì tạo — nên chuỗi service gọi service thường là một giao dịch duy nhất: bất kỳ mắt xích nào lỗi, tất cả rollback. Đó thường là điều bạn muốn; khi không muốn — audit log, bộ đếm, outbox — mới cần REQUIRES_NEW.

  • REQUIRES_NEW tạm dừng giao dịch ngoài và mở giao dịch riêng, commit độc lập — cái giá là thêm một connection từ pool trong lúc giao dịch ngoài vẫn giữ connection của nó; lồng vài tầng trong request nặng là cạn pool và treo. NESTED (savepoint) nghe hấp dẫn nhưng JPA/Hibernate không hỗ trợ.

  • Bẫy UnexpectedRollbackException: method trong ném RuntimeException đã đánh dấu rollback-only cho giao dịch chung, method ngoài catch rồi tưởng commit được — Spring từ chối ở commit với thông điệp "silently rolled back". Muốn "phần trong lỗi, phần ngoài vẫn lưu" thì phần trong phải REQUIRES_NEW, không phải try/catch.

Quy tắc rollback — checked exception không rollback

// Quy tắc rollback mặc định — nguồn gốc của rất nhiều "sao dữ liệu vẫn được lưu?"
@Transactional
public void xuLy() throws Exception {
    repo.save(donHang);
    throw new Exception("lỗi nghiệp vụ");     // CHECKED exception
}
// Kết quả: COMMIT! Mặc định chỉ rollback với RuntimeException và Error.
// Lý do lịch sử: checked exception được coi là "tình huống nghiệp vụ dự kiến"
// (theo EJB) — còn RuntimeException là lỗi lập trình bất ngờ.

// Sửa 1: khai báo rõ
@Transactional(rollbackFor = Exception.class)
public void xuLy() throws Exception { ... }

// Sửa 2 (thường tốt hơn): exception nghiệp vụ kế thừa RuntimeException
public class KhongDuSoDuException extends RuntimeException { ... }

// Ngược lại — lỗi mà KHÔNG muốn rollback:
@Transactional(noRollbackFor = TrungLapException.class)

// Bẫy "nuốt" exception: bắt bên trong thì interceptor KHÔNG BIẾT có lỗi
@Transactional
public void luu() {
    try {
        repo.save(x);
        gui.email();                       // ném RuntimeException
    } catch (RuntimeException e) {
        log.error("gửi mail lỗi", e);      // giao dịch vẫn COMMIT — x được lưu
    }
}
// Muốn "log rồi vẫn rollback"? Ném tiếp, hoặc đánh dấu tay:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

// Timeout & isolation — hai tham số hay quên
@Transactional(timeout = 5)                             // giây — quá là rollback
@Transactional(isolation = Isolation.SERIALIZABLE)      // mặc định: theo DB (Postgres = READ_COMMITTED)
  • Mặc định chỉ RuntimeExceptionError gây rollback; checked exception vẫn commit — di sản từ EJB coi checked là "kết quả nghiệp vụ dự kiến". Đây là nguyên nhân số một của "ném exception rồi mà dữ liệu vẫn lưu": khai báo rollbackFor, hoặc tốt hơn, cho exception nghiệp vụ kế thừa RuntimeException như series vẫn làm từ Ngày 15.

  • Interceptor chỉ biết có lỗi khi exception bay ra khỏi method: try/catch bên trong để ghi log là nuốt tín hiệu rollback — giao dịch commit bình thường. Muốn log mà vẫn hủy: ném tiếp, hoặc setRollbackOnly() tường minh.

  • Hai tham số hay quên: timeout (giây) chặn giao dịch treo giữ khóa quá lâu, và isolation — mặc định theo DB (PostgreSQL là READ_COMMITTED); chỉ nâng lên REPEATABLE_READ/SERIALIZABLE khi có lý do và đã đo, vì chúng đổi hiệu năng lấy độ chặt.

Bẫy proxy — self-invocation và các anh em

// BẪY PROXY #1 — self-invocation: gọi method @Transactional từ CÙNG một class
@Service
public class DonHangService {

    public void nhapHangLoat(List<DonHang> ds) {
        for (DonHang d : ds) {
            luuMot(d);          // this.luuMot() — KHÔNG đi qua proxy → KHÔNG có giao dịch!
        }
    }

    @Transactional
    public void luuMot(DonHang d) { repo.save(d); ... }
}
// Proxy chỉ bao BÊN NGOÀI bean. Bên trong, this là đối tượng thật —
// annotation trên luuMot() bị bỏ qua hoàn toàn, không một dòng cảnh báo.

// Sửa A (đúng nhất): tách sang bean khác — ranh giới giao dịch là ranh giới bean
@Service
public class DonHangGhiService {
    @Transactional
    public void luuMot(DonHang d) { ... }
}

// Sửa B: tự inject chính mình qua proxy (dùng @Lazy để tránh vòng lặp phụ thuộc)
@Service
public class DonHangService {
    private final DonHangService self;
    public DonHangService(@Lazy DonHangService self) { this.self = self; }
    public void nhapHangLoat(List<DonHang> ds) { ds.forEach(self::luuMot); }   // qua proxy ✓
}

// Sửa C: TransactionTemplate cho đoạn cần giao dịch — không phụ thuộc proxy

// BẪY PROXY #2 — method không public
@Transactional
private void luu() { ... }     // Spring 5: bị BỎ QUA im lặng; Spring 6.0+: protected/package
                               // được hỗ trợ với CGLIB, private vẫn KHÔNG bao giờ

// BẪY PROXY #3 — @Transactional trên interface + CGLIB proxy → không được kế thừa
//   Đặt annotation trên LỚP TRIỂN KHAI (class hoặc method), không đặt trên interface.

// BẪY #4 — @Transactional trong @PostConstruct hay constructor: proxy CHƯA sẵn sàng
//   → dùng @EventListener(ApplicationReadyEvent.class) cho việc khởi tạo dữ liệu.
  • Self-invocation là bẫy số một: gọi this.luuMot() từ method cùng class không đi qua proxy — annotation bị bỏ qua hoàn toàn, không cảnh báo, và bạn chỉ phát hiện khi dữ liệu nửa vời xuất hiện. Cách sửa đúng nhất là tách bean: ranh giới giao dịch nên trùng ranh giới bean; tự inject qua @Lazy hoặc TransactionTemplate là phương án dự phòng.

  • Cùng gốc rễ: method private không bao giờ được proxy (CGLIB không override được), annotation trên interface không được CGLIB kế thừa, và trong constructor/@PostConstruct proxy chưa sẵn sàng — khởi tạo dữ liệu nên chờ ApplicationReadyEvent.

  • Phản xạ khi nghi ngờ: hỏi "lời gọi này có đi qua proxy không?" — isActualTransactionActive() trả lời trong một dòng; và luôn tự nhắc rằng @Transactional của Spring (org.springframework) mới có đủ tùy chọn, jakarta.transaction.Transactional thiếu readOnly/timeout/isolation.

Bài tập nhỏ

  • Viết ChuyenKhoanService.chuyen không@Transactional, cho nap() ném exception, và xem số dư trong DB — rồi thêm annotation và so sánh.

  • Bật TRACE cho transaction.interceptor, gọi hai repo.save liên tiếp từ controller và đếm số giao dịch; bọc vào một service method và đếm lại.

  • Tạo AuditService.ghiLog với REQUIRED rồi REQUIRES_NEW; cho use-case ngoài rollback và kiểm tra bảng audit ở mỗi trường hợp.

  • Ném một checked Exception từ method @Transactional và kiểm chứng dữ liệu vẫn được lưu; sửa bằng rollbackFor rồi bằng RuntimeException.

  • Tái hiện self-invocation: isActualTransactionActive() in false bên trong luuMot khi gọi qua this — sửa bằng tách bean và in lại.

Kết luận

Kim chỉ nam giao dịch gói trong bốn ý: @Transactional đặt ở service, gói trọn một use-case, readOnly cho đường đọc; nó là proxy — mọi hành vi suy ra từ đó; REQUIRED gộp thành một giao dịch, REQUIRES_NEW tách ra với giá một connection, và try/catch không thay được nó; checked exception không rollback và exception bị nuốt cũng vậy — hãy để exception nghiệp vụ kế thừa RuntimeException; còn self-invocation thì tách bean. Ngày 29 ta để Spring tự ghi ai-tạo-lúc-nào: Auditing@CreatedDate, @LastModifiedDateAuditorAware. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 28: Regex trong Java

Pattern & Matcher, cú pháp dùng hằng ngày, group có tên để trích xuất, replaceAll/split với các bẫy — và ranh giới nơi regex nên dừng lại.

24 thg 8, 202611 phút0
99 Ngày Spring — Ngày 27: Lazy vs Eager & bài toán N+1

Ngày 27 của 99 Ngày Spring: FetchType và cơ chế proxy của LAZY (cùng LazyInitializationException và sự thật về open-in-view), tự tay tái hiện bài toán N+1 với 21 câu SQL cho 20 bài viết, sửa bằng JOIN FETCH với cặp LEFT + DISTINCT, @EntityGraph cho derived query, và bẫy phân trang trong bộ nhớ.

23 thg 8, 20267 phút10
99 Ngày Java — Ngày 27: Math, Random & BigDecimal

Ngày 27 của 99 Ngày Java: vì sao 0.1 + 0.2 không bằng 0.3 và ranh giới double-cho-đo-lường vs BigDecimal-cho-tiền, hai công cụ bị bỏ quên của Math là floorMod và addExact, Random tất định theo seed vs SecureRandom bắt buộc cho bảo mật, .....

23 thg 8, 20268 phút2