Spring REST API & Data JPA/Propagation, Isolation & readOnly — kiểm soát transaction boundary
40/46
Bài 40 / 46~13 phútRelationships & TransactionsMiễn phí lượt xem

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

Ba thuộc tính @Transactional quan trọng nhất sau khi đã hiểu AOP proxy: propagation (REQUIRED vs REQUIRES_NEW — B chạy trong T1 hay mở T2 riêng?), isolation level (READ_COMMITTED ngăn dirty read; REPEATABLE_READ ngăn non-repeatable; SERIALIZABLE ngăn phantom), và readOnly hint tắt dirty checking để tăng hiệu năng read endpoint.

TL;DR: propagation quyết định boundary: REQUIRED (default) chia sẻ transaction cha — rollback một thì rollback tất cả; REQUIRES_NEW mở transaction độc lập, commit/rollback riêng — dùng cho audit log và notification cần "best-effort". isolation kiểm soát visibility: READ_COMMITTED (Postgres mặc định) ngăn dirty read; REPEATABLE_READ thêm bảo vệ non-repeatable read; SERIALIZABLE đầy đủ nhất nhưng tốn lock nhiều nhất. readOnly = true là hint Hibernate tắt dirty checkingFlushMode.MANUAL — giảm memory và CPU đo được trên endpoint read-heavy. Ba thuộc tính độc lập nhưng chọn sai bất kỳ cái nào đều dẫn đến silent data corruption hoặc deadlock production.

Bài 03 — @Transactional & AOP proxy đã giải thích cơ chế proxy và vì sao self-call bypass transaction. Bài này tiếp nối và đào sâu ba thuộc tính của chính @Transactional — phần quyết định transaction chạy như thế nào, không chỉ có chạy hay không.

1. Scenario — khi nào cần kiểm soát boundary?

Hệ thống đặt hàng thực tế có 3 yêu cầu conflict nhau:

  • placeOrder() phải atomic: order + inventory + payment rollback cùng nhau nếu bất kỳ bước nào lỗi.
  • auditService.log() phải commit độc lập: audit log được ghi dù business transaction thất bại hay không.
  • findOrdersByUser() chạy 500 lần/giây — không cần dirty checking, chỉ đọc.

Nếu dùng mặc định @Transactional cho tất cả, audit log bị rollback khi order lỗi (mất traceability), và read endpoint tốn CPU check dirty unnecessarily. Đây chính là lý do propagation, isolation, readOnly tồn tại.

2. Propagation — transaction của B chạy trong T1 hay T2 riêng?

propagation trả lời câu hỏi: khi method B được gọi từ trong method A đang có transaction T1, B join T1 hay mở transaction T2 mới?

Spring cung cấp 7 loại propagation. Ba loại quan trọng nhất:

PropagationOuter chưa có txOuter đang có tx T1Inner fail → tác động
REQUIRED (mặc định)Tạo T1 mớiB join T1 — dùng chungT1 bị đánh dấu rollback-only → outer cũng rollback
REQUIRES_NEWTạo T2 mớiSuspend T1, mở T2 riêngT2 rollback, T1 tiếp tục bình thường
NESTEDTạo T1 mớiĐặt savepoint trong T1Rollback đến savepoint, T1 tiếp tục

Bốn loại còn lại (SUPPORTS, NOT_SUPPORTED, MANDATORY, NEVER) dùng trong framework và integration code — hiếm gặp trong business service.

Hai hành vi đối lập khi outer transaction rollback:

Đối chiếu hai propagation trên cùng một kịch bản: cột REQUIRED có auditService.log join T1 nên khi T1 rollback thì dòng audit biến mất theo, cột REQUIRES_NEW suspend T1 để mở T2 rồi commit T2 nên khi T1 rollback dòng audit vẫn còn

2.1 REQUIRED — chia sẻ transaction, mặc định đúng cho 99% case

@Service
public class OrderService {

    @Autowired
    private InventoryService inventoryService;

    @Transactional                          // REQUIRED (default) -- tao T1
    public Order placeOrder(OrderRequest req) {
        Order order = orderRepo.save(new Order(req));
        inventoryService.reserve(req.items()); // join T1
        paymentService.charge(order);          // join T1
        return order;                          // T1 commit
    }
}

@Service
public class InventoryService {

