Перейти к основному содержимому

Публичное собеседование по System Design проектируем публичный чат (Денис Костоусов, Николай Марков)

· 39 мин. чтения

Сегодня мы разберём публичное собеседование по системному дизайну, в ходе которого интервьюер Денис Костоусов выступал в роли заказчика, а кандидат Николай Марков проектировал архитектуру массового чат-приложения, рассчитанного на миллион пользователей. Собеседование проходило в формате живого диалога с постепенным углублением от сбора бизнес-требований к детальной проработке компонентов системы: авторизации, хранения сообщений, маршрутизации, очередей доставки и геораспределения. Несмотря на некоторые замечания зрителей о недостаточной детализации в середине интервью, кандидат продемонстрировал сильные навыки системного мышления, умение задавать уточняющие вопросы и выстраивать масштабируемую архитектуру в условиях неопределённости.

Вопрос 1. Расскажите немного о себе.

Таймкод: 00:00:14

Ответ собеседника: Правильный. Работает в Тинькофф, центр разработки, занимается системным дизайном, последние полгода регулярно проводит дизайн-ревью и публичные собеседования.

Правильный ответ:

Отличный формат самопрезентации — кратко, по делу, с указанием ключевых активностей. Для позиции Go-разработчика рекомендуется структурировать ответ следующим образом:

Структура самопрезентации для Go-разработчика

  1. Текущая роль и компания — название компании, команда, основной стек технологий
  2. Ключевой опыт — сколько лет в разработке, сколько именно с Go, какие типы систем строил (микросервисы, высоконагруженные системы, распределённые системы)
  3. Зона ответственности и достижения — что конкретно делал, какие результаты принёс (например, снизил latency на 40%, спроектировал систему обработки N тысяч RPS)
  4. Смежные компетенции — code review, менторинг, системный дизайн, участие в собеседованиях (как у кандидата — это сильный плюс, говорит о зрелости)
  5. Что ищу / чем хочу заниматься дальше — показывает осознанность и мотивацию

Пример развёрнутого ответа:

«Я работаю Go-разработчиком около 4 лет, из них последние 2 года в компании X. Основной фокус — проектирование и разработка высоконагруженных микросервисов на Go. За это время спроектировал и реализовал систему обработки событий, которая обрабатывает порядка 50 000 событий в секунду с p99 latency менее 50 мс. Активно участвую в code review и design review, последний год провожу технические собеседования. Также занимаюсь менторингом джуниор-разработчиков. Интересуюсь распределёнными системами, оптимизацией производительности и углублением в архитектурные решения.»

Важные моменты:

  • Не стоит перечислять всё резюме — интервьюер его уже видел
  • Акцент на технических достижениях с цифрами производит сильное впечатление
  • Участие в собеседованиях и design review (как у кандидата) — это признак senior-уровня, стоит подчёркивать
  • Ответ должен занимать 1–2 минуты, не больше

Вопрос 2. Без чего вы не представляете начало своего рабочего дня?

Таймкод: 00:01:25

Ответ собеседника: Правильный. Проверить сообщения в Telegram, выпить кофе. Упомянул традицию виртуальных утренних посиделок с коллегами — вся компания собирается онлайн за кофе и общается.

Правильный ответ:

Это вопрос на культурное соответствие и самоорганизацию. Ответ кандидата хороший — он показывает, что человек социально адаптирован в команде и имеет устоявшийся ритм. Для Go-разработчика на техническом интервью этот вопрос обычно задают в начале, чтобы снять напряжение и понять, как человек вписывается в командную культуру.

Типичные сильные ответы для разработчика:

  1. Кофе + приоритизация задач — проверить мессенджеры/почту, посмотреть доску задач (Jira, Trello), определить приоритеты на день. Показывает дисциплинированный подход к работе.

  2. Кофе + мониторинг — если человек отвечает за продакшен-системы, логично начинать день с проверки дашбордов Grafana, алертов, состояния сервисов. Это демонстрирует ответственность за надёжность системы.

  3. Кофе + code review — некоторые разработчики предпочут начать день с разбора pull request'ов, пока голова свежая и нет контекст-свитчинга на свои задачи.

  4. Кофе + командный ритуал — как у кандидата. Виртуальные посиделки, стендапы, синхронизация с командой. Это важно для удалённых команд и говорит о хорошей интеграции в коллектив.

Что оценивает интервьюер:

  • Есть ли у человека устоявшийся рабочий ритм
  • Насколько он командно-ориентирован
  • Показывает ли ответ проактивность (проверка мониторинга, приоритизация) или реактивность (просто «отвечаю на сообщения»)
  • Культурное соответствие — в IT-компаниях культура «кофе + неформальное общение» очень распространена

Рекомендация для подготовки: Отвечайте искренне, но если есть возможность — добавьте технический элемент (проверка алертов, просмотр метрик, code review). Это покажет, что вы думаете не только о своём комфорте, но и о качестве системы.

Вопрос 3. Что должен сказать коллега, чтобы вас порадовать?

Таймкод: 00:03:19

Ответ собеседника: Правильный. «Релиз прошёл очень успешно, теперь можно отдыхать» — это то, что порадует больше всего.

Правильный ответ:

Это вопрос на мотивацию и ценности. Ответ кандидата абсолютно правильный и показывает, что человек ориентирован на результат и переживает за качество релизов. Это типичный и сильный ответ для практикующего разработчика.

Что оценивает интервьюер:

  1. Ориентация на результат — радость от успешного релиза показывает, что человек видит конечную цель своей работы и получает удовлетворение от доставленного до пользователя значения

  2. Командность — успешный релиз — это всегда командная работа, и радость от этого говорит о зрелом отношении к коллективному труду

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

Другие примеры сильных ответов:

  • «Твой PR смержен без замечаний» — показывает стремление к качеству кода
  • «Клиенты не заметили миграцию» — для тех, кто ценит бесшовные изменения
  • «Твой сервис отработал без единого алерта за неделю» — для SRE-ориентированных разработчиков
  • «Мы закрыли технический долг по этому модулю» — для тех, кто заботится о здоровье кодовой базы

Чего стоит избегать:

  • Ответов вроде «Мне повысили зарплату» — хотя это честно, но создаёт впечатление, что мотивация чисто материальная
  • Ответов вроде «Ничего, я всегда в хорошем настроении» — выглядит поверхностно и не раскрывает личность
  • Слишком общих фраз без контекста разработки

Вывод: Ответ кандидата — один из лучших возможных. Он простой, человеческий и при этом профессионально релевантный. Именно так и стоит отвечать — искренне и в контексте работы разработчика.

Вопрос 4. Каковы основные требования к проектируемому чату: масштаб, количество пользователей, функциональность?

Таймкод: 00:05:07

Ответ собеседника: Правильный. Чат на миллион пользователей, комнаты до 10 тысяч человек одновременно, поддержка текста, видео, вложений. Использование для трансляций. Мобильное приложение — отдельная команда.

Правильный ответ:

Отличный ответ — кандидат чётко обозначил ключевые требования. Для полноты системного дизайна чата на таком масштабе стоит рассмотреть требования более структурированно.

Функциональные требования:

  • Обмен сообщениями — текст, медиа (фото, видео, файлы), эмодзи/реакции, цитирование сообщений, редактирование и удаление
  • Комнаты/каналы — создание комнат, до 10 000 участников одновременно, роли и права (админ, модератор, участник), приватные и публичные комнаты
  • Трансляции — стриминг видео в реальном времени, чат поверх трансляции с высокой пропускной способностью, задержка минимальная
  • История сообщений — поиск по истории, пагинация, хранение истории (навсегда или с TTL)
  • Уведомления — push-уведомления, упоминания (@username), настройки уведомлений на уровне комнаты
  • Онлайн-статус — отображение присутствия пользователей (online/offline/typing)

Нефункциональные требования:

  • Масштаб — 1 000 000 пользователей, 10 000 одновременно в одной комнате. При трансляциях возможен всплеск до 100 000+ зрителей
  • Latency — доставка сообщений в пределах 200 мс для текстовых сообщений (p99), для трансляций — буферизация 2–5 секунд допустима
  • Доступность — 99.9% uptime, деградация функциональности при частичном отказе, а не полная недоступность
  • Надёжность доставки — at-least-once delivery для сообщений, гарантия порядка сообщений в рамках одной комнаты
  • Безопасность — E2E-опционально, авторизация через OAuth/JTLS, модерация контента, rate limiting

