> Как обрабатывать ошибки при отмене брони на складе (Go)
Уровень: senior · Роль: backend · Категория: Технические вопросы
Компании: sferaplatform.ru
Стек: Go
> Пример ответа
Короткий ответ
Обработка ошибок при отмене брони на складе строится вокруг трёх ключевых моментов: идемпотентность (повторный вызов не должен ломать состояние), атомарность (обновление брони и возврат товара в остаток - в одной транзакции) и явное различие между бизнес-ошибками (бронь не найдена, уже отменена) и техническими сбоями (таймаут БД, сеть). В Go это реализуется через typed errors, sentinel errors и middleware для маппинга ошибок в HTTP-статусы. Отмену делаем идемпотентной: повторная отмена возвращает успех, но не меняет состояние.
Подробное объяснение
Отмена брони - операция с побочными эффектами: нужно пометить бронь как cancelled и вернуть зарезервированное количество товара в доступный остаток. Основные сценарии ошибок:
- Бронь не найдена - бизнес-ошибка, клиент получил неверный идентификатор.
- Бронь уже отменена - идемпотентный случай, не ошибка, а нормальное состояние.
- Бронь в статусе completed - нельзя отменить завершённую операцию, это нарушение инварианта.
- Конфликт остатков - при возврате товара остаток может быть изменён параллельной операцией (например, списание). Нужна блокировка строки или optimistic locking.
- Технические сбои - недоступность БД, таймаут, сетевые ошибки. Их нельзя маппить в 4xx, только 5xx.
Ключевой паттерн - транзакция с явной блокировкой:
SELECT ... FOR UPDATEпо строке брони и строке товара.- Проверка статуса внутри транзакции.
- Обновление статуса и остатка.
- Commit или rollback.
Для идемпотентности используем уникальный ключ операции (например, operation_id в таблице отмен) или проверку статуса: если status = cancelled, возвращаем успех без изменений.
Ошибки в Go: определяем sentinel errors (ErrReservationNotFound, ErrReservationAlreadyCancelled) и оборачиваем через fmt.Errorf("...: %w", err). Для HTTP-слоя - middleware, который по errors.Is маппит в статусы: 404, 409, 422, 500.
На практике
- Всегда начинай с валидации входных данных (id брони, id товара, количество).
- Используй
context.Contextс таймаутом для запросов к БД. - Для конкурентного доступа к остаткам -
SELECT FOR UPDATEили версионирование строки (полеversion, проверка при UPDATE). - Логируй ошибки с контекстом: id брони, trace id, длительность операции.
- Не возвращай внутренние детали ошибок клиенту - только код и сообщение.
- Для распределённых систем (если склад и заказы в разных сервисах) - используй outbox pattern или сагу, чтобы отмена брони и возврат остатка были согласованы.
Пример кода
GOvar (ErrReservationNotFound = errors.New("reservation not found")ErrReservationAlreadyClosed = errors.New("reservation already closed")ErrInsufficientStock = errors.New("insufficient stock"))type Reservation struct {ID int64Status string // "active", "cancelled", "completed"}type StockItem struct {ID int64Quantity intVersion int}func CancelReservation(ctx context.Context, db *sql.DB, reservationID int64) error {tx, err := db.BeginTx(ctx, nil)if err != nil {return fmt.Errorf("begin tx: %w", err)}defer tx.Rollback() //nolint:errcheck// Блокируем строку брониvar res Reservationerr = tx.QueryRowContext(ctx,`SELECT id, status FROM reservations WHERE id = $1 FOR UPDATE`,reservationID,).Scan(&res.ID, &res.Status)if errors.Is(err, sql.ErrNoRows) {return ErrReservationNotFound}if err != nil {return fmt.Errorf("select reservation: %w", err)}// Идемпотентностьif res.Status == "cancelled" {return nil}if res.Status == "completed" {return ErrReservationAlreadyClosed}// Блокируем строку товара (нужен item_id из брони)var item StockItemerr = tx.QueryRowContext(ctx,`SELECT id, quantity, version FROM stock_items WHERE id = $1 FOR UPDATE`,res.ID, // упрощение: item_id = reservation_id).Scan(&item.ID, &item.Quantity, &item.Version)if err != nil {return fmt.Errorf("select stock item: %w", err)}// Возвращаем количество (в примере - фиксированное 1)newQuantity := item.Quantity + 1_, err = tx.ExecContext(ctx,`UPDATE stock_items SET quantity = $1, version = version + 1 WHERE id = $2 AND version = $3`,newQuantity, item.ID, item.Version,)if err != nil {return fmt.Errorf("update stock: %w", err)}_, err = tx.ExecContext(ctx,`UPDATE reservations SET status = 'cancelled' WHERE id = $1`,reservationID,)if err != nil {return fmt.Errorf("update reservation: %w", err)}if err := tx.Commit(); err != nil {return fmt.Errorf("commit: %w", err)}return nil}
Как отвечать на собеседовании
Начни с классификации ошибок: бизнес-ошибки vs технические. Затем опиши транзакционность и идемпотентность как два главных принципа. Приведи конкретный пример с SELECT FOR UPDATE и sentinel errors. Упомяни, как обрабатываешь повторные вызовы (повторная отмена - успех). Если спросят про распределённый случай - расскажи про сагу или outbox. Не углубляйся в детали реализации фреймворка, говори про паттерны.
Что проверяет интервьюер
- Понимание разницы между бизнес- и техническими ошибками.
- Умение проектировать идемпотентные операции.
- Знание транзакций и конкурентного доступа в SQL.
- Навык работы с ошибками в Go: wrapping,
errors.Is, sentinel errors. - Умение маппить ошибки в HTTP-статусы.
- Способность рассуждать о консистентности данных.
Типичные ошибки
- Возвращать 500 на бизнес-ошибки (бронь не найдена).
- Не делать операцию идемпотентной - повторный вызов падает с ошибкой.
- Обновлять остаток и статус брони без транзакции - рассинхрон.
- Игнорировать гонки: два запроса одновременно отменяют бронь, оба возвращают товар.
- Возвращать клиенту внутренние детали ошибки (стек, SQL-запрос).
- Не использовать
contextс таймаутом - зависание при недоступной БД.
> Похожие задачи по backend
Сколько разработчиков в команде, на каких языках они работают и как устроена команда
Как обнаруживать и реагировать на проблемы с отменой брони
Как снять бронь товара при отмене заказа
Как гарантировать, что заказ не будет выполнен без исполнителя после резерва товара
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью