Real-Time Backend

Chat - архитектура

WhatsApp доставляет 100 млрд сообщений в день. Telegram держит группы до 200 000 человек. Discord обслуживает 19 млн серверов. Что общего у их архитектур?

  • **WhatsApp** (100 млрд сообщений/день) использует детерминированный ключ канала `min(id):max(id)` - никакого поиска в БД при первом сообщении
  • **Slack** ввёл threads в 2017 и добавил `threadId` + `parentId` - два поля вместо одного для поддержки будущей вложенности
  • **Discord** шардирует fan-out по размеру сервера: до 1000 участников - прямой broadcast, больше - Kafka pipeline
  • **Telegram** мегагруппы (200k участников) переходят в pull-модель при открытии чата вместо push всем

Архитектура 1:1 чата

1:1 чат - простейшая топология, но с нетривиальным вопросом: как определить канал между двумя пользователями? Создавать комнату при первом сообщении - значит иметь N*(N-1)/2 потенциальных комнат. WhatsApp и Telegram используют детерминированный ключ: `min(userId_A, userId_B):max(userId_A, userId_B)`.

Telegram обрабатывает 15 млрд сообщений в день. Ключ их масштабируемости - шардирование по `channelId`: все сообщения одного диалога живут на одном шарде, что гарантирует локальность данных при пагинации истории.

Storing канала лениво (при первом сообщении) экономит хранилище: у пользователя с 1000 контактами потенциально 1000 DM-каналов, но реально используется 20-30. Facebook Messenger именно так и работает.

Пользователи A (id=5) и B (id=3) начинают переписку. Детерминированный channel ID - это...

Групповые чаты: масштабирование доставки

Групповые чаты разрывают простую модель 1:1. Telegram-группа может содержать 200 000 участников. При новом сообщении нужно доставить его всем онлайн-участникам - это fan-out проблема.

Discord использует гибридный подход: до 1000 участников - прямой fan-out через internal pub/sub, больше - асинхронная очередь через Elixir/Phoenix Channels. При 19 млн серверов Discord обрабатывает 4 млн сообщений в минуту.

  • **Small groups (< 100)**: прямой fan-out через WebSocket broadcast
  • **Medium groups (100-10k)**: Redis Pub/Sub с шардированием по серверам
  • **Large groups (> 10k)**: Kafka fan-out с batch-доставкой по воркерам
  • **Мегагруппы Telegram (> 100k)**: сообщения не гарантированно доходят всем - только онлайн + pull при открытии

Discord-сервер с 50 000 участниками получает новое сообщение. Почему прямой fan-out через WebSocket не подходит?

Threads: вложенные обсуждения

Threads (треды) - ответы на конкретное сообщение, образующие вложенное обсуждение. Slack ввёл их в 2017, значительно усложнив модель данных: каждое сообщение теперь может быть корнем дерева.

Slack хранит 10+ млрд сообщений. Ключевое решение: `threadId` и `parentId` - это два разных поля. `threadId` указывает на корень треда (для group-by), `parentId` - на непосредственного родителя (для будущей поддержки вложенных ответов). Slack использует только один уровень вложенности.

Денормализация `replyCount` и `lastReplyAt` критична для производительности. Без них каждый рендер списка каналов требовал бы агрегирующего запроса по всем сообщениям тредов.

При запросе сообщений канала в Slack-подобном приложении нужно показать только корневые сообщения (не ответы в тредах). Какой WHERE-clause правильный?

Reactions: счётчики в реальном времени

Reactions (эмодзи-реакции) кажутся простыми, но создают hotspot: популярное сообщение в Slack получает сотни реакций в минуту. Наивное решение - UPDATE счётчика в БД на каждую реакцию - убивает производительность.

Slack использует CRDT-подобный подход для реакций: каждое добавление/удаление - это отдельная запись в append-only лог. Итоговый счётчик вычисляется при чтении. Это позволяет обрабатывать конкурентные реакции без блокировок.

  • Хранить `(messageId, userId, emoji)` как уникальную запись - один пользователь не может поставить одну реакцию дважды
  • Денормализовать счётчики в `message.reactionSummary` JSONB - читать из одной строки
  • Использовать атомарный UPDATE с JSONB-функциями - избежать race condition
  • Throttle broadcast реакций: если приходит 10 реакций за 100мс - слать один батч

Reactions - это просто счётчики, INCREMENT/DECREMENT в одной колонке

Реакции требуют отдельной таблицы с уникальностью по (messageId, userId, emoji), а счётчики - это денормализация для производительности чтения

Без отдельной таблицы невозможно: (1) показать кто поставил реакцию, (2) гарантировать что один пользователь не поставит одну реакцию дважды, (3) атомарно отменить реакцию

Почему для хранения реакций используется отдельная таблица `(messageId, userId, emoji)` вместо простого счётчика в таблице сообщений?

Итоги

  • **Детерминированный channel ID**: `min(A,B):max(A,B)` - канал не нужно создавать заранее, ключ вычисляется на лету
  • **Fan-out стратегия зависит от размера**: малые группы - прямой broadcast, большие - Kafka + воркеры
  • **Reactions = отдельная таблица**: счётчик - денормализация, источник правды - `(messageId, userId, emoji)`

Связанные темы

Chat-архитектура строится на нескольких фундаментальных паттернах:

  • WebSocket scaling — Доставка сообщений в реальном времени участникам канала
  • Database sharding — Шардирование по channelId для локальности данных
  • Fan-out patterns — Pub/Sub и очереди для масштабирования групповой доставки

Вопросы для размышления

  • Как изменится архитектура если добавить поддержку пересылки сообщений между каналами?
  • Telegram-каналы (не группы) имеют миллионы подписчиков. Как доставка отличается от групповых чатов?
  • Reactions на сообщение с 1 млн просмотров получают 10k реакций в минуту. Как защитить БД?

Связанные уроки

  • sd-14-twitter
Chat - архитектура

0

1

Войти