Оценка нагрузки (back-of-the-envelope):

  • 1 000 000 пользователей, из них ~100 000 онлайн одновременно
  • Среднее количество сообщений: 10 на пользователя в минуту → ~1 667 сообщений/сек
  • Пиковые нагрузки (трансляции): до 10 000 сообщений/сек в одной комнате
  • Трафик медиа: средний размер вложения 2 МБ, ~1000 загрузок/мин → ~33 МБ/с
  • Хранение: ~50 ГБ текстовых сообщений в день, ~5 ТБ медиа в день

Ключевые архитектурные решения:

  • WebSocket-соединения для real-time доставки сообщений
  • Pub/Sub брокер (Kafka, NATS, Redis Streams) для маршрутизации сообщений
  • Шардирование комнат по серверам для горизонтального масштабирования
  • CDN для медиа-контента
  • Отдельный сервис для трансляций (WebRTC или HLS/DASH)
  • Горизонтальное масштабирование WebSocket-серверов с использованием sticky sessions или маршрутизации через балансировщик

Вопрос 5. Какой максимальный размер текстового сообщения планируется?

Таймкод: 00:09:06

Ответ собеседника: Правильный. До 5 килобайт на одно текстовое сообщение.

Правильный ответ:

Отличный ответ — 5 КБ (примерно 5120 байт) это разумный лимит для текстового сообщения в чате. Давайте разберём, почему это хорошее значение и какие нюансы стоит учитывать.

Обоснование лимита в 5 КБ:

  • В UTF-8 один символ занимает от 1 до 4 байт. 5 КБ ≈ 1280–5120 символов в зависимости от языка
  • Для русского текста: ~2500 символов, что соответствует примерно 400–500 словам — это уже очень длинное сообщение
  • Для английского текста: ~5000 символов, примерно 800–1000 слов
  • Средняя длина сообщения в популярных мессенджерах: 20–50 символов, так что 5 КБ с большим запасом покрывает 99.9% сообщений

Сравнение с реальными системами:

  • Telegram — лимит 4096 символов для обычных сообщений, до 4096 байт в зависимости от кодировки
  • WhatsApp — лимит 65536 символов
  • Slack — лимит 40000 символов
  • Discord — лимит 2000 символов (можно увеличить до 40000 для Nitro)

Почему важно устанавливать лимит:

  1. Защита от злоупотреблений — без лимита пользователь может отправить мегабайты «текста», создавая нагрузку на систему
  2. Производительность рендеринга — UI должен рендерить сообщения быстро, огромные блоки текста тормозят прокрутку
  3. Размер WebSocket-фрейма — каждое сообщение передаётся как один фрейм, и слишком большие фреймы увеличивают latency
  4. Хранение в базе данных — при 1000 сообщений/сек и 5 КБ на сообщение получаем ~5 МБ/с → ~430 ГБ/день только текста
  5. Индексация и поиск — полнотекстовый поиск по огромным сообщениям неэффективен

Рекомендации по реализации на Go:

const MaxMessageSize = 5 * 1024 // 5 KB

func (s *ChatServer) handleMessage(conn *websocket.Conn, raw []byte) error {
if len(raw) > MaxMessageSize {
return fmt.Errorf("message exceeds maximum size of %d bytes", MaxMessageSize)
}

// Дополнительная валидация: проверка на пустое сообщение
if len(strings.TrimSpace(string(raw))) == 0 {
return fmt.Errorf("empty message")
}

// Обработка сообщения...
return nil
}

Дополнительные валидации:

  • Проверка на пустые сообщения или сообщения из одних пробелов
  • Rate limiting на отправку сообщений (например, не более 10 сообщений в секунду от одного пользователя)
  • Проверка на спам-паттерны (повторяющиеся символы, одно и то же сообщение много раз)
  • Валидация кодировки — убедиться, что текст валидный UTF-8

Вывод: 5 КБ — это зрелое и обоснованное решение. Оно достаточно велико для комфортного общения и достаточно мало для защиты системы от злоупотреблений.

Вопрос 6. Нужна ли функция поиска по истории сообщений и как долго хранить историю?

Таймкод: 00:09:54

Ответ собеседника: Правильный. Поиск по истории нужен, особенно для делового общения. Хранить предпочтительно бессрочно, но есть вопросы к стоимости хранения.

Правильный ответ:

Кандидат верно подметил ключевые аспекты. Давайте разберём тему подробнее.

Необходимость поиска по истории:

Поиск по истории — критически важная фича для деловых чатов. Пользователи должны находить конкретные решения, ссылки, договорённости, обсуждённые ранее. Без поиска история превращается в мусорную корзину.

Стратегия хранения истории:

Бессрочное хранение — идеальный вариант для деловых чатов. Реализуется через многоуровневое хранение:

  • Горячие данные (последние 30 дней) — быстрое хранилище (SSD, in-memory кэш), мгновенный доступ
  • Тёплые данные (30 дней — 1 год) — стандартное хранилище (HDD), доступ за 100–500 мс
  • Холодные данные (более 1 года) — объектное хранилище (S3, GCS), доступ за 1–5 секунд, но значительно дешевле

Оценка стоимости хранения:

При 1 000 000 пользователей, 10 сообщений/пользователь/день, средний размер 500 байт:

  • В день: 1 000 000 × 10 × 500 = ~5 ГБ
  • В год: ~1.8 ТБ
  • Стоимость на S3: ~0.023/ГБ/мес 0.023/ГБ/мес → ~420/мес для годового объёма

Это вполне приемлемо для бизнес-приложения.

Архитектура поиска:

Для полнотекстового поиска оптимально использовать Elasticsearch или Meilisearch:

