> Как изменить взаимодействие между сервисами, чтобы избежать дублирования заказов при ретраях (PHP)

Уровень: senior · Роль: backend · Язык: PHP · Категория: Технические вопросы

Компании: Travelata

Стек: PHP

> Пример ответа

Короткий ответ

Основная проблема - идемпотентность: повторная обработка одного и того же события не должна создавать новый заказ. Решение - добавить ключ идемпотентности (например, X-Idempotency-Key или UUID события) на уровне API, проверять его в БД через уникальный индекс и возвращать уже созданный заказ при повторном запросе. Дополнительно - использовать outbox pattern для атомарной записи события и заказа, а также корректно обрабатывать ретраи на стороне клиента с экспоненциальной задержкой.

Подробное объяснение

Дублирование заказов при ретраях возникает из-за отсутствия гарантии, что операция выполнится ровно один раз. Ретраи могут происходить на разных уровнях: сетевые таймауты, повторная отправка от клиента, повторная доставка из очереди. Каждый уровень требует своего подхода.

Ключевая идея - идемпотентность. Сервис должен уметь отличать "новый запрос" от "повторной попытки того же запроса". Для этого:

  1. Клиент генерирует уникальный ключ (UUID) для каждой логической операции (создание заказа). Ключ передаётся в заголовке или теле запроса.

  2. Сервис проверяет ключ: если запись с таким ключом уже существует - возвращает ранее созданный результат, не выполняя операцию повторно.

  3. Уникальный индекс в БД на колонке idempotency_key гарантирует, что даже при конкурентных запросах только один пройдёт, остальные получат ошибку или существующий результат.

  4. Outbox pattern: если создание заказа и отправка события в очередь происходят в одной транзакции, то при сбое после коммита, но до отправки, событие не потеряется - его подхватит отдельный процесс из таблицы outbox.

  5. Обработка ретраев в очереди: consumer должен использовать тот же ключ идемпотентности, чтобы повторная доставка сообщения не привела к дублированию.

Важно понимать trade-off: идемпотентность требует дополнительного хранения ключей и проверок, но это стандартная практика для распределённых систем.

На практике

В PHP-проектах (Symfony, Laravel) это реализуется через middleware или event listener. Для очередей (RabbitMQ, Kafka) - через ключ сообщения. Для API - через заголовок Idempotency-Key.

Типичный сценарий:

  • Клиент отправляет POST /orders с Idempotency-Key: uuid.
  • Сервис проверяет наличие записи в таблице order_idempotency по ключу.
  • Если нет - создаёт заказ и записывает ключ в той же транзакции.
  • Если есть - возвращает существующий заказ (HTTP 200 или 201 с тем же телом).

Для очередей: сообщение содержит event_id, consumer проверяет его в таблице processed_events. Если уже обработан - просто подтверждает (ack) и не выполняет логику.

Пример кода

PHP
// Контроллер в Symfony
public function createOrder(Request $request): JsonResponse
{
$idempotencyKey = $request->headers->get('Idempotency-Key');
if (!$idempotencyKey) {
return new JsonResponse(['error' => 'Idempotency-Key required'], 400);
}
$existingOrder = $this->orderRepository->findByIdempotencyKey($idempotencyKey);
if ($existingOrder) {
return $this->json($existingOrder, 200);
}
try {
$order = $this->orderService->createOrder($request->toArray(), $idempotencyKey);
return $this->json($order, 201);
} catch (UniqueConstraintViolationException $e) {
// Конкурентный запрос - возвращаем существующий
$order = $this->orderRepository->findByIdempotencyKey($idempotencyKey);
return $this->json($order, 200);
}
}
// Сервис с транзакцией
public function createOrder(array $data, string $key): Order
{
return $this->entityManager->transactional(function () use ($data, $key) {
$order = new Order($data);
$this->entityManager->persist($order);
$idempotencyRecord = new IdempotencyRecord($key, $order->getId());
$this->entityManager->persist($idempotencyRecord);
$this->entityManager->flush();
return $order;
});
}

Как отвечать на собеседовании

Начните с определения проблемы: "Ретраи ломают идемпотентность". Затем предложите решение через ключ идемпотентности и уникальный индекс. Упомяните outbox pattern для гарантии доставки событий. Объясните, как обрабатывать конкурентные запросы (catch unique violation). Если спросят про очереди - расскажите про дедупликацию на стороне consumer. Завершите trade-off: хранение ключей, срок жизни записей, очистка старых ключей.

Что проверяет интервьюер

  • Понимание идемпотентности и её важности в распределённых системах.
  • Умение проектировать API с учётом ретраев.
  • Знание паттернов: idempotency key, outbox, deduplication.
  • Понимание конкурентного доступа и работы с уникальными индексами.
  • Практический опыт с PHP-фреймворками и очередями.

Типичные ошибки

  • Предложение "просто проверять наличие заказа по данным" - не работает, так как данные могут совпадать у разных заказов.
  • Игнорирование конкурентных запросов - без уникального индекса два параллельных запроса создадут два заказа.
  • Отсутствие обработки ошибки уникальности - нужно ловить исключение и возвращать существующий результат.
  • Хранение ключей бессрочно - нужно TTL или периодическая очистка.
  • Неучёт ретраев на уровне очереди - consumer должен быть идемпотентным, а не только API.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью