Системный дизайн мессенджера на собеседовании: WebSocket, доставка и группы

Как спроектировать чат на system design интервью: требования, оценка нагрузки, WebSocket-шлюзы, хранение сообщений, статусы доставлено и прочитано, группы, онлайн-статус и push.

13 мин чтениясистемный дизайн · масштабирование · базы данных · собеседования

Коротко

ВопросРешение в этом разборе
Нагрузкаоколо 46 тыс. сообщений в секунду, в пике 140 тыс.
Соединениядо 30 млн одновременных WebSocket, около 300 шлюзов
Порядок сообщенийномер seq внутри чата, выдаёт сервер
Повторная отправкаclient_msg_id от клиента, дубли отсекаются по нему
Хранениеwide-column БД, партиция по чату и блоку номеров
Доставлено/прочитанокурсор «прочитал до seq N» на участника, а не флаг на каждое сообщение
Офлайн-получательсообщение лежит в хранилище, клиент догружает по seq, плюс push

Мессенджер на собеседовании проверяет не знание слова WebSocket. Интервьюер смотрит, понимаете ли вы, что соединение держит конкретный сервер, что сообщения теряются и дублируются, и что «прочитано» в группе на тысячу человек нельзя хранить в лоб.

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

Шаг 1. Требования

Сначала вопросы. Каждый ответ должен что-то менять на доске.

ВопросОтвет интервьюераЧто это меняет
Личные чаты или ещё группы?и то и другое, группы до 500доставка в группе, хранение статусов
Несколько устройств у пользователя?да, телефон и десктопсинхронизация по номеру, а не «отправил и забыл»
История хранится на сервере?да, бессрочнообъём хранилища, модель как у Telegram, не у SMS
Нужны статусы доставки и прочтения?даотдельный поток событий и курсоры прочтения
Онлайн-статус и «был недавно»?даheartbeat, TTL, подписки на статус
Медиа?да, но детально не разбираемфайлы идут мимо чата, в объектное хранилище
Сквозное шифрование?вне рамок, упомянуть в концесервер видит текст, можно делать поиск

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

  • Сообщение, на которое отправитель получил подтверждение, не теряется.
  • В одном чате все участники видят сообщения в одном порядке. Глобальный порядок между чатами не нужен.
  • Доставка онлайн-получателю за доли секунды, p99 в пределах 500 мс внутри нашей инфраструктуры.
  • Онлайн-статус может отставать на десятки секунд, это никого не ранит.

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

Шаг 2. Оценка нагрузки

Допущения предлагаете вы:

  • 100 млн активных пользователей в день.
  • Каждый отправляет 40 сообщений в день.
  • Сообщение с метаданными занимает 200 байт.
  • Пик в 3 раза выше среднего.
  • В пиковый час онлайн 30% дневной аудитории.
  • Один шлюз держит 100 тыс. соединений. Это допущение, его проверяют нагрузочным тестом.
ПараметрРасчётРезультат
Сообщений в сутки100 млн × 404 млрд
Запись, среднее4 млрд / 86 400около 46 тыс. в секунду
Запись, пик46 тыс. × 3около 140 тыс. в секунду
Объём в сутки4 млрд × 200 байт800 ГБ
Объём в год800 ГБ × 365292 ТБ, около 876 ТБ с тремя копиями
Одновременных соединений100 млн × 30%30 млн
Шлюзов30 млн / 100 тыс.300 плюс запас

Выводы, ради которых всё это считалось:

  1. Запись тяжёлая. В отличие от большинства сайтов, чтений здесь не на порядки больше, чем записей: сообщение читают почти сразу, пока оно свежее, а старую историю открывают редко. Нужна база, которая хорошо пишет, например на LSM-деревьях, а не классический B-tree под 140 тыс. вставок в секунду.
  2. Почти петабайт в год. Шардирование с первого дня, и шард определяется чатом.
  3. Соединения — отдельный ресурс. 30 млн долгоживущих TCP-соединений означают сотни серверов, которые надо уметь перезапускать без потери сообщений.

Шаг 3. Протокол и API

Клиент держит постоянное соединение со шлюзом. Чем WebSocket отличается от long polling и SSE, здесь не разбираем: для двусторонней связи с низкой задержкой на интервью обычно выбирают WebSocket, и этого достаточно.