    @Transactional                          // REQUIRED -- join T1 cua OrderService
    public void reserve(List<Item> items) {
        for (Item item : items) {
            Inventory inv = inventoryRepo.findByProduct(item.productId());
            inv.decrease(item.quantity());  // trong T1
            inventoryRepo.save(inv);
        }
    }
}

Khi reserve() join T1, toàn bộ INSERT order + UPDATE inventory + INSERT payment nằm trong một transaction duy nhất. Nếu paymentService.charge() ném RuntimeException, T1 rollback hoàn toàn — inventory không bị trừ, order không được tạo.

Đây là hành vi cần cho mọi use case "all-or-nothing". Không cần cấu hình gì thêm.

2.2 REQUIRES_NEW — tại sao audit log cần transaction độc lập

Vấn đề với REQUIRED cho audit log: nếu auditService.log() join T1 và T1 rollback vì lý do business, audit log cũng bị xóa — mất hoàn toàn dấu vết lỗi xảy ra ở đâu. Đây là trường hợp điển hình cần REQUIRES_NEW.

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, Long entityId) {
        // Chay trong T2 doc lap -- commit/rollback khong phu thuoc T1
        auditRepo.save(new AuditLog(action, entityId, Instant.now()));
    }
}

@Service
public class OrderService {

    @Transactional                             // T1
    public Order placeOrder(OrderRequest req) {
        Order order = orderRepo.save(new Order(req));

        try {
            auditService.log("order.placed", order.getId()); // mo T2 rieng
        } catch (Exception e) {
            // T2 fail -> rollback chi T2, T1 van chay
            log.error("Audit failed, order proceeds", e);
        }

        inventoryService.reserve(req.items());
        return order;
    }
}

Cơ chế bên dưới khi REQUIRES_NEW được gọi:

  1. Spring transaction interceptor suspend T1 — lưu trạng thái T1 vào TransactionSynchronizationManager.
  2. Mở T2 mới trên cùng connection (hoặc acquire connection mới từ pool).
  3. auditRepo.save(...) thực thi trong T2.
  4. T2 commit (hoặc rollback nếu exception).
  5. T1 resume — tiếp tục từ điểm bị suspend.

Vì sao chọn REQUIRES_NEW cho audit log, notification, analytics:

  • Audit log phải tồn tại kể cả khi business transaction lỗi — đây là yêu cầu compliance.
  • Email xác nhận, push notification là "best-effort" — không nên kéo order transaction xuống nếu SMTP down.
  • Analytics event cần được ghi ngay cả khi sau đó có rollback — data pipeline cần biết attempt xảy ra.

Chi phí của REQUIRES_NEW: Spring phải flush persistence context hiện tại, suspend transaction, và thường acquire một connection mới từ Hikari pool. Mỗi lần REQUIRES_NEW tốn khoảng 2-5ms thêm và giữ connection pool lâu hơn. Không dùng trong hot path.

2.3 Pitfall — REQUIRED + catch exception = UnexpectedRollbackException

Đây là bug hay gặp nhất khi hiểu chưa đủ về propagation:

// SAI -- tưởng catch exception là đủ
@Transactional
public Order placeOrder(OrderRequest req) {
    Order order = orderRepo.save(new Order(req));

    try {
        inventoryService.reserve(req.items()); // REQUIRED -- join T1
    } catch (InventoryException e) {
        log.warn("Inventory failed, order anyway");
        // Tưởng là order vẫn commit được
    }

    return order; // <-- UnexpectedRollbackException thrown here!
}

Luồng thực tế: InventoryExceptionRuntimeException nên Spring interceptor mark T1 "rollback-only" tại điểm reserve() throw, không phải tại điểm catch. Dù caller catch và tiếp tục, khi method kết thúc Spring cố commit T1 nhưng T1 đã bị mark — kết quả là UnexpectedRollbackException. Cả order và inventory đều rollback.

Thứ tự trên trục thời gian mới là chỗ quyết định — cờ được ghi trước khi khối catch chạy:

Trục thời gian của placeOrder: T1 mở, orderRepo.save chạy INSERT trong T1, inventoryService.reserve với propagation REQUIRED ném InventoryException, interceptor của reserve đánh dấu T1 rollback-only ngay tại đó, khối catch bắt được exception và chạy tiếp như không có gì, tới lúc return thì Spring cố commit T1 và ném UnexpectedRollbackException

Fix đúng là chuyển inventoryService.reserve() sang REQUIRES_NEW để nó chạy trong T2 riêng — fail T2 không ảnh hưởng T1:

