Optimistic vs Pessimistic Locking trong JPA

4 câu hỏi~28 phútTừ khóa: lost update, @Version, OptimisticLockException, PESSIMISTIC_WRITE, FOR UPDATE, lock timeout, retry, atomic update, deadlock

Quay lại bài toán API cộng tiền ở chương Concurrency: bắn nhiều request cùng lúc thì số dư bị sai. Trong JPA giải quyết bằng lock thế nào?

Nhắc lại nhanh: keyword lớn nhất là Concurrency / Race Condition, cụ thể hơn là Lost Update. Phần lý thuyết race condition đã có ở Bài 3.5; bài này làm phần JPA trên shop-app: trừ tồn kho Product.

1. Bài toán

Product có stock = 10. Hai khách hàng đồng thời mua 7 sản phẩm.

Transaction A              Transaction B
Read stock = 10            Read stock = 10
10 - 7 = 3                 10 - 7 = 3
UPDATE stock = 3           UPDATE stock = 3
            └──── Lost Update ────┘

Cả hai request đều được xác nhận, nhưng stock cuối cùng là 3 thay vì phát hiện không đủ hàng cho request thứ hai. Với API cộng tiền: balance 100, A +50, B +30, kết quả 130 thay vì 180.

2. Vấn đề

Code "đọc → tính trong Java → ghi" dùng dirty checking của JPA:

@Transactional
public void purchase(Long id, int qty) {
    Product p = repository.findById(id).orElseThrow();
    if (p.getStock() < qty) throw new IllegalStateException("Insufficient stock");
    p.setStock(p.getStock() - qty);     // UPDATE products SET stock=? WHERE id=?
}

@Transactional với READ COMMITTED (mặc định PostgreSQL) không chặn chuyện này: cả hai cùng đọc 10 trước khi ai kịp ghi.

3. Giải pháp: 3 cách

CáchVí dụKhi nào dùng
Pessimistic LockSELECT ... FOR UPDATEConflict cao, cần chắc chắn
Optimistic LockWHERE id=? AND version=?Conflict ít, không muốn giữ lock
Atomic Updatestock = stock - 7Phép update đơn giản

4. Code từng bước

Bước 1: Dependency: spring-boot-starter-data-jpa; thêm spring-retry + spring-boot-starter-aop nếu muốn @Retryable.

Bước 2: Optimistic Locking bằng @Version

@Entity
@Table(name = "products")
public class Product {
    @Id
    private Long id;
    private Integer stock;

    @Version
    private Long version;
}

Ban đầu id=1, stock=10, version=1. Hai transaction đều đọc version = 1. A cập nhật:

UPDATE products SET stock = 3, version = 2 WHERE id = 1 AND version = 1;  -- 1 row

B cố cập nhật:

UPDATE products SET stock = 3, version = 2 WHERE id = 1 AND version = 1;  -- 0 row

Không còn bản ghi nào khớp version = 1. Hibernate ném OptimisticLockException, Spring dịch thành ObjectOptimisticLockingFailureException, transaction B rollback.

Retry đúng cách: chạy lại toàn bộ logic trong một transaction mới, nên retry phải ở ngoài @Transactional:

@Service
public class PurchaseFacade {
    private final PurchaseService purchaseService;
    PurchaseFacade(PurchaseService s) { this.purchaseService = s; }

    public void purchase(Long productId, int qty) {
        int attempts = 0;
        while (true) {
            try {
                purchaseService.purchase(productId, qty);   // @Transactional bên trong
                return;
            } catch (ObjectOptimisticLockingFailureException e) {
                if (++attempts >= 3) throw e;           // hết lượt → 409 Conflict
                try { Thread.sleep(20L * attempts); }
                catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw e; }
            }
        }
    }
}

Hoặc dùng @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3) trên bean ngoài, với @EnableRetry.

Bước 3: Pessimistic Locking

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

@Transactional
public void purchase(Long productId, int quantity) {
    Product product = repository.findByIdForUpdate(productId).orElseThrow();
    if (product.getStock() < quantity) {
        throw new IllegalStateException("Insufficient stock");
    }
    product.setStock(product.getStock() - quantity);
}

Với PostgreSQL, SQL tương đương:

SELECT * FROM products WHERE id = 1 FOR UPDATE;

