Публичное собеседование по System Design проектируем публичный чат (Денис Костоусов, Николай Марков)
Сегодня мы разберём публичное собеседование по системному дизайну, в ходе которого интервьюер Денис Костоусов выступал в роли заказчика, а кандидат Николай Марков проектировал архитектуру массового чат-приложения, рассчитанного на миллион пользователей. Собеседование проходило в формате живого диалога с постепенным углублением от сбора бизнес-требований к детальной проработке компонентов системы: авторизации, хранения сообщений, маршрутизации, очередей доставки и геораспределения. Несмотря на некоторые замечания зрителей о недостаточной детализации в середине интервью, кандидат продемонстрировал сильные навыки системного мышления, умение задавать уточняющие вопросы и выстраивать масштабируемую архитектуру в условиях неопределённости.
Вопрос 1. Расскажите немного о себе.
Таймкод: 00:00:14
Ответ собеседника: Правильный. Работает в Тинькофф, центр разработки, занимается системным дизайном, последние полгода регулярно проводит дизайн-ревью и публичные собеседования.
Правильный ответ:
Отличный формат самопрезентации — кратко, по делу, с указанием ключевых активностей. Для позиции Go-разработчика рекомендуется структурировать ответ следующим образом:
Структура самопрезентации для Go-разработчика
- Текущая роль и компания — название компании, команда, основной стек технологий
- Ключевой опыт — сколько лет в разработке, сколько именно с Go, какие типы систем строил (микросервисы, высоконагруженные системы, распределённые системы)
- Зона ответственности и достижения — что конкретно делал, какие результаты принёс (например, снизил latency на 40%, спроектировал систему обработки N тысяч RPS)
- Смежные компетенции — code review, менторинг, системный дизайн, участие в собеседованиях (как у кандидата — это сильный плюс, говорит о зрелости)
- Что ищу / чем хочу заниматься дальше — показывает осознанность и мотивацию
Пример развёрнутого ответа:
«Я работаю 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-разработчика на техническом интервью этот вопрос обычно задают в начале, чтобы снять напряжение и понять, как человек вписывается в командную культуру.
Типичные сильные ответы для разработчика:
-
Кофе + приоритизация задач — проверить мессенджеры/почту, посмотреть доску задач (Jira, Trello), определить приоритеты на день. Показывает дисциплинированный подход к работе.
-
Кофе + мониторинг — если человек отвечает за продакшен-системы, логично начинать день с проверки дашбордов Grafana, алертов, состояния сервисов. Это демонстрирует ответственность за надёжность системы.
-
Кофе + code review — некоторые разработчики предпочут начать день с разбора pull request'ов, пока голова свежая и нет контекст-свитчинга на свои задачи.
-
Кофе + командный ритуал — как у кандидата. Виртуальные посиделки, стендапы, синхронизация с командой. Это важно для удалённых команд и говорит о хорошей интеграции в коллектив.
Что оценивает интервьюер:
- Есть ли у человека устоявшийся рабочий ритм
- Насколько он командно-ориентирован
- Показывает ли ответ проактивность (проверка мониторинга, приоритизация) или реактивность (просто «отвечаю на сообщения»)
- Культурное соответствие — в IT-компаниях культура «кофе + неформальное общение» очень распространена
Рекомендация для подготовки: Отвечайте искренне, но если есть возможность — добавьте технический элемент (проверка алертов, просмотр метрик, code review). Это покажет, что вы думаете не только о своём комфорте, но и о качестве системы.
Вопрос 3. Что должен сказать коллега, чтобы вас порадовать?
Таймкод: 00:03:19
Ответ собеседника: Правильный. «Релиз прошёл очень успешно, теперь можно отдыхать» — это то, что порадует больше всего.
Правильный ответ:
Это вопрос на мотивацию и ценности. Ответ кандидата абсолютно правильный и показывает, что человек ориентирован на результат и переживает за качество релизов. Это типичный и сильный ответ для практикующего разработчика.
Что оценивает интервьюер:
-
Ориентация на результат — радость от успешного релиза показывает, что человек видит конечную цель своей работы и получает удовлетворение от доставленного до пользователя значения
-
Командность — успешный релиз — это всегда командная работа, и радость от этого говорит о зрелом отношении к коллективному труду
-
Понимание жизненного цикла разработки — разработчик, который ценит успешный релиз, понимает, что код — это не самоцель, а средство решения бизнес-задач
Другие примеры сильных ответов:
- «Твой 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)
Почему важно устанавливать лимит:
- Защита от злоупотреблений — без лимита пользователь может отправить мегабайты «текста», создавая нагрузку на систему
- Производительность рендеринга — UI должен рендерить сообщения быстро, огромные блоки текста тормозят прокрутку
- Размер WebSocket-фрейма — каждое сообщение передаётся как один фрейм, и слишком большие фреймы увеличивают latency
- Хранение в базе данных — при 1000 сообщений/сек и 5 КБ на сообщение получаем ~5 МБ/с → ~430 ГБ/день только текста
- Индексация и поиск — полнотекстовый поиск по огромным сообщениям неэффективен
Рекомендации по реализации на 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: ~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 пользователями публичное облако — это практически безальтернативный выбор. Давайте разберём почему и какие конкретные сервисы стоит использовать.
Почему публичное облако для чата:
- Непредсказуемый трафик — чаты имеют пиковые нагрузки (утренние часы, время трансляций). Автоскейлинг в облаке покрывает это без избыточного резервирования
- WebSocket-соединения — требуют стабильной сети и горизонтального масштабирования, что проще в облаке
- Глобальная доступность — пользователи могут быть в разных регионах, облако даёт размещение в дата-центрах по всему миру
- Управляемые сервисы — не нужно тратить ресурсы на администрирование 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 аутентификации:
- Клиент инициирует OAuth flow → редирект на провайдера
- Пользователь авторизуется у провайдера → получаем authorization code
- Обмениваем code на access token у провайдера
- Запрашиваем профиль пользователя (email, имя, аватар)
- Создаём/находим пользователя в нашей системе
- Выпускаем собственный JWT с claims (user_id, email, roles)
- Клиент использует 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:
- Сообщение записывается в локальный Kafka (EU)
- Kafka MirrorMaker / Confluent Replicator копирует в US Kafka
- 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 receipts | Eventual 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 |
| Метаданные | PostgreSQL | ACID, сложные запросы, надёжность |
| Кэш/Presence | Redis | Низкий latency, TTL, pub/sub |
| Очереди | Kafka | Высокая throughput, persistence, порядок |
| Поиск | Elasticsearch | Полнотекстовый поиск, фильтрация |
| Медиа | S3 + CDN | Дешёвое хранение, глобальная раздача |
| Контейнеризация | Kubernetes | Оркестрация, автоскейлинг |
Вывод: Кандидат продемонстрировал широкий кругозор, но не структурировал ответ. Для системного дизайна важно не просто перечислить технологии, а обосновать выбор для каждого слоя с учётом паттернов доступа, требований к consistency и cost.
