Дубль заказа, которого не должно быть

Пользователь дважды нажал кнопку оплаты с разницей в полсекунды. Оба запроса дошли до сервиса заказов, оба прошли валидацию, оба создали запись. Клиент увидел два одинаковых заказа в истории и два списания.

В коде уже был идемпотентный ключ — но проверка выглядела так: сначала SELECT, есть ли заказ с таким ключом, потом INSERT, если нет. Между SELECT и INSERT — окно в несколько миллисекунд, ровно туда и попал второй запрос.

Optional existing = orderRepository.findByIdempotencyKey(key); if (existing.isEmpty()) { Order order = new Order(key, userId, amount); orderRepository.save(order); }

Это классический race condition на уровне бизнес-логики, а не многопоточки внутри одного процесса: два разных инстанса сервиса, два потока в пуле обработчиков HTTP, без разницы — окно между чтением и записью существует всегда.

Починили через unique-constraint на колонке idempotency_key в БД и catch на DataIntegrityViolationException — второй insert падает на уровне БД, а не на уровне бизнес-логики. Приложение просто ловит исключение и возвращает существующий заказ. Проверка exists-then-insert без constraint в БД — это не идемпотентность, это надежда, что не успеют.

Идемпотентный ключ без ограничения на уровне хранения — это ключ, который проверяют, но не гарантируют.

Тренажёр: 600 вопросов, мок с таймером, план повторов

senior·base — что спрашивают на самом деле


В этом посте были ссылки, но мы их удалили по правилам Сетки