Внутри соединения ходят кадры. Минимальный набор:

КадрНаправлениеПоля
sendклиент → серверchat_id, client_msg_id, text
ackсервер → клиентclient_msg_id, seq, created_at
messageсервер → клиентchat_id, seq, sender_id, text
readклиент → серверchat_id, seq: «прочитал всё до этого»
receiptсервер → клиентchat_id, user_id, delivered_seq, read_seq

История, список чатов и создание групп удобнее сделать обычным HTTP API: это запросы «спросил и получил», им не нужен открытый канал.

GET /api/v1/chats/{chat_id}/messages?after_seq=1520&limit=100

Главная мысль: подтверждение ack приходит только после записи в хранилище. Пока его нет, клиент показывает часики и при обрыве переотправляет сообщение с тем же client_msg_id.

Шаг 4. Архитектура

клиент <-WS-> балансировщик L4 -> шлюз (WebSocket) | ^ v | сервис сообщений --> хранилище сообщений | +--> реестр сессий: user_id -> шлюз | +--> очередь --> push-сервис -> APNs / FCM | +--> сервис присутствия

Шлюзы ничего не решают, они только держат соединения, проверяют токен при подключении и передают кадры дальше. Их можно перезапускать: клиент переподключится к другому шлюзу и догрузит пропущенное.

Реестр сессий отвечает на вопрос «на каком шлюзе сейчас пользователь». Обычно это Redis: при подключении шлюз пишет session:<user_id>:<device_id> -> gateway-17 с TTL и продлевает его. У пользователя несколько устройств, поэтому записей несколько.

Балансировка. Соединения живут часами, поэтому round robin по новым подключениям даёт перекос после перезапусков: свежий шлюз пустой, старые забиты. Помогает балансировка по числу активных соединений и мягкий разрыв части соединений при перекосе. Подробнее о стратегиях в статье про балансировку нагрузки.

Путь сообщения

  1. Алиса шлёт send на свой шлюз, шлюз передаёт его сервису сообщений.
  2. Сервис проверяет, что Алиса состоит в чате, присваивает seq и пишет сообщение в хранилище.
  3. Алисе уходит ack с номером.
  4. Сервис смотрит в реестр, где сейчас Боб. Если онлайн, отправляет кадр на его шлюз. Если нет, кладёт задачу в очередь push-уведомлений.
  5. Шлюз Боба доставляет message, клиент Боба отвечает подтверждением, и Алиса видит две галочки.

Сервис сообщений не должен ходить на шлюз Боба синхронно в том же запросе, что и запись: если шлюз завис, Алиса будет ждать ack. Доставку разумно вынести в очередь или pub/sub, о гарантиях доставки есть отдельная статья про очереди сообщений.

Шаг 5. Порядок и дубли

Время на устройствах врёт, а время на разных серверах расходится на миллисекунды. Сортировать по created_at нельзя: два сообщения в одну миллисекунду перемешаются у разных участников.

Решение: номер внутри чата. Сервер выдаёт seq по возрастанию, и все клиенты сортируют по нему. Глобальный счётчик не нужен, нужен счётчик на каждый чат. Его хранят рядом с метаданными чата и увеличивают атомарно, а все записи одного чата направляют на один шард, чтобы не было конкуренции между узлами.

Второй вопрос — дубли. Сеть оборвалась после записи, но до ack. Клиент не знает, дошло ли сообщение, и отправляет снова. Чтобы собеседник не увидел «привет» дважды, клиент генерирует client_msg_id один раз и шлёт его при каждом повторе. Это обычная идемпотентность, только внутри WebSocket.

from dataclasses import dataclass, field @dataclass class Chat: last_seq: int = 0 messages: list = field(default_factory=list) # (sender_id, client_msg_id) -> seq: защита от повторной отправки dedup: dict = field(default_factory=dict) def send(chat: Chat, sender_id: int, client_msg_id: str, text: str) -> int: key = (sender_id, client_msg_id) if key in chat.dedup: # Клиент переотправил после обрыва связи return chat.dedup[key] chat.last_seq += 1 chat.messages.append((chat.last_seq, sender_id, text)) chat.dedup[key] = chat.last_seq return chat.last_seq def sync(chat: Chat, after_seq: int, limit: int = 100) -> list: # Сообщения сортированы по seq, в реальной БД это range-запрос return [m for m in chat.messages if m[0] > after_seq][:limit] chat = Chat() print(send(chat, 1, "a1", "привет")) # 1 print(send(chat, 2, "b1", "привет!")) # 2 print(send(chat, 1, "a1", "привет")) # 1, дубль не создан print(sync(chat, after_seq=1)) # [(2, 2, 'привет!')]