Transaction B phải chờ lock được giải phóng (khi A commit/rollback) hoặc gặp timeout. Về lock timeout:

Bước 3b: Atomic Update, rất đáng học khi làm tồn kho:

@Modifying
@Query("""
    UPDATE Product p
    SET p.stock = p.stock - :quantity
    WHERE p.id = :id
      AND p.stock >= :quantity
    """)
int decreaseStock(@Param("id") Long id, @Param("quantity") int quantity);

@Transactional
public void purchase(Long productId, int quantity) {
    if (repository.decreaseStock(productId, quantity) == 0) {
        throw new IllegalStateException("Insufficient stock");
    }
}

Kiểm tra stock >= quantity và trừ stock nằm trong cùng một câu UPDATE; database khóa row trong lúc update nên an toàn. Lưu ý: bulk JPQL bỏ qua dirty checking và không tăng @Version; nếu đã load entity trước đó thì dữ liệu trong context bị stale.

Bước 4: Test bằng curl (bắn song song)

for i in 1 2; do
  curl -s -X POST "http://localhost:8080/products/1/purchase?qty=7" &
done; wait
# 1 request 200 OK, 1 request 409 "Insufficient stock" hoặc "Conflict"
psql -c "select stock, version from products where id = 1"   # stock = 3

5. Cách hoạt động

OPTIMISTIC (@Version)                PESSIMISTIC (FOR UPDATE)
A: SELECT v=1                        A: SELECT ... FOR UPDATE  (giữ lock)
B: SELECT v=1                        B: SELECT ... FOR UPDATE  ──► CHỜ
A: UPDATE ... WHERE v=1 → 1 row ✓    A: UPDATE stock=3; COMMIT (nhả lock)
B: UPDATE ... WHERE v=1 → 0 row ✗    B: nhận row stock=3
   → OptimisticLockException            3 < 7 → "Insufficient stock"
   → retry transaction mới             (hoặc lock timeout)

Deadlock khi khóa nhiều row (chuyển tiền A→B và B→A cùng lúc):

T1: lock Account A ──► chờ Account B
T2: lock Account B ──► chờ Account A
PostgreSQL phát hiện deadlock → hủy một transaction

Tránh bằng cách luôn khóa theo cùng một thứ tự:

Long firstId  = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
Account first  = accountRepository.findByIdForUpdate(firstId).orElseThrow();
Account second = accountRepository.findByIdForUpdate(secondId).orElseThrow();

Thêm: giữ transaction ngắn, có index phù hợp, retry có kiểm soát khi gặp deadlock, không gọi HTTP API chậm khi đang giữ lock.

6. So sánh

OptimisticPessimisticAtomic Update
Dựa vào version checkingDựa vào database row lockMột câu UPDATE có điều kiện
Phù hợp ít conflictPhù hợp khi conflict cao, cần khóa trước khi sửaPhép tính đơn giản trên 1 row
Conflict gây lỗi, cần retryCó thể blocking, lock timeout, deadlockKhông retry, kiểm tra số row
Không giữ row lock khi đọcGiữ lock tới khi commitLock rất ngắn
Hợp cả "long conversation" (form sửa, gửi version lên)Chỉ trong 1 transactionKhông cần load entity

7. Khi nào nên dùng / không nên dùng

Vậy client bấm cộng tiền, timeout rồi retry 3 lần thì lock có cứu được không?

Không, đó là vấn đề khác. Lock chống nhiều request khác nhau ghi đè nhau (lost update). Còn cùng một request bị retry 3 lần thì mỗi lần đều là một lệnh +50k hợp lệ, cộng thành 150k. Đó là duplicate request / idempotency.

Nhiều request khác nhau chạy đồng thời → Race condition → Lost Update
   → Lock / Optimistic Lock / Atomic Update
Cùng một request bị retry nhiều lần → Duplicate processing
   → Idempotency Key / Transaction ID (unique constraint)
CREATE TABLE deposits (
  id BIGSERIAL PRIMARY KEY,
  idempotency_key VARCHAR(64) NOT NULL UNIQUE,
  account_id BIGINT NOT NULL,
  amount NUMERIC(19,2) NOT NULL
);
-- insert deposit + UPDATE balance trong cùng transaction;
-- key trùng → unique violation → trả lại kết quả lần trước

