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

Что произойдет с полем 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, решается:

  1. Pessimistic Lock - блокировка строки при SELECT
  2. Optimistic Lock - проверка версии при UPDATE
  3. Atomic SQL - одна SQL команда для обновления
  4. Distributed Lock - блокировка через Redis/Zookeeper

Выбор зависит от архитектуры и паттернов конфликтов обновлений в приложении.

Что произойдет с полем quantity в таблице products, если два пользователя одновременно купят один и тот же товар? | PrepBro