Системный дизайн мессенджера на собеседовании: WebSocket, доставка и группы
Как спроектировать чат на system design интервью: требования, оценка нагрузки, WebSocket-шлюзы, хранение сообщений, статусы доставлено и прочитано, группы, онлайн-статус и push.
Коротко
| Вопрос | Решение в этом разборе |
|---|---|
| Нагрузка | около 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 млн × 40 | 4 млрд |
| Запись, среднее | 4 млрд / 86 400 | около 46 тыс. в секунду |
| Запись, пик | 46 тыс. × 3 | около 140 тыс. в секунду |
| Объём в сутки | 4 млрд × 200 байт | 800 ГБ |
| Объём в год | 800 ГБ × 365 | 292 ТБ, около 876 ТБ с тремя копиями |
| Одновременных соединений | 100 млн × 30% | 30 млн |
| Шлюзов | 30 млн / 100 тыс. | 300 плюс запас |
Выводы, ради которых всё это считалось:
- Запись тяжёлая. В отличие от большинства сайтов, чтений здесь не на порядки больше, чем записей: сообщение читают почти сразу, пока оно свежее, а старую историю открывают редко. Нужна база, которая хорошо пишет, например на LSM-деревьях, а не классический B-tree под 140 тыс. вставок в секунду.
- Почти петабайт в год. Шардирование с первого дня, и шард определяется чатом.
- Соединения — отдельный ресурс. 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 по новым подключениям даёт перекос после перезапусков: свежий шлюз пустой, старые забиты. Помогает балансировка по числу активных соединений и мягкий разрыв части соединений при перекосе. Подробнее о стратегиях в статье про балансировку нагрузки.
Путь сообщения
- Алиса шлёт
sendна свой шлюз, шлюз передаёт его сервису сообщений. - Сервис проверяет, что Алиса состоит в чате, присваивает
seqи пишет сообщение в хранилище. - Алисе уходит
ackс номером. - Сервис смотрит в реестр, где сейчас Боб. Если онлайн, отправляет кадр на его шлюз. Если нет, кладёт задачу в очередь push-уведомлений.
- Шлюз Боба доставляет
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 назвали с цифрами, этого достаточно для уверенного ответа.