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 реакций в минуту. Как защитить БД?