// DUNG -- inventory trong transaction doc lap
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void reserve(List<Item> items) { ... }

3. Isolation — visibility giữa các transaction đồng thời

isolation kiểm soát mức độ một transaction thấy được thay đổi của transaction khác đang chạy đồng thời. Mỗi mức cao hơn ngăn thêm một loại anomaly, nhưng cũng tốn thêm lock và giảm throughput.

Ba anomaly cần hiểu:

  • Dirty read: T1 đọc row mà T2 đang sửa nhưng chưa commit. Nếu T2 rollback, T1 đã dùng dữ liệu không bao giờ tồn tại.
  • Non-repeatable read: T1 đọc row hai lần trong cùng transaction — lần hai trả giá trị khác vì T2 đã commit UPDATE ở giữa.
  • Phantom read: T1 đọc tập row theo điều kiện hai lần — lần hai có thêm (hoặc thiếu) row vì T2 đã commit INSERT/DELETE ở giữa.
Isolation levelNgăn dirty readNgăn non-repeatable readNgăn phantom read
READ_UNCOMMITTEDKhôngKhôngKhông
READ_COMMITTED (Postgres default)KhôngKhông
REPEATABLE_READ (MySQL InnoDB default)Không*
SERIALIZABLE

*Postgres REPEATABLE_READ thực tế ngăn phantom read qua MVCC snapshot — nhưng spec SQL không đảm bảo điều này.

Caption: Bảng anomaly — ô "Có" nghĩa isolation level đó ngăn được anomaly tương ứng.

3.1 READ_COMMITTED — tại sao Postgres chọn làm mặc định

READ_COMMITTED đảm bảo mỗi câu query trong transaction đọc snapshot của những gì đã commit tại thời điểm query đó chạy. T1 không bao giờ thấy row T2 đang modify nhưng chưa commit.

-- T1 (READ_COMMITTED, Postgres default)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- tra ve 100 (T2 chua commit)

-- (T2 dang chay: UPDATE balance = 200 nhung chua COMMIT)

SELECT balance FROM accounts WHERE id = 1;  -- tra ve 200 (T2 da commit giua 2 query)
COMMIT;
-- Non-repeatable read xay ra: 2 lan SELECT cung row, ket qua khac nhau

Postgres chọn READ_COMMITTED làm mặc định vì nó cân bằng tốt: ngăn dirty read (dữ liệu uncommitted không bao giờ leak ra), nhưng vẫn cho phép concurrency cao bằng cách không giữ snapshot toàn transaction — mỗi statement có snapshot riêng.

3.2 REPEATABLE_READ — khi nào cần

REPEATABLE_READ giữ một snapshot nhất quán suốt toàn transaction. Mọi SELECT trên cùng row trong transaction đều trả về giá trị tại thời điểm transaction bắt đầu, bất kể các transaction khác đã commit gì ở giữa.

// Use case: financial ledger -- doc balance nhieu lan trong 1 tx
@Transactional(isolation = Isolation.REPEATABLE_READ)
public TransferResult transfer(Long fromId, Long toId, BigDecimal amount) {
    Account from = accountRepo.findById(fromId).orElseThrow();
    Account to   = accountRepo.findById(toId).orElseThrow();

    // Kiem tra balance
    if (from.getBalance().compareTo(amount) < 0) {
        throw new InsufficientFundsException();
    }

    // Khoảng cach giữa check và update -- với REPEATABLE_READ,
    // from.balance van giu gia tri ban dau du T2 co update o giua
    from.debit(amount);
    to.credit(amount);

    accountRepo.save(from);
    accountRepo.save(to);
    return new TransferResult(from, to);
}

Tại sao REPEATABLE_READ cho financial transaction: nếu T1 đọc balance = 1000, check đủ tiền, rồi giữa đó T2 commit trừ 900 còn 100 — khi T1 đọc lại với READ_COMMITTED sẽ thấy 100 (thay đổi so với lần đọc đầu). Với REPEATABLE_READ, T1 vẫn thấy 1000 suốt transaction, đảm bảo logic check-then-update nhất quán.

Chi phí: Postgres dùng MVCC (Multi-Version Concurrency Control) — giữ nhiều phiên bản row. REPEATABLE_READ tăng memory usage và có thể gây thêm conflict (serialization failure với rollback tự động trong Postgres).

3.3 SERIALIZABLE — khi nào thực sự cần

