MBL
Go / avito / Сжато / Тестовое задание — сжато
Go сложный

Тестовое задание — сжато

avitosummary

Сжатый конспект всех 9 статей про мой реальный проект «Avito.Кухня» (kitchen-service + establishment-service, Go + Postgres + Kafka) — для быстрого повторения перед собесом, не для первого чтения. Полные разборы с кодом — в папке «Тестовое задание».

Архитектура

  • Слои: domain (модели, ни от чего не зависит) → repository (только SQL) → usecase (бизнес-правила) → handler (HTTP, маппинг ошибок). Интерфейсы репозиториев объявлены в usecase/ports.go, не в repository — consumer dictates the contract: usecase тестируется без импорта pgx.
  • Зачем интерфейсы при одной реализации (Postgres): не ради гипотетической смены БД, а ради тестируемости бизнес-логики без реальной БД в каждом unit-тесте + явная документируемая граница слоя.
  • Деньги — int в центах, не float (IEEE 754 не гарантирует точность десятичных дробей).
  • Soft delete (is_active=false), не DELETE — order_items.item_id внешний ключ, реальный DELETE либо блокируется FK, либо CASCADE ломает историю заказов.
  • Аутентификации нет — явно вне ТЗ (платформа выше сервиса), но API заведений закрыт статическим API-ключом — два разных требования, не непоследовательность.

Списание остатка и блокировки (Postgres)

  • Атомарный UPDATE items SET stock=stock-$1 WHERE id=$2 AND stock>=$1 + RowsAffected()==0 → недостаточно остатка. Один SQL-statement, WHERE сама проверка, короче критическая секция, чем SELECT...FOR UPDATE+UPDATE.
  • Позиции заказа сортируются по item_id перед списанием в цикле — иначе два параллельных заказа с теми же товарами в разном порядке ловят deadlock (A ждёт то, что держит B, и наоборот). Единый порядок захвата блокировок — общий паттерн, не хак.
  • READ COMMITTED (дефолт Postgres) НЕ спасает от read-then-write гонки, даже без единой аномалии чтения — проблема не в видимости данных, а в том, что SELECT+UPDATE в приложении не атомарны как пара. Проверено вживую на контейнере: read-then-write даёт lost update (итог 5 вместо 0 при двух списаниях по 5 из 10), атомарный UPDATE...WHERE и SELECT...FOR UPDATE оба дают верные 0 — но FOR UPDATE держит лок всю транзакцию, атомарный UPDATE — только сам оператор.
  • Уровни изоляции: READ COMMITTED — снимок на начало оператора, не защищает non-repeatable/phantom read. REPEATABLE READ — снимок на начало транзакции, не защищает write skew. SERIALIZABLE — единственный без write skew, откат по коду 40001.
  • FOR UPDATE SKIP LOCKED — пропускает уже залоченные строки вместо ожидания; паттерн "таблица как очередь задач". Мой relay читает outbox обычным SELECT без SKIP LOCKED — известный пробел, безопасно при одном инстансе, при горизонтальном масштабировании два relay возьмут одни строки дважды (не катастрофа — consumer идемпотентен).
  • pg_advisory_lock — блокировка без строки-носителя, для "не больше одного одновременного выполнения" (пересчёт отчёта, разовая джоба). _xact_lock-версия освобождается сама в конце транзакции.
  • CreateOrder — одна транзакция на весь заказ (списание + insert заказа + insert позиций + outbox) — атомарность, но при большом заказе транзакция держится дольше. При росте — batch UPDATE ... FROM (VALUES...) вместо цикла.

Идемпотентность — 5 разных механизмов, ни один не взаимозаменяем

Точка входа Угроза Механизм
POST /orders Повтор клиентского запроса Idempotency-Key + UNIQUE(user_id, key) — НЕ глобальный unique, иначе чужой клиент может занять ключ навсегда
Kafka-коллбэк статуса Дубль доставки (at-least-once) Детерминированный ключ "{order_id}:{status}" + UNIQUE(order_id, key)
Любая смена статуса Гонка параллельных запросов БЕЗ ключа SELECT...FOR UPDATE + if status==newStatus return no-op — идемпотентность по построению
Kafka consumer (kitchen) Потеря сообщения при падении Commit offset ПОСЛЕ успешной обработки = at-least-once, не at-most-once
/advance (establishment) 502 после локального изменения статуса Повтор безопасен: локальный статус уже целевой (no-op) + republish с тем же детерминированным ключом

