← Назад к вопросам

Как избежать одновременную перезапись результата предыдущего изменения с помощью Hibernate?

1.8 Middle🔥 191 комментариев
#ORM и Hibernate

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как избежать перезапись изменений в Hibernate (Optimistic Locking)

Это проблема Lost Update (потеря обновления) в многопользовательских системах. Когда два пользователя одновременно редактируют один объект, второе обновление может перезаписать первое.

Проблема: Lost Update

Время   Пользователь 1       Пользователь 2
─────────────────────────────────────────────
T1      Читает счёт: 1000
T2                             Читает счёт: 1000
T3      Обновляет на 1100
T4                             Обновляет на 900
T5      Сохраняет 1100
T6                             Сохраняет 900

Результат: балнс 900 (потеряно +100 от пользователя 1!)

Решение 1: Optimistic Locking (рекомендуется)

Используем версионирование объектов через аннотацию @Version:

// Hibernate модель с версией
@Entity
@Table(name = "accounts")
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String accountNumber;
    private BigDecimal balance;
    
    // ✅ Версия для optimistic locking
    @Version
    private Long version;  // или @Version private Integer version;
    
    // getters/setters
    public Long getId() { return id; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
    public Long getVersion() { return version; }
}

Как работает: Каждый раз при обновлении Hibernate:

  1. Проверяет версию в БД
  2. Если версия не совпадает → выбрасывает OptimisticLockException
  3. Разработчик обрабатывает исключение (retry, показывает ошибку, merge и т.д.)
// Service с обработкой optimistic lock exception
@Service
public class AccountService {
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional
    public void transferMoney(Long accountId, BigDecimal amount) {
        try {
            Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new AccountNotFoundException(accountId));
            
            // Увеличиваем баланс
            account.setBalance(account.getBalance().add(amount));
            
            accountRepository.save(account);
            // Если была конкурирующая транзакция, выбросится исключение
            
        } catch (OptimisticLockingFailureException e) {
            // Обработка конфликта
            log.warn("Optimistic lock failed for account: " + accountId);
            // Вариант 1: retry
            // return transferMoney(accountId, amount);
            
            // Вариант 2: выбросить custom исключение
            // throw new ConcurrentUpdateException("Account updated by another user");
            
            // Вариант 3: merge данных
            // Account latest = accountRepository.findById(accountId).get();
            // latest.setBalance(latest.getBalance().add(amount));
            // accountRepository.save(latest);
        }
    }
}

Сгенерированный SQL:

-- Hibernate генерирует запрос с проверкой версии
UPDATE accounts 
SET balance = ?, version = version + 1 
WHERE id = ? AND version = ?;

-- Параметры:
-- balance = 1100
-- id = 1
-- version = 0 (текущая версия)

-- Если версия изменилась, обновлено 0 строк → OptimisticLockException

Решение 2: Pessimistic Locking (пессимистичная блокировка)

Блокируем запись на время редактирования:

// JPA Query с pessimistic lock
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
    
    // Блокируем для чтения-записи
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") Long id);
    
    // Блокируем только для чтения
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForRead(@Param("id") Long id);
}

// Использование
@Service
public class AccountService {
    @Autowired
    private AccountRepository accountRepository;
    
    @Transactional
    public void transferMoney(Long accountId, BigDecimal amount) {
        // Блокируем запись на время транзакции
        Account account = accountRepository.findByIdForUpdate(accountId)
            .orElseThrow(() -> new AccountNotFoundException(accountId));
        
        // Пока мы обновляем, другие транзакции ждут
        account.setBalance(account.getBalance().add(amount));
        accountRepository.save(account);
        // Блокировка снимается при коммите транзакции
    }
}

Виды pessimistic lock:

LockModeTypeОписаниеSQL
PESSIMISTIC_READДругие не могут читать/писатьSELECT ... FOR SHARE (или эквивалент)
PESSIMISTIC_WRITEДругие не могут вообще трогатьSELECT ... FOR UPDATE
PESSIMISTIC_FORCE_INCREMENTУвеличивает версиюFOR UPDATE + инкремент версии
-- PostgreSQL
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;  -- Pessimistic write
SELECT * FROM accounts WHERE id = 1 FOR SHARE;   -- Pessimistic read

