Ngữ cảnh lỗiBạn có thể đã gặp lỗi này trong các giai đoạn lưu lượng truy cập cao: mọi thứ hoạt động bình thường trong môi trường phát triển, nhưng log hệ thống bùng nổ khi hai người dùng nhấn 'Lưu' vào cùng một mili giây. Điều này xảy ra do Spring Data JPA sử dụng một trường @Version để ngăn chặn một transaction ghi đè lên một transaction khác một cách âm thầm — một chiến lược được gọi là Optimistic Locking (Khóa lạc quan).
Hibernate dự kiến sẽ cập nhật chính xác một hàng. Tuy nhiên, nếu một luồng (thread) khác đã thay đổi bản ghi trước đó, số phiên bản (version number) trong cơ sở dữ liệu sẽ không còn khớp với những gì luồng hiện tại đang giữ. Việc cập nhật thất bại vì mệnh đề WHERE không tìm thấy hàng nào khớp, dẫn đến ngoại lệ:
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
Một ví dụ thực tếHãy tưởng tượng một hệ thống kho hàng nơi một mặt hàng có 10 đơn vị trong kho (Phiên bản 1). Người dùng A và Người dùng B cùng tải trang đồng thời. Người dùng A mua 2 đơn vị. Hệ thống cập nhật kho thành 8 và tăng phiên bản lên 2. Một mili giây sau, Người dùng B cố gắng mua 1 đơn vị. Yêu cầu của họ vẫn nghĩ rằng phiên bản là 1. Khi cơ sở dữ liệu tìm kiếm id=X AND version=1, nó không tìm thấy gì vì phiên bản đã là 2. Việc cập nhật thất bại.
Nguyên nhân gốc rễ: Sự sai lệch phiên bảnThủ phạm hầu như luôn là cột đánh dấu phiên bản trong entity của bạn. Hầu hết các thiết lập JPA trông như thế này:
@Entity
public class Inventory {
@Id
private Long id;
private Integer stockCount;
@Version
private Long version;
}
Đằng sau hậu trường, Hibernate thực thi một truy vấn SQL cụ thể:
UPDATE inventory SET stock_count = ?, version = 2 WHERE id = ? AND version = 1;
Nếu một tiến trình khác đã thay đổi phiên bản đó thành 2 trước khi truy vấn này hoàn tất, mệnh đề WHERE sẽ trả về không có hàng nào bị ảnh hưởng. Spring nhận thấy điều này và ngay lập tức ném ra lỗi ObjectOptimisticLockingFailureException để bảo vệ tính toàn vẹn dữ liệu của bạn.
Cách khắc phục lỗi### Cách 1: Sử dụng Spring Retry (Tiêu chuẩn công nghiệp)Vì những xung đột này thường mang tính tạm thời, cách khắc phục đơn giản nhất là thử lại. Lần thử thứ hai sẽ lấy phiên bản mới nhất từ cơ sở dữ liệu và có khả năng thành công. Điều này diễn ra liền mạch đối với người dùng cuối.
Đầu tiên, hãy thêm các dependency này vào tệp pom.xml của bạn:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
Bật cơ chế retry trong cấu hình của bạn:
@Configuration
@EnableRetry
public class RetryConfig {
}
Bây giờ, chỉ cần chú thích (annotate) phương thức service của bạn. Việc đặt maxAttempts = 3 giúp hệ thống có đủ khoảng trống để giải quyết xung đột:
@Service
public class InventoryService {
@Autowired
private InventoryRepository repository;
@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public void updateStock(Long id, Integer quantity) {
Inventory inventory = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found"));
inventory.setStockCount(inventory.getStockCount() - quantity);
repository.save(inventory);
}
}
Cách 2: Bắt lỗi và phục hồi thủ côngXử lý thủ công sẽ tốt hơn nếu bạn cần logic cụ thể, chẳng hạn như ghi log cảnh báo hoặc thông báo cho hệ thống giám sát khi xảy ra xung đột. Nó cung cấp nhiều quyền kiểm soát hơn so với phương pháp dựa trên annotation.
public void safeUpdate(Long id, Integer quantity) {
int attempts = 0;
while (attempts < 3) {
try {
businessLogicService.updateStock(id, quantity);
return;
} catch (ObjectOptimisticLockingFailureException e) {
attempts++;
if (attempts == 3) throw e;
log.warn("Conflict detected for ID {}. Retrying... attempt {}", id, attempts);
}
}
}
Cách 3: Chuyển sang Pessimistic LockingBạn đang chạy một chương trình flash sale với hơn 500 yêu cầu đồng thời mỗi giây trên một mặt hàng duy nhất? Trong các tình huống tranh chấp cao, việc thử lại trở nên kém hiệu quả. Thay vào đó, hãy khóa hàng trong cơ sở dữ liệu ngay tại thời điểm bạn đọc nó.
@Repository
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Inventory i WHERE i.id = :id")
Optional<Inventory> findByIdForUpdate(@Param("id") Long id);
}
Điều này buộc các transaction khác phải chờ trong hàng đợi. Nó loại bỏ hoàn toàn ngoại lệ nhưng có thể làm chậm ứng dụng của bạn nếu các khóa được giữ quá lâu.
Các bước xác minhĐừng đoán — hãy kiểm tra nó. Sử dụng CountDownLatch để buộc hai luồng xung đột trong một integration test. Điều này đảm bảo logic retry của bạn thực sự hoạt động dưới áp lực.
@Test
void testConcurrentUpdate() throws InterruptedException {
int threadCount = 2;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.execute(() -> {
try {
inventoryService.updateStock(1L, 1);
} finally {
latch.countDown();
}
});
}
latch.await(5, TimeUnit.SECONDS);
Inventory finalState = repository.findById(1L).get();
assertEquals(8, finalState.getStockCount()); // 10 gốc - 2 lần cập nhật
}
Mẹo phòng ngừa
- **Giữ các transaction tinh gọn:** Không bao giờ thực hiện các xử lý nặng hoặc gọi các API bên ngoài bên trong một khối `@Transactional`. Transaction càng nhanh, khả năng xảy ra xung đột càng thấp.
- **Sử dụng Cập nhật Nguyên tử (Atomic Updates):** Đối với các phép toán đơn giản như `stock = stock - 1`, hãy sử dụng một truy vấn native: `UPDATE inventory SET stock_count = stock_count - :q WHERE id = :id`. Điều này tránh hoàn toàn việc kiểm tra phiên bản bằng cách thực hiện phép toán ngay trong cơ sở dữ liệu.
- **Kiểm tra giao diện người dùng (UI):** Đảm bảo frontend của bạn không gửi các yêu cầu trùng lặp. Một trạng thái 'vô hiệu hóa' (disabled) đơn giản trên nút bấm sau lần nhấp đầu tiên có thể ngăn chặn 50% các lỗi này.

