← Назад к вопросам
Что произойдет с полем quantity в таблице products, если два пользователя одновременно купят один и тот же товар?
2.0 Middle🔥 241 комментариев
#Базы данных и SQL#Многопоточность
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Race Condition в quantity — Проблема одновременного доступа
Это классическая проблема race condition в многопоточной среде. Когда два пользователя одновременно покупают один товар, может произойти потеря обновления (Lost Update).
Проблема: Lost Update
Предположим, quantity = 10
Время | Пользователь 1 | Пользователь 2 | quantity
-------|----------------------|----------------------|----------
t0 | SELECT quantity=10 | | 10
t1 | | SELECT quantity=10 | 10
t2 | UPDATE quantity = 9 | | 9
t3 | | UPDATE quantity = 9 | 9
| | |
| ПРОБЛЕМА: оба купили по 1, но quantity = 9 вместо 8
| Потеря одного обновления!
Код, который приводит к проблеме
@Service
public class ProductService {
// НЕПРАВИЛЬНО: Race Condition
@Transactional
public void buyProduct(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElse(null);
// Пользователь 1 читает quantity = 10
if (product.getQuantity() >= quantity) {
// Пользователь 2 читает quantity = 10 (еще не обновлено!)
product.setQuantity(product.getQuantity() - quantity);
// Пользователь 1 сохраняет quantity = 9
// Пользователь 2 сохраняет quantity = 9 (перезаписывает)
productRepository.save(product);
}
}
}
Решение 1: SELECT FOR UPDATE (Pessimistic Lock)
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Product findByIdWithLock(@Param("id") Long id);
}
@Service
public class ProductService {
// ПРАВИЛЬНО: Блокировка строки
@Transactional
public void buyProduct(Long productId, int quantity) {
// SELECT * FROM products WHERE id = 1 FOR UPDATE
// Другие транзакции ЖДУТ блокировку
Product product = productRepository.findByIdWithLock(productId);
if (product.getQuantity() >= quantity) {
product.setQuantity(product.getQuantity() - quantity);
productRepository.save(product);
}
}
}
Теперь сценарий выглядит правильно:
Время | Пользователь 1 | Пользователь 2 | quantity
-------|-------------------------|---------------------- |----------
t0 | SELECT...FOR UPDATE | | 10 (заблокирована)
t1 | | SELECT...FOR UPDATE | ЖДЕТ блокировку
| | (блокируется) |
t2 | UPDATE quantity = 9 | | 9
t3 | COMMIT | | 9
t4 | | Получает блокировку | 9
t5 | | UPDATE quantity = 8 | 8
t6 | | COMMIT | 8
| | |
| ПРАВИЛЬНО: оба купили, quantity = 8
Решение 2: Version/Optimistic Lock
@Entity
@Table(name = "products")
public class Product {
@Id
private Long id;
private String name;
private int quantity;
@Version // Версия для оптимистической блокировки
private Long version;
}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {}
@Service
public class ProductService {
// Оптимистическая блокировка
@Transactional
public void buyProduct(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElse(null);
if (product.getQuantity() >= quantity) {
product.setQuantity(product.getQuantity() - quantity);
// При save(): если version не совпадает -> OptimisticLockException
productRepository.save(product);
}
}
}
При оптимистической блокировке:
Время | Пользователь 1 | Пользователь 2 | quantity | version
-------|---------------------|---------------------|----------|--------
t0 | SELECT (v=1) | | 10 | 1
t1 | | SELECT (v=1) | 10 | 1
t2 | UPDATE, SET v=2 | | 9 | 2
t3 | | UPDATE, v=1 != 2 | | 2
| | OptimisticLockEx! | |
t4 | | Retry или error | |
Решение 3: SQL UPDATE (Native Query)
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Modifying
@Transactional
@Query(value = "UPDATE products SET quantity = quantity - :qty WHERE id = :id AND quantity >= :qty",
nativeQuery = true)
int updateQuantity(@Param("id") Long id, @Param("qty") int quantity);
}
@Service
public class ProductService {
// ATOMICITY на уровне БД
public boolean buyProduct(Long productId, int quantity) {
// Одна SQL команда - атомарная операция
// Нет race condition
int updated = productRepository.updateQuantity(productId, quantity);
return updated > 0; // 1 = успех, 0 = не хватает товара
}
}
Решение 4: Distributed Lock (для микросервисов)
@Service
public class ProductService {
private final RedisTemplate<String, String> redisTemplate;
public void buyProduct(Long productId, int quantity) {
String lockKey = "product:" + productId;
// Попробовать получить блокировку (с timeout)
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", Duration.ofSeconds(5));
if (!lockAcquired) {
throw new LockAcquisitionFailedException("Cannot acquire lock");
}
try {
// Критическая секция
Product product = productRepository.findById(productId).orElse(null);
if (product.getQuantity() >= quantity) {
product.setQuantity(product.getQuantity() - quantity);
productRepository.save(product);
}
} finally {
// Освободить блокировку
redisTemplate.delete(lockKey);
}
}
}
Сравнение подходов
| Подход | Производительность | Сложность | Когда использовать |
|---|---|---|---|
| Pessimistic Lock | Низкая (блокировки) | Простая | Высокий конфликт обновлений |
| Optimistic Lock | Высокая (retry) | Средняя | Низкий конфликт |
| SQL UPDATE | Высокая | Простая | Простые операции |
| Distributed Lock | Средняя | Сложная | Микросервисная архитектура |
Рекомендуемое решение
Для эcommerce с товарами:
@Entity
public class Product {
@Id
private Long id;
private int quantity;
@Version
private Long version; // Оптимистическая блокировка
}
@Service
public class ProductService {
@Transactional
public void buyProduct(Long productId, int quantity) throws RetryableException {
try {
Product product = productRepository.findById(productId).orElse(null);
if (product.getQuantity() < quantity) {
throw new InsufficientQuantityException();
}
product.setQuantity(product.getQuantity() - quantity);
productRepository.save(product);
} catch (OptimisticLockException e) {
// Retry механизм
throw new RetryableException("Conflict, please retry", e);
}
}
}
Вывод
Это классическая проблема Race Condition, решается:
- Pessimistic Lock - блокировка строки при SELECT
- Optimistic Lock - проверка версии при UPDATE
- Atomic SQL - одна SQL команда для обновления
- Distributed Lock - блокировка через Redis/Zookeeper
Выбор зависит от архитектуры и паттернов конфликтов обновлений в приложении.