Ключ (2) отличает "два разных события" от дубля; блокировка (3) защищает от гонки за один и тот же переход. Нужны оба, ни один не заменяет другой.

Kafka: outbox, DLQ, consumer groups

  • Проблема без outbox: tx.Commit() потом producer.Publish() — между ними процесс может упасть, заказ есть в БД, сообщение никогда не уйдёт. Переставить местами не решает, только переносит окно.
  • Решение: outbox_events пишется той же транзакцией, что заказ. Фоновый Relay вычитывает неопубликованное, публикует, помечает published_at — 3 исхода: publish упал → ретрай следующим тиком; publish ок, пост-хук упал → НЕ помечать (значит publish повторится — осознанно допустимо, consumer идемпотентен); оба ок → пометить.
  • Outbox есть только у kitchen-service (создаёт заказ — потеря тут реальна). Establishment публикует статус синхронно из /advance — человек нажмёт кнопку повторно при 502, естественный retry.
  • Offset — watermark на партицию ("всё до этой точки обработано"), не per-message ack — нельзя закоммитить N+1, оставив N необработанным. Отсюда разделение ошибок: доменная (ErrInvalidTransition, никогда не станет валидной) → сразу в DLQ-топик, commit offset. Транзиентная (БД недоступна) → до 5 попыток backoff внутри обработки этого сообщения, не через re-fetch; исчерпав — тоже в DLQ. Без этого одно "вечно падающее" сообщение блокирует партицию для всех навсегда (poison message).
  • e2e-тест (TestOrderStatusUpdated_InvalidTransitionGoesToDLQ) публикует напрямую в топик невалидный переход, проверяет: сообщение в DLQ + заказ не сдвинут + партиция НЕ заблокирована (следующее валидное сообщение на том же ключе проходит).
  • Consumer group: партиция читается ровно одним инстансом группы; ребалансировка (eager) — stop-the-world пауза для всей группы, не только упавшего. Дубль возможен: обработал, не успел закоммитить до ребаланса → новый владелец партиции переобработает.
  • Kafka из коробки = at-least-once, не exactly-once (обработка и коммит — раздельные действия, транзакционность за пределы самой Kafka не распространяется). Outbox (atomicity на входе) + идемпотентный consumer (безопасность повтора на выходе) вместе дают "эффективно ровно один раз", не будучи им технически.
  • Порядок гарантирован только внутри партиции (по ключу, обычно order_id) — hash(key) % N меняется при увеличении N партиций, старые сообщения остаются на старых местах → гарантия порядка по ключу ломается на границе смены N.
  • Kafka vs RabbitMQ: RabbitMQ — exchange (direct/topic/fanout) + routing key, per-message ack/nack, проще для сложной маршрутизации и низкого объёма без нужды в ретенции лога. Kafka — для устойчиво высокого объёма, строгого порядка по ключу, replay истории, нескольких независимых consumer group на одном потоке.
  • Почему Kafka, а не HTTP push+callback (это я сам сначала выбрал HTTP, потом переделал по запросу на пересмотр): push требовал знать URL заведения + ретраи + отдельный статус на недоступность (потом убрал — статус без выхода из state machine хуже отсутствия). Kafka — durable-очередь + at-least-once + consumer group offset "из коробки", взамен требует outbox для надёжности на входе. Честно: для реального объёма MVP RabbitMQ был бы пропорциональнее — выбор Kafka обоснован демонстрацией инструмента, не нагрузкой.

Тестирование: два слоя, разные цели

  • Unit (usecase, fake-репозитории на map, без единой SQL-строки) — микросекунды, без Docker, бизнес-правила изолированно (пример: дубль item_id схлопывается до похода в БД).
  • Чего fake никогда не проверит: атомарность SQL-оператора, реальные блокировки (FOR UPDATE), гонки между процессами, транзакционность нескольких операций вместе.
  • e2e (testcontainers-go, тот же docker-compose.yml, не отдельный e2e-файл — иначе рассинхрон конфигов) — реальный Postgres + Kafka, полный сценарий create→outbox→Kafka→establishment видит→advance-серия→completed, ~90 секунд, отдельный медленный слой, не замена unit.
  • Осознанно НЕ мокается: repository-слой — мок pgx не исполняет реальный SQL, синтаксическая ошибка тестом не ловится. Не пробел, а решение.
  • Небезопасный resp["id"].(string) без , ok — паника с неинформативным stack trace вместо t.Fatalf с понятной причиной.

