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:

sequenceDiagram
    participant A as placeOrder() -- T1
    participant B as inner method
    participant DB as Database
    rect rgb(254, 226, 226)
    Note over A,DB: REQUIRED -- inner JOIN T1, song chet cung nhau
    A->>B: goi inner (join T1)
    B->>DB: UPDATE trong T1
    A--xDB: T1 ROLLBACK (outer fail)
    Note over DB: thay doi cua inner CUNG MAT
    end
    rect rgb(220, 252, 231)
    Note over A,DB: REQUIRES_NEW -- suspend T1, mo T2 doc lap
    A->>B: goi inner (suspend T1, mo T2)
    B->>DB: INSERT trong T2
    B->>DB: T2 COMMIT
    A--xDB: T1 ROLLBACK (outer fail)
    Note over DB: row cua T2 VAN SONG -- audit log con nguyen
    end

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.

flowchart TB
  A["placeOrder() -- bat dau T1"] --> B["inventoryService.reserve()<br/>join T1"]
  B --> C["paymentService.charge()<br/>join T1"]
  C -->|"thanh cong"| D["T1 COMMIT<br/>toan bo insert/update"]
  C -->|"RuntimeException"| E["T1 ROLLBACK<br/>toan bo hoan tac"]

Đâ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.
flowchart TB
  A["placeOrder() -- bat dau T1"] --> B["orderRepo.save()<br/>trong T1"]
  B --> C["auditService.log()<br/>REQUIRES_NEW -- suspend T1, mo T2"]
  C -->|"T2 commit thanh cong"| D["T1 resume<br/>inventory + payment"]
  C -->|"T2 fail -- rollback chi T2"| E["T1 van resume<br/>audit mat nhung order ok"]
  D --> F["T1 COMMIT"]

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.

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.

flowchart LR
  subgraph WRITE["@Transactional (default)"]
    direction TB
    L1["load entity"] --> S1["luu snapshot<br/>(copy toan bo field)"]
    S1 --> W1["business logic"]
    W1 --> D1["dirty check:<br/>so sanh current vs snapshot"]
    D1 --> F1["flush UPDATE SQL<br/>neu co thay doi"]
  end
  subgraph READ["@Transactional(readOnly=true)"]
    direction TB
    L2["load entity"] --> W2["business logic"]
    W2 --> F2["skip dirty check<br/>skip flush<br/>(FlushMode.MANUAL)"]
  end

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
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;
}

InventoryExceptionRuntimeException. Khi reserve() ném exception, Spring transaction interceptor tại điểm đó **lập tức đánh dấu transaction T1 là "rollback-only"** — không phải lúc exception bubble lên caller.

Caller catch exception và tiếp tục chạy, nhưng trạng thái "rollback-only" đã được ghi vào TransactionSynchronizationManager và không thể hủy bỏ. Khi method kết thúc và Spring cố commit T1, nó phát hiện T1 đã bị mark — ném UnexpectedRollbackException. Cả order lẫn inventory đều rollback.

Fix: chuyển inventoryService.reserve() sang propagation = REQUIRES_NEW để nó chạy trong T2 riêng. Khi T2 fail, chỉ T2 rollback — T1 không bị ảnh hưởng và có thể commit bình thường.

Bài học: trong REQUIRED propagation, exception ở bất kỳ method nào join transaction đều có thể mark toàn bộ transaction rollback-only. Catch exception ở outer không "undo" được điều đó.

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.

REQUIRED — chọn khi operation phải atomic cùng với caller. Ví dụ: orderRepo.save(), inventoryService.reserve(), paymentService.charge() cùng một flow đặt hàng — tất cả phải commit hoặc rollback cùng nhau. Nếu payment fail, inventory không nên bị trừ.