type Message struct {
ID string `json:"id"`
RoomID string `json:"room_id"`
UserID string `json:"user_id"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
MessageType string `json:"message_type"` // text, image, file
}

func (s *SearchService) SearchMessages(ctx context.Context, query string, roomID string, from, to time.Time) ([]Message, error) {
// Elasticsearch запрос с фильтрацией по комнате и диапазону дат
esQuery := map[string]interface{}{
"query": map[string]interface{}{
"bool": map[string]interface{}{
"must": []map[string]interface{}{
{"match": map[string]interface{}{"content": query}},
{"term": map[string]interface{}{"room_id": roomID}},
{"range": map[string]interface{}{
"timestamp": map[string]interface{}{
"gte": from.Format(time.RFC3339),
"lte": to.Format(time.RFC3339),
},
}},
},
},
},
"size": 50,
"sort": []map[string]interface{}{
{"timestamp": map[string]interface{}{"order": "desc"}},
},
}
// Выполнение запроса к Elasticsearch...
}

Индексация сообщений:

Сообщения индексируются асинхронно через очередь (Kafka/NATS):

func (s *Indexer) HandleMessageEvent(ctx context.Context, event MessageEvent) error {
doc := MessageDocument{
ID: event.MessageID,
RoomID: event.RoomID,
UserID: event.UserID,
Content: event.Content,
Timestamp: event.Timestamp,
}

_, err := s.esClient.Index().
Index("chat_messages").
Id(doc.ID).
BodyJson(doc).
Do(ctx)

return err
}

Дополнительные фичи поиска:

  • Поиск по отправнику: from:username ключевое_слово
  • Поиск по типу контента has:link, has:image, has:file
  • Фильтрация по диапазону дат
  • Подсветка найденных фрагментов (highlighting)
  • Автодополнение и подсказки при вводе поискового запроса

Вывод: Бессрочное хранение с многоуровневой архитектурой и Elasticsearch для поиска — это зрелое решение, которое балансирует между удобством пользователей и стоимостью инфраструктуры.

Вопрос 7. Где планируется развёртывать инфраструктуру: на собственном железе или в публичном облаке?

Таймкод: 00:11:17

Ответ собеседника: Правильный. Публичное облако (AWS) с преимуществами автоскейлинга, мониторинга, облачных сервисов. Лёгкое масштабирование и возможность миграции между облаками.

Правильный ответ:

Отличный ответ. Для чата с 1 000 000 пользователями публичное облако — это практически безальтернативный выбор. Давайте разберём почему и какие конкретные сервисы стоит использовать.

Почему публичное облако для чата:

  1. Непредсказуемый трафик — чаты имеют пиковые нагрузки (утренние часы, время трансляций). Автоскейлинг в облаке покрывает это без избыточного резервирования
  2. WebSocket-соединения — требуют стабильной сети и горизонтального масштабирования, что проще в облаке
  3. Глобальная доступность — пользователи могут быть в разных регионах, облако даёт размещение в дата-центрах по всему миру
  4. Управляемые сервисы — не нужно тратить ресурсы на администрирование Kafka, Elasticsearch, баз данных

Рекомендуемый стек на AWS:

  • Compute — EKS (Kubernetes) или ECS Fargate для контейнеризированных сервисов
  • WebSocket — API Gateway WebSocket или ALB с target groups для балансировки WS-соединений
  • Messaging — Amazon MSK (managed Kafka) или Amazon SQS/SNS для маршрутизации сообщений
  • Database — Amazon DynamoDB или Aurora PostgreSQL для метаданных комнат и пользователей
  • Search — Amazon OpenSearch (managed Elasticsearch) для полнотекстового поиска
  • Storage — S3 для медиа-файлов, CloudFront как CDN
  • Monitoring — CloudWatch, X-Ray, Prometheus + Grafana через managed сервисы
  • Autoscaling — KEDA (Kubernetes Event-Driven Autoscaling) для масштабирования по метрикам (длина очереди Kafka, количество WS-соединений)

Мультиоблачная стратегия:

Кандидат правильно упомянул возможность миграции между облаками. Для этого стоит использовать:

  • Kubernetes как абстракцию от облачного провайдера
  • Terraform/Pulumi для инфраструктуры как кода
  • Managed сервисы через абстракции — например, использовать Kafka (через Strimzi operator), а не Amazon MSK напрямую
// Абстракция для работы с очередью сообщений — легко переключить между провайдерами
type MessageBroker interface {
Publish(ctx context.Context, topic string, message []byte) error
Subscribe(ctx context.Context, topic string, handler MessageHandler) error
Close() error
}

// Реализация для Kafka
type KafkaBroker struct {
writer *kafka.Writer
reader *kafka.Reader
}

func NewKafkaBroker(brokers []string) (*KafkaBroker, error) {
writer := &kafka.Writer{
Addr: kafka.TCP(brokers...),
Balancer: &kafka.LeastBytes{},
Async: true,
}
return &KafkaBroker{writer: writer}, nil
}

Ориентировочная стоимость инфраструктуры на AWS:

  • EKS кластер (10–20 нод): ~$2000–5000/мес
  • MSK (Kafka): ~$1000–2000/мес
  • OpenSearch: ~$1000–3000/мес
  • DynamoDB/Aurora: ~$500–1500/мес
  • S3 + CloudFront: ~$500–1000/мес
  • Итого: ~$5000–12500/мес для 1 000 000 пользователей

Вывод: Публичное облако — правильный выбор для старта и масштабирования. Kubernetes + managed сервисы дают баланс между гибкостью и операционной простотой.

Вопрос 8. Как будет организована авторизация и аутентификация пользователей в чате?

Таймкод: 00:12:42

Ответ собеседника: Правильный. Базовый сервис авторизации через API, поддержка OAuth/OIDC (Google, ВКонтакте), собственная регистрация. Провайдер передаёт профиль с согласия пользователя.

Правильный ответ:

Отличный ответ — кандидат охватил ключевые аспекты. Давайте разберём архитектуру аутентификации для чата подробнее.

Архитектура аутентификации:

OAuth 2.0 / OpenID Connect — стандарт для интеграции с внешними провайдерами. Пользователь авторизуется у провайдера (Google, VK), получаем токен с базовой информацией профиля.

JWT (JSON Web Token) — для передачи аутентификации между микросервисами. После успешной авторизации через OAuth, наш сервис авторизации выпускает JWT, который клиент использует для последующих запросов.

Flow аутентификации:

  1. Клиент инициирует OAuth flow → редирект на провайдера
  2. Пользователь авторизуется у провайдера → получаем authorization code
  3. Обмениваем code на access token у провайдера
  4. Запрашиваем профиль пользователя (email, имя, аватар)
  5. Создаём/находим пользователя в нашей системе
  6. Выпускаем собственный JWT с claims (user_id, email, roles)
  7. Клиент использует JWT для всех запросов и для установки WebSocket-соединения

Реализация на Go:

type AuthService struct {
db *sql.DB
jwtSecret []byte
oauthConfigs map[string]*oauth2.Config // google, vk, etc.
}

type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Provider string `json:"provider"`
CreatedAt time.Time `json:"created_at"`
}

type TokenClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Rooms []string `json:"rooms"` // комнаты, в которых состоит пользователь
jwt.RegisteredClaims
}

func (s *AuthService) HandleOAuthCallback(ctx context.Context, provider, code string) (string, error) {
// Обмен authorization code на token
token, err := s.oauthConfigs[provider].Exchange(ctx, code)
if err != nil {
return "", fmt.Errorf("oauth exchange failed: %w", err)
}

// Получение профиля пользователя
userInfo, err := s.fetchUserInfo(ctx, provider, token)
if err != nil {
return "", fmt.Errorf("fetch user info failed: %w", err)
}

// Поиск или создание пользователя
user, err := s.findOrCreateUser(ctx, userInfo, provider)
if err != nil {
return "", fmt.Errorf("find or create user failed: %w", err)
}

// Выпуск JWT
return s.generateJWT(user)
}

func (s *AuthService) generateJWT(user *User) (string, error) {
claims := TokenClaims{
UserID: user.ID,
Email: user.Email,
Rooms: []string{}, // заполняется из БД
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "chat-service",
},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}

// Middleware для проверки JWT
func JWTMiddleware(secretKey []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := extractToken(r)
if tokenString == "" {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}

claims := &TokenClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return secretKey, nil
})

if err != nil || !token.Valid {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}

ctx := context.WithValue(r.Context(), "user", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

Аутентификация WebSocket-соединений:

WebSocket не поддерживает заголовки напрямую, поэтому токен передаётся через query-параметр или первое сообщение:

func (s *ChatServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")

claims, err := s.authService.ValidateToken(r.Context(), token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

conn, err := websocket.Upgrade(w, r, nil)
if err != nil {
return
}

client := &Client{
conn: conn,
userID: claims.UserID,
rooms: make(map[string]bool),
}

s.register <- client
}

Хеширование паролей (для собственной регистрации):

import "golang.org/x/crypto/bcrypt"

func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}

func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

Безопасность:

  • JWT с коротким TTL (1–24 часа) + refresh token для обновления
  • HTTPS обязательно для всех соединений
  • Rate limiting на эндпоинты авторизации
  • Хранение паролей через bcrypt/argon2
  • CSRF-защита для OAuth flow
  • Валидация redirect_uri для предотвращения атак

Вывод: OAuth 2.0/OIDC + JWT — это стандартная и безопасная архитектура. Кандидат верно определил все ключевые компоненты системы аутентификации.

Вопрос 9. Будут ли в чате поддерживаться личные сообщения между двумя пользователями или только групповые комнаты?

Таймкод: 00:15:49

Ответ собеседника: Правильный. Помимо групповых комнат, планируется поддержка личных сообщений (чат один на один). Два типа чатов: групповые комнаты и приватные диалоги.

Правильный ответ:

Ответ правильный. Поддержка обоих типов чатов — стандартное требование для современных мессенджеров. Давайте разберём архитектурные нюансы.

Два типа чатов — одна сущность:

С точки зрения данных, личные сообщения и групповые комнаты — это по сути одна и та же сущность «комната» (room/channel). Разница только в количестве участников и метаданных:

type Room struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` // для групповых комнат
Type RoomType `json:"type" db:"type"` // "direct" или "group"
CreatorID string `json:"creator_id" db:"creator_id"`
MaxMembers int `json:"max_members" db:"max_members"` // 2 для DM, 10000 для групповых
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

type RoomType string

const (
RoomTypeDirect RoomType = "direct"
RoomTypeGroup RoomType = "group"
)

type RoomMember struct {
RoomID string `json:"room_id" db:"room_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"` // "owner", "admin", "member"
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
}

Ключевые различия между DM и групповыми комнатами:

Личные сообщения (DM):

  • Ровно 2 участника
  • Нельзя добавлять/удалять участников
  • Нет ролей (оба участника равноправны)
  • Имя комнаты — производное от имён участников
  • Онлайн-статус собеседника важен
  • Индикатор прочтения (read receipts) особенно важен

Групповые комнаты:

  • До 10 000 участников
  • Роли: owner, admin, member
  • Управление участниками: приглашение, бан, мут
  • Настройки комнаты: приватная/публичная, описание, аватар
  • Для трансляций: режим «только чтение» для большинства участников

Оптимизация для DM — идемпотентность создания:

При создании личного чата между двумя пользователями важно не создавать дубликаты. Используем детерминированный ID комнаты:

func GenerateDirectRoomID(userID1, userID2 string) string {
// Сортируем ID, чтобы комната между A и B была такой же, как между B и A
ids := []string{userID1, userID2}
sort.Strings(ids)
return fmt.Sprintf("dm_%s_%s", ids[0], ids[1])
}

func (s *RoomService) GetOrCreateDirectRoom(ctx context.Context, userID1, userID2 string) (*Room, error) {
roomID := GenerateDirectRoomID(userID1, userID2)

// Пытаемся найти существующую комнату
room, err := s.db.GetRoom(ctx, roomID)
if err == nil {
return room, nil
}

// Если не найдена — создаём
room = &Room{
ID: roomID,
Type: RoomTypeDirect,
CreatorID: userID1,
MaxMembers: 2,
CreatedAt: time.Now(),
}

err = s.db.CreateRoom(ctx, room)
if err != nil {
// Возможно, комната уже создана другим запросом — пытаемся найти снова
return s.db.GetRoom(ctx, roomID)
}

// Добавляем обоих участников
s.db.AddRoomMember(ctx, roomID, userID1, "member")
s.db.AddRoomMember(ctx, roomID, userID2, "member")

return room, nil
}

SQL-схема:

CREATE TABLE rooms (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255),
type VARCHAR(10) NOT NULL CHECK (type IN ('direct', 'group')),
creator_id VARCHAR(64) NOT NULL REFERENCES users(id),
max_members INTEGER NOT NULL DEFAULT 10000,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE room_members (
room_id VARCHAR(64) REFERENCES rooms(id) ON DELETE CASCADE,
user_id VARCHAR(64) REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member',
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (room_id, user_id)
);

CREATE INDEX idx_room_members_user ON room_members(user_id);

CREATE TABLE messages (
id VARCHAR(64) PRIMARY KEY,
room_id VARCHAR(64) NOT NULL REFERENCES rooms(id),
user_id VARCHAR(64) NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
content_type VARCHAR(20) NOT NULL DEFAULT 'text',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);

CREATE INDEX idx_messages_room_time ON messages(room_id, created_at DESC);

Маршрутизация сообщений:

Для DM сообщение маршрутизируется напрямому получателю. Для групповых — всем участникам комнаты:

func (s *MessageRouter) RouteMessage(ctx context.Context, msg *Message) error {
room, err := s.roomService.GetRoom(ctx, msg.RoomID)
if err != nil {
return err
}

members, err := s.roomService.GetMembers(ctx, msg.RoomID)
if err != nil {
return err
}

// Публикуем сообщение в топик Kafka, специфичный для комнаты
topic := fmt.Sprintf("room.%s", room.ID)
return s.broker.Publish(ctx, topic, msg.ToJSON())
}

Вывод: Единая модель комнаты с типом (direct/group) — это правильный подход. Это упрощает код, хранение и маршрутизацию сообщений, при этом позволяя иметь разную логику для разных типов чатов.

Вопрос 10. Нужно ли отслеживать статус онлайн/оффлайн пользователей и как это реализовать?

Таймкод: 00:16:40

Ответ собеседника: Правильный. Отслеживание статуса — полезный функционал. Реализация через heartbeat-сообщения (пинг) от клиента. Если пинг не пришёл в течение тайм-аута — статус оффлайн.

Правильный ответ:

Отличный ответ. Heartbeat-механизм — это стандартный подход. Давайте разберём реализацию подробнее.

Зачем нужен статус онлайн:

  • Пользователи видят, кто сейчас доступен для общения
  • В DM — индикатор «собеседник печатает» и «прочитано» зависят от статуса
  • В групповых комнатах — список активных участников
  • Для бизнес-чатов — понимание, кто на смене/доступен

Архитектура heartbeat:

WebSocket heartbeat (ping/pong): WebSocket протокол имеет встроенный механизм ping/pong фреймов, но он работает на уровне протокола и не доходит до приложения. Поэтому приложение реализует свой heartbeat:

type Client struct {
conn *websocket.Conn
userID string
lastActivity time.Time
rooms map[string]bool
send chan []byte
mu sync.RWMutex
}

type HeartbeatManager struct {
clients map[string]*Client // userID -> client
timeout time.Duration
interval time.Duration
mu sync.RWMutex
}

func NewHeartbeatManager(timeout, interval time.Duration) *HeartbeatManager {
hm := &HeartbeatManager{
clients: make(map[string]*Client),
timeout: timeout,
interval: interval,
}
go hm.checkLoop()
return hm
}

func (hm *HeartbeatManager) Register(userID string, client *Client) {
hm.mu.Lock()
hm.clients[userID] = client
hm.mu.Unlock()

// Публикуем событие "пользователь онлайн"
hm.publishStatus(userID, "online")
}

func (hm *HeartbeatManager) UpdateActivity(userID string) {
hm.mu.RLock()
client, exists := hm.clients[userID]
hm.mu.RUnlock()

if exists {
client.mu.Lock()
client.lastActivity = time.Now()
client.mu.Unlock()
}
}

func (hm *HeartbeatManager) checkLoop() {
ticker := time.NewTicker(hm.interval)
defer ticker.Stop()

for range ticker.C {
now := time.Now()
hm.mu.RLock()
clients := make(map[string]*Client, len(hm.clients))
for k, v := range hm.clients {
clients[k] = v
}
hm.mu.RUnlock()

for userID, client := range clients {
client.mu.RLock()
lastActivity := client.lastActivity
client.mu.RUnlock()

if now.Sub(lastActivity) > hm.timeout {
// Пользователь оффлайн
hm.publishStatus(userID, "offline")
}
}
}
}

func (hm *HeartbeatManager) publishStatus(userID, status string) {
event := map[string]interface{}{
"type": "presence",
"user_id": userID,
"status": status,
"timestamp": time.Now().Unix(),
}
data, _ := json.Marshal(event)
// Публикуем в Kafka для рассылки подписчикам
hm.broker.Publish("presence."+userID, data)
}

Обработка heartbeat на стороне сервера:

func (c *Client) readPump(hm *HeartbeatManager) {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()

c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
hm.UpdateActivity(c.userID)
return nil
})

