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

В России ~300 тысяч автомобилей, владельцы которых готовы к обмену. Сейчас они разбросаны по форумам, Avito и тематическим каналам в Telegram. Процесс обмена — это боль: нужно вручную искать подходящий вариант, договариваться о доплате, проверять VIN. Дилеры берут 10-30% комиссии и затягивают процесс на недели.
Мы решили это автоматизировать. Свайп-механика решает проблему поиска: вместо бесконечного скроллинга — быстрое «да/нет». Мэтч решает проблему доверия: оба хотят машину друг друга. ИИ-оценка и VIN-проверка решают проблему прозрачности.
Первую версию мы запустили как Telegram Mini App — для быстрой проверки гипотезы без App Store и регистрации. Валидировали спрос, собрали первых пользователей, обкатали matching engine. Сейчас готовимся к переходу на нативное мобильное приложение. Бэкенд с самого начала проектировался как API-first — смена клиента не затрагивает серверную часть.

На старте была развилка: микросервисы или монолит. Я выбрал монолит — и ни разу не пожалел.
Когда всего несколько разработчиков — микросервисы убивают. Я попробовал прикинуть: отдельный сервис для авто, для свайпов, для мэтчей, для уведомлений. Получилось 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 | Надёжно. |
Кеш | Redis 7 | Сессии, очереди свайпов, rate limiting, кеш статистики — всё в одном месте |
S3 | MinIO → Selectel S3 | Локально MinIO для разработки, в проде — S3-совместимое облако. Один интерфейс, разные бэкенды |
Логи | zerolog | Структурированный JSON, самый быстрый логгер для Go, context-aware через |
Отдельно про Huma. Я перепробовал gin, echo, chi — и везде одна проблема: ты описываешь API в коде, а OpenAPI-спеку генерируешь отдельно, и они расходятся. В Huma ты описываешь Go-структуры с тегами — и получаешь валидацию, документацию, типизированные ошибки из одного места. Впервые я реально открыл Swagger UI и он совпал с тем, что отдаёт сервер.
Ловушка: Huma использует указатели для optional-полей. Когда я написал BrandID *int в query-параметре — получил panic при обращении к nil. Потратил час на дебаг, прежде чем понял: в query-параметрах Huma нужно использовать обычные типы, а не указатели. Указатели — только для body.
Это самая интересная часть системы, и здесь больше всего набитых шишек.
Очередь свайпов
Первая наивная реализация: пользователь нажимает «свайп» → бэкенд идёт в 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 стека — напишите, сделаю отдельную статью.