Как мы строим Tinder для автомобилей: для обмена авто на авто, архитектура Go-монолита за 3 месяца

от автора

Я Шевкопляс Дмитрий, технический руководитель проекта Swapno — сервис для обмена автомобилями ключ-в-ключ, без дилеров. Механика — как в Tinder: свайпаешь чужие авто, если оба владельца лайкнули машины друг друга — Swap Match, начинается обмен. В этой статье расскажу, как мы спроектировали и написали бэкенд на Go за 3 месяца: от выбора архитектуры до matching engine, ИИ-модерации фото и observability в продакшене. С реальными ошибками, которые мы допустили, и тем, как их чинили.

Проблема: рынок обмена авто сломан

В России ~300 тысяч автомобилей, владельцы которых готовы к обмену. Сейчас они разбросаны по форумам, Avito и тематическим каналам в Telegram. Процесс обмена — это боль: нужно вручную искать подходящий вариант, договариваться о доплате, проверять VIN. Дилеры берут 10-30% комиссии и затягивают процесс на недели.

Мы решили это автоматизировать. Свайп-механика решает проблему поиска: вместо бесконечного скроллинга — быстрое «да/нет». Мэтч решает проблему доверия: оба хотят машину друг друга. ИИ-оценка и VIN-проверка решают проблему прозрачности.

От MVP к мобильному приложению

Первую версию мы запустили как Telegram Mini App — для быстрой проверки гипотезы без App Store и регистрации. Валидировали спрос, собрали первых пользователей, обкатали matching engine. Сейчас готовимся к переходу на нативное мобильное приложение. Бэкенд с самого начала проектировался как API-first — смена клиента не затрагивает серверную часть.

Mini App Swapno

Mini App Swapno

Архитектура: монолит с чистой архитектурой

На старте была развилка: микросервисы или монолит. Я выбрал монолит — и ни разу не пожалел.

Когда всего несколько разработчиков — микросервисы убивают. Я попробовал прикинуть: отдельный сервис для авто, для свайпов, для мэтчей, для уведомлений. Получилось 5 репозиториев, 5 Dockerfile, service mesh для общения между ними, и главное — каждый раз когда меняешь формат ответа в одном сервисе, ты идёшь чинить десериализацию в другом. В монолите поменял структуру — компилятор сразу показал все места, где она используется. Всё.

Но «монолит» не значит «каша». Я сразу жёстко разделил слои:

internal/
├── domain/ # Сущности и бизнес-правила (чистые структуры, нет зависимостей)
├── service/       # Бизнес-логика, оркестрация
├── repository/
│   ├── postgres/  # SQL-запросы
│   └── redis/     # Кеш и очереди
├── handler/       # HTTP-хендлеры (Huma)
├── middleware/    # Auth, rate limiting, logging
└── telegram/      # Бот, уведомления

Правило одно: зависимости направлены внутрь. handler → service → repository. domain не импортирует вообще ничего. Если я завтра захочу вынести свайпы в отдельный сервис — это service/swipe.go + repository/postgres/swipe.go + свой main. Пара дней, не месяцев.

Стек

Компонент

Технология

Почему именно это

HTTP

Fiber v2 + Huma v2

Fiber — быстрый, привычный express-стиль. Huma — типизированный OpenAPI поверх любого роутера. Автоматическая валидация, генерация документации

БД

PostgreSQL 17 + pgx/v5

Надёжно. FILTER WHERE для аналитики — одна из лучших фич, про которую мало кто знает

Кеш

Redis 7

Сессии, очереди свайпов, rate limiting, кеш статистики — всё в одном месте

S3

MinIO → Selectel S3

Локально MinIO для разработки, в проде — S3-совместимое облако. Один интерфейс, разные бэкенды

Логи

zerolog

Структурированный JSON, самый быстрый логгер для Go, context-aware через logger.L(ctx)

Отдельно про Huma. Я перепробовал gin, echo, chi — и везде одна проблема: ты описываешь API в коде, а OpenAPI-спеку генерируешь отдельно, и они расходятся. В Huma ты описываешь Go-структуры с тегами — и получаешь валидацию, документацию, типизированные ошибки из одного места. Впервые я реально открыл Swagger UI и он совпал с тем, что отдаёт сервер.

Ловушка: Huma использует указатели для optional-полей. Когда я написал BrandID *int в query-параметре — получил panic при обращении к nil. Потратил час на дебаг, прежде чем понял: в query-параметрах Huma нужно использовать обычные типы, а не указатели. Указатели — только для body.

Matching Engine: сердце продукта

Это самая интересная часть системы, и здесь больше всего набитых шишек.

Очередь свайпов

