Go
сложный
Тестовое задание — сжато
Сжатый конспект всех 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) — атомарность, но при большом заказе транзакция держится дольше. При росте — batchUPDATE ... 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), код получал меньше позиций → misleading404. Урок: две коллекции по одному ключу — из одного источника, не порознь. - Невалидный
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— обычный индекс не ускоряет ведущий%; для прод-объёма нужен GINpg_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) — иначе одно "плохое" сообщение блокирует партицию для всех навсегда.
- "Какие у решения ограничения?" — называю сам, до вопроса, по формуле "что не сделано → почему не критично → что изменю при росте" — именно это, по словам интервьюера, не сделал провалившийся кандидат.
Как усвоено?
Сложность
Вода
ИИ-шность
Фичи движка
Подача