for {
_, message, err := c.conn.ReadMessage()
if err != nil {
break
}

hm.UpdateActivity(c.userID)

var msg map[string]interface{}
if err := json.Unmarshal(message, &msg); err != nil {
continue
}

msgType, _ := msg["type"].(string)
switch msgType {
case "ping":
// Отвечаем pong
c.send <- []byte(`{"type":"pong","timestamp":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`)
case "message":
c.handleChatMessage(msg)
case "typing":
c.handleTyping(msg)
}
}
}

Настройка тайм-аутов:

  • Ping interval: 30 секунд — клиент отправляет ping каждые 30 секунд
  • Pong wait: 60 секунд — сервер ждёт ответ 60 секунд
  • Offline timeout: 90–120 секунд — если нет активности 90 секунд, считаем пользователя оффлайн
const (
pingInterval = 30 * time.Second
pongWait = 60 * time.Second
offlineTimeout = 90 * time.Second
)

Хранение статуса в Redis:

Для быстрого доступа к статусам всех пользователей используем Redis:

type PresenceStore struct {
redis *redis.Client
}

func (ps *PresenceStore) SetOnline(ctx context.Context, userID string) error {
key := fmt.Sprintf("presence:%s", userID)
return ps.redis.Set(ctx, key, "online", 2*time.Minute).Err()
}

func (ps *PresenceStore) SetOffline(ctx context.Context, userID string) error {
key := fmt.Sprintf("presence:%s", userID)
return ps.redis.Set(ctx, key, "offline", 2*time.Minute).Err()
}

func (ps *PresenceStore) GetStatus(ctx context.Context, userID string) (string, error) {
key := fmt.Sprintf("presence:%s", userID)
return ps.redis.Get(ctx, key).Result()
}

func (ps *PresenceStore) GetBulkStatus(ctx context.Context, userIDs []string) (map[string]string, error) {
pipe := ps.redis.Pipeline()
cmds := make(map[string]*redis.StringCmd)

for _, userID := range userIDs {
key := fmt.Sprintf("presence:%s", userID)
cmds[userID] = pipe.Get(ctx, key)
}

_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
return nil, err
}