В проде таблица дедупликации не вечная: хватит окна в несколько часов, за которое клиент точно перестанет переотправлять.

Функция sync — это и есть ответ на вопрос «что делать, если клиент был офлайн». Клиент помнит последний seq в каждом чате и после переподключения просит всё, что больше. Отсюда же работает несколько устройств: каждое синхронизируется само, сервер не обязан помнить, что именно он отправил на десктоп.

Чтобы клиенту не обходить все свои чаты, сервис хранит для пользователя список «в каких чатах что-то изменилось с момента X». При подключении клиент получает этот короткий список и догружает только нужное.

Шаг 6. Хранение сообщений

Запросы к хранилищу всего два: записать сообщение и прочитать диапазон по номеру в одном чате. Это идеальный случай для wide-column базы вроде Cassandra или ScyllaDB: запись дешёвая, данные одного чата лежат рядом и уже отсортированы.

CREATE TABLE messages ( chat_id bigint, bucket int, -- seq / 10000 seq bigint, sender_id bigint, client_msg_id uuid, body text, created_at timestamp, PRIMARY KEY ((chat_id, bucket), seq) ) WITH CLUSTERING ORDER BY (seq DESC);

Зачем bucket, если можно взять партицию по chat_id? Активный групповой чат за годы набирает миллионы сообщений, и партиция разрастается до размеров, с которыми база работает плохо. Блок по номеру ограничивает партицию 10 тыс. сообщений, а нужный блок вычисляется из seq без лишних запросов. Похожую схему, только с блоками по времени, описывала Discord в своей статье о хранении сообщений в Cassandra.

Почему не PostgreSQL? На 140 тыс. вставок в секунду реляционную базу тоже можно растянуть шардированием, и для старта это нормальный ответ. Но запросы здесь не используют ни соединений, ни транзакций между чатами, а платить за них придётся. Как и в любой секции, важнее назвать критерий, чем название. Про то, как резать данные и что с репликами, — в статье про шардирование и репликацию.

Ключ шардирования — chat_id. Все сообщения чата на одном шарде, счётчик seq там же. Распределять чаты по узлам удобно через консистентное хеширование, чтобы добавление узла не перетасовывало всё.

Медиа в эту таблицу не попадает. Клиент загружает файл в объектное хранилище по подписанной ссылке, а в сообщении лежит только идентификатор. Раздавать картинки лучше через CDN.

Шаг 7. Доставлено и прочитано

Наивная идея: колонка status у каждого сообщения. В личном чате работает, в группе на 500 человек превращается в 500 обновлений на каждое сообщение, а это 140 тыс. × 500 записей в секунду в худшем случае.

Правильная модель: курсор на участника. «Боб прочитал всё в этом чате до seq 1520». Одна строка на пару (чат, участник), одно обновление, когда Боб пролистал чат, а не по одному на сообщение. То же самое для «доставлено».

# Курсоры прочтения: user_id -> последний прочитанный seq в этом чате last_read = {1: 7, 2: 5, 3: 7} last_seq = 7 def unread(user_id: int) -> int: return last_seq - last_read[user_id] def read_by_all(seq: int, sender_id: int) -> bool: return all(r >= seq for u, r in last_read.items() if u != sender_id) def mark_read(user_id: int, seq: int) -> None: # Курсор только растёт: старый пакет «прочитано» не откатит его назад last_read[user_id] = max(last_read[user_id], seq) print(unread(2)) # 2 print(read_by_all(6, sender_id=1)) # False: участник 2 дочитал до 5 mark_read(2, 7) mark_read(2, 4) print(read_by_all(6, sender_id=1), unread(2)) # True 0

Счётчик непрочитанных получается вычитанием, хранить его отдельно не нужно. max в mark_read важен: кадры с двух устройств приходят в любом порядке, и курсор не должен откатываться.

В больших группах статусы обычно упрощают: показывают «прочитано» только отправителю и только по запросу, а не рассылают каждое событие всем участникам.