Первая наивная реализация: пользователь нажимает «свайп» → бэкенд идёт в PostgreSQL с запросом на фильтрацию, исключение просмотренных, сортировку, LIMIT 1. Работает? Работает. На 100 юзерах — нормально. Но запрос занимает 50-100ms, а свайпают быстро — по карточке в секунду. Ощущение задержки убивает UX.

Решение — предзаполненная очередь в Redis:

swipe:queue:{user_id} → [car_id_1, car_id_2, ..., car_id_50]

GET /swipe/next:

1. LPOP swipe:queue:{user_id} — O(1), ~0.1ms. Моментально.

2. Если очередь пуста — пересобираем из PostgreSQL одним тяжёлым запросом, кладём 50 результатов в Redis.

SELECT c.id FROM cars c JOIN user_interests ui ON ui.user_id = $1 WHERE c.status = 'active'   AND c.user_id != $1   AND c.body_type = ANY(ui.body_types)   AND c.brand_id IN (SELECT id FROM brands WHERE brand_group = ANY(ui.brand_groups))   AND c.price BETWEEN (my_car.price - ui.max_surcharge) AND (my_car.price + ui.max_surcharge)   AND c.id NOT IN (SELECT swiped_car_id FROM swipes WHERE swiper_user_id = $1) ORDER BY random() LIMIT 50

Тяжёлый запрос выполняется раз в 50 свайпов, а не на каждый. Пользователь видит карточку за 0.1ms.

Проблема, которую я не предвидел: что если пользователь свайпнул авто, а оно за это время было удалено или деактивировано? LPOP уже выдал этот car_id. Решение — в хендлере проверяем существование и статус авто. Если не подходит — берём следующий из очереди. В худшем случае — 2-3 LPOP’а вместо одного, всё равно быстро.

Мэтч-детекция

Мэтч — это когда оба владельца лайкнули авто друг друга. При лайке проверяем взаимность:

