Mock-собеседование по System Design | Ex-Team Lead Яндекс
Сегодня мы разберём собеседование по System Design, в котором кандидат проектирует систему для сервиса знакомств наподобие Tinder — с фокусом на показ анкет, механику лайков и матчинг. Интервью прошло продуктивно: кандидат самостоятельно формализовал требования, оценил нагрузку (~8 500 RPS на чтение, ~6 Гбит/с трафика), спроектировал API и архитектуру с разделением на сервисы (WebSocket-кластер, сервис профилей, сервис матчинга), а также грамотно обосновал выбор между SQL и NoSQL хранилищами. В ходе обсуждения также затронули вопросы кэширования предподбранных анкет, оптимизации алгоритма матчинга и масштабируемости системы при выходе на глобальный рынок.
Вопрос 1. Каковы основные функциональные требования к системе типа Tinder?
Таймкод: 00:00:04
Ответ собеседника: Правильный. Пользователи регистрируются, система показывает анкеты противоположного пола по возрастному интервалу и близости по локации. Пользователь может смахнуть влево (дизлайк) или вправо (лайк). При взаимном лайке происходит матч, и люди соединяются.
Правильный ответ:
Функциональные требования к системе типа Tinder можно разбить на несколько ключевых доменов.
1. Управление профилем пользователя
Хотя регистрация выходит за скоуп, система должна поддерживать хранение и отображение профилей: фотографии (с возможностью загрузки нескольких), текстовое описание (bio), возраст, пол, предпочтения по возрасту и расстоянию, геолокацию (широта/долгота или geohash). Профиль должен быть редактируемым.
2. Механика свайпов (Swipe)
Пользователь получает ленту анкет (feed), отсортированных по релевантности. Для каждой анкеты доступны два действия — лайк (свайп вправо) и дизлайк (свайп влево). Критически важно, чтобы один и тот же профиль не показывался повторно после свайпа. Для этого нужно хранить историю уже просмотренных анкет.
3. Система матчинга (Matching)
При взаимном лайке создаётся запись о матче. Это ключевая бизнес-логика: когда пользователь A лайкает пользователя B, система проверяет, лайкал ли B ранее A. Если да — создаётся матч. Если нет — записывается односторонний лайк и ожидается ответ от B.
4. Уведомления о матче
После создания матча обеим сторонам отправляется событие (в данном случае — в Kafka) для дальнейшей обработки другой командой (создание чата). Событие должно содержать идентификатор матча, идентификаторы обоих пользователей и метку времени.
5. Лента анкет (Feed Generation)
Система должна формировать персонализированную ленту для каждого пользователя с учётом:
- геолокации (ближайшие пользователи в заданном радиусе),
- возрастного диапазона из предпочтений,
- пола (предпочтения пользователя),
- исключения уже просмотренных и лайкнутых/дизлайкнутых профилей.
6. Лайк с приоритетом (Super Like / Boost)
Опционально, но часто требуется в реальных системах — механика «супер-лайка», который увеличивает вероятность матча, или «буст», который поднимает профиль в чужих лентах.
7. Пагинация и бесконечная лента
Лента должна подгружаться порциями (pagination / infinite scroll), чтобы не нагружать систему при большом количестве пользователей.
8. Анти-спам и ограничения
Лимиты на количество лайков в сутки (особенно для бесплатных пользователей), rate limiting на API свайпов, защита от ботов.
Вопрос 2. Каковы нефункциональные требования: целевой регион, количество пользователей, шаблоны использования и планы по масштабированию?
Таймкод: 00:03:37
Ответ собеседника: Правильный. Целевой регион — Россия, русскоязычные пользователи. Ожидается 100 млн пользователей. В месяц активно 60 млн, в сутки — около 20 млн. Планируется масштабируемость с запасом 3x для дальнейшего роста. Средний пользователь совершает около 30 просмотров анкет в день за 3 сеанса по 10 просмотров.
Правильный ответ:
Нефункциональные требования определяют масштаб системы и являются фундаментом для архитектурных решений.
1. Целевой регион и география
Россия — крупнейшая страна по территории, что создаёт специфические вызовы: разница часовых поясов (11 зон), неравномерное распределение пользователей (основная концентрация — Москва, Санкт-Петербург, крупные города), различия в качестве интернет-соединения. Необходимо предусмотреть размещение дата-центров минимум в нескольких регионах (Европейская часть, Урал, Сибирь).
2. Масштаб пользовательской базы
- Общая база пользователей: ~100 млн зарегистрированных.
- MAU (Monthly Active Users): ~60 млн.
- DAU (Daily Active Users): ~20 млн.
- Пиковая нагрузка может превышать среднюю в 2–3 раза (вечерние часы, выходные).
3. Шаблоны использования (Usage Patterns)
Средний пользователь совершает 30 свайпов в день, разбитых на 3 сеанса по ~10 свайпов. Это даёт:
- 20 млн DAU × 30 свайпов = 600 млн свайпов в сутки.
- Средний RPS для свайпов: 600 млн / 86400 ≈ 7 000 RPS.
- Пиковый RPS (с учётом x3): ~21 000 RPS.
Генерация ленты: 20 млн × 3 сеанса = 60 млн запросов ленты в сутки ≈ 700 RPS средний, ~2 100 RPS пиковый.
Матчинг: при конверсии лайк→матч ~5–10%, это ~30–60 млн проверок матча в сутки.
4. Требования к доступности и задержкам
- Доступность (SLA): 99.95% (допустимый downtime ~4.4 часа в год).
- Latency генерации ленты: p99 < 200 мс.
- Latency свайпа: p99 < 100 мс.
- Latency проверки матча: p99 < 50 мс (синхронная часть).
5. Требования к масштабированию
Архитектура должна поддерживать горизонтальное масштабирование с запасом 3x от текущей нагрузки, то есть рассчитана на 60 млн DAU и ~60 000 пиковых RPS на свайпы. Масштабирование должно быть эластичным — автоматическое добавление/удаление инстансов в зависимости от нагрузки.
6. Хранение данных
- Профили пользователей: ~100 млн записей, в среднем 2–5 КБ на профиль (без фото) → 200–500 ГБ.
- История свайпов: 600 млн/день × 50 байт × 365 дней ≈ 10 ТБ в год. Необходима стратегия TTL или архивации.
- Матчи: ~30–60 млн в год.
- Фотографии: хранятся в object storage (S3-совместимое), не в реляционной БД.
7. Безопасность и приватность
Соответствие ФЗ-152 (персональные данные граждан РФ должны храниться на территории РФ), защита от скрейпинга данных, шифрование данных в покое и при передаче.
Вопрос 3. Какой размер данных на одну анкету и какова оценка RPS на чтение для системы?
Таймкод: 00:05:49
Ответ собеседника: Правильный. Анкета состоит из фотографии (~300 КБ), описания (~500 символов ≈ 600 байт), параметров (имя, фамилия, возраст, пол, локация — ~100 байт). Итого ~300 КБ + ~700 байт. RPS на чтение рассчитан так: 20 млн пользователей × 30 просмотров × 1.2 (пиковый коэффициент) = 720 млн запросов в день, что даёт ~8 500 RPS.
Правильный ответ:
1. Размер данных на одну анкету
Анкета состоит из двух частей: метаданные (хранятся в БД) и медиафайлы (хранятся в object storage).
Метаданные анкеты в базе данных:
- user_id: 8 байт (BIGINT)
- first_name, last_name: ~50 байт
- age: 1 байт (SMALLINT)
- gender: 1 байт
- bio: ~600 байт (UTF-8, до 500 символов)
- latitude, longitude: 16 байт (два FLOAT8)
- geohash: 12 байт
- preferences (age_min, age_max, distance_km, gender_pref): ~16 байт
- photo_urls (массив ссылок): ~200 байт
- created_at, updated_at: 16 байт
- Прочие флаги и поля: ~50 байт
Итого метаданные: ~1–2 КБ на анкету.
Медиафайлы (фотографии):
- Основная фотография: ~300 КБ (после сжатия и ресайза)
- Дополнительные фото (до 5): ~1.5 МБ
- Итого на пользователя: ~1.8 МБ в object storage.
Важно: при расчёте RPS на чтение нужно разделять запросы метаданных и загрузку фотографий — это разные каналы с разными паттернами нагрузки.
2. Расчёт RPS на чтение
Запросы на чтение делятся на два типа: получение ленты (список анкет) и загрузка фотографий.
Получение ленты:
- 20 млн DAU × 3 сеанса в день = 60 млн запросов ленты в сутки.
- Средний RPS: 60 000 000 / 86 400 ≈ 700 RPS.
- Пиковый RPS (коэффициент ×3): ~2 100 RPS.
Загрузка фотографий (CDN/bucket):
- 20 млн DAU × 30 просмотров = 600 млн загрузок фото в сутки.
- Средний RPS: ~7 000 RPS.
- Пиковый RPS: ~21 000 RPS.
Общий RPS на чтение (ленты + фото): средний ~7 700 RPS, пиковый ~23 100 RPS.
3. Расчёт RPS на запись
Свайпы (лайки/дизлайки):
- 20 млн DAU × 30 свайпов = 600 млн записей в сутки.
- Средний RPS: ~7 000 RPS.
- Пиковый RPS: ~21 000 RPS.
Обновление локации:
- 20 млн DAU × 3 обновления = 60 млн записей в сутки.
- Средний RPS: ~700 RPS.
- Пиковый RPS: ~2 100 RPS.
Итого запись: средний ~7 700 RPS, пиковый ~23 100 RPS.
4. Итоговая нагрузка
Суммарная нагрузка (чтение + запись): средняя ~15 400 RPS, пиковая ~46 200 RPS. Это определяет требования к количеству инстансов сервисов и реплик баз данных.
Вопрос 4. Сколько места потребуется для хранения фотографий и текстовых данных 100 млн пользователей, и какая сетевая нагрузка (трафик) ожидается при 8 500 RPS с учётом размера одной анкеты?
Таймкод: 00:09:30
Ответ собеседника: Правильный. Для фотографий: 100 млн × 300 КБ = 30 ТБ. Для текстовых данных: 100 млн × 700 байт = 70 ГБ — незначительный объём. Сетевой трафик: 8 500 RPS × ~400 КБ (фото + метаданные) ≈ 6 ГБ/сек.
Правильный ответ:
1. Хранение фотографий
Для 100 млн пользователей с учётом нескольких фотографий на профиль:
- Основная фотография: 300 КБ (оптимизированная, WebP/AVIF).
- Дополнительные фото (в среднем 3 на пользователя): 3 × 300 КБ = 900 КБ.
- Итого на пользователя: ~1.2 МБ фотографий.
- Общий объём фотографий: 100 млн × 1.2 МБ = 120 ТБ.
С учётом репликации object storage (обычно 3 реплики): 120 ТБ × 3 = 360 ТБ физического хранилища. Однако современные системы используют erasure coding, что снижает overhead до ~1.5x вместо 3x, то есть ~180 ТБ.
2. Хранение текстовых данных (метаданные)
- Профиль: ~2 КБ × 100 млн = 200 ГБ.
- История свайпов: 600 млн/день × 50 байт × 90 дней (TTL) = 2.7 ТБ.
- Матчи: ~60 млн/год × 100 байт × 3 года = 18 ГБ.
- Локации: ~100 байт × 100 млн = 10 ГБ.
Итого текстовые данные: ~2.9 ТБ с учётом индексов и overhead.
3. Сетевой трафик (Network Bandwidth)
Расчёт исходящего трафика:
- Загрузка ленты: 2 100 пиковых RPS × 2 КБ (JSON с метаданными 10 анкет) = 4.2 МБ/с ≈ 33.6 Мбит/с.
- Загрузка фотографий через CDN: 21 000 пиковых RPS × 300 КБ = 6.3 ГБ/с ≈ 50 Гбит/с.
Общий исходящий трафик: ~6.3 ГБ/с (50 Гбит/с). Это значительная нагрузка, которая требует использования CDN и географически распределённой инфраструктуры.
Входящий трафик (свайпы, обновления локации):
- 23 100 пиковых RPS × 200 байт (размер запроса) = 4.6 МБ/с ≈ 37 Мбит/с — незначительный.
4. Распределение трафика по времени
Пиковая нагрузка приходится на вечерние часы (19:00–23:00 местного времени). Из-за 11 часовых поясов России пик растягивается на ~8 часов, но всё равно создаёт значительную нагрузку. CDN должен быть способен обслуживать пиковый трафик с учётом cache hit ratio ~95% для фотографий.
Вопрос 5. Какие API-эндпоинты необходимы для системы типа Tinder?
Таймкод: 00:12:42
Ответ собеседника: Правильный. GET для получения списка анкет (батчами по 10 штук, с предзагрузкой для плавного свайпа). POST для лайка/дислайка с параметрами user_id и partner_id. Также нужен эндпоинт для детекта матча — когда оба пользователи лайкнули друг друга, событие отправляется в Kafka.
Правильный ответ:
API системы можно разделить на несколько групп по функциональности.
1. Управление профилем
GET /api/v1/profile— получение своего профиля.PUT /api/v1/profile— обновление профиля (bio, имя, предпочтения).POST /api/v1/profile/photos— загрузка фотографий (multipart/form-data, загрузка в object storage через presigned URL).DELETE /api/v1/profile/photos/{photo_id}— удаление фотографии.PUT /api/v1/profile/location— обновление геолокации (вызывается периодически с мобильного клиента).
2. Лента анкет (Feed)
GET /api/v1/feed?cursor={cursor}&limit=10— получение батча анкет. Курсорная пагинация предпочтительнее offset-based, так как обеспечивает стабильную работу при изменении данных. Клиент запрашивает следующий батч заранее (prefetch), пока пользователь свайпает текущий, для обеспечения плавного UX.
3. Свайпы (Swipe)
POST /api/v1/swipes— совершение действия (лайк/дизлайк). Тело запроса:
{
"target_user_id": "uuid",
"action": "like" // или "dislike"
}
Этот эндпоинт является наиболее нагруженным (~21 000 пиковых RPS). Он должен быть идемпотентным (повторный запрос с теми же параметрами не создаёт дубликат). В ответе возвращается информация о том, произошёл ли матч:
{
"match": true,
"match_id": "uuid"
}
4. Матчи
GET /api/v1/matches— список текущих матчей с пагинацией.GET /api/v1/matches/{match_id}— детали конкретного матча.
5. Уведомления
GET /api/v1/notifications— список уведомлений (новый матч, суперлайк и т.д.).GET /api/v1/notifications/stream— SSE (Server-Sent Events) или WebSocket для real-time уведомлений о новых матчах.
6. Системные эндпоинты
GET /api/v1/health— health check для балансировщиков.GET /api/v1/config— конфигурация клиента (лимиты лайков, радиус поиска и т.д.).
7. Версионирование и формат
Все эндпоинты версионируются через URL path (/api/v1/). Формат — JSON. Аутентификация через JWT в заголовке Authorization: Bearer <token>. Rate limiting применяется на уровне API gateway с разными лимитами для разных эндпоинтов.
Вопрос 6. Как учитывать текущее местоположение пользователя при подборе ближайших анкет, если пользователь перемещается?
Таймкод: 00:15:57
Ответ собеседника: Правильный. Использовать текущее местоположение пользователя, а не место регистрации. Передавать гео-координаты в каждом запросе, чтобы сервер мог находить ближайшие анкеты. Запрос на получение анкет должен быть POST с параметрами геолокации.
Правильный ответ:
Геолокация — один из ключевых факторов ранжирования анкет, и её обработка требует комплексного подхода.
1. Передача координат клиентом
Мобильное приложение передаёт текущие координаты при каждом запросе ленты. Используется POST-запрос, так как GET с телом — антипаттерн, а параметры локации чувствительны (не должны логироваться в access log прокси):
POST /api/v1/feed
{
"cursor": "base64_cursor",
"limit": 10,
"location": {
"latitude": 55.7558,
"longitude": 37.6173
}
}
Клиент также периодически (каждые 5–15 минут при активном использовании) отправляет PUT /api/v1/profile/location для обновления кэшированной позиции на сервере. Это нужно для фоновых процессов (например, пересчёта рекомендаций).
2. Индексация геолокации в базе данных
Для эффективного поиска «ближайших» используются следующие подходы:
Geohash + PostgreSQL PostGIS: Каждый профиль хранит geohash своей последней известной позиции. Для поиска в радиусе R вычисляются соседние geohash-ячейки нужного уровня точности:
-- С PostGIS
SELECT u.user_id, u.first_name, u.age, u.bio,
ST_Distance(u.location, ST_MakePoint($1, $2)::geography) AS distance_meters
FROM user_profiles u
WHERE ST_DWithin(
u.location,
ST_MakePoint($1, $2)::geography,
$3 -- радиус в метрах
)
AND u.user_id != $4
AND u.age BETWEEN $5 AND $6
AND u.gender = $7
AND u.user_id NOT IN (
SELECT target_user_id FROM swipes WHERE user_id = $4
)
ORDER BY distance_meters ASC
LIMIT $8;
Redis GEO: Для быстрого поиска в реальном времени можно использовать Redis GEO-команды:
// Обновление позиции в Redis GEO
func UpdateLocation(ctx context.Context, userID string, lat, lon float64) error {
return redisClient.GeoAdd(ctx, "user_locations", &redis.GeoLocation{
Name: userID,
Latitude: lat,
Longitude: lon,
}).Err()
}
// Поиск ближайших пользователей
func FindNearby(ctx context.Context, lat, lon, radiusKm float64, limit int) ([]redis.GeoLocation, error) {
return redisClient.GeoRadius(ctx, "user_locations", lon, lat, &redis.GeoRadiusQuery{
Radius: radiusKm,
Unit: "km",
Sort: "ASC",
Count: limit * 3, // запас для фильтрации
}).Result()
}
3. Обработка перемещений
При перемещении пользователя координаты обновляются в нескольких местах:
- В Redis GEO — для оперативного поиска (обновляется при каждом запросе ленты и при фоновом обновлении локации).
- В PostgreSQL — для персистентного хранения (обновляется при фоновом обновлении локации).
- В кэше рекомендаций — инвалидируется при значительном перемещении (>5 км).
4. Проблема «пограничных» пользователей
Пользователи вблизи границы поиска могут получать анкеты из другого города/региона. Для этого используется поиск по соседним geohash-ячейкам. Также стоит учитывать, что пользователь, который путешествует, может захотеть видеть анкеты в текущем городе, а не в городе регистрации — это поведение по умолчанию при использовании текущей локации.
5. Приватность локации
Точные координаты не передаются в ответе API. Вместо этого возвращается округлённое расстояние («5 км», «12 км»). Координаты хранятся в зашифрованном виде в базе данных, доступ к ним ограничен.
Вопрос 7. Предложите высокоуровневую архитектуру системы типа Tinder.
Таймкод: 00:17:40
Ответ собеседника: Правильный. Архитектура включает: пользователь → API Gateway → сервисы. База данных пользователей для хранения профилей. Kafka (внешняя) для отправки событий о матчах. S3-подобное хранилище для фотографий. Разделение на потоки: один — для просмотра анкет, другой — для обработки лайков и детекции матчей.
Правильный ответ:
Архитектура строится вокруг разделения на два ключевых потока: чтение (просмотр анкет) и запись (свайпы/матчинг), которые имеют разные требования к задержкам и пропускной способности.
1. Общая схема
Мобильный клиент / Web
│
▼
┌──────────┐
│ CDN │ ← фотографии, статика
└──────────┘
│
▼
┌──────────────┐
│ API Gateway │ ← rate limiting, auth, routing
└──────────────┘
│
├──→ ┌─────────────┐ ┌──────────────────┐
│ │ Feed Service │────→│ Feed Cache (Redis)│
│ └─────────────┘ └──────────────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ │ User Service │
│ └──────────────────┘
│ │
├──→ ┌──────────────┐ ┌──────────────────┐
│ │ Swipe Service │────→│ Swipe Store (DB) │
│ └──────────────┘ └──────────────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ │ Matching Engine │
│ └──────────────────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ Kafka │ → внешняя команда (чат)
│ └──────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Data Layer │
│ PostgreSQL (профили, матчи, свайпы) │
│ Redis (кэш лент, GEO-индекс, сессии) │
│ S3/MinIO (фотографии) │
└──────────────────────────────────────────┘
2. Компоненты и их ответственность
API Gateway: Единая точка входа. Выполняет аутентификацию (валидация JWT), rate limiting (разные лимиты для разных эндпоинтов), маршрутизацию запросов, логирование, SSL termination. Может быть реализован на основе Kong, Envoy или самописного решения.
Feed Service: Отвечает за формирование ленты анкет. Читает из кэша (Redis) или генерирует на основе геолокации, предпочтений и истории свайпов. Кэширует предвычисленные ленты для каждого пользователя (TTL 5–15 минут, инвалидируется при обновлении локации).
Swipe Service: Обрабатывает лайки и дизлайки. Записывает действие в базу данных, вызывает Matching Engine для проверки взаимного лайка. Должен быть идемпотентным — каждый свайп идентифицируется уникальным ключом (user_id + target_id).
Matching Engine: Проверяет, есть ли обратный лайк. При обнаружении матча создаёт запись в БД и отправляет событие в Kafka. Для проверки использует Redis SET или Bloom filter для быстрого ответа: «лайкал ли target_user данного пользователя?»
User Service: Управление профилями, обновление локации, получение метаданных анкет.
3. Поток данных при свайпе
Клиент → API Gateway → Swipe Service → Запись в PostgreSQL (swipes)
→ Проверка в Redis (обратный лайк?)
│
├── Нет → конец (ответ "ok")
│
└── Да → Создание матча в PostgreSQL
→ Отправка события в Kafka
→ Ответ клиенту "match!"
4. Разделение Read/Write (CQRS-подобный подход)
Поток чтения (лента) и поток записи (свайпы) разделены:
- Лента читается из кэша (Redis) или read-replica PostgreSQL.
- Свайпы пишутся в master PostgreSQL и обрабатываются асинхронно.
- Это позволяет масштабировать чтение и запись независимо.
5. Внешние интеграции
- Kafka — событие о матче публикуется в топик
matches. Внешняя команда (чат) читает из этого топика и создаёт чат-комнату. - Push Notification Service — при получении события о матче (из Kafka или напрямую от Matching Engine) отправляет push-уведомление обоим пользователям через Firebase Cloud Messaging (Android) или APNs (iOS).
- Object Storage (S3/MinIO) — фотографии загружаются через presigned URL напрямую из клиента, минуя серверную часть.
6. Масштабирование
- Feed Service и Swipe Service масштабируются горизонтально (stateless).
- PostgreSQL — master + несколько read-replica. Шардирование по user_id при необходимости.
- Redis — кластер с шардированием.
- Kafka — несколько партиций в топиках для параллельной обработки.
Вопрос 8. Как организовать быструю и бесшовную доставку анкет пользователю с учётом необходимости поддержания постоянного соединения?
Таймкод: 00:19:56
Ответ собеседника: Правильный. Использовать WebSocket для поддержания постоянного соединения с пользователем. Создать кластер сервисов (WebSocket Gateway), который поддерживает активные соединения и внутри системы взаимодействует с сервисом профилей (Profiles) и сервисом матчинга (Matching). Это позволяет быстро доставлять анкеты и мгновенно обрабатывать лайки/дизлайки.
Правильный ответ:
Бесшовная доставка анкет — это комплексная задача, требующая оптимизации на нескольких уровнях: от prefetching на клиенте до кэширования на сервере.
1. Prefetching на клиенте
Клиент запрашивает следующий батч анкет заранее, пока пользователь свайпает текущий. Типичная стратегия:
- Пользователь видит 10 анкет (батч).
- Когда пользователь свайпнул 7-ю анкету (70% батча), клиент автоматически запрашивает следующий батч.
- Если сеть медленная — prefetch запускается раньше (после 50%).
- Загруженные фотографии кэшируются локально на устройстве.
Это создаёт иллюзию бесконечной ленты без задержек.
2. Кэширование ленты на сервере
Для каждого пользователя предвычисленная лента кэшируется в Redis:
type FeedCache struct {
redis *redis.Client
}
func (f *FeedCache) GetFeed(ctx context.Context, userID string, cursor string, limit int) ([]Profile, string, error) {
cacheKey := fmt.Sprintf("feed:%s:%s", userID, cursor)
// Попытка чтения из кэша
data, err := f.redis.Get(ctx, cacheKey).Bytes()
if err == nil {
var cached CachedFeed
if json.Unmarshal(data, &cached) == nil {
return cached.Profiles, cached.NextCursor, nil
}
}
// Cache miss — генерация ленты
profiles, nextCursor, err := f.generateFeed(ctx, userID, cursor, limit)
if err != nil {
return nil, "", err
}
// Сохранение в кэш с TTL 10 минут
cached := CachedFeed{Profiles: profiles, NextCursor: nextCursor}
encoded, _ := json.Marshal(cached)
f.redis.Set(ctx, cacheKey, encoded, 10*time.Minute)
return profiles, nextCursor, nil
}
Кэш инвалидируется при:
- Обновлении локации пользователя (>5 км смещение).
- Изменении предпочтений.
- Достижении TTL.
3. WebSocket для real-time событий
WebSocket используется не для доставки анкет (это делает REST API с prefetching), а для real-time уведомлений:
- Уведомление о новом матче.
- Уведомление о суперлайке.
- Обновление списка матчей.
// WebSocket Gateway — обработка подключения
func (gw *Gateway) HandleConnection(w http.ResponseWriter, r *http.Request) {
userID := authenticate(r)
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Регистрация соединения
gw.connections.Set(userID, conn)
defer gw.connections.Delete(userID)
// Подписка на события пользователя из Kafka
go gw.subscribeToUserEvents(userID, conn)
// Чтение сообщений от клиента (ping/pong, ack)
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
// Отправка уведомления о матче
func (gw *Gateway) SendMatchNotification(userID string, matchEvent MatchEvent) {
conn, ok := gw.connections.Get(userID)
if !ok {
// Пользователь оффлайн — отправляем push
pushService.Send(userID, matchEvent)
return
}
conn.WriteJSON(WSMessage{
Type: "match",
Data: matchEvent,
})
}
4. Масштабирование WebSocket Gateway
При 20 млн DAU одновременно онлайн может быть 2–5 млн соединений. Каждое WebSocket-соединение потребляет ~10–50 КБ памяти. Итого: 20–250 ГБ RAM на все соединения.
Для масштабирования:
- WebSocket Gateway — stateless кластер за балансировщом с sticky sessions (или с использованием Redis Pub/Sub для маршрутизации сообщений к нужному инстансу).
- Каждый инстанс хранит локальную map соединений.
- При получении события о матче из Kafka, сообщение публикуется в Redis Pub/Sub канал
user:{userID}:events, и тот инстанс, который держит соединение с данным пользователем, пересылает его клиенту.
5. Оптимизация размера ответа
Для минимизации задержек при передаче анкет:
- В ответе ленты фотографии не включаются — только URL.
- Используется формат с минимум полей:
{id, name, age, bio, photo_urls, distance}. - Сжатие gzip/brotli на уровне API Gateway.
- Размер одного ответа ленты (10 анкет): ~2–5 КБ после сжатия.
6. CDN для фотографий
Фотографии загружаются через CDN с edge-серверов, расположенных близко к пользователю. Используются адаптивные форматы (WebP, AVIF) и ресайз на лету через CDN (например, Cloudflare Images или AWS CloudFront + Lambda@Edge). Это снижает нагрузку на origin и ускоряет загрузку.
Вопрос 9. Как реализовать логику скрытия уже просмотренных/лайкнутых анкет от пользователя? Какой тип хранилища выбрать для хранения лайков/дизлайков и профилей?
Таймкод: 00:23:04
Ответ собеседняка: Правильный. При лайке или дизлайке пользователь не должен снова видеть эту анкету в течение настраиваемого периода (настраиваемого периода (например, 30 дней). За месяц пользователь может просмотреть ~1000 анкет, за год — ~12 000. Для хранения лайков/дизлайков рекомендуется key-value хранилище (NoSQL), так как лучше масштабируется горизонтально и не требует сложных join-ов. Для профилей пользователей подойдёт реляционная БД (PostgreSQL). Для аналитики можно рассмотреть Cassandra.
Правильный ответ:
1. Механика скрытия анкет
После свайпа (лайк или дизлайк) пара (user_id, target_id) записывается в хранилище с TTL. При генерации ленты все такие пары исключаются из выдачи.
Период скрытия может быть настраиваемым:
- Дизлайк — скрытие на 30 дней (или до сброса подписки).
- Лайк — скрытие навсегда (если не произошёл матч, анкета больше не показывается).
- Для платных подписок (Tinder Plus/Gold) — возможность «отмотать» последний свайп (undo).
2. Хранение свайпов — PostgreSQL
Несмотря на привлекательность NoSQL, PostgreSQL является оптимальным выбором для хранения свайпов по нескольким причинам:
- Нужны гарантии консистентности при проверке матча (транзакционность).
- Объём данных управляем: 600 млн записей/день × 50 байт = 30 ГБ/день, но с TTL 90 дней это ~2.7 ТБ — в пределах возможностей PostgreSQL.
- Составные индексы
(user_id, target_id)обеспечивают быструю проверку матча.
CREATE TABLE swipes (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
target_user_id UUID NOT NULL,
action VARCHAR(10) NOT NULL CHECK (action IN ('like', 'dislike', 'superlike')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '90 days',
UNIQUE(user_id, target_user_id)
);
-- Индекс для быстрой проверки обратного лайка
CREATE INDEX idx_swikes_target_action ON swipes (target_user_id, user_id, action)
WHERE action = 'like';
-- Индекс для поиска всех свайпов пользователя (генерация ленты)
CREATE INDEX idx_swipes_user_created ON swipes (user_id, created_at DESC);
-- Автоматическая очистка старых записей
CREATE INDEX idx_swipes_expires ON swipes (expires_at);
-- Периодический DELETE или использование pg_partman для партиционирования по времени
3. Ускорение проверки матча — Redis
Для мгновенной проверки «лайкал ли target_user данного пользователя?» используется Redis SET:
// При лайке пользователя A на пользователя B
func RecordLike(ctx context.Context, userID, targetID string) error {
// Добавляем targetID в множество лайков пользователя A
key := fmt.Sprintf("likes:%s", userID)
pipe := redisClient.Pipeline()
pipe.SAdd(ctx, key, targetID)
pipe.Expire(ctx, key, 90*24*time.Hour) // TTL 90 дней
_, err := pipe.Exec(ctx)
return err
}
// Проверка взаимного лайка
func CheckMutualLike(ctx context.Context, userID, targetID string) (bool, error) {
key := fmt.Sprintf("likes:%s", targetID)
return redisClient.SIsMember(ctx, key, userID).Result()
}
При свайпе «лайк» от A к B:
- Записываем в Redis:
SADD likes:A B(с TTL). - Проверяем в Redis:
SISMEMB likes:B A. - Если true — матч! Создаём запись в PostgreSQL и отправляем в Kafka.
- Записываем в PostgreSQL для персистентности (async или sync).
4. Хранение профилей — PostgreSQL
Реляционная модель для профилей:
CREATE TABLE user_profiles (
user_id UUID PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100),
age SMALLINT NOT NULL CHECK (age >= 18),
gender VARCHAR(20) NOT NULL,
bio TEXT,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
geohash VARCHAR(12),
pref_age_min SMALLINT DEFAULT 18,
pref_age_max SMALLINT DEFAULT 99,
pref_distance_km INTEGER DEFAULT 50,
pref_gender VARCHAR(20),
is_active BOOLEAN DEFAULT TRUE,
last_location_update TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_profiles_geohash ON user_profiles (geohash);
CREATE INDEX idx_profiles_active ON user_profiles (is_active, gender, age) WHERE is_active = TRUE;
5. Исключение просмотренных при генерации ленты
При генерации ленты используется комбинация Redis (для быстрого исключения) и PostgreSQL (для финальной фильтрации):
func GenerateFeed(ctx context.Context, userID string, lat, lon float64, limit int) ([]Profile, error) {
// 1. Быстрый кандидатат из Redis GEO
nearbyIDs, err := redisGeo.FindNearby(ctx, lat, lon, 50, limit*5)
// 2. Исключение уже свайпнутых из Redis SET
swipedKey := fmt.Sprintf("swiped:%s", userID)
pipe := redisClient.Pipeline()
for _, loc := range nearbyIDs {
pipe.SIsMember(ctx, swipedKey, loc.Name)
}
results, _ := pipe.Exec(ctx)
// 3. Фильтрация и получение профилей из PostgreSQL (только оставшиеся)
// 4. Ранжирование по расстоянию, возрасту, активности
}
6. Аналитика — отдельное хранилище
Для аналитических запросов (метрики конверсии, A/B тесты, рекомендации) данные из свайпов и профилей реплицируются в ClickHouse или Cassandra через Kafka Connect или Debezium (CDC из PostgreSQL). Это разгружает основную БД от аналитических запросов.
Вопрос 10. Какова итоговая высокоуровневая архитектура системы типа Tinder с учётом всех обсуждённых компонентов и потоков данных?
Таймкод: 00:27:43
Ответ собеседника: Правильный. Архитектура: пользователь → WebSocket Gateway (поддержание соединений) → сервис Profiles (получение анкет из PostgreSQL с фильтрацией по гео/возрасту/полу и кэшированием в Redis) и сервис Matching (обработка лайков/дизлайков). Лайки записываются в key-value NoSQL БД через Kafka с паттерном 'listen to yourself' для атомарности. При обнаружении взаимного лайка событие отправляется во внешнюю Kafka для создания чата. Фотографии хранятся в S3, CDN для кэширования. Мастер-реплика разделение для чтения/записи в БД матчинга. Геолокация хранится как last geo в профиле пользователя с геоиндексом в PostgreSQL.
Правильный ответ:
1. Полная архитектурная диаграмма
┌─────────────────────────────────────────────────────────────────────┐
│ Клиенты │
│ iOS App Android App Web App │
└──────┬──────────────┬──────────────┬────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ CDN (CloudFront/Cloudflare) │
│ Фотографии, статика, edge-ресайз изображений │
└──────────────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ API Gateway (Kong/Envoy) │
│ Auth (JWT), Rate Limiting, Routing, SSL, Logging │
└───┬────────────────┬─────────────────┬───────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────────┐ ┌──────────────────┐
│ Feed │ │ Swipe │ │ WebSocket │
│ Service │ │ Service │ │ Gateway │
└────┬────┘ └──────┬───────┘ └────────┬─────────┘
│ │ │
▼ ▼ │
┌─────────┐ ┌──────────────┐ │
│ Feed │ │ Matching │ │
│ Cache │ │ Engine │ │
│ (Redis) │ └──────┬───────┘ │
└─────────┘ │ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Kafka │ │ Redis │
│ (внутрен.) │ │ Pub/Sub │
└──────┬───────┘ └──────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────────┐ ┌────────────────┐
│ Push │ │ Kafka │ │ Analytics │
│ Notif. │ │ (внешняя) │ │ Pipeline │
│ Service │ │ → Чат │ │ → ClickHouse │
└─────────┘ └──────────────┘ └────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ S3 / MinIO │ │
│ │ (Master + │ │ (Cluster) │ │ (Фото) │ │
│ │ Replicas) │ │ GEO, Cache, │ │ │ │
│ │ Профили, │ │ Sessions, │ │ │ │
│ │ Свайпы, │ │ Likes SET │ │ │ │
│ │ Матчи │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
2. Поток данных: просмотр ленты
Клиент → API Gateway → Feed Service → Redis (кэш ленты)
→ Redis GEO (поиск ближайших)
→ Redis SET (исключение свайпнутых)
→ PostgreSQL (получение профилей, cache miss)
← JSON с анкетами (без фото, только URL) ←
Клиент → CDN → загрузка фотографий по URL
3. Поток данных: свайп и матчинг
Клиент → API Gateway → Swipe Service → PostgreSQL (запись свайпа)
→ Redis SET (SADD likes:user target)
→ Redis SET (SISMEMB likes:target user)
│
├── Нет → ответ {"match": false}
│
└── Да → PostgreSQL (создание матча)
→ Kafka (событие матча)
→ ответ {"match": true, "match_id": "..."}
Kafka Consumer (Push Service) → FCM/APNs → Push-уведомление
Kafka Consumer (внешняя команда) → Создание чата
Kafka Consumer (Analytics) → ClickHouse → Метрики
4. Поток данных: real-time уведомления
Matching Engine → Kafka (топик matches) → WebSocket Gateway Consumer
│
├── Пользователь онлайн → WebSocket → Push в UI
└── Пользователь оффлайн → Push Notification Service → FCM/APNs
5. Ключевые архитектурные решения и их обоснование
PostgreSQL как основное хранилище: ACID-транзакции критичны для матчинга — нельзя допустить дублирование матча или потерю свайпа. PostgreSQL с партиционированием по времени (pg_partman) справляется с объёмом свайпов. Read-replicas разгружают чтение для ленты.
Redis как ускоритель:
- GEO-индекс для поиска ближайших пользователей (O(log N)).
- SET для хранения лайков — проверка взаимного лайка за O(1).
- Кэш лент — снижение нагрузки на PostgreSQL.
- Pub/Sub для маршрутизации WebSocket-сообщений.
Kafka как шина событий: Развязывает сервисы, обеспечивает гарантию доставки событий о матчах. Паттерн «listen to yourself» — Swipe Service читает из Kafka события о свайпах, агрегирует их и проверяет матчи, что позволяет обрабатывать свайпы в порядке поступления для каждого пользователя.
Разделение Read/Write: Feed Service читает из Redis и read-replica PostgreSQL. Swipe Service пишет в master PostgreSQL. Это позволяет масштабировать чтение и запись независимо.
6. Отказоустойчивость
- PostgreSQL: автоматический failover (Patroni), бэкапы каждые 6 часов + WAL archiving.
- Redis: Sentinel или Cluster mode с автоматическим переключением.
- Kafka: репликация партиций (replication factor 3).
- Все сервисы: graceful shutdown, health checks, circuit breakers при обращении к зависимостям.
- Dead Letter Queue (DLQ) в Kafka для необработанных событий.
7. Мониторинг и наблюдаемость
- Метрики: Prometheus + Grafana (RPS, latency, error rate, cache hit ratio).
- Логирование: централизованное (ELK/Loki) с correlation ID.
- Трейсинг: Jaeger/Zipkin для отслеживания запроса через все сервисы.
- Алертинг: на рост latency, падение cache hit ratio, рост error rate, отставание Kafka consumer lag.
Вопрос 11. Как обеспечить атомарность операции лайка и детекции матча с учётом возможных падений сервисов? Какой паттерн использовать?
Таймкод: 00:31:11
Ответ собеседника: Правильный. Использовать паттерн «listen to yourself» через Kafka. Сервис матчинга отправляет событие лайка/дизлайка в Kafka, и другой инстанс того же сервиса слушает эту очередь. Если сервис упал после отправки — сообщение сохранено в Kafka, и другой инстанс подхватит его. Далее записывает в key-value БД, проверяет обратный лайк и при совпадении отправляет событие матча во внешнюю Kafka.
Правильный ответ:
Атомарность лайка и детекции матча — критически важное требование. Пользователь не должен потерять лайк из-за падения сервиса, и матч не должен быть потерян или задублирован.
1. Проблема
Рассмотрим сценарий без защитных механизмов:
- Пользователь A лайкает пользователя B.
- Swipe Service записывает лайк в Redis.
- Swipe Service проверяет обратный лайк — находит.
- Swipe Service падает до записи матча в PostgreSQL и отправки в Kafka.
Результат: оба пользователя видят, что лайкнули, но матча нет. Пользователи могут свайпнуть друг друга повторно, создавая дубликат.
2. Паттерн «Listen to Yourself» (Transactional Outbox через Kafka)
Суть: Swipe Service не выполняет бизнес-логику матчинга самостоятельно. Вместо этого он записывает событие свайпа в Kafka и возвращает ответ клиенту. Отдельный consumer (того же сервиса или отдельный Matching Engine) обрабатывает событие и выполняет проверку матча.
Swipe Service Kafka Matching Engine
│ │ │
│ 1. Записать свайп в PostgreSQL │ │
│ (status = 'pending') │ │
│ │ │
│ 2. Отправить событие ────────────→│ │
│ (topic: swipes) │ │
│ │ 3. Прочитать событие ──→│
│ │ │
│ 4. Ответить клиенту │ 5. Проверить обратный │
│ {"status": "ok"} │ лайк в Redis │
│ │ │
│ │ 6. Если матч: │
│ │ - Создать матч в БД │
│ │ - Отправить в Kafka │
│ │ (topic: matches) │
│ │ │
│ │ 7. Обновить статус │
│ │ свайпа на 'processed' │
3. Идемпотентность — ключевое требование
Каждый свайп должен обрабатываться ровно один раз. Для этого:
type SwipeEvent struct {
EventID string `json:"event_id"` // UUID v4, генерируется клиентом
UserID string `json:"user_id"`
TargetID string `json:"target_id"`
Action string `json:"action"` // "like", "dislike", "superlike"
Timestamp time.Time `json:"timestamp"`
}
// Consumer — идемпотентная обработка
func (m *MatchingEngine) ProcessSwipe(ctx context.Context, event SwipeEvent) error {
// Проверка идемпотентности — обрабатывали ли мы этот event_id?
exists, err := m.redis.Exists(ctx, fmt.Sprintf("processed:%s", event.EventID)).Result()
if err != nil || exists > 0 {
return nil // Уже обработан — пропускаем
}
// Проверка обратного лайка
if event.Action == "like" {
mutualLike, err := m.redis.SIsMember(ctx,
fmt.Sprintf("likes:%s", event.TargetID),
event.UserID).Result()
if err != nil {
return err // Kafka повторит доставку
}
if mutualLike {
// Создание матча в PostgreSQL (idempotent INSERT)
matchID, err := m.createMatch(ctx, event.UserID, event.TargetID, event.EventID)
if err != nil {
return err
}
// Отправка события матча во внешнюю Kafka
m.producer.Send("matches", MatchEvent{
MatchID: matchID,
UserA: event.UserID,
UserB: event.TargetID,
EventID: event.EventID,
})
}
}
// Пометить event_id как обработанный
m.redis.Set(ctx, fmt.Sprintf("processed:%s", event.EventID), "1", 7*24*time.Hour)
return nil
}
4. Exactly-Once Semantics
Для гарантии ровно однократной обработки:
- Kafka producer:
enable.idempotence=trueиacks=allдля гарантии записи. - Kafka consumer: ручной commit offset только после успешной обработки (
enable.auto.commit=false). - PostgreSQL:
INSERT ... ON CONFLICT DO NOTHINGдля идемпотентной записи матча:
INSERT INTO matches (match_id, user_a_id, user_b_id, created_at, source_event_id)
VALUES ($1, $2, $3, NOW(), $4)
ON CONFLICT (user_a_id, user_b_id) DO NOTHING
RETURNING match_id;
5. Обработка падений
Падение Swipe Service после записи в PostgreSQL, но до отправки в Kafka:
Свайп записан с status='pending'. Фоновый процесс (reconciliation job) периодически сканирует таблицу свайпов с status='pending', старше N секунд, и досылает их в Kafka.
Падение Matching Engine во время обработки:
Kafka не подтвердит offset, и событие будет повторно доставлено. Благодаря идемпотентности (проверка processed:{event_id}), повторная обработка будет пропущена.
Падение между созданием матча и отправкой в Kafka: Используется паттерн Transactional Outbox: матч и outbox-запись записываются в одной транзакции PostgreSQL. Отдельный процесс (outbox relay) читает outbox и отправляет в Kafka.
BEGIN;
INSERT INTO matches (match_id, user_a_id, user_b_id, created_at)
VALUES ($1, $2, $3, NOW());
INSERT INTO outbox (id, topic, payload, created_at)
VALUES ($4, 'matches', '{"match_id":"$1", ...}', NOW());
COMMIT;
6. Graceful degradation
При недоступности Kafka:
- Swipe Service записывает свайпы в PostgreSQL с
status='pending'. - Возвращает клиенту
{"status": "accepted", "match": null}. - Фоновый процесс досылает события в Kafka после восстановления.
- Матч может быть обнаружен с задержкой, но не потерян.
При недоступности Redis:
- Matching Engine проверяет обратный лайк напрямую в PostgreSQL:
SELECT 1 FROM swipes WHERE user_id=$1 AND target_user_id=$2 AND action='like'. - Медленнее, но работает как fallback.
Вопрос 12. Как реализовать процесс перемачивания — на потоке каждого лайка или батчами с задержкой? Почему нужен persistence layer?
Таймкод: 00:36:00
Ответ собеседника: Правильный. На начальном этапе — синхронно сразу после лайка. При росте нагрузки — вынести в отдельный асинхронный сервис. Батчевая обработка эффективнее: пользователь лайкает нескольких людей подряд, можно группировать запросы к БД. Обязателен persistence layer (Kafka), а не очередь в памяти, чтобы при падении сервиса лайки не терялись. Шардирование Kafka по партициям (по пользователям) позволяет группировать батчи.
Правильный ответ:
1. Синхронная обработка (event-per-event)
На начальном этапе системы синхронная обработка каждого лайка — оптимальный подход:
Клайк → Swipe Service → Запись в Redis + PostgreSQL → Проверка матча → Ответ клиенту
Преимущества:
- Простота реализации и отладки.
- Мгновенная обратная связь — пользователь видит матч сразу.
- Latency < 100 мс при правильной архитектуре.
Недостатки:
- Каждый свайп требует отдельного обращения к Redis и PostgreSQL.
- При 21 000 пиковых RPS на свайпы — 21 000 проверок матча в секунду.
Реализация:
func (s *SwipeService) HandleSwipe(ctx context.Context, req SwipeRequest) (*SwipeResponse, error) {
// 1. Запись свайпа в PostgreSQL (idempotent)
err := s.db.Exec(ctx,
`INSERT INTO swipes (user_id, target_user_id, action, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, target_user_id) DO NOTHING`,
req.UserID, req.TargetID, req.Action)
if err != nil {
return nil, fmt.Errorf("failed to record swipe: %w", err)
}
// 2. Запись в Redis для быстрого доступа
pipe := s.redis.Pipeline()
pipe.SAdd(ctx, fmt.Sprintf("swiped:%s", req.UserID), req.TargetID)
if req.Action == "like" {
pipe.SAdd(ctx, fmt.Sprintf("likes:%s", req.UserID), req.TargetID)
}
pipe.Exec(ctx)
// 3. Проверка матча (только для лайков)
if req.Action == "like" {
isMutual, err := s.redis.SIsMember(ctx,
fmt.Sprintf("likes:%s", req.TargetID), req.UserID).Result()
if err != nil {
// Fallback на PostgreSQL
isMutual, err = s.checkMutualLikeFromDB(ctx, req.UserID, req.TargetID)
}
if isMutual {
matchID, err := s.createMatch(ctx, req.UserID, req.TargetID)
if err == nil {
s.producer.Send("matches", MatchEvent{MatchID: matchID, ...})
return &SwipeResponse{Match: true, MatchID: matchID}, nil
}
}
}
return &SwipeResponse{Match: false}, nil
}
2. Асинхронная батчевая обработка
При росте нагрузки выгоднее обрабатывать свайпы батчами:
Клиент → Swipe Service → Kafka (topic: swipes, партиция = user_id)
↓
Matching Engine (consumer group, читает батчами)
↓
Пакетная проверка матчей → Kafka (topic: matches)
Преимущества батчей:
- Группировка запросов к Redis (MGET/MSET вместо отдельных команд).
- Пакетная запись в PostgreSQL (COPY или batch INSERT).
- Снижение количества round-trip к базам данных.
Недостатки:
- Задержка в обнаружении матча (от сотен миллисекунд до нескольких секунд).
- Усложнение архитектуры.
Реализация батчевого consumer:
func (m *MatchingEngine) ProcessBatch(ctx context.Context, events []SwipeEvent) error {
// 1. Группировка лайков по target_user_id для пакетной проверки
targetToSources := make(map[string][]string)
for _, event := range events {
if event.Action == "like" {
targetToSources[event.TargetID] = append(targetToSources[event.TargetID], event.UserID)
}
}
// 2. Пакетная проверка обратных лайков через Redis Pipeline
pipe := m.redis.Pipeline()
results := make(map[string]map[string]bool) // targetID -> sourceID -> isMutual
for targetID, sourceIDs := range targetToSources {
for _, sourceID := range sourceIDs {
pipe.SIsMember(ctx, fmt.Sprintf("likes:%s", targetID), sourceID)
}
}
cmders, err := pipe.Exec(ctx)
// 3. Обработка результатов и создание матчей
var matches []MatchEvent
idx := 0
for targetID, sourceIDs := range targetToSources {
for _, sourceID := range sourceIDs {
isMutual, _ := cmders[idx].(*redis.BoolCmd).Result()
if isMutual {
matchID := uuid.New().String()
matches = append(matches, MatchEvent{
MatchID: matchID,
UserA: sourceID,
UserB: targetID,
})
}
idx++
}
}
// 4. Пакетная запись матчей
if len(matches) > 0 {
m.batchCreateMatches(ctx, matches)
m.producer.SendBatch("matches", matches)
}
return nil
}
3. Гибридный подход (рекомендуемый)
На практике используется комбинация:
- Синхронная запись свайпа в Redis и PostgreSQL — для гарантии сохранения.
- Синхронная проверка матча через Redis — для мгновенной обратной связи (если Redis доступен).
- Асинхронная реконсиляция через Kafka — для надёжности и обработки случаев, когда Redis недоступен или данные устарели.
4. Почему нужен persistence layer (Kafka), а не очередь в памяти
Очередь в памяти (channel, in-memory queue):
- При падении процесса все сообщения в очереди теряются.
- Невозможно масштагоровать — очередь привязана к одному процессу.
- Нет гарантии порядка при нескольких consumer'ах.
Kafka:
- Сообщения записываются на диск и реплицируются.
- При падении consumer'а сообщения сохраняются и будут обработаны после восстановления.
- Поддерживает consumer groups для параллельной обработки.
- Гарантия порядка внутри партиции (шардирование по user_id обеспечивает порядок свайпов одного пользователя).
5. Шардирование Kafka по партициям
Партиция определяется по user_id (или target_user_id для проверки матча):
partition := hash(userID) % numPartitions
Это гарантирует:
- Все свайпы одного пользователя попадают в одну партицию → порядок сохранён.
- Параллельная обработка разных пользователей на разных партициях.
- Масштабирование consumer group — каждый consumer обрабатывает свой набор партиций.
Вопрос 13. Как реализовать фильтрацию анкет по геолокации, полу, возрасту и исключение уже просмотренных? Как оптимизировать через кэширование?
Таймкод: 00:40:50
Ответ собеседника: Правильный. Порядок фильтрации: сначала по гео (самая селективная выборка), затем по возрасту и полу, затем исключение уже пролайканных/дизлайканных. Для оптимизации использовать key-value кэш (Redis), где ключ — ID пользователя, значение — список потенциальных партнёров (~1000 записей). Кэш обновляется по TTL (30 минут — время сеанса) или при нехватке данных. Шардирование Redis по регионам.
Правильный ответ:
Генерация ленты — самая частая операция чтения в системе. Оптимизация этого пути критически важна для производительности.
1. Порядок фильтрации (от наиболее селективной к наименее)
Порядок фильтров определяется селективностью — сколько кандидатов отсеивает каждый фильтр:
Шаг 1: Геолокация (самая селективная) Поиск пользователей в радиусе R от текущей позиции. В густонаселённом городе это ~50 000–200 000 человек в радиусе 50 км.
// Redis GEO — O(log(N))
nearby, err := redisClient.GeoRadius(ctx, "user_locations", lon, lat, &redis.GeoRadiusQuery{
Radius: 50, // км
Unit: "km",
Sort: "ASC",
Count: 500, // запас для дальнейшей фильтрации
}).Result()
Шаг 2: Пол и возраст (составной индекс в PostgreSQL)
Из найденных по гео кандидатов отфильтровываются по предпочтениям пользователя. Составной индекс (gender, age, geohash) в PostgreSQL ускоряет этот этап.
SELECT user_id, first_name, age, bio, photo_urls,
ST_Distance(location, ST_MakePoint($1, $2)::geography) AS dist
FROM user_profiles
WHERE user_id = ANY($3) -- кандидаты из гео-поиска
AND gender = $4
AND age BETWEEN $5 AND $6
AND is_active = TRUE
ORDER BY dist ASC
LIMIT 100;
Шаг 3: Исключение просмотренных (Redis SET) Из результата исключаются пользователи, которых текущий пользователь уже лайкнул или дизлайкнул.
// Пакетная проверка через Pipeline
swipedKey := fmt.Sprintf("swiped:%s", userID)
pipe := redisClient.Pipeline()
for _, candidate := range candidates {
pipe.SIsMember(ctx, swipedKey, candidate.UserID)
}
results, _ := pipe.Exec(ctx)
var filtered []Candidate
for i, candidate := range candidates {
isSwiped, _ := results[i].(*redis.BoolCmd).Result()
if !isSwiped {
filtered = append(filtered, candidate)
}
}
2. Стратегия кэширования ленты
Предвычисленная лента (Pre-computed Feed):
Для каждого пользователя заранее генерируется список кандидатов и кэшируется в Redis:
type FeedCache struct {
redis *redis.Client
ttl time.Duration // 10-15 минут
}
func (fc *FeedCache) GetFeed(ctx context.Context, userID string, cursor int, limit int) ([]string, error) {
cacheKey := fmt.Sprintf("feed:%s", userID)
// Чтение из кэша через LRANGE (список кандидатов)
ids, err := fc.redis.LRange(ctx, cacheKey, int64(cursor), int64(cursor+limit-1)).Result()
if err == nil && len(ids) >= limit {
return ids, nil // Cache hit
}
// Cache miss или недостаточно элементов — генерация
ids, err = fc.generateAndCache(ctx, userID)
if err != nil {
return nil, err
}
if cursor < len(ids) {
end := cursor + limit
if end > len(ids) {
end = len(ids)
}
return ids[cursor:end], nil
}
return nil, nil
}
func (fc *FeedCache) generateAndCache(ctx context.Context, userID string) ([]string, error) {
// 1. Получить профиль пользователя (локация, предпочтения)
profile, err := fc.getUserProfile(ctx, userID)
// 2. Поиск кандидатов по гео + фильтрация
candidates, err := fc.findCandidates(ctx, profile)
// 3. Исключение просмотренных
filtered, err := fc.excludeSwiped(ctx, userID, candidates)
// 4. Ранжирование (по расстоянию, активности, совместимости)
ranked := fc.rankCandidates(filtered, profile)
// 5. Сохранение в Redis списком
cacheKey := fmt.Sprintf("feed:%s", userID)
pipe := fc.redis.Pipeline()
pipe.Del(ctx, cacheKey)
if len(ranked) > 0 {
pipe.RPush(ctx, cacheKey, ranked)
}
pipe.Expire(ctx, cacheKey, fc.ttl)
pipe.Exec(ctx)
return ranked, nil
}
3. Инвалидация кэша
Кэш ленты инвалидируется при:
- Обновлении локации (>5 км смещение).
- Изменении предпочтений (возраст, пол, расстояние).
- Истечении TTL (10–15 минут).
- Истощении кэша (пользователь просмотрел >80% кэшированных анкет).
func (fc *FeedCache) InvalidateOnLocationUpdate(ctx context.Context, userID string, oldLat, oldLon, newLat, newLon float64) {
distance := haversine(oldLat, oldLon, newLat, newLon)
if distance > 5.0 { // более 5 км
fc.redis.Del(ctx, fmt.Sprintf("feed:%s", userID))
}
}
4. Ранжирование кандидатов
После фильтрации кандидаты ранжируются по комплексному скору:
type Candidate struct {
UserID string
Distance float64 // метры
Age int
ActiveAt time.Time // последняя активность
Score float64
}
func rankCandidates(candidates []Candidate, prefs Preferences) []Candidate {
for i := range candidates {
// Нормализованный скор: ближе = лучше, активнее = лучше
distanceScore := 1.0 / (1.0 + candidates[i].Distance/1000.0) // обратная дистанция
activityScore := 1.0 / (1.0 + time.Since(candidates[i].ActiveAt).Hours()/24.0)
ageDiff := math.Abs(float64(candidates[i].Age - prefs.PreferredAge))
ageScore := 1.0 / (1.0 + ageDiff/10.0)
candidates[i].Score = 0.5*distanceScore + 0.3*activityScore + 0.2*ageScore
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].Score > candidates[j].Score
})
return candidates
}
5. Проблема «пустой ленты»
В малонаселённых регионах или при узких предпочтениях лента может быть пустой. Стратегии:
- Постепенное расширение радиуса (50 → 100 → 200 км).
- Ослабление фильтра по возрасту (±5 лет).
- Показ «популярных» анкет из ближайших крупных городов.
- Уведомление пользователя: «Рядом с вами мало анкет, расширить поиск?»
6. Оптимизация памяти в Redis
Хранение полных анкет в Redis дорого. Вместо этого:
- В кэше ленты хранятся только
user_id(8 байт × 1000 кандидатов = 8 КБ на пользователя). - Метаданные анкет получаются пакетным запросом к PostgreSQL или read-replica.
- Фотографии загружаются отдельно через CDN по URL.
Для 20 млн DAU: 20 млн × 8 КБ = 160 ГБ в Redis. Это требует кластера из 10–20 нод по 16 ГБ.
Вопрос 14. Как уйти от квадратичной сложности алгоритма перемачивания при большом количестве лайков?
Таймкод: 00:48:34
Ответ собеседника: Правильный. Наивный алгоритм — O(n²). Оптимизация: хранить для каждого пользователя два списка — «кого он лайкнул» (likes) и «кто его лайкнул» (liked_by). При записи нового лайка предвычислять пересечение этих множеств. Пересечение двух отсортированных списков работает за O(n+m), что значительно быстрее квадратичного перебора.
Правильный ответ:
1. Проблема квадратичной сложности
Наивный подход: при каждом лайке пользователя A на пользователя B перебирать все лайки A и проверять, есть ли среди них B. Или ещё хуже — при каждом лайке перебирать все пары лайков в системе.
Для пользователя с N лайками и M входящих лайков:
- Наивный перебор всех пар: O(N × M).
- При N = M = 1000 (активный пользователь за месяц): 1 000 000 операций на каждый свайп.
2. Оптимизация через Redis SET — O(1) на проверку
Ключевое наблюдение: при лайке A→B нужно проверить только одно условие — «лайкал ли B ранее A?». Это точечный запрос, а не перебор.
// Структура данных в Redis для каждого пользователя:
// SET likes:{user_id} — множество user_id, которых лайнул user_id
// SET liked_by:{user_id} — множество user_id, которые лайкнули user_id
func (m *MatchingEngine) ProcessLike(ctx context.Context, userID, targetID string) (*MatchResult, error) {
// 1. Записать лайк: A лайкнул B
likesKey := fmt.Sprintf("likes:%s", userID)
likedByKey := fmt.Sprintf("liked_by:%s", targetID)
pipe := m.redis.Pipeline()
pipe.SAdd(ctx, likesKey, targetID)
pipe.SAdd(ctx, likedByKey, userID)
pipe.Expire(ctx, likesKey, 90*24*time.Hour)
pipe.Expire(ctx, likedByKey, 90*24*time.Hour)
_, err := pipe.Exec(ctx)
if err != nil {
return nil, err
}
// 2. Проверить взаимный лайк: лайкал ли B ранее A?
// SIsMember — O(1) средняя сложность
targetLikesKey := fmt.Sprintf("likes:%s", targetID)
isMutual, err := m.redis.SIsMember(ctx, targetLikesKey, userID).Result()
if err != nil {
return nil, err
}
if isMutual {
return &MatchResult{Match: true}, nil
}
return &MatchResult{Match: false}, nil
}
Сложность: O(1) на проверку матча (Redis SIsMember — хеш-таблица).
3. Пересечение множеств для батчевой реконсиляции
При восстановлении после сбоя или периодической реконсиляции может потребоваться найти все матчи между двумя множествами:
// Найти всех взаимные лайки для пользователя
func (m *MatchingEngine) FindAllMutualLikes(ctx context.Context, userID string) ([]string, error) {
likesKey := fmt.Sprintf("likes:%s", userID)
likedByKey := fmt.Sprintf("liked_by:%s", userID)
// Redis SINTER — пересечение множеств
// Сложность: O(N*M) где N — размер меньшего множества, M — количество множеств
// Для двух множеств: O(min(N, M))
mutualIDs, err := m.redis.SInter(ctx, likesKey, likedByKey).Result()
if err != nil {
return nil, err
}
return mutualIDs, nil
}
Redis SINTER оптимизирован: итерация идёт по меньшему множеству, и для каждого элемента проверяется принадлежность к большему (O(1) на проверку). Итого: O(min(N, M)).
4. Bloom Filter для экономии памяти
Если множество лайков велико (тысячи записей), можно использовать Bloom Filter для быстрой проверки «точно нет»:
// Вероятностная структура данных
// Если Bloom Filter говорит "нет" — точно нет
// Если говорит "да" — возможно есть (false positive ~1%)
// В Redis через модуль RedisBloom или самостоятельно
func (m *MatchingEngine) CheckMutualLikeBloom(ctx context.Context, userID, targetID string) (bool, error) {
bloomKey := fmt.Sprintf("bloom:likes:%s", targetID)
// BF.EXISTS — O(1), очень компактно (~10 бит на элемент)
return m.redis.Do(ctx, "BF.EXISTS", bloomKey, userID).Bool()
}
Bloom Filter для 10 000 элементов с 1% false positive rate: ~12 КБ. Для сравнения, Redis SET для 10 000 UUID: ~1 МБ.
5. Предвычисление матчей (Materialized View)
Для аналитических запросов («покажи все матчи пользователя») можно поддерживать materialized view:
CREATE MATERIALIZED VIEW user_matches AS
SELECT
LEAST(s1.user_id, s2.user_id) AS user_a,
GREATEST(s1.user_id, s2.user_id) AS user_b,
GREATEST(s1.created_at, s2.created_at) AS matched_at
FROM swipes s1
JOIN swipes s2 ON s1.user_id = s2.target_user_id
AND s1.target_user_id = s2.user_id
AND s1.action = 'like'
AND s2.action = 'like';
CREATE UNIQUE INDEX idx_user_matches ON user_matches (user_a, user_b);
Обновление: по расписанию (раз в минуту) или через триггер при создании матча.
6. Итоговая сложность
| Подход | Сложность проверки матча | Память на пользователя |
|---|---|---|
| Наивный перебор | O(N × M) | O(N + M) |
| Redis SET + SIsMember | O(1) | O(N + M) × ~100 байт |
| Redis SET + SINTER (батч) | O(min(N, M)) | O(N + M) × ~100 байт |
| Bloom Filter | O(1) | O(N) × ~10 бит |
Рекомендуемый подход: Redis SET для точных проверок + Bloom Filter для быстрого отсеивания при батчевой обработке.
Вопрос 15. Какова итоговая архитектура системы и насколько она отказоустойчива и масштабируема для глобального масштабирования?
Таймкод: 00:51:46
Ответ собеседника: Правильный. Архитектура: WebSocket Gateway (кластер, stateless, ~17 CPU на Go для 8500 соединений) → сервис Profiles (PostgreSQL + Redis кэш) и сервис Matching (Kafka + key-value NoSQL БД). Фото в S3 + CDN. Все компоненты stateless и горизонтально масштабируемы. PostgreSQL шардируется по гео при глобальном масштабировании. Отказоустойчивость: Kafka сохраняет сообщения, Redis перестраивает кэш из БД, реплики БД. Аналитика, логирование и мониторинг подразумеваются по умолчанию. Архитектура способна масштабироваться на весь мир.
Правильный ответ:
1. Итоговая архитектура для России (текущий масштаб)
┌─────────────────────────────────────────────────────────────────────────┐
│ Клиенты │
│ iOS Android Web │
└──────┬──────────────┬──────────────┬────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ CDN (3+ PoP: Москва, СПб, Новосибирск) │
│ Фотографии, статика, edge-ресайз │
└──────────────────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ API Gateway (3 региона) │
│ Auth, Rate Limiting, Routing, SSL, Circuit Breaker │
└───┬────────────────┬─────────────────┬───────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────────┐ ┌──────────────────┐
│ Feed │ │ Swipe │ │ WebSocket │
│ Service │ │ Service │ │ Gateway │
│ (10 │ │ (15 │ │ (20 инстансов) │
│ инстан- │ │ инстансов) │ │ │
│ сов) │ │ │ │ │
└────┬────┘ └──────┬───────┘ └────────┬─────────┘
│ │ │
▼ ▼ │
┌─────────┐ ┌──────────────┐ │
│ Redis │ │ Matching │ │
│ Cluster │ │ Engine │ │
│ (6 нод) │ │ (10 │ │
│ │ │ инстансов) │ │
│ GEO, │ └──────┬───────┘ │
│ Cache, │ │ │
│ Likes │ ▼ ▼
│ SET │ ┌──────────────┐ ┌──────────────┐
└─────────┘ │ Kafka │ │ Redis │
│ (6 брокеров)│ │ Pub/Sub │
└──────┬───────┘ └──────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────────┐ ┌────────────────┐
│ Push │ │ Kafka │ │ ClickHouse │
│ Notif. │ │ (внешняя) │ │ (аналитика) │
│ Service │ │ → Чат │ │ │
└─────────┘ └──────────────┘ └────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ │
│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ S3 / MinIO │ │
│ │ (Master + 3 │ │ Cluster │ │ (3 региона) │ │
│ │ Replicas) │ │ (6 нод) │ │ │ │
│ │ │ │ │ │ │ │
│ │ Профили: 200 ГБ │ │ GEO, Cache, │ │ Фото: 120 ТБ │ │
│ │ Свайпы: 2.7 ТБ │ │ Likes, │ │ (с репликацией) │ │
│ │ Матчи: 18 ГБ │ │ Sessions │ │ │ │
│ └──────────────────┘ └──────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
2. Отказоустойчивость по компонентам
API Gateway: Stateless, развёрнут в 3 availability zones. При падении одного инстанса — балансировщик перенаправляет трафик. SLA: 99.99%.
Feed Service / Swipe Service / Matching Engine: Stateless, горизонтально масштабируемые. При падении инстанса — Kubernetes перезапускает pod, трафик перераспределяется. Никакой потери данных, так как состояние хранится в БД и Redis.
PostgreSQL:
- Master + 3 read-replicas (по одному в каждом AZ).
- Автоматический failover через Patroni (время переключения: 10–30 секунд).
- WAL archiving для point-in-time recovery.
- При падении master — один из реплик становится master'ом, приложения переключаются через service discovery.
- RPO (Recovery Point Objective): < 1 минуты (потери данных при synchronous replication — 0).
- RTO (Recovery Time Objective): < 30 секунд.
Redis:
- Cluster mode: 3 master + 3 replica.
- При падении master — автоматический failover на replica.
- При полной потере Redis — кэш перестраивается из PostgreSQL (деградация производительности, но система работает).
- Данные лайков в Redis — дублируются в PostgreSQL, поэтому потеря Redis не означает потерю данных.
Kafka:
- 6 брокеров, replication factor 3, min.insync.replicas 2.
- При падении 1–2 брокеров — кластер продолжает работать.
- Сообщения сохраняются на диске (retention: 7 дней).
- При падении consumer'а — сообщения накапливаются и обрабатываются после восстановления.
WebSocket Gateway:
- 20 инстансов, каждый держит ~250 000 соединений.
- При падении инстанса — клиенты переподключаются к другому инстансу (автоматически на уровне мобильного SDK).
- Состояние соединений не сохраняется — это нормально, клиент авторизуется заново.
3. Масштабирование для глобального использования
При выходе на глобальный рынок (100+ стран, 1+ млрд пользователей) архитектура эволюционирует:
Географическое шардирование PostgreSQL: Данные шардируются по регионам (EU, US, Asia, etc.):
func GetShard(userID string) string {
region := getRegionFromUserID(userID)
switch region {
case "EU":
return "postgres-eu-master"
case "US":
return "postgres-us-master"
case "ASIA":
return "postgres-asia-master"
}
}
Каждый регион — кластер PostgreSQL с локальными репликами. Между регионами — асинхронная репликация для пользователей, которые путешествуют.
Kafka MirrorMaker 2: Репликация топиков между регионами для кросс-региональных матчей (пользователь из России лайкает пользователя из Германии при путешествии).
Redis Cluster по регионам: Каждый регион — свой кластер Redis. Данные не реплицируются между регионами (кроме GEO-индекса для путешественников).
Глобальный CDN: Edge-серверы в 50+ точках присутствия для минимальной загрузки фотографий.
4. Деградация при сбоях (Graceful Degradation)
| Компонент | Сбой | Поведение системы |
|---|---|---|
| Redis недоступен | Лента генерируется из PostgreSQL (медленнее, но работает). Проверка матча через PostgreSQL. | |
| Kafka недоступна | Свайпы записываются в PostgreSQL с status='pending'. Матчи обнаруживаются с задержкой через reconciliation job. | |
| PostgreSQL master недоступен | Failover на replica (30 сек). Запись заблокирована на это время, чтение с реплик продолжается. | |
| CDN недоступен | Фотографии загружаются напрямую из S3 (медленнее, но работает). | |
| WebSocket Gateway недоступен | Клиенты переподключаются. Push-уведомления приходят с задержкой. |
5. Мониторинг и наблюдаемость
- Метрики: Prometheus + Grafana. Ключевые метрики: RPS, latency (p50, p95, p99), error rate, cache hit ratio, Kafka consumer lag, PostgreSQL replication lag.
- Логирование: Централизованное (ELK/Loki) с correlation ID для трассировки запроса через все сервисы.
- Трейсинг: Jaeger для распределённой трассировки.
- Алертинг: PagerDuty/Opsgenie для критических алертов (latency > 500ms, error rate > 1%, Kafka lag > 10000).
- SLO: p99 latency < 200ms для ленты, < 100ms для свайпа, доступность 99.95%.
6. Итоговая оценка ресурсов (Россия)
| Компонент | Инстансы | CPU | RAM | Хранилище |
|---|---|---|---|---|
| API Gateway | 6 | 4 CPU | 8 ГБ | — |
| Feed Service | 10 | 4 CPU | 8 ГБ | — |
| Swipe Service | 15 | 4 CPU | 8 ГБ | — |
| Matching Engine | 10 | 4 CPU | 8 ГБ | — |
| WebSocket Gateway | 20 | 8 CPU | 16 ГБ | — |
| PostgreSQL | 4 (1M+3R) | 16 CPU | 64 ГБ | 3 ТБ SSD |
| Redis | 6 | 8 CPU | 32 ГБ | — |
| Kafka | 6 | 8 CPU | 32 ГБ | 10 ТБ |
| S3 | — | — | — | 120 ТБ |
Архитектура полностью горизонтально масштабируема: добавление инстансов сервисов и нод БД пропорционально увеличивает пропускную способность.
