Optimistic vs Pessimistic Locking trong JPA
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ách | Ví dụ | Khi nào dùng |
|---|---|---|
| Pessimistic Lock | SELECT ... FOR UPDATE | Conflict cao, cần chắc chắn |
| Optimistic Lock | WHERE id=? AND version=? | Conflict ít, không muốn giữ lock |
| Atomic Update | stock = stock - 7 | Phé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:
- Hint
jakarta.persistence.lock.timeout=0→ Hibernate sinhFOR UPDATE NOWAITtrên PostgreSQL, lỗi ngay nếu row đang bị khóa. - Giá trị khác 0 tùy dialect; trên PostgreSQL cách chắc chắn là đặt
SET LOCAL lock_timeout = '3s'trong transaction, hoặclock_timeoutở role/database. - Hết thời gian →
PessimisticLockingFailureException/CannotAcquireLockException, trả 409 hoặc 503 tùy nghiệp vụ. - Hàng đợi việc kiểu job:
FOR UPDATE SKIP LOCKED(hint timeout =-2trong Hibernate) để worker bỏ qua row đang bị người khác giữ.
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
| Optimistic | Pessimistic | Atomic Update |
|---|---|---|
| Dựa vào version checking | Dựa vào database row lock | Một câu UPDATE có điều kiện |
| Phù hợp ít conflict | Phù hợp khi conflict cao, cần khóa trước khi sửa | Phép tính đơn giản trên 1 row |
| Conflict gây lỗi, cần retry | Có thể blocking, lock timeout, deadlock | Không retry, kiểm tra số row |
| Không giữ row lock khi đọc | Giữ lock tới khi commit | Lock rất ngắn |
| Hợp cả "long conversation" (form sửa, gửi version lên) | Chỉ trong 1 transaction | Không cần load entity |
7. Khi nào nên dùng / không nên dùng
- Tồn kho, cộng/trừ tiền đơn giản → atomic update trước tiên.
- Sửa entity phức tạp, ít tranh chấp (admin sửa sản phẩm) →
@Version, trả 409 cho client. - Tranh chấp cao, logic nhiều bước trên cùng row (flash sale, chuyển khoản) →
PESSIMISTIC_WRITE+ lock timeout + khóa theo thứ tự. - Không retry bên trong cùng transaction đã fail (transaction đó đã bị đánh dấu rollback).
- Nhiều instance app:
synchronizedtrong Java vô dụng, phải khóa ở DB.
saveAll lại chạy mất vài phút → Bài 4.3.11 Batch.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 exampleUPDATE ... 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èmWHERE 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), sinhSELECT 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
@Versioncolumn: the UPDATE includesWHERE 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 issuesSELECT 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 conflict →
@Version+ retry ngoài transaction. - Nhiều conflict →
PESSIMISTIC_WRITE+ lock timeout + khóa theo thứ tự. - Phép tính đơn giản → atomic
UPDATE ... WHERE stock >= ?. - Retry cùng request → idempotency, không phải lock.
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.