Шаг 8. Групповые чаты

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

Оптимизация, которую стоит назвать: группировать отправку по шлюзам. Если 40 участников сидят на одном шлюзе, ему уходит один кадр со списком получателей, а не 40.

Для каналов на сотни тысяч подписчиков логика переворачивается. Рассылать каждое сообщение всем бессмысленно: большинство сейчас не смотрит в этот канал. Сообщение пишется один раз, а клиенты, открывшие канал, подписываются на его обновления или догружают по seq. Это тот же выбор между fan-out on write и on read, что и в ленте новостей.

Шаг 9. Онлайн-статус

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

Механика простая: клиент шлёт heartbeat, сервис хранит время последнего сигнала с TTL. Пропустил пару сигналов — офлайн.

import time HEARTBEAT = 30 # Клиент шлёт пинг раз в 30 секунд TTL = 2 * HEARTBEAT + 10 # Пропустил два пинга подряд, значит офлайн last_seen = {} # В проде это Redis: SET presence:<id> <ts> EX 70 def heartbeat(user_id: int, now: float) -> None: last_seen[user_id] = now def status(user_id: int, now: float) -> str: ts = last_seen.get(user_id) if ts is None: return "давно не заходил" if now - ts <= TTL: return "в сети" minutes = int((now - ts) // 60) return f"был {minutes} мин. назад" t0 = time.time() heartbeat(42, t0) print(status(42, t0 + 45)) # в сети print(status(42, t0 + 600)) # был 10 мин. назад print(status(7, t0)) # давно не заходил

Сложность не в хранении, а в рассылке изменений. Если у человека 1000 контактов и каждый его выход в метро рассылать всем, получится шторм событий. Что помогает:

  • Ленивые подписки. Статус отправляется только тем, у кого сейчас открыт чат с этим человеком или список чатов на экране.
  • Сглаживание. Переход «в сети → офлайн» отправляют с задержкой в десятки секунд: человек в лифте не должен мигать.
  • Без гарантий. Событие присутствия можно потерять, через 30 секунд придёт следующее. Очередь с подтверждениями здесь не нужна.

Шаг 10. Push-уведомления

Если у получателя нет активного соединения, сервис сообщений кладёт задачу в очередь, а push-сервис отправляет уведомление через APNs для iOS и FCM для Android. Детали, которые оценят:

  • Push не канал доставки, а будильник. Само сообщение клиент после пробуждения забирает через sync. Уведомление может не дойти или прийти дважды, и это ни на что не влияет.
  • Токены устройств хранятся отдельно и протухают: на ответ «токен недействителен» его надо удалять.
  • Сообщения в активную группу склеивают: десять уведомлений подряд из одного чата раздражают.
  • На внешние сервисы действуют лимиты, поэтому push-сервису нужен свой rate limiter и повтор с паузой.

Узкие места и вопросы вглубь

Шлюз упал. 100 тыс. клиентов переподключаются одновременно. Клиенты должны ждать случайную паузу перед повтором (backoff с джиттером), иначе соседние шлюзы лягут следом. Сообщения не теряются: всё, что получило ack, уже в хранилище, а пропущенное догрузится по seq.

Горячий чат. Счётчик seq и партиция одного очень активного чата сидят на одном узле. Для обычных групп этого хватает с запасом, для гигантских каналов запись пачками и отдельные шарды.

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

Согласованность. Сообщения требуют строгой записи (кворум), присутствие и счётчики — нет. Это хороший момент вспомнить про CAP и PACELC.

Типичные ошибки

  • Сортировать сообщения по времени клиента или сервера вместо номера в чате.
  • Отвечать отправителю «доставлено» до записи в хранилище.
  • Хранить статус прочтения у каждого сообщения для каждого участника.
  • Рассылать онлайн-статус всем контактам при каждом изменении.
  • Считать push способом доставки сообщения, а не сигналом «зайди и забери».
  • Забыть про несколько устройств: если сервер помнит только «отправил Бобу», десктоп Боба никогда не узнает о сообщениях, которые получил телефон.

Если за 45 минут вы успели разобрать путь сообщения, порядок, дубли, хранение и статусы, а присутствие и push назвали с цифрами, этого достаточно для уверенного ответа.

Потренироваться в спокойной обстановке

Все задачи