> Как гарантировать, что заказ не будет выполнен без исполнителя после резерва товара (Go)

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

Компании: sferaplatform.ru

Стек: Go

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

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

Гарантия "заказ не выполнен без исполнителя" - это задача атомарности и консистентности между резервом товара и назначением исполнителя. Решение - использовать транзакцию с блокировками (SELECT ... FOR UPDATE) или распределённую транзакцию (Saga, Outbox) в зависимости от архитектуры. Ключевое: резерв товара и фиксация исполнителя должны быть в одной логической единице, либо резерв должен быть "мягким" с таймаутом и компенсацией, если исполнитель не найден. В Go - это паттерн с использованием database/sql и context.

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

Проблема возникает, когда резерв товара и назначение исполнителя выполняются в разных сервисах или в разных транзакциях. Если резерв прошёл, а исполнитель не назначен (например, из-за сбоя), заказ "зависает": товар заблокирован, но работа не выполняется.

Основные подходы:

  1. Локальная транзакция - если оба действия в одной БД: BEGIN; SELECT ... FOR UPDATE по товару; INSERT заказа с исполнителем; UPDATE резерва; COMMIT. Блокировка гарантирует, что другой процесс не изменит состояние до завершения.

  2. Распределённая транзакция (Saga) - если сервисы разные. Шаги: резерв товара → назначение исполнителя. При сбое на втором шаге - компенсация (отмена резерва). Требует идемпотентности и журналирования.

  3. Outbox + retry - событие "заказ создан" пишется в ту же БД, что и резерв. Отдельный воркер читает outbox, назначает исполнителя, при ошибке повторяет. Резерв не снимается, пока не будет подтверждения.

  4. Мягкий резерв с TTL - резерв действует N минут. Если исполнитель не назначен за это время, резерв автоматически снимается, заказ переходит в статус "требует подтверждения".

Выбор зависит от требований к консистентности: строгая (транзакция) или eventual (Saga/Outbox). Для high-load систем предпочтительнее Saga или Outbox, так как длительные блокировки строк убивают производительность.

На практике

В Go с PostgreSQL типичный сценарий - использование pgx или database/sql с уровнем изоляции ReadCommitted и FOR UPDATE. Важно: блокировка должна быть короткой, все внешние вызовы (HTTP к сервису исполнителей) - вне транзакции. Иначе - deadlock и падение производительности.

Паттерн:

  1. Открыть транзакцию.
  2. SELECT ... FOR UPDATE по товару (или по заказу).
  3. Проверить, что товар доступен и резерв не просрочен.
  4. Вставить запись о заказе с исполнителем (или обновить статус).
  5. Commit.

Если назначение исполнителя - внешний вызов, то:

  • сначала резерв с TTL;
  • затем вызов сервиса исполнителей;
  • при успехе - подтверждение и снятие TTL;
  • при неудаче - компенсация (отмена резерва) или ожидание retry.

Для распределённой системы - использовать Saga-координатор (например, Temporal, Cadence) или самописный outbox с таблицей outbox_events.

Пример кода

GO
// Резерв товара + назначение исполнителя в одной транзакции (PostgreSQL)
func ReserveAndAssign(ctx context.Context, db *sql.DB, orderID, itemID, executorID string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Блокируем строку товара
var available bool
err = tx.QueryRowContext(ctx,
`SELECT available FROM items WHERE id = $1 FOR UPDATE`, itemID,
).Scan(&available)
if err != nil {
return err
}
if !available {
return errors.New("item not available")
}
// Резервируем товар
_, err = tx.ExecContext(ctx,
`UPDATE items SET available = false, reserved_until = now() + interval '10 minutes' WHERE id = $1`, itemID)
if err != nil {
return err
}
// Создаём заказ с исполнителем
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, item_id, executor_id, status) VALUES ($1, $2, $3, 'assigned')`,
orderID, itemID, executorID)
if err != nil {
return err
}
return tx.Commit()
}

Для Saga - пример с компенсацией:

GO
func ReserveAndAssignSaga(ctx context.Context, orderID, itemID, executorID string) error {
// Шаг 1: резерв
if err := reserveItem(ctx, itemID); err != nil {
return err
}
// Шаг 2: назначение исполнителя
if err := assignExecutor(ctx, orderID, executorID); err != nil {
// компенсация
_ = cancelReserve(ctx, itemID)
return err
}
return nil
}

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

Начни с уточнения: "Вопрос про консистентность между резервом и назначением исполнителя". Затем опиши два сценария: монолит (одна БД) и микросервисы. Для монолита - транзакция с FOR UPDATE, подчеркни важность коротких транзакций и отсутствия внешних вызовов внутри. Для микросервисов - Saga или Outbox, объясни trade-off: строгая консистентность vs доступность. Упомяни TTL для мягкого резерва как защиту от "зависших" заказов. Если спросят про Go - скажи про pgx, database/sql, context для отмены операций. Не углубляйся в детали реализации, пока не попросят.

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

  • Понимание атомарности и изоляции транзакций.
  • Умение выбирать между локальной и распределённой транзакцией.
  • Знание паттернов Saga, Outbox, компенсации.
  • Понимание trade-off между консистентностью и производительностью.
  • Практические навыки работы с БД в Go (блокировки, транзакции).
  • Умение рассуждать об отказах и восстановлении (retry, идемпотентность).

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

  • Предложение "просто сделать два запроса" без транзакции - не гарантирует атомарность.
  • Использование SELECT без FOR UPDATE - гонки при конкурентном доступе.
  • Внешний HTTP-вызов внутри транзакции - долгая блокировка, риск deadlock.
  • Игнорирование таймаутов и отмены контекста в Go - зависшие транзакции.
  • Отсутствие компенсации в Saga - при сбое на втором шаге резерв остаётся навсегда.
  • Неучёт идемпотентности при retry - дублирование заказов или исполнителей.

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

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