REQUIRES_NEW — chọn khi operation cần commit độc lập, không phụ thuộc kết quả của caller. Ba use case kinh điển:

  • Audit log: phải được ghi kể cả khi business transaction rollback — đây là yêu cầu compliance và debugging.
  • Notification / email: best-effort — SMTP down không nên kéo order transaction xuống.
  • Analytics event: data pipeline cần biết attempt xảy ra dù sau đó có rollback.

Chi phí của REQUIRES_NEW: Spring suspend transaction hiện tại, flush persistence context, và thường acquire connection mới từ pool. Tốn khoảng 2-5ms thêm. Không dùng trong hot path (vd vòng lặp 10k record).

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.

Scenario: chuyển tiền với hai bước — (1) đọc balance để check đủ tiền, (2) trừ balance và lưu.

Với READ_COMMITTED:

  • T1 đọc balance tài khoản A: 1000 → đủ để chuyển 800.
  • T2 commit: trừ 900 từ tài khoản A, balance còn 100.
  • T1 tiếp tục trừ 800 → lưu balance = 1000 - 800 = 200. Nhưng thực tế balance chỉ còn 100 — T1 đã ghi đè mất T2.

Đây là lost update — một dạng non-repeatable read. T1 đọc lại balance (nếu có) sẽ thấy giá trị khác với lần đọc đầu vì T2 đã commit ở giữa.

Fix: dùng isolation = Isolation.REPEATABLE_READ. T1 giữ snapshot balance tại thời điểm bắt đầu transaction. Khi T1 cố lưu lại, Postgres phát hiện conflict (T2 đã modify row) và rollback T1 với serialization failure — caller retry.

Alternative nhẹ hơn: optimistic locking với @Version field — Spring Data sẽ ném OptimisticLockingFailureException nếu version không match, không cần isolation cao hơn.

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?

readOnly = truehint, không phải constraint ở database. Postgres không từ chối INSERT hay UPDATE nếu nhận được SET TRANSACTION READ ONLY — thực tế Postgres sẽ reject modification trong read-only transaction, nhưng Hibernate không cho phép flush ở tầng application trước đó.

Ba thứ xảy ra ở tầng application:

1. Hibernate tắt dirty checking: không lưu snapshot field khi load entity, không so sánh current vs snapshot tại flush time. Giảm memory khoảng 30% per entity và giảm CPU.

2. FlushMode.MANUAL: không có auto-flush trước query. Entity bị sửa trong transaction sẽ không được persist — không exception, chỉ silently no-op.

3. JDBC connection.setReadOnly(true): driver gửi hint đến DB. Postgres optimize execution plan. Proxy như PgBouncer hoặc AWS RDS Proxy có thể route read-only transaction đến read replica tự động.

Hệ quả quan trọng: nếu vô tình sửa entity trong readOnly transaction, code compile và chạy không lỗi — nhưng thay đổi không bao giờ được ghi vào database. Đây là bug silent khó debug.

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
  }
}

updateStatus()hai bug:

Bug 1 — kế thừa readOnly từ class: updateStatus() không có @Transactional riêng, nên kế thừa readOnly = true từ class. Hibernate tắt dirty checking — order.setStatus(status) sẽ không được flush thành UPDATE SQL. Method kết thúc bình thường, không exception, nhưng thay đổi mất hoàn toàn.

Bug 2 — dựa vào dirty checking không tường minh: ngay cả khi fix readOnly, không gọi orderRepo.save(order) mà chỉ dựa vào dirty checking là style dễ gây confusion. Với readOnly = true, dirty checking đã bị tắt nên pattern này chắc chắn không hoạt động.

Fix:

@Transactional   // override class-level readOnly = true
public void updateStatus(Long id, OrderStatus status) {
  Order order = orderRepo.findById(id).orElseThrow();
  order.setStatus(status);
  orderRepo.save(order);   // explicit save -- ro rang hon
}

Thêm @Transactional (không có readOnly) override class default, và gọi save() tường minh. Class-level readOnly = true + write method override là pattern tốt — nhưng phải nhớ override mọi write method.

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

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