@Version có cần dùng cùng @Lock(OPTIMISTIC) không?

Không cần. Chỉ cần entity có @Version là Hibernate tự kiểm tra version khi UPDATE/DELETE. LockModeType.OPTIMISTIC chỉ dùng khi muốn kiểm tra version của entity bạn đọc mà không sửa (Hibernate check lại version lúc commit). OPTIMISTIC_FORCE_INCREMENT thì ép tăng version, ví dụ thêm OrderItem mà muốn version của Order (aggregate root) cũng tăng.

Phỏng vấn hỏi locking trong JPA thì trả lời sao?

Q1. Làm sao tránh lost update khi nhiều request cùng sửa một dữ liệu?

🇻🇳 Tiếng Việt:

Khi nhiều request cùng update một resource có thể xảy ra race condition, cụ thể là lost update. Em xử lý bằng pessimistic lock như SELECT ... FOR UPDATE, hoặc optimistic lock bằng version. Nếu phép tính đơn giản, em dùng atomic update ngay ở database, ví dụ UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?. Tùy mức độ tranh chấp em chọn cách phù hợp. Với API tiền, em còn xử lý idempotency bằng idempotency key.

🇬🇧 English:

When several requests update the same resource, a race condition such as a lost update can happen. I handle it with pessimistic locking like SELECT ... FOR UPDATE, or optimistic locking with a version column. For simple arithmetic I use an atomic conditional update in the database, for example UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?. The choice depends on contention. For money APIs I also handle idempotency with an idempotency key.

Q2. Optimistic và pessimistic locking khác nhau thế nào?

🇻🇳 Tiếng Việt:

Optimistic dùng cột @Version: UPDATE kèm WHERE version = ?, không giữ lock khi đọc, nếu 0 row thì ném OptimisticLockException và phải retry toàn bộ trong transaction mới. Hợp khi ít conflict. Pessimistic dùng @Lock(PESSIMISTIC_WRITE), sinh SELECT FOR UPDATE, transaction khác phải chờ. Hợp khi conflict cao, nhưng phải đặt lock timeout và khóa theo thứ tự để tránh deadlock.

🇬🇧 English:

Optimistic locking uses a @Version column: the UPDATE includes WHERE version = ?, no lock is held while reading, and if zero rows match Hibernate throws OptimisticLockException, so we retry the whole operation in a new transaction. It fits low contention. Pessimistic locking uses @Lock(PESSIMISTIC_WRITE), which issues SELECT FOR UPDATE, so other transactions wait. It fits high contention, but we need a lock timeout and a consistent lock order to avoid deadlocks.

Q3. Retry khi gặp OptimisticLockException đặt ở đâu?

🇻🇳 Tiếng Việt:

Ở ngoài ranh giới transaction. Transaction bị lỗi đã bị đánh dấu rollback và Persistence Context chứa dữ liệu cũ, nên phải đọc lại và chạy lại toàn bộ logic trong transaction mới. Em giới hạn số lần retry, có backoff ngắn, hết lượt thì trả 409 Conflict.

🇬🇧 English:

Outside the transaction boundary. The failed transaction is marked rollback-only and its persistence context holds stale data, so we must re-read and rerun the whole logic in a new transaction. I limit the attempts, add a short backoff, and return 409 Conflict when retries are exhausted.

Cách nhớ ngắn gọn


Tự kiểm tra

@Transactional mặc định có chặn được lost update không?

Không. Với READ COMMITTED hai transaction vẫn cùng đọc giá trị cũ rồi ghi đè nhau.

Câu UPDATE mà @Version sinh ra trông thế nào?

UPDATE products SET stock=?, version=? WHERE id=? AND version=?; 0 row → OptimisticLockException.

Vì sao không retry bên trong cùng method @Transactional?

Transaction đã bị rollback-only và context giữ dữ liệu stale; phải bắt đầu transaction mới.

Hint lock timeout = 0 trên PostgreSQL sinh ra gì?

FOR UPDATE NOWAIT: lỗi ngay nếu row đang bị khóa.

Tránh deadlock khi chuyển tiền giữa hai account?

Luôn khóa theo thứ tự id tăng dần, giữ transaction ngắn, retry có giới hạn.