> Как обрабатывать ошибки при отмене брони на складе (Go)

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

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

Стек: Go

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

При обработке отмены брони на складе в Go важно учитывать несколько ключевых сценариев: бронь уже отменена, товар отгружен, или возникла ошибка базы данных. Рекомендую использовать паттерн "retry" с экспоненциальной задержкой для временных сбоев и чёткое разделение ошибок на бизнес-логику и инфраструктурные.

Пример реализации:

GO
type BookingService struct {
db *sql.DB
}
var (
ErrBookingNotFound = errors.New("бронь не найдена")
ErrBookingCancelled = errors.New("бронь уже отменена")
ErrBookingShipped = errors.New("товар уже отгружен, отмена невозможна")
ErrConcurrentModify = errors.New("конфликт при изменении брони")
)
func (s *BookingService) CancelBooking(ctx context.Context, bookingID int64) error {
const maxRetries = 3
var lastErr error
for i := 0; i < maxRetries; i++ {
err := s.cancelBookingTx(ctx, bookingID)
if err == nil {
return nil
}
// Если ошибка бизнес-логики - не повторяем
if errors.Is(err, ErrBookingNotFound) ||
errors.Is(err, ErrBookingCancelled) ||
errors.Is(err, ErrBookingShipped) {
return err
}
// Если конфликт или временная ошибка - повторяем
if errors.Is(err, ErrConcurrentModify) || isTemporaryError(err) {
lastErr = err
time.Sleep(time.Duration(100*(1<<i)) * time.Millisecond) // 100ms, 200ms, 400ms
continue
}
// Неизвестная ошибка - возвращаем сразу
return fmt.Errorf("отмена брони %d: %w", bookingID, err)
}
return fmt.Errorf("отмена брони %d после %d попыток: %w", bookingID, maxRetries, lastErr)
}
func (s *BookingService) cancelBookingTx(ctx context.Context, bookingID int64) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return fmt.Errorf("начало транзакции: %w", err)
}
defer tx.Rollback()
// Проверяем статус брони с блокировкой строки
var status string
err = tx.QueryRowContext(ctx,
"SELECT status FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&status)
if err == sql.ErrNoRows {
return ErrBookingNotFound
} else if err != nil {
return fmt.Errorf("запрос брони: %w", err)
}
switch status {
case "cancelled":
return ErrBookingCancelled
case "shipped":
return ErrBookingShipped
case "active":
// Выполняем отмену
_, err = tx.ExecContext(ctx,
"UPDATE bookings SET status = 'cancelled', updated_at = NOW() WHERE id = $1", bookingID)
if err != nil {
return fmt.Errorf("обновление статуса: %w", err)
}
// Возвращаем товар на склад
_, err = tx.ExecContext(ctx,
"UPDATE stock SET quantity = quantity + 1 WHERE booking_id = $1", bookingID)
if err != nil {
return fmt.Errorf("возврат на склад: %w", err)
}
default:
return fmt.Errorf("неизвестный статус брони: %s", status)
}
return tx.Commit()
}
func isTemporaryError(err error) bool {
// Проверка на временные ошибки БД (deadlock, timeout и т.д.)
var pqErr *pq.Error
if errors.As(err, &pqErr) {
return pqErr.Code == "40001" // serialization_failure
}
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.ErrUnexpectedEOF)
}

Ключевые моменты:

  • Используйте уровень изоляции Serializable для предотвращения race conditions.

  • Применяйте SELECT ... FOR UPDATE для блокировки строки.

  • Разделяйте бизнес-ошибки (возвращаются клиенту) и технические (повторяются).

  • Логируйте все ошибки с контекстом (bookingID, попытка, ошибка).

  • Для распределённых систем добавьте идемпотентность (например, через уникальный ключ запроса).

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

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