Реальные найденные баги (готовые ответы на "как проверяешь код")

  • Дубль item_id в одном заказе: список ID строился с дублями, количество — в map без дублей → repository схлопывал дубли через WHERE id=ANY($1), код получал меньше позиций → misleading 404. Урок: две коллекции по одному ключу — из одного источника, не порознь.
  • Невалидный fulfillment_type маппился в 409 invalid_transition вместо 400 — переход тут вообще ни при чём, это ошибка валидации входа. Найдено на отдельном ревью, не сразу.
  • Отрицательные price_cents/stock падали на Postgres CHECK-constraint → клиент видел 500, а не 400 — граница валидации должна быть в коде (usecase), не переложена на БД.
  • Списочные ручки (ListByUser) отдавали items: null — GetByID дозагружал order_items, списочные методы нет. Починка — общая батч-подгрузка WHERE order_id=ANY(...), не N+1.
  • Гонка на первом рестарте Postgres: healthcheck фиксировал healthy ДО внутреннего рестарта после initdb-скрипта → зависимые сервисы ловили connection refused. Не находится чтением кода, только реальным docker compose up с чистого volume. Фикс — restart: on-failure:5 на миграциях.
  • go.mod требует go 1.26.5, в Docker Hub только тег golang:1.26-alpine — не баг: Go разрешает тулчейн новее объявленного, образ не старше — сети при сборке не требуется.

Формула на любую находку: что сломалось → как обнаружено (ревью/прогон/тест) → почему фикс закрывает причину, а не симптом.

Известные ограничения — говорить формулой "что не сделано → почему не критично сейчас → что изменится при росте"

  • Заказы, зависшие в confirmed при недоступности Kafka, не освобождают остаток автоматически — нужен scheduler с TTL-политикой, для MVP избыточно.
  • GET /orders/{id} не проверяет владение (UUID делает перебор непрактичным, но это не проверка) — вне ТЗ (аутентификация выше уровня сервиса); при необходимости — order.UserID != requestingUserID → 404, не 403.
  • Offset-пагинация, не keyset/cursor — большие offset деградируют (Postgres сканирует и отбрасывает N строк); keyset быстрее, но теряет прямой переход "на страницу N".
  • Поиск ILIKE '%q%' без pg_trgm — обычный индекс не ускоряет ведущий %; для прод-объёма нужен GIN pg_trgm.
  • Демо API-ключи — статичные значения в .env.example, не генерируются — осознанное упрощение демо-стенда.
  • Асимметрия outbox (только kitchen-service) — не недосмотр: establishment публикует из человеко-инициируемого /advance, повтор естественен; риск потери реален там, где событие рождается автоматически без человека, готового нажать кнопку ещё раз.

Если спросят прямо

  • "Почему интерфейсы в usecase, а не в repository?" — потребитель диктует контракт; usecase тестируется без импорта pgx; не про смену БД, про тестируемость и явную границу слоя.
  • "Что вернётся, если тут ошибка?" — не "ошибка", а конкретный HTTP-статус через единый mapError; было 2 реальных бага именно на этом (409 вместо 400, 500 вместо 400).
  • "Почему in-memory в unit-тестах, не Postgres?" — намеренно: unit проверяет бизнес-правила изолированно и быстро, e2e (testcontainers, тот же compose) поднимает настоящий Postgres+Kafka там, где нужна реальная семантика SQL/блокировок.
  • "Выдержит 100 RPS?" — встречный вопрос "какая операция": чтение — не проблема (индексы), запись с конкурентным списанием одного товара — узкое место в сериализации доступа к строке, решено атомарным UPDATE, не блокировкой на всю транзакцию.
  • "Почему Kafka, а не HTTP callback?" — честно: сначала выбрал HTTP, переделал по запросу на пересмотр; Kafka даёт надёжность доставки "из коробки" ценой необходимости outbox.
  • "Почему не в БД лежит очередь на публикацию?" — она и лежит: outbox_events в той же транзакции, что заказ; фоновый relay публикует и метит published_at только после успеха.
  • "Как обработаешь сообщение, которое никогда не станет валидным?" — различаю доменную ошибку (сразу DLQ) и транзиентную (backoff, потом тоже DLQ) — иначе одно "плохое" сообщение блокирует партицию для всех навсегда.
  • "Какие у решения ограничения?" — называю сам, до вопроса, по формуле "что не сделано → почему не критично → что изменю при росте" — именно это, по словам интервьюера, не сделал провалившийся кандидат.
Как усвоено?