Hai người cùng rút tiền từ một tài khoản. Câu chuyện kinh điển, và bài này đo ba cách xử lý.

Bài toán

Tài khoản có 100 đồng. Hai luồng cùng rút 100.

@Transactional
public void rut(Long id, int n) {
    var t = repo.findById(id).orElseThrow();
    Thread.sleep(50);                     // nới khe hở, như bài 96 sê-ri Java
    t.soDu = t.soDu - n;
}

Đây là kiểm-rồi-làm ở tầng CSDL, và nó không nguyên tử.

Ba kết quả

  @Version (khoá lạc quan)      soDu=   0  thành công=1  [ObjectOptimisticLockingFailureException]
  PESSIMISTIC_WRITE             soDu=-100  thành công=2
  một câu UPDATE có điều kiện   soDu=   0  thành công=1  [không đủ tiền]

Dòng giữa trông như thất bại. Nó không phải.

Vì sao -100 là kết quả đúng của khoá bi quan

Nhìn qua thì soDu=0 (dòng 1 và 3) có vẻ an toàn, còn -100 là hỏng. Ngược lại.

Khoá bi quan làm hai giao dịch chạy nối tiếp thật sự:

  luồng A: SELECT ... FOR UPDATE -> đọc 100, ghi 0, commit
  luồng B: chờ A                 -> đọc 0,   ghi -100, commit

B chờ A xong rồi mới đọc, và nó đọc ra giá trị mới là 0. 0 - 100 = -100.

Nếu khoá không hoạt động, cả hai đã đọc 100 và cùng ghi 0 — kết quả sẽ là 0, và một trăm đồng biến mất khỏi sổ sách mà không ai biết. Đó là mất cập nhật (lost update), và nó trông vô hại.

Số dư âm là bằng chứng khoá đã hoạt động. Lỗi còn lại không nằm ở đồng thời mà ở nghiệp vụ: mã không hề kiểm số dư trước khi trừ. Khoá bảo vệ tính nhất quán của dữ liệu, không thay bạn kiểm quy tắc nghiệp vụ.

Đây là bài học tôi thấy quan trọng nhất trong bài này: gặp dữ liệu sai, đừng vội kết luận cơ chế đồng thời hỏng. Con số trông "hợp lý" đôi khi là con số đã mất mát.

Khoá lạc quan

@Entity
class TaiKhoan {
    @Id @GeneratedValue Long id;
    int soDu;
    @Version int phienBan;      // chỉ cần dòng này
}

Hibernate thêm điều kiện phiên bản vào mọi câu cập nhật:

UPDATE tai_khoan SET so_du = ?, phien_ban = 6 WHERE id = ? AND phien_ban = 5

Ai ghi trước thì phiên bản lên 6. Người thứ hai cập nhật 0 dòng, và Hibernate ném ObjectOptimisticLockingFailureException — đúng như đo được.

Không khoá gì cả, nên không có chờ đợi và không có deadlock. Cái giá là bạn phải xử lý ngoại lệ:

@Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
           maxAttempts = 3, backoff = @Backoff(delay = 50))
@Transactional
public void rut(Long id, int n) { ... }

Thử lại chỉ đúng khi thao tác đọc lại dữ liệu mới rồi tính lại — mà @Transactional mở giao dịch mới nên đúng là như vậy. Đừng thử lại một phép tính đã dùng dữ liệu cũ.

Ba điều dễ sai:

@Version phải là int, long, short hoặc Timestamp. Dùng Timestamp thì hai lần ghi trong cùng một mili giây sẽ lọt.

Đừng tự sửa trường phiên bản. Nó thuộc về Hibernate.

Thay đổi ở bảng con không tăng phiên bản của cha — trừ khi có @OptimisticLock. Sửa danh sách bài của tác giả không đụng tới phienBan của tác giả.

Khoá bi quan

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from TaiKhoan t where t.id = :id")
TaiKhoan layKhoaGhi(@Param("id") Long id);

Sinh ra SELECT ... FOR UPDATE. Người thứ hai chờ tới khi người thứ nhất commit.

Lạc quan Bi quan
Chi phí khi không tranh chấp gần bằng không một khoá ở CSDL
Khi tranh chấp ném, phải thử lại chờ
Deadlock không có thể
Giữ kết nối ngắn tới hết giao dịch
Hợp với phần lớn ứng dụng web tranh chấp cao, hàng đợi công việc

Mặc định chọn lạc quan. Tranh chấp trong ứng dụng web thường hiếm, và một dòng @Version là đủ.

Chuyển sang bi quan khi tranh chấp thật sự cao — và khi đó nhớ đặt hạn chờ, nếu không một giao dịch treo sẽ chặn mọi người:

@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})

luôn khoá theo cùng một thứ tự ở mọi nơi trong mã. Giao dịch A khoá tài khoản 1 rồi 2, giao dịch B khoá 2 rồi 1 — đó là deadlock, đúng như bài 72 sê-ri Java.

Cách thứ ba, và thường là cách tốt nhất

@Modifying
@Query("update TaiKhoan t set t.soDu = t.soDu - :n where t.id = :id and t.soDu >= :n")
int truTrucTiep(@Param("id") Long id, @Param("n") int n);
  một câu UPDATE có điều kiện   soDu=0  thành công=1  [không đủ tiền]

Không khoá, không thử lại, không phiên bản. Một câu lệnh, và CSDL tự lo tính nguyên tử.

Điều kiện soDu >= :n nằm ngay trong câu lệnh, nên quy tắc nghiệp vụ được thực thi cùng lúc với việc ghi. Giá trị trả về là số dòng đổi được: 1 là thành công, 0 là không đủ tiền.

Đây chính là mẫu mà CLAUDE.md của blog này ghi lại sau khi hai tiến trình viết bài cùng nhận một chủ đề: đọc-rồi-ghi được thay bằng một câu UPDATE ... WHERE có điều kiện, rồi xét số dòng đổi được.

Hạn chế: nó bỏ qua persistence context (thêm clearAutomatically nếu cần, nhưng cẩn thận với cảnh báo ở bài 25), không chạy được logic Java phức tạp, và không kích hoạt các sự kiện vòng đời entity.

Nhưng với thao tác kiểu tăng, giảm, đổi trạng thái có điều kiện — nó đúng, nhanh và đơn giản nhất.

Thử ba mươi giây

Tìm trong mã của bạn:

grep -rn -A5 "findById" --include='*.java' src/main | grep -E "set[A-Z]|\.\w+ ="

Mỗi chỗ đọc entity rồi sửa trường của nó là một chỗ có thể mất cập nhật. Thêm một dòng @Version vào entity là cách rẻ nhất để biết — nó không làm chậm gì khi không có tranh chấp, và nó ném ngay khi có.

Ngày mai: truy vấn động với Specification — khi bộ lọc do người dùng chọn.