SERIALIZABLE là mức cao nhất — đảm bảo kết quả giống như các transaction chạy tuần tự (không đồng thời), kể cả phantom read. Chi phí là lock aggressive nhất.

// Dung khi: critical money transfer, ledger reconciliation
@Transactional(isolation = Isolation.SERIALIZABLE)
public void reconcile(Long accountId) {
    // Doc tong so du
    BigDecimal total = txRepo.sumByAccount(accountId);
    // INSERT reconciliation record
    reconRepo.save(new Reconciliation(accountId, total));
}
// Neu T2 INSERT tx moi giua 2 buoc tren -- SERIALIZABLE phat hien va rollback 1 trong 2 tx

Khuyến nghị thực tế: SERIALIZABLE gây tăng conflict rate, serialization failure (transaction bị rollback tự động, cần retry), và giảm throughput đáng kể. Chỉ dùng khi đã đo được anomaly thực tế và không có phương án khác (optimistic lock, application-level check).

3.4 Chọn isolation level trong thực tế

// 99% service -- Postgres default la READ_COMMITTED, khong can ghi
@Transactional
public List<Order> findByUser(Long userId) { ... }

// Financial ledger -- REPEATABLE_READ de ngan non-repeatable read
@Transactional(isolation = Isolation.REPEATABLE_READ)
public TransferResult transfer(Long fromId, Long toId, BigDecimal amount) { ... }

// Critical reconciliation -- SERIALIZABLE chi khi thuc su can
@Transactional(isolation = Isolation.SERIALIZABLE)
public void reconcile(Long accountId) { ... }

Nguyên tắc: bắt đầu với default, tăng isolation chỉ khi đã đo được anomaly cụ thể. Higher isolation không phải "an toàn hơn miễn phí" — mỗi bậc tăng giảm throughput và tăng lock contention có thể đo được.

4. readOnly — cơ chế tắt dirty checking

readOnly = true là hint cho Hibernate và JDBC driver, không phải constraint của database. Postgres không từ chối INSERT nếu bạn quên đặt readOnly = true — đây là optimization hint, không phải enforcement.

@Transactional(readOnly = true)
public List<OrderSummary> findRecentOrders(Long userId) {
    return orderRepo.findTop20ByUserIdOrderByCreatedAtDesc(userId);
}

Ba thứ xảy ra khi readOnly = true:

1. Hibernate tắt dirty checking:

Persistence context bình thường giữ snapshot của mọi entity được load — copy toàn bộ field tại thời điểm load. Khi flush (trước commit hoặc trước query), Hibernate so sánh current state với snapshot để phát hiện thay đổi và generate UPDATE SQL. Với readOnly = true, Hibernate bỏ qua bước lưu snapshot và bỏ qua dirty checking — giảm memory khoảng 30% per entity và giảm CPU tại flush time.

Đối chiếu hai transaction: bên mặc định load entity rồi chụp snapshot mọi field, gọi setStatus, tới lúc flush thì so hiện trạng với snapshot và sinh câu UPDATE; bên readOnly true không chụp snapshot và đặt FlushMode MANUAL nên sau setStatus không có mốc nào để so và không câu UPDATE nào chạy

2. FlushMode.MANUAL:

Hibernate đặt FlushMode.MANUAL thay vì FlushMode.AUTO. Không có auto-flush trước query — query đọc thẳng từ DB mà không cần flush pending changes. Điều này cũng có nghĩa nếu vô tình sửa entity trong readOnly transaction, thay đổi silently không được persist — không exception, chỉ no-op.

3. JDBC hint cho DB:

Spring gọi connection.setReadOnly(true). JDBC driver chuyển thành SET TRANSACTION READ ONLY (Postgres) hoặc tương đương. Postgres optimize execution plan cho read-only transaction. Với setup read replica, proxy (PgBouncer, AWS RDS Proxy) có thể route read-only transaction đến replica tự động — không cần thay đổi application code.

4.1 Pattern recommend — class-level + method override

@Service
@Transactional(readOnly = true)          // class default: moi method la read-only
public class OrderService {

    public Order findById(Long id) {
        return orderRepo.findById(id).orElseThrow();
    }

    public List<Order> findByUser(Long userId) {
        return orderRepo.findByUserId(userId);
    }

    public Page<Order> page(Pageable pageable) {
        return orderRepo.findAll(pageable);
    }

    @Transactional                          // override: write transaction
    public Order create(OrderRequest req) {
        return orderRepo.save(new Order(req));
    }