-- MySQL
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;

Сравнение подходов

АспектOptimisticPessimistic
Когда использоватьРедкие конфликтыЧастые конфликты
ПроизводительностьВысокаяНиже (ждём блокировку)
DeadlocksНетВозможны
СложностьСредняя (обработка исключения)Низкая (БД сама блокирует)
МасштабируемостьХорошаяПлохая под высокой нагрузкой
ПримерыWeb приложения, CMSБухгалтерия, критичные операции

Практический пример: Full-stack решение

// 1. Entity с версией
@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private BigDecimal price;
    private Integer quantity;
    
    @Version
    private Long version; // Для optimistic locking
    
    public void updatePrice(BigDecimal newPrice) {
        this.price = newPrice;
    }
    
    // getters/setters
}

// 2. Repository
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {}

// 3. Service с retry логикой
@Service
@Slf4j
public class ProductService {
    @Autowired
    private ProductRepository repository;
    
    private static final int MAX_RETRIES = 3;
    private static final int RETRY_DELAY_MS = 100;
    
    @Transactional
    public void updateProductPrice(Long productId, BigDecimal newPrice) 
            throws MaxRetriesExceededException {
        
        for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
            try {
                Product product = repository.findById(productId)
                    .orElseThrow(() -> new ProductNotFoundException(productId));
                
                product.updatePrice(newPrice);
                repository.save(product);
                
                log.info("Product price updated successfully on attempt {}", attempt);
                return; // Успех!
                
            } catch (OptimisticLockingFailureException e) {
                log.warn("Optimistic lock conflict on attempt {}/{}", attempt, MAX_RETRIES);
                
                if (attempt == MAX_RETRIES) {
                    throw new MaxRetriesExceededException(
                        "Failed to update product after " + MAX_RETRIES + " attempts"
                    );
                }
                
                // Ждём перед retry
                try {
                    Thread.sleep(RETRY_DELAY_MS * attempt);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException("Interrupted during retry", ie);
                }
            }
        }
    }
    
    // Альтернатива: merge with conflict resolution
    @Transactional
    public void updateProductPriceWithMerge(Long productId, BigDecimal newPrice) {
        try {
            Product product = repository.findById(productId).orElseThrow();
            product.updatePrice(newPrice);
            repository.save(product);
        } catch (OptimisticLockingFailureException e) {
            // Получаем最свежую версию и меджируем
            Product latest = repository.findById(productId).orElseThrow();
            latest.updatePrice(newPrice);
            repository.save(latest);
        }
    }
}

// 4. REST контроллер
@RestController
@RequestMapping("/api/products")
public class ProductController {
    @Autowired
    private ProductService productService;
    
    @PutMapping("/{id}/price")
    public ResponseEntity<String> updatePrice(
            @PathVariable Long id,
            @RequestParam BigDecimal price) {
        
        try {
            productService.updateProductPrice(id, price);
            return ResponseEntity.ok("Price updated");
        } catch (MaxRetriesExceededException e) {
            return ResponseEntity
                .status(HttpStatus.CONFLICT)
                .body("Price update failed due to concurrent modifications. Please retry.");
        }
    }
}

Конфигурация в application.yml

spring:
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        enable_lazy_load_no_trans: false
        # Для лучшей обработки optimistic lock exception
jpa:
  show-sql: false
  properties:
    hibernate:
      jdbc:
        batch_size: 20
        fetch_size: 50

Когда что использовать

Optimistic Locking:

  • Web приложения, CMS
  • Низкий процент одновременных редактирований
  • Предпочтительно для масштабируемости
  • REST API, где фронт может retry'ить

Pessimistic Locking:

  • Бухгалтерия, банки
  • Высокий процент конфликтов
  • Критичные операции, которые не должны конфликтовать
  • Короткие транзакции

Итог

Для Hibernate используй Optimistic Locking с @Version — это современный, масштабируемый подход. Добавь версионный столбец в entity, обрабатывай OptimisticLockingFailureException с retry логикой. Pessimistic Locking используй только в специальных случаях для критичных операций.