result := make(map[string]string)
for userID, cmd := range cmds {
status, err := cmd.Result()
if err == redis.Nil {
status = "offline"
}
result[userID] = status
}

return result, nil
}

Подписка на изменение статусов:

Пользователи подписываются на статусы тех, с кем общаются:

func (c *Client) subscribeToPresence(userIDs []string) {
for _, userID := range userIDs {
topic := fmt.Sprintf("presence.%s", userID)
c.hub.broker.Subscribe(topic, func(data []byte) {
c.send <- data
})
}
}

Оптимизация для больших комнат:

В комнате на 10 000 человек показывать статусы всех участников неэффективно. Оптимизации:

  • Показывать онлайн-статус только для первых 100–200 участников
  • Ленивая загрузка статусов при скролле списка участников
  • Кэширование статусов в браузере на 5–10 секунд

Вывод: Heartbeat через WebSocket + Redis для хранения статусов — это масштабируемое решение. Тайм-аут 90–120 секунд — разумный баланс между точностью и устойчивостью к кратковременным разрывам связи.

Вопрос 11. Какие основные серверные компоненты необходимы для архитектуры чата и как организовать маршрутизацию пользователей между серверами?

Таймкод: 00:19:00

Ответ собеседника: Правильный. Выделены компоненты: сервер авторизации, хранилище сообщений (Cassandra/ScyllaDB), объектное хранилище для вложений, сервер маршрутизации сообщений, сервер присутствия, сервер управления комнатами, сервис Discovery. Балансировка через DNS и GeoDNS.

Правильный ответ:

Отличный ответ — кандидат выделил все ключевые компоненты. Давайте расширим и структурируем архитектуру.

Основные серверные компоненты:

1. API Gateway / Load Balancer

  • Принимает все входящие соединения
  • Терминирует TLS
  • Маршрутизирует HTTP-запросы к соответствующим сервисам
  • Управляет WebSocket-соединениями
  • Пример: NGINX, AWS ALB, Envoy

2. Auth Service

  • Регистрация и аутентификация пользователей
  • OAuth 2.0 / OIDC интеграция
  • Выпуск и валидация JWT
  • Refresh token management

3. Room Service

  • Создание, управление комнатами
  • Управление участниками (добавление, удаление, роли)
  • Настройки комнат (приватность, лимиты)

4. Chat Service (WebSocket Server)

  • Удержание WebSocket-соединений
  • Приём и отправка сообщений в реальном времени
  • Heartbeat / presence tracking
  • Маршрутизация сообщений внутри и между серверами

5. Message Router / Pub-Sub

  • Kafka / NATS / Redis Streams как брокер сообщений
  • Маршрутизация сообщений между Chat Service инстансами
  • Гарантия доставки и порядка сообщений

6. Message Storage Service

  • Сохранение истории сообщений
  • Чтение истории с пагинацией
  • Использует Cassandra/ScyllaDB для горизонтального масштабирования

7. Search Service

  • Полнотекстовый поиск по истории сообщений
  • Elasticsearch / Meilisearch

8. Media Service

  • Загрузка, обработка и хранение медиа-файлов
  • Интеграция с S3-совместимым хранилищем
  • CDN для раздачи контента

9. Presence Service

  • Отслеживание статуса онлайн/оффлайн
  • Хранение в Redis с TTL

10. Notification Service

  • Push-уведомления (FCM, APNs)
  • Email-уведомления
  • Настройки уведомлений на уровне пользователя и комнаты

11. Discovery / Routing Service

  • Определяет, на каком Chat Service инстансе находится пользователь
  • Хранит маппинг userID → serverID
  • Redis как хранилище маршрутов

Маршрутизация пользователей между серверами:

Это ключевая проблема в архитектуре чата. Пользователь A подключён к серверу 1, пользователь B — к серверу 2. Как доставить сообщение от A к B?

// Discovery Service — хранит маппинг пользователь → сервер
type DiscoveryService struct {
redis *redis.Client
}

func (ds *DiscoveryService) RegisterUserServer(ctx context.Context, userID, serverID string) error {
key := fmt.Sprintf("user_server:%s", userID)
// TTL — если сервер упадёт, запись автоматически удалится
return ds.redis.Set(ctx, key, serverID, 5*time.Minute).Err()
}

func (ds *DiscoveryService) GetUserServer(ctx context.Context, userID string) (string, error) {
key := fmt.Sprintf("user_server:%s", userID)
return ds.redis.Get(ctx, key).Result()
}

func (ds *DiscoveryService) UnregisterUser(ctx context.Context, userID string) error {
key := fmt.Sprintf("user_server:%s", userID)
return ds.redis.Del(ctx, key).Err()
}

Маршрутизация сообщений через Kafka:

// Chat Service при получении сообщения
func (cs *ChatServer) handleMessage(senderID string, msg *Message) error {
// 1. Сохраняем сообщение
if err := cs.messageStore.Save(msg); err != nil {
return err
}

// 2. Определяем целевой сервер для получателя
targetServer, err := cs.discovery.GetUserServer(context.Background(), msg.ReceiverID)
if err != nil {
// Получатель оффлайн — отправляем push-уведомление
cs.notificationService.NotifyOffline(msg.ReceiverID, msg)
return nil
}

// 3. Публикуем в Kafka топик, специфичный для целевого сервера
topic := fmt.Sprintf("server.%s", targetServer)
return cs.broker.Publish(context.Background(), topic, msg.ToJSON())
}

// Каждый Chat Service инстанс подписан на свой топик
func (cs *ChatServer) consumeMessages() {
topic := fmt.Sprintf("server.%s", cs.serverID)
cs.broker.Subscribe(topic, func(data []byte) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return
}

// Отправляем сообщение пользователю через WebSocket
client := cs.getClient(msg.ReceiverID)
if client != nil {
client.send <- data
}
})
}

Альтернативный подход — шардирование по комнате:

Вместо маршрутизации по пользователю, можно шардировать по комнате — все сообщения комнаты X всегда обрабатываются сервером Y:

func GetRoomServer(roomID string, totalServers int) int {
hash := fnv.New32a()
hash.Write([]byte(roomID))
return int(hash.Sum32()) % totalServers
}

GeoDNS и географическая маршрутизация:

┌─────────────┐
│ Client │
└──────┬──────┘
│ DNS resolution

┌─────────────────┐
│ GeoDNS │ ← определяет ближайший регион
└──────┬──────────┘

┌───┴───┐
▼ ▼
┌─────┐ ┌─────┐
│EU-LB│ │US-LB│ ← Regional Load Balancers
└──┬──┘ └──┬──┘
│ │
▼ ▼
┌─────────────────┐
│ Chat Servers │ ← в каждом регионе свой кластер
│ (WebSocket) │
└────────┬────────┘


┌─────────────────┐
│ Kafka Cluster │ ← межрегиональная репликация
└─────────────────┘

Вывод: Архитектура кандидата полная и корректная. Kafka как центральный брокер + Discovery Service с Redis + GeoDNS для географической маршрутизации — это зрелое решение для чата на миллион пользователей.

Вопрос 12. Как организовать отправку и доставку сообщений, включая сообщения с вложениями, обеспечить глобальный порядок сообщений, обработать множественные устройства и географическое распределение серверов?

Таймкод: 00:30:35

Ответ собеседника: Неполный. Сообщение содержит ID получателя, текст, ссылку на медиаконтент. Сообщение сохраняется в хранилище, затем доставляется через очереди. Для DM — одна очередь на получателя, для групповых — репликация на всех участников. Порядок сообщений через составной ID (время + ID дата-центра + ID сервера + счётчик). Очередь содержит ссылки на сообщения. Для неактивных — история из базы. Для множественных устройств — отдельная очередь на устройство. Географическое распределение — eventual consistency с репликацией.

Правильный ответ:

Кандидат затронул многие важные аспекты, но ответ требует систематизации и дополнения. Давайте разберём каждую тему подробно.

Структура сообщения:

type Message struct {
ID string `json:"id"` // Глобально уникальный ID (Snowflake/KSUID)
RoomID string `json:"room_id"` // ID комнаты
SenderID string `json:"sender_id"` // ID отправителя
Content string `json:"content"` // Текст сообщения
ContentType string `json:"content_type"` // "text", "image", "video", "file"
Attachments []Attachment `json:"attachments,omitempty"`
CreatedAt time.Time `json:"created_at"` // Время создания на сервере
ClientID string `json:"client_id"` // Клиентский ID для дедупликации
}

