Tất cả bài viết

Optimistic lock — hai điều dưỡng sửa cùng MedicalRecord

@Version và OptimisticLockException: last-write-wins im lặng vs báo conflict cho user merge. Khác race booking slot nhưng cùng đau production.

case-studyconcurrencyjpaoptimistic-lock

Điều dưỡng A mở hồ sơ bệnh nhân lúc 9:00, sửa ghi chú điều trị. Điều dưỡng B mở cùng hồ sơ lúc 9:02, thêm dị ứng thuốc. B save trước. A save sau — thành công, không lỗi.

Sáng hôm sau bác sĩ đọc hồ sơ: mất dòng dị ứng B vừa thêm. Không ai báo lỗi. Không exception trong log. Chỉ có A thắng vì save sau.

Đây là lost update — và với dữ liệu y tế, nó nguy hiểm hơn race đặt lịch vì im lặng hơn.


Khác gì với Redis lock đặt slot

Bài đặt lịch cùng slot: hai người tranh một tài nguyên duy nhất — slot hoặc được giữ hoặc không. Redis Lua atomic.

Hai người sửa cùng một row MedicalRecord: cả hai đều đọc version cũ, sửa field khác nhau, ghi đè lên nhau. DB không tự merge field. Cần optimistic locking hoặc pessimistic lock (SELECT FOR UPDATE).


Optimistic lock với @Version

@Entity
public class MedicalRecord {
  @Id
  private UUID id;

  @Version
  private Long version;

  private String treatmentNotes;
  private String allergyNotes;
  // ...
}

Mỗi lần update thành công, Hibernate tăng version và WHERE clause gồm version cũ:

UPDATE medical_record
SET treatment_notes = ?, version = 1
WHERE id = ? AND version = 0

Nếu B đã update lên version = 1, update của A với version = 0 affect 0 rowsOptimisticLockException.

@Service
public class MedicalRecordService {

  @Transactional
  public MedicalRecordResponse update(UUID id, UpdateMedicalRecordRequest req) {
    var record = medicalRecordRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("RECORD_NOT_FOUND", id));

    record.applyUpdate(req); // set fields từ DTO
    try {
      return mapper.toResponse(medicalRecordRepository.save(record));
    } catch (OptimisticLockException ex) {
      throw new ConflictException("RECORD_CONFLICT",
          "Hồ sơ vừa được người khác cập nhật. Tải lại và thử lại.");
    }
  }
}

API trả 409 Conflict — frontend reload diff, cho user merge hoặc ghi đè có chủ đích.


UX khi conflict

Đừng chỉ hiện “Lỗi hệ thống”. Trả thêm snapshot mới nhất (hoặc version + updatedAt + updatedBy):

public record ConflictError(
    String code,
    String message,
    MedicalRecordResponse currentVersion
) {}

Client hiển thị: “Hồ sơ đã thay đổi bởi [tên]. Bạn muốn ghi đè hay hợp nhất?”

Với PHI, ghi đè không hỏi là thiếu trách nhiệm.


Pessimistic lock — khi nào cần

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM MedicalRecord m WHERE m.id = :id")
Optional<MedicalRecord> findByIdForUpdate(@Param("id") UUID id);

Giữ row lock đến hết transaction — phù hợp thao tác ngắn, bắt buộc serial (cấp số thuốc, trừ kho). Không giữ lock trong khi user đọc form 20 phút — connection pool chết.

Rule: form dài, user suy nghĩ → optimistic. Transaction ngắn, contention cao trên cùng row → cân nhắc pessimistic.


Audit bổ sung

@Version chặn lost update lúc save. Không thay thế audit ai sửa gì lúc nào — vẫn nên có updated_by, updated_at, hoặc bảng audit riêng cho compliance.


Takeaway

Nếu entity nhiều người sửa song song — MedicalRecord, Prescription draft — thêm @Version sớm, map OptimisticLockException → 409. Đừng tin “save sau cùng thắng” là acceptable. Và đừng nhầm với Redis booking: đây là row-level version, không phải slot atomic.


Bài tiếp theo: Outbox pattern — email không mất sau commit.