    @Transactional                          // override: write transaction
    public void cancel(Long id) {
        Order order = orderRepo.findById(id).orElseThrow();
        order.cancel();
        orderRepo.save(order);
    }
}

Pattern này đảo ngược default: class opt-in read-only, write method opt-out. Hiệu quả hơn vì phần lớn method trong service là read.

4.2 Pitfall — sửa entity trong readOnly transaction

// SAI -- readOnly nhung van goi set
@Transactional(readOnly = true)
public void updateName(Long id, String newName) {
    Order order = orderRepo.findById(id).orElseThrow();
    order.setCustomerName(newName);   // dirty checking da bi tat -- KHONG flush
    // Method ket thuc binh thuong, khong exception
    // Nhung UPDATE KHONG CHAY -- thay doi mat im lang
}

Bug này đặc biệt nguy hiểm vì compile pass, không có runtime exception, code trông đúng — nhưng thay đổi không bao giờ được persist. Luôn match readOnly với actual behavior của method.

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

  • Bài 03 — @Transactional & AOP proxy: cơ chế tại sao @Transactional hoạt động qua CGLIB proxy và pitfall self-invocation. Bài này build trực tiếp trên nền đó — propagation và isolation là thuộc tính của transaction, AOP proxy là cơ chế tạo ra transaction. Hiểu bài 03 trước để không confuse "transaction không hoạt động" với "transaction hoạt động nhưng propagation sai".
  • Tổng kết module: recap toàn bộ module Relationships & Transactions, kết nối propagation/isolation với relationship cascade và transaction boundary khi persist entity có quan hệ.

6. Tóm tắt

  • propagation = REQUIRED (default): B join transaction cha — atomic all-or-nothing. Dùng 99% case.
  • propagation = REQUIRES_NEW: B mở transaction độc lập — commit/rollback riêng. Dùng cho audit log, notification, analytics cần "best-effort". Chi phí: suspend + acquire connection mới.
  • Pitfall REQUIRED: exception ở inner mark outer tx "rollback-only" — dù catch ở outer, commit vẫn fail với UnexpectedRollbackException.
  • isolation = READ_COMMITTED (Postgres default): ngăn dirty read. Đủ cho 99% case.
  • isolation = REPEATABLE_READ: thêm ngăn non-repeatable read — dùng cho financial ledger.
  • isolation = SERIALIZABLE: ngăn phantom read — dùng rất hạn chế, tốn lock, cần đo trước.
  • Higher isolation = more lock = less throughput. Không tăng isolation "cho an toàn" nếu chưa đo anomaly.
  • readOnly = true: tắt dirty checking + FlushMode.MANUAL + JDBC read-only hint. Hiệu năng đo được với entity load nhiều.
  • Pattern recommend: class @Transactional(readOnly = true), write method override @Transactional.
  • Bug cổ điển readOnly: sửa entity nhưng không persist — compile pass, runtime silent no-op.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao đoạn code sau ném UnexpectedRollbackException dù đã catch exception?
    @Transactional
    public Order placeOrder(OrderRequest req) {
      Order order = orderRepo.save(new Order(req));
      try {
          inventoryService.reserve(req.items()); // propagation REQUIRED
      } catch (InventoryException e) {
          log.warn("Inventory failed, order anyway");
      }
      return order;
    }
  2. Q2
    So sánh REQUIREDREQUIRES_NEW: khi nào chọn cái nào? Cho ví dụ use case cụ thể cho mỗi loại.
  3. Q3
    Bảng isolation cho thấy READ_COMMITTED không ngăn non-repeatable read. Mô tả một scenario cụ thể trong ứng dụng tài chính mà điều này gây bug, và isolation level nào cần dùng để fix.
  4. Q4
    Giải thích chính xác readOnly = true làm gì bên dưới. Nó có ngăn INSERT/UPDATE ở database không?
  5. Q5
    Service dưới dùng class-level @Transactional(readOnly = true). Method updateStatus() có bug không? Tại sao?
    @Service
    @Transactional(readOnly = true)
    public class OrderService {
    
      public Order findById(Long id) {
          return orderRepo.findById(id).orElseThrow();
      }
    
      public void updateStatus(Long id, OrderStatus status) {
          Order order = orderRepo.findById(id).orElseThrow();
          order.setStatus(status);
          // Khong goi orderRepo.save() -- tin vao dirty checking
      }
    }

Bài tiếp theo: Tổng kết module

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

Relationships & Transactions — tổng kết