type Attachment struct {
ID string `json:"id"`
Type string `json:"type"` // "image", "video", "file"
URL string `json:"url"` // Ссылка на S3
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
Width int `json:"width,omitempty"` // для изображений/видео
Height int `json:"height,omitempty"`
}

Обработка вложений:

Загрузка вложений — отдельный flow, не блокирующий отправку сообщения:

type MediaService struct {
s3Client *s3.Client
db *sql.DB
}

func (ms *MediaService) UploadAttachment(ctx context.Context, fileData []byte, fileName, mimeType string) (*Attachment, error) {
// 1. Генерируем уникальный ключ для S3
attachmentID := generateID()
ext := filepath.Ext(fileName)
s3Key := fmt.Sprintf("attachments/%s/%s%s",
time.Now().Format("2006/01/02"), attachmentID, ext)

// 2. Загружаем в S3
_, err := ms.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String("chat-attachments"),
Key: aws.String(s3Key),
Body: bytes.NewReader(fileData),
ContentType: aws.String(mimeType),
})
if err != nil {
return nil, fmt.Errorf("s3 upload failed: %w", err)
}

// 3. Генерируем CDN-URL
cdnURL := fmt.Sprintf("https://cdn.example.com/%s", s3Key)

// 4. Для изображений — генерируем превью (thumbnail)
var width, height int
if strings.HasPrefix(mimeType, "image/") {
width, height = getImageDimensions(fileData)
go ms.generateThumbnail(s3Key, fileData)
}

attachment := &Attachment{
ID: attachmentID,
Type: getAttachmentType(mimeType),
URL: cdnURL,
FileName: fileName,
FileSize: int64(len(fileData)),
MimeType: mimeType,
Width: width,
Height: height,
}

// 5. Сохраняем метаданные в БД
if err := ms.db.SaveAttachment(ctx, attachment); err != nil {
return nil, err
}

return attachment, nil
}

Глобальный порядок сообщений — Snowflake ID:

Кандидат верно предложил составной ID. Стандартный подход — Snowflake ID (Twitter) или KSUID:

// Snowflake ID: 41 бит timestamp + 10 бит nodeID + 12 бит sequence
type SnowflakeID struct {
epoch int64
nodeID int64
sequence int64
mu sync.Mutex
}

func NewSnowflakeID(nodeID int64) *SnowflakeID {
return &SnowflakeID{
epoch: 1609459200000, // 2021-01-01 в миллисекундах
nodeID: nodeID,
}
}

func (sf *SnowflakeID) Generate() int64 {
sf.mu.Lock()
defer sf.mu.Unlock()

now := time.Now().UnixMilli() - sf.epoch

if now == sf.sequence>>12 {
sf.sequence = (sf.sequence + 1) & 0xFFF
if sf.sequence == 0 {
// Ждём следующую миллисекунду
for now <= sf.sequence>>12 {
now = time.Now().UnixMilli() - sf.epoch
}
}
} else {
sf.sequence = 0
}

return (now << 22) | (sf.nodeID << 12) | sf.sequence
}

// ID монотонно возрастает → естественный порядок сортировки
// Пример: 1753958472938475000
// [41 бит timestamp][10 бит nodeID][12 бит sequence]

Альтернатива — KSUID (сортируемый, более читаемый):

import "github.com/segmentio/ksuid"

func GenerateMessageID() string {
return ksuid.New().String()
// Пример: "2I33F5oKE2J5a4fKz1X5r3nQ9Bp"
// Содержит timestamp + random payload
// Сортируется по времени создания
}

Доставка сообщений — архитектура:

type MessageDeliveryService struct {
broker *KafkaBroker
discovery *DiscoveryService
presence *PresenceService
messageStore *MessageStorage
}

func (mds *MessageDeliveryService) DeliverMessage(ctx context.Context, msg *Message) error {
// 1. Сохраняем сообщение в БД
if err := mds.messageStore.Save(ctx, msg); err != nil {
return fmt.Errorf("save message failed: %w", err)
}

// 2. Получаем список участников комнаты
members, err := mds.roomService.GetMembers(ctx, msg.RoomID)
if err != nil {
return fmt.Errorf("get members failed: %w", err)
}

// 3. Для каждого участника определяем, онлайн ли он
for _, memberID := range members {
if memberID == msg.SenderID {
continue // Не отправляем отправителю
}

status, err := mds.presence.GetStatus(ctx, memberID)
if err != nil || status == "offline" {
// Пользователь оффлайн — отправляем push-уведомление
mds.notificationService.SendPush(ctx, memberID, msg)
continue
}

// Пользователь онлайн — отправляем через Kafka
// Топик определяется сервером, к которому подключён пользователь
serverID, err := mds.discovery.GetUserServer(ctx, memberID)
if err != nil {
continue
}

topic := fmt.Sprintf("server.%s", serverID)
delivery := &MessageDelivery{
MessageID: msg.ID,
UserID: memberID,
RoomID: msg.RoomID,
}

if err := mds.broker.Publish(ctx, topic, delivery.ToJSON()); err != nil {
log.Printf("failed to deliver message %s to user %s: %v", msg.ID, memberID, err)
}
}

return nil
}

Обработка множественных устройств:

Один пользователь может быть онлайн с телефона и десктопа одновременно:

type DevicePresence struct {
UserID string
DeviceID string
ServerID string
DeviceType string // "mobile", "desktop", "web"
}

func (ps *PresenceService) UpdateDevicePresence(ctx context.Context, dp *DevicePresence) error {
key := fmt.Sprintf("device:%s:%s", dp.UserID, dp.DeviceID)

// Сохраняем информацию об устройстве
data, _ := json.Marshal(dp)
ps.redis.Set(ctx, key, data, 2*time.Minute)

// Обновляем общий статус пользователя
// Пользователь онлайн, если хотя бы одно устройство онлайн
devicesKey := fmt.Sprintf("user_devices:%s", dp.UserID)
ps.redis.SAdd(ctx, devicesKey, dp.DeviceID)
ps.redis.Expire(ctx, devicesKey, 2*time.Minute)

return nil
}

// Отправка сообщения на все устройства пользователя
func (mds *MessageDeliveryService) deliverToAllDevices(ctx context.Context, userID string, msg *Message) error {
devicesKey := fmt.Sprintf("user_devices:%s", userID)
deviceIDs, err := mds.redis.SMembers(ctx, devicesKey).Result()
if err != nil {
return err
}

for _, deviceID := range devicesKey {
key := fmt.Sprintf("device:%s:%s", userID, deviceID)
data, err := mds.redis.Get(ctx, key).Bytes()
if err != nil {
continue
}

var dp DevicePresence
if err := json.Unmarshal(data, &dp); err != nil {
continue
}

topic := fmt.Sprintf("server.%s", dp.ServerID)
delivery := &MessageDelivery{
MessageID: msg.ID,
UserID: userID,
DeviceID: deviceID,
RoomID: msg.RoomID,
}

mds.broker.Publish(ctx, topic, delivery.ToJSON())
}

return nil
}

Географическое распределение:

┌─────────────────────────────────────────────────────────┐
│ Global Layer │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Auth Service│ │ Room Service │ │ Media Service │ │
│ │ (global) │ │ (global) │ │ (global) │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Regional Layer │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ EU Region │ │ US Region │ │
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ Chat Servers │ │ │ │ Chat Servers │ │ │
│ │ └───────┬───────┘ │ │ └───────┬───────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌───────▼───────┐ │ │ ┌───────▼───────┐ │ │
│ │ │Kafka Cluster │◄├────┤►│Kafka Cluster │ │ │
│ │ │ (regional) │ │repl│ │ (regional) │ │ │
│ │ └───────────────┘ │lica│ └───────────────┘ │ │
│ └───────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────┘

Репликация между регионами:

Для пользователя в EU, отправляющего сообщение пользователю в US:

  1. Сообщение записывается в локальный Kafka (EU)
  2. Kafka MirrorMaker / Confluent Replicator копирует в US Kafka
  3. US Chat Server получает сообщение из локального Kafka и доставляет получателю

Проблемы и решения:

ПроблемаРешение
Задержка между регионами (100–200 мс)Eventual consistency, порядок гарантирован в рамках одной комнаты
Конфликты при одновременной отправкеSnowflake ID с уникальным nodeID для каждого сервера
Потеря сообщений при репликацииAt-least-once delivery + идемпотентность по MessageID
Разные версии истории на разных регионахRead-your-writes consistency через чтение из локальной БД