func (s SwipeService) Like(ctx context.Context, swiperID uuid.UUID, carID uuid.UUID) (MatchResult, error) {     if err := s.swipes.Create(ctx, swiperID, carID, "like"); err != nil {         return nil, err     }         // Проверяем: владелец swiped_car лайкнул наше авто?     swiperCar,  := s.cars.GetByUserID(ctx, swiperID)     targetCar,  := s.cars.GetByID(ctx, carID)         reciprocal, _ := s.swipes.Exists(ctx, targetCar.UserID, swiperCar.ID)     if !reciprocal {         return &MatchResult{Match: false}, nil     }         // Мэтч! Считаем доплату.     surcharge := math.Abs(swiperCar.Price - targetCar.Price)         match, err := s.matches.Create(ctx, swiperCar.ID, carID, swiperID, targetCar.UserID, surcharge)     if err != nil {         return nil, err     }         go s.notifier.NotifyMatch(ctx, match)         return &MatchResult{Match: true, MatchID: match.ID, Surcharge: surcharge}, nil }

Тут я первое время боялся race condition: два пользователя лайкают друг друга одновременно → два мэтча? На практике matches таблица имеет UNIQUE constraint на пару (car1_id, car2_id), и мы нормализуем порядок (меньший UUID первым). Даже при параллельном запросе один INSERT пройдёт, второй получит conflict → ON CONFLICT DO NOTHING. Мэтч создаётся ровно один.

Rate Limiting свайпов

Мы заложили многоуровневую систему лимитов — конкретные значения ещё подбираем по метрикам, но архитектура уже готова. Реализация — Redis INCR с TTL до полуночи:

func (s *SwipeService) checkLimit(ctx context.Context, userID uuid.UUID) error {     key := fmt.Sprintf("rate:swipe:%s", userID)     count, _ := s.redis.Incr(ctx, key).Result()         if count == 1 {         s.redis.ExpireAt(ctx, key, endOfDay())     }         limit := s.getLimitForUser(ctx, userID) // 5, 10, or ∞     if count > int64(limit) {         return apperr.ErrSwipeLimitReached     }     return nil }

Баг, который я словил в проде: endOfDay() возвращала полночь по серверному времени (UTC+3), а Redis TTL работает в абсолютных Unix timestamp’ах. В итоге для пользователей из Владивостока лимит сбрасывался в 4 утра по их времени. Пришлось перевести на UTC и смириться с тем, что «день» для всех — это UTC-день. Не идеально, но предсказуемо.

ИИ-модерация фото: fail-open и его последствия

При публикации авто мы модерируем все фото одним batch-запросом к OpenAI Vision API. Проверяем: это фотография автомобиля? Нет NSFW? Нет номеров телефонов поверх фото?

Ключевое архитектурное решение — fail-open: если ИИ недоступен — публикуем авто, алертим в Telegram-канал и отправляем в очередь ручной модерации. Модератор получает уведомление, открывает админку и проверяет фото вручную. Пользователь не заблокирован, а мы не пропускаем контент без проверки — просто проверка становится асинхронной.

func (m *Moderator) CheckImages(ctx context.Context, images []ImageData) []int {     if m == nil || len(images) == 0 {         return nil // модерация отключена — пропускаем     }         result, err := m.client.Moderate(ctx, images)     if err != nil {         logger.L(ctx).Error().Err(err).Msg("moderation API failed, fail-open → ручная модерация")         return nil // AI не справился — модератор разберётся     }         return result.RejectedIndices }

На практике ИИ-модерация отрабатывает в 95%+ случаев. Ручная модерация — это страховка, а не основной поток. Но когда OpenAI API лёг на 10 минут и 3 пользователей пытались опубликовать авто — все опубликовали без задержки, а модератор проверил их фото в 5 минут.

Временные фото и S3 lifecycle

Загруженные фото сначала попадают в tmp/cars/{car_id}/ в S3. При успешной публикации — перемещаются в cars/{car_id}/. S3 lifecycle rule удаляет всё из tmp/ через 14 дней.

Изначально я поставил TTL 1 день. Казалось логичным: зачем хранить черновики? Через неделю получил алерт: «Не удалось скачать ни одного фото для модерации». Пользователь загрузил фото в пятницу вечером, а опубликовать решил в воскресенье. Фото уже удалены. Он получил невнятную ошибку и ушёл. После этого поднял TTL до 14 дней:

if len(imageData) == 0 && len(photos) > 0 {     return nil, apperr.ErrPhotosExpired     // → HTTP 422 "фото устарели и были удалены. Загрузите фото заново" }

Ещё один сюрприз с фото: nginx стоит перед Go-сервером и имеет свой client_max_body_size. Я выставил лимит в Go (20 МБ), но забыл про nginx (по умолчанию 1 МБ). Пользователь загружает фото с iPhone — 15 МБ HEIC — и получает HTML-страницу с 413 Request Entity Too Large от nginx. Не JSON, а сырой HTML. Пришлось: поднять nginx лимит до 50 МБ, добавить раннюю проверку Content-Length в Go-хендлере, и научить фронтенд парсить HTML-ошибки от nginx.

Оптимизация SQL: как мы ускорили дашборд в 4 раза

Для админского дашборда нужны 15 метрик: пользователи, авто, свайпы, мэтчи — всего, за сегодня, активные, премиум и т.д. Первая реализация — 15 подзапросов в одном SELECT:

SELECT     (SELECT COUNT(*) FROM users),     (SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE),     (SELECT COUNT(*) FROM users WHERE subs_status = 'premium'),     ...

Каждая таблица сканировалась 3-5 раз. На 10 000 записях — нормально. На 100 000 — запрос занимал 200ms. Для дашборда, который Prometheus скрейпит каждые 15 секунд — это проблема.

Переписал на CTE с FILTER WHERE — одна из самых недооценённых фич PostgreSQL:

WITH   u AS (     SELECT       COUNT(*)                                           AS total,       COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE) AS today,       COUNT(*) FILTER (WHERE subs_status = 'premium')    AS premium,       COUNT(*) FILTER (WHERE banned_at IS NOT NULL)       AS banned     FROM users   ),   c AS (     SELECT       COUNT(*)                                           AS total,       COUNT(*) FILTER (WHERE status = 'active')          AS active,       COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE) AS today,       COALESCE(AVG(price) FILTER (WHERE status = 'active' AND price > 0), 0) AS avg_price,       COUNT(DISTINCT user_id)                            AS users_with     FROM cars   ),   -- swipes, matches аналогично SELECT ... FROM u, c, sw, m;

4 seq-scan’а вместо 15. Один round-trip. Плюс Redis-кеш на 60 секунд. Запрос с 200ms упал до 15ms, а с кешем — 0.1ms.

N+1: 50 запросов → 2

Классика жанра. Список 25 авто в админке — для каждого нужно имя бренда и модели. Первая реализация:

for i, car := range cars {     brand,  := catalog.GetBrandByID(ctx, car.BrandID)   // запрос в PG     model,  := catalog.GetModelByID(ctx, car.ModelID)    // ещё запрос в PG     items[i].BrandName = brand.Name     items[i].ModelName = model.Name } // 25 авто × 2 запроса = 50 запросов

Классический N+1. Решение — batch:

brandIDs, modelIDs := collectIDs(cars) brandsMap,  := catalog.GetBrandsByIDs(ctx, brandIDs)  // WHERE id = ANY($1) — 1 запрос modelsMap,  := catalog.GetModelsByIDs(ctx, modelIDs)   // 1 запрос for i, car := range cars {     items[i].BrandName = brandsMap[car.BrandID].Name     items[i].ModelName = modelsMap[car.ModelID].Name } // 2 запроса вместо 50

Я знал про N+1, но всё равно написал наивную версию первой. Потому что «сначала работает, потом быстро». Оптимизировал когда увидел в логах, что admin car list отвечает за 400ms.

Observability: то, что спасает в 3 часа ночи

Я подключил полный стек мониторинга с первого дня в продакшене. Не потому что так написано в книжках, а потому что уже обжёгся: на предыдущем проекте ловил баги по скриншотам от пользователей и grep по логам на сервере через SSH.

Трейсинг (OpenTelemetry → Jaeger):

Каждый HTTP-запрос создаёт трейс. Внутри — спаны на PostgreSQL (otelpgx), Redis (redisotel), S3, внешние API. Реальная история: пользователь жалуется что «публикация тормозит». Открываю Jaeger, нахожу трейс — 3.2 секунды. Из них 2.8 секунды — загрузка 5 фото в S3 последовательно. Переделал на параллельную загрузку — 800ms. Без трейсинга я бы гадал неделю.

Ещё один кейс: /metrics эндпоинт возвращал 500, Prometheus не мог скрейпить метрики. В логах — ничего полезного, потому что panic ловился middleware. Проблема оказалась в gofiber/adaptor — он не реализует http.Flusher, а promhttp.Handler() ожидает его. Заменил на нативный Fiber handler с prometheus.DefaultGatherer.Gather() — починилось. Без мониторинга мониторинга (тавтология, да) я бы узнал об этом когда отвалились все алерты.

Метрики (Prometheus + Grafana):

Бизнес-метрики: swapno.swipes{direction="like"}, swapno.matches, swapno.registrations, swapno.photo_uploads{status="success|fail"}. Технические: latency, error rate, goroutines. Всё через OTel SDK → Prometheus exporter.

Важный паттерн — nil-check на метриках. Если OTel не инициализирован (например, в тестах или в dev-режиме без Jaeger) — метрики = nil. Вместо if-else на каждом вызове:

if appOtel.SwipesTotal != nil {     appOtel.SwipesTotal.Add(ctx, 1, metric.WithAttributes(...)) }

Некрасиво, но безопасно. Сервис работает без OTel, метрики просто не пишутся.

Логи (zerolog → Loki):

Структурированный JSON с trace_id в каждой строке:

logger.L(ctx).Info().     Str("car_id", carID.String()).     Int("photos", len(photos)).     Msg("car published") // → {"level":"info","car_id":"...","photos":3,"trace_id":"abc123","message":"car published"}

В Grafana: видишь ошибку в логе → кликаешь на trace_id → попадаешь в Jaeger с полным трейсом запроса. Это экономит часы дебага.

Алертинг через Telegram-бота:

Критичные ошибки улетают прямо в Telegram-канал админам для оперативности, пример формата:

🔴 Ошибка в бэкенде
⚙️ Операция: publish_moderation_skipped
❌ Ошибка: не удалось скачать ни одного фото для модерации
📋 Детали: car_id=167f0617-..., photos=3
🕐 08.04.2026 13:50:04

Именно такой алерт помог мне поймать баг с истёкшими фото, о котором я рассказывал выше.

Что дальше

  • Нативное мобильное приложение — Telegram Mini App была первой итерацией. Бэкенд API-first, переход на мобильный клиент не требует изменений серверной части.

  • Улучшение matching — учёт гео-локации, предпочтений, ML-модель на основе истории свайпов

  • Чат между мэтчами — обсуждение деталей обмена прямо в приложении

Итоги

За 3 месяца мы написали production-ready бэкенд на Go:

  • ~15 000 строк Go-кода

  • 40+ API-эндпоинтов

  • Полная observability с первого дня

  • Время ответа — p95 < 50ms для основных эндпоинтов

Монолит с чистой архитектурой — не dirty hack, а осознанный выбор для маленькой команды. Главное что я вынес: не бойся писать наивный код первой итерацией. Пиши тупо, деплой, смотри метрики, оптимизируй по факту. Половина оптимизаций из этой статьи родились из реальных проблем в продакшене, а не из планирования на бумаге.

Если хотите разбор конкретного компонента — matching engine, ИИ-модерации или observability стека — напишите, сделаю отдельную статью.

ссылка на оригинал статьи https://habr.com/ru/articles/1023278/