Вывод: Кандидат верно определил ключевые подходы — Snowflake ID для порядка, отдельные очереди для устройств, eventual consistency для географического распределения. Для полноты стоит добавить: явную обработку вложений через отдельный flow, read receipts для множественных устройств, и механизм разрешения конфликтов при кросс-региональной коммуникации.

Вопрос 13. Как обрабатывать доставку сообщений при множественных устройствах одного пользователя и как обеспечить консистентность при географическом распределении серверов?

Таймкод: 00:54:15

Ответ собеседника: Неполный. Для устройств — отдельный ID и очередь на устройство, статус отслеживается по устройствам. Для географического распределения — eventual consistency с репликацией, очереди привязаны к ближайшему серверу. Признаны задержки репликации как допустимый компромисс.

Правильный ответ:

Кандидат верно описал базовые подходы, но ответ нуждается в углублении. Это по сути уточняющий вопрос к предыдущему, поэтому сфокусируемся на деталях, которые не были раскрыты.

Множественные устройства — подробная архитектура:

Модель данных для устройств:

type UserDevice struct {
DeviceID string `json:"device_id" db:"device_id"`
UserID string `json:"user_id" db:"user_id"`
DeviceType string `json:"device_type" db:"device_type"` // "ios", "android", "web", "desktop"
ServerID string `json:"server_id" db:"server_id"` // сервер, к которому подключено
LastSeen time.Time `json:"last_seen" db:"last_seen"`
PushToken string `json:"push_token" db:"push_token"` // для push-уведомлений
}

type ReadReceipt struct {
UserID string `json:"user_id" db:"user_id"`
DeviceID string `json:"device_id" db:"device_id"`
RoomID string `json:"room_id" db:"room_id"`
MessageID string `json:"message_id" db:"message_id"`
ReadAt time.Time `json:"read_at" db:"read_at"`
}

Стратегия доставки на множественные устройства:

func (mds *MessageDeliveryService) deliverToUser(ctx context.Context, userID string, msg *Message) error {
// Получаем все активные устройства пользователя
devices, err := mds.presence.GetActiveDevices(ctx, userID)
if err != nil {
return err
}

if len(devices) == 0 {
// Все устройства оффлайн — отправляем push
return mds.notificationService.SendPush(ctx, userID, msg)
}

// Отправляем на все онлайн-устройства
for _, device := range devices {
serverID, err := mds.discovery.GetDeviceServer(ctx, userID, device.DeviceID)
if err != nil {
continue
}

topic := fmt.Sprintf("server.%s", serverID)
delivery := &MessageDelivery{
MessageID: msg.ID,
UserID: userID,
DeviceID: device.DeviceID,
RoomID: msg.RoomID,
}

if err := mds.broker.Publish(ctx, topic, delivery.ToJSON()); err != nil {
log.Printf("delivery failed for device %s: %v", device.DeviceID, err)
}
}

return nil
}

Read receipts — синхронизация прочтения между устройствами:

Когда пользователь прочитал сообщение на телефоне, десктоп тоже должен показать его как прочитанное:

func (rs *RoomService) MarkAsRead(ctx context.Context, userID, deviceID, roomID, messageID string) error {
// 1. Сохраняем read receipt
receipt := &ReadReceipt{
UserID: userID,
DeviceID: deviceID,
RoomID: roomID,
MessageID: messageID,
ReadAt: time.Now(),
}

if err := rs.db.SaveReadReceipt(ctx, receipt); err != nil {
return err
}

// 2. Публикуем событие о прочтении
event := &ReadEvent{
UserID: userID,
RoomID: roomID,
MessageID: messageID,
ReadAt: receipt.ReadAt,
}

// 3. Рассылаем всем устройствам пользователя (чтобы синхронизировать UI)
topic := fmt.Sprintf("user.%s.read", userID)
return rs.broker.Publish(ctx, topic, event.ToJSON())
}

// На каждом устройстве подписываемся на read events
func (c *Client) handleReadEvent(data []byte) {
var event ReadEvent
if err := json.Unmarshal(data, &event); err != nil {
return
}

// Обновляем UI — помечаем сообщение как прочитанное
c.updateMessageReadStatus(event.MessageID, event.ReadAt)
}

Географическое распределение — глубокий разбор:

Проблема split-brain:

Когда пользователь A в EU отправляет сообщение пользователю B в US, а пользователь C в EU отвечает — порядок сообщений может нарушиться из-за задержки репликации.

Решение — логические часы и векторные таймстампы:

type VectorClock struct {
Clocks map[string]uint64 // serverID -> logical time
}

func (vc *VectorClock) Increment(serverID string) {
vc.Clocks[serverID]++
}

func (vc *VectorClock) Merge(other *VectorClock) {
for serverID, time := range other.Clocks {
if current, exists := vc.Clocks[serverID]; !exists || time > current {
vc.Clocks[serverID] = time
}
}
}

func (vc *VectorClock) HappensBefore(other *VectorClock) bool {
allLessOrEqual := true
atLeastOneLess := false

for serverID, time := range vc.Clocks {
if otherTime, exists := other.Clocks[serverID]; exists {
if time > otherTime {
allLessOrEqual = false
break
}
if time < otherTime {
atLeastOneLess = true
}
}
}

return allLessOrEqual && atLeastOneLess
}

Стратегия разрешения конфликтов:

func (cs *ChatServer) resolveConflict(msg1, msg2 *Message) *Message {
// Если одно сообщение произошло до другого — порядок определён
if msg1.VectorClock.HappensBefore(msg2.VectorClock) {
return msg1 // msg1 было раньше
}
if msg2.VectorClock.HappensBefore(msg1.VectorClock) {
return msg2 // msg2 было раньше
}

// Конкурентные сообщения — детерминированный разрешение
// Сортируем по лексикографическому порядку ID
if msg1.ID < msg2.ID {
return msg1
}
return msg2
}

Read-Your-Writes Consistency:

Для отправителя сообщения важно видеть своё сообщение сразу, даже если оно ещё не реплицировано:

func (cs *ChatServer) handleSendMessage(client *Client, msg *Message) error {
// 1. Сохраняем в локальную БД
if err := cs.messageStore.Save(msg); err != nil {
return err
}

// 2. Сразу отправляем подтверждение отправителю
ack := &MessageAck{
MessageID: msg.ID,
Status: "delivered",
Timestamp: time.Now(),
}
client.send <- ack.ToJSON()

// 3. Асинхронно публикуем в Kafka для доставки другим
go cs.deliverToRoom(msg)

return nil
}

Мониторинг задержек репликации:

type ReplicationMonitor struct {
kafka *KafkaBroker
}

func (rm *ReplicationMonitor) RecordReplicationLag(region string, lag time.Duration) {
metrics.ReplicationLag.WithLabelValues(region).Set(lag.Seconds())

if lag > 5*time.Second {
log.Printf("WARNING: replication lag for %s is %v", region, lag)
// Алерт в PagerDuty / Slack
}
}

Итоговые компромиссы для географического распределения:

АспектПодходКомпромисс
Порядок сообщенийSnowflake ID + векторные часыНебольшая задержка при кросс-региональной коммуникации
ДоставкаAt-least-once + идемпотентностьВозможны дубликаты, обрабатываются на клиенте
Read receiptsEventual consistencyПрочтение может отобразиться с задержкой
Онлайн-статусEventual consistencyСтатус может быть неточным на 1–2 секунды
ИсторияRead from local DB + async replicationНовые сообщения могут появиться с задержкой

Вывод: Кандидат правильно принял eventual consistency как компромисс. Для полноты стоит добавить: read receipts с синхронизацией между устройствами, read-your-writes consistency для отправителя, векторные часы для разрешения конфликтов, и мониторинг задержек репликации. Это стандартные паттерны для распределённых систем чата.

Вопрос 14. Какие технологии хранения данных и очередей сообщений вы бы выбрали для данной системы и почему?

Таймкод: 01:19:50

Ответ собеседника: Неполный. Cassandra/ScyllaDB для сообщений, S3 для медиа, Kafka для очередей, Redis как буфер, HBase как вариант. Упомянут backpressure. Признано, что знание технологий важно.

Правильный ответ:

Кандидат упомянул много технологий, но ответ неструктурирован. Давайте разберём выбор технологий системно, с обоснованием для каждого слоя.

Хранение сообщений — Cassandra/ScyllaDB:

Правильный выбор. Cassandra идеальна для хранения сообщений чата благодаря:

  • Горизонтальное масштабирование — линейный рост производительности при добавлении нод
  • Модель данных — естественное соответствие паттернам доступа (чтение по room_id + временной диапазон)
  • Tunable consistency — можно настраивать уровень консистентности под каждую операцию
  • TTL — автоматическое удаление старых сообщений при необходимости
-- Cassandra schema для сообщений
CREATE TABLE messages (
room_id UUID,
bucket TEXT, -- временной бакет: "2024-01-15"
message_id TIMEUUID, -- встроенная сортировка по времени
sender_id UUID,
content TEXT,
content_type TEXT,
attachments LIST<FROZEN<attachment>>,
created_at TIMESTAMP,
PRIMARY KEY ((room_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC)
AND compaction = {'class': 'TimeWindowCompactionStrategy'}
AND default_time_to_live = 31536000; -- 1 год TTL

-- Чтение последних сообщений комнаты
SELECT * FROM messages
WHERE room_id = ? AND bucket IN ('2024-01-15', '2024-01-14')
LIMIT 50;

ScyllaDB vs Cassandra:

ScyllaDB — переписанная на C++ версия Cassandra. Преимущества:

  • В 3–10 раз выше пропускная способность на той же железе
  • Меньше latency (p99 на порядок лучше)
  • Полная совместимость с Cassandra API и форматом данных
  • Для 1 000 000 пользователей ScyllaDB предпочтительнее

Метаданные — PostgreSQL:

Для пользователей, комнат, настроек — реляционная БД:

-- PostgreSQL для метаданных
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
avatar_url TEXT,
status VARCHAR(20) DEFAULT 'offline',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255),
type VARCHAR(10) NOT NULL CHECK (type IN ('direct', 'group')),
creator_id UUID REFERENCES users(id),
max_members INTEGER DEFAULT 10000,
created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE room_members (
room_id UUID REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member',
joined_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (room_id, user_id)
);

-- Партиционирование для больших таблиц
CREATE TABLE room_members_partitioned (
LIKE room_members INCLUDING ALL
) PARTITION BY HASH (room_id);

Кэш и Presence — Redis:

// Redis используется для:
// 1. Хранения онлайн-статусов
// 2. Маппинга userID -> serverID (Discovery)
// 3. Rate limiting
// 4. Кэширования горячих данных (списки комнат пользователя)

type RedisCache struct {
client *redis.Client
}

func (rc *RedisCache) SetUserServer(ctx context.Context, userID, serverID string) error {
return rc.client.Set(ctx,
fmt.Sprintf("user:server:%s", userID),
serverID,
5*time.Minute, // TTL — если сервер упадёт, запись устареет
).Err()
}

func (rc *RedisCache) GetUserServer(ctx context.Context, userID string) (string, error) {
return rc.client.Get(ctx, fmt.Sprintf("user:server:%s", userID)).Result()
}

// Rate limiting для отправки сообщений
func (rc *RedisCache) CheckRateLimit(ctx context.Context, userID string, limit int, window time.Duration) (bool, error) {
key := fmt.Sprintf("ratelimit:msg:%s", userID)

pipe := rc.client.Pipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, window)

results, err := pipe.Exec(ctx)
if err != nil {
return false, err
}

count := results[0].(*redis.IntCmd).Val()
return count <= int64(limit), nil
}

Очереди сообщений — Kafka:

Kafka — правильный выбор для основного брокера сообщений:

  • Высокая пропускная способность — миллионы сообщений в секунду
  • Persistence — сообщения сохраняются на диск, можно перечитать историю
  • Partitioning — естественное шардирование по комнатам
  • Consumer groups — каждый Chat Service инстанс — consumer в своей группе
// Kafka producer для сообщений
type KafkaMessageProducer struct {
writer *kafka.Writer
}

func (kmp *KafkaMessageProducer) PublishMessage(ctx context.Context, roomID string, msg *Message) error {
// Ключ — roomID, чтобы все сообщения одной комнаты попадали в один партиition
// Это гарантирует порядок сообщений в рамках комнаты

return kmp.writer.WriteMessages(ctx, kafka.Message{
Key: []byte(roomID),
Value: msg.ToJSON(),
Topic: "chat-messages",
Headers: []kafka.Header{
{Key: "message_id", Value: []byte(msg.ID)},
{Key: "room_id", Value: []byte(roomID)},
},
})
}

// Kafka consumer для Chat Service
type KafkaMessageConsumer struct {
reader *kafka.Reader
}

func (kmc *KafkaMessageConsumer) Consume(handler func(*Message) error) {
for {
msg, err := kmc.reader.ReadMessage(context.Background())
if err != nil {
log.Printf("kafka read error: %v", err)
continue
}

var message Message
if err := json.Unmarshal(msg.Value, &message); err != nil {
continue
}

if err := handler(&message); err != nil {
log.Printf("message handler error: %v", err)
}
}
}

Топология Kafka для чата:

Topic: chat-messages
├── Partition 0: room_1, room_5, room_9...
├── Partition 1: room_2, room_6, room_10...
├── Partition 2: room_3, room_7, room_11...
└── Partition N: room_N...

Topic: presence-events
├── Partition 0: user events for hash(userID) % N

Topic: notifications
├── Partition 0: push notifications
└── Partition 1: email notifications

Поиск — Elasticsearch:

type ElasticsearchClient struct {
client *elasticsearch.Client
}

func (ec *ElasticsearchClient) IndexMessage(ctx context.Context, msg *Message) error {
doc := map[string]interface{}{
"message_id": msg.ID,
"room_id": msg.RoomID,
"sender_id": msg.SenderID,
"content": msg.Content,
"created_at": msg.CreatedAt,
}

data, _ := json.Marshal(doc)

_, err := ec.client.Index(
"chat-messages",
bytes.NewReader(data),
ec.client.Index.WithContext(ctx),
ec.client.Index.WithDocumentID(msg.ID),
)

return err
}

func (ec *ElasticsearchClient) SearchMessages(ctx context.Context, roomID, query string) ([]Message, error) {
searchQuery := map[string]interface{}{
"query": map[string]interface{}{
"bool": map[string]interface{}{
"must": []map[string]interface{}{
{"match": map[string]interface{}{"content": query}},
{"term": map[string]interface{}{"room_id": roomID}},
},
},
},
"sort": []map[string]interface{}{
{"created_at": map[string]interface{}{"order": "desc"}},
},
"size": 50,
}

data, _ := json.Marshal(searchQuery)

res, err := ec.client.Search(
ec.client.Search.WithContext(ctx),
ec.client.Search.WithIndex("chat-messages"),
ec.client.Search.WithBody(bytes.NewReader(data)),
)

if err != nil {
return nil, err
}
defer res.Body.Close()

// Парсинг результатов...
}

Медиа — S3-совместимое хранилище:

type MediaStorage struct {
s3Client *s3.Client
bucket string
cdnBase string
}

func (ms *MediaStorage) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
_, err := ms.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(ms.bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentType: aws.String(contentType),
Metadata: map[string]string{
"uploaded_at": time.Now().Format(time.RFC3339),
},
})
if err != nil {
return "", err
}

return fmt.Sprintf("%s/%s", ms.cdnBase, key), nil
}

// Presigned URL для прямой загрузки с клиента
func (ms *MediaStorage) GenerateUploadURL(ctx context.Context, key, contentType string, expiry time.Duration) (string, error) {
presignClient := s3.NewPresignClient(ms.s3Client)

req, err := presignClient.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(ms.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
}, s3.WithPresignExpires(expiry))

if err != nil {
return "", err
}

return req.URL, nil
}

Сводная таблица технологий:

СлойТехнологияПочему
СообщенияScyllaDBГоризонтальный скейл, высокая запись, TTL
МетаданныеPostgreSQLACID, сложные запросы, надёжность
Кэш/PresenceRedisНизкий latency, TTL, pub/sub
ОчередиKafkaВысокая throughput, persistence, порядок
ПоискElasticsearchПолнотекстовый поиск, фильтрация
МедиаS3 + CDNДешёвое хранение, глобальная раздача
КонтейнеризацияKubernetesОркестрация, автоскейлинг

Вывод: Кандидат продемонстрировал широкий кругозор, но не структурировал ответ. Для системного дизайна важно не просто перечислить технологии, а обосновать выбор для каждого слоя с учётом паттернов доступа, требований к consistency и cost.