Модуль: System Design · Уровень: Senior
TL;DR#
Чат-система — это набор stateful WebSocket-коннектов поверх stateless-логики доставки. Главная боль: коннекты живут на конкретных серверах, а сообщение приходит «не туда». Решается через session registry (Redis: user_id → ws_node) и брокер для маршрутизации между нодами. Доставка — at-least-once + idempotency key + ACK, порядок — монотонный seq per channel (а не глобальный). Хранение горячего потока — wide-column (Cassandra/ScyllaDB) с ключом (channel_id, bucket) / seq. Offline — fallback на push (APNs/FCM) + полная синхронизация по seq при reconnect. Узкое место senior-уровня — не throughput сообщений, а миллионы одновременных stateful-коннектов, presence-стадо и hot groups.
Требования#
Functional#
- 1:1 и групповые чаты (до ~1000 участников в группе для базового дизайна; «каналы»/broadcast — отдельная история).
- Доставка сообщений в (near) real-time, индикатор доставки/прочтения (delivered/read receipts).
- Presence: online / offline / last seen, typing-индикаторы.
- История сообщений, пагинация, синхронизация между устройствами одного пользователя (multi-device).
- Offline-доставка: push-уведомление + догон сообщений при возврате online.
- Гарантии: сообщение не теряется (durable), не дублируется на UI (dedup), порядок в рамках одного чата сохраняется.
Non-functional#
- Latency p99 доставки online↔online < 200 ms.
- Доступность 99.99% на сервисе доставки.
- Durability сообщений: после ACK сервера сообщение не теряется (репликация RF=3).
- Масштаб: десятки миллионов одновременных коннектов.
- Горизонтальное масштабирование gateway и storage.
- Безопасность: TLS, auth токены, опционально E2E-шифрование (меняет дизайн — сервер не видит контент, нет server-side поиска).
Оценки нагрузки#
Возьмём средний по размеру мессенджер.
| Параметр | Значение | Расчёт |
|---|---|---|
| DAU | 50M | дано |
| Concurrent connections | ~10M | ~20% DAU онлайн одновременно |
| Сообщений на пользователя/день | 40 | дано |
| Messages/day | 2B | 50M × 40 |
| Avg write QPS | ~23k | 2B / 86400 |
| Peak write QPS | ~115k | ×5 пик |
| Fan-out factor (avg) | ~3 | 1:1 и средние группы |
| Delivery QPS (peak) | ~350k | 115k × 3 |
| Размер сообщения (метаданные + текст) | ~1 KB | payload+headers |
| Storage/day | ~2 TB | 2B × 1 KB |
| Storage/год | ~730 TB | до репликации; ×3 RF ≈ 2.2 PB |
| Память на коннект | ~10–50 KB | буферы рида/райта + структура сессии |
| RAM на 1M коннектов | ~10–50 GB | + overhead Go runtime/GC |
| Коннектов на WS-ноду | ~250k–500k | при тюнинге (ulimit, эфемерные порты, buffers) |
| Число WS-нод | ~20–40 | 10M / 300k + запас на failover |
Вывод по числам: write-нагрузка (23k–115k QPS) для wide-column store — рутина. Боль — 10M stateful TCP-коннектов: это про лимиты ОС, GC-давление от миллионов горутин/буферов и про маршрутизацию.
Архитектура#
┌─────────────────────────┐
┌──────────┐ WSS (TLS) │ Session Registry │
│ Clients │◄─────────────┐ │ (Redis Cluster) │
│ mobile/ │ │ │ user_id → {node, conn} │
│ web │ │ │ presence + heartbeat │
└──────────┘ │ └───────────▲─────────────┘
│ │ register/lookup
┌──────┴──────┐ │
│ L4 LB / │ ┌─────┴──────────────┐
│ consistent │──►│ WS Gateway nodes │
│ hashing │ │ (Go: hub + │
└─────────────┘ │ goroutine/conn) │
└───┬──────────▲──────┘
│ publish │ deliver
▼ │ (pull/push)
┌─────────────────────────┐
│ Message Broker / Bus │
│ (Kafka / Redis Streams │
│ / NATS) topic per node │
│ or per channel shard │
└───┬───────────────┬──────┘
│ │
┌────────────▼──┐ ┌─────▼──────────┐
│ Chat Service │ │ Push Service │
│ - validate │ │ APNs / FCM │
│ - assign seq │ │ for offline │
│ - fan-out │ └────────────────┘
│ - persist │
└───┬───────────┘
│ write
┌───────────▼────────────┐ ┌──────────────────┐
│ Message Store │ │ Metadata DB │
│ Cassandra/ScyllaDB │ │ (Postgres): │
│ (channel_id,bucket)/seq │ │ users, channels, │
│ RF=3 │ │ membership │
└─────────────────────────┘ └──────────────────┘Компоненты#
- WS Gateway — терминирует WSS, держит коннекты, на каждый коннект горутина (или пара read/write горутин) + центральный hub. При коннекте регистрирует
user_id → node_idв session registry. Stateless по бизнес-логике, stateful по коннектам. - Session Registry (Redis) — карта «где сидит пользователь». Любая нода, получив сообщение для
user_id, ищет его ноду и роутит туда. Также хранит presence (heartbeat с TTL). - Message Broker — транспорт между нодами и сервисами. Варианты: topic-per-node (нода подписана на свой topic, отправитель публикует в topic ноды-получателя) или topic-per-channel-shard. Развязывает приём и доставку, даёт буфер на пиках.
- Chat Service — валидация, проверка membership, присвоение seq (монотонного per-channel), fan-out, запись в store, постановка push для offline-получателей. Stateless, горизонтально масштабируется.
- Message Store (Cassandra/ScyllaDB) — горячий поток сообщений, оптимизирован под запись и под чтение «диапазон по каналу».
- Metadata DB (Postgres) — пользователи, чаты, состав групп, настройки. Сильная консистентность, транзакции.
- Push Service — для offline-получателей шлёт APNs/FCM.
Ключевые решения и trade-offs#
Транспорт: WebSocket vs long-polling vs SSE#
- WebSocket — полнодуплекс, один TCP-коннект, низкий overhead на сообщение, нативный push с сервера. Минус: stateful коннект, балансировка/деплой сложнее, нужны heartbeat (ping/pong) и reconnect-логика.
- Long-polling — работает везде, но дорого (новый HTTP-запрос на каждое сообщение), высокая latency и нагрузка на LB. Годится как fallback.
- SSE — только сервер→клиент, для отправки нужен отдельный HTTP. Проще WS, но половинчатый. Подходит для feed/notifications, не для чата. Выбор: WebSocket как основной, long-polling как degraded fallback за корп. прокси.
Connection state и маршрутизация#
Коннект пользователя B живёт на ноде N2. Сообщение от A приходит на ноду N1. Нужно доставить B.
- N1 (через Chat Service) персистит сообщение и присваивает seq.
- Lookup в session registry:
B → N2. - Публикация в broker (topic ноды N2 или прямой gRPC N1→N2).
- N2 находит локальный коннект B и пишет в его write-горутину.
Session registry — Redis с TTL; при разрыве коннекта TTL чистит запись (или explicit deregister). Multi-device:
user_id→ set активных {device, node}.
Доставка: at-least-once + dedup + ACK#
- Сервер при приёме генерит/принимает
client_msg_id(UUID от клиента) → дедуп на записи (idempotency). Присваивает серверныйseq. - Доставка получателю → клиент шлёт ACK; нет ACK в таймаут → ретрай (возможны дубли, поэтому клиент дедупит по
client_msg_id/seq). - Exactly-once на практике = at-least-once + идемпотентность на обоих концах. «Честный» exactly-once в распределёнке слишком дорог.
Ordering — seq per channel#
- Не глобальный ordering (нереалистично и не нужен). Монотонный
seqв рамках канала: единый аллокатор на канал (шард Chat Service, владеющий каналом, или атомарный counter в Redis/Cassandra LWT). - Клиент сортирует/детектит дыры по seq → если seq не подряд, делает sync-запрос недостающего диапазона.
- Альтернатива для слабых требований: server timestamp + tie-break, но при clock skew ломается; seq надёжнее.
Presence#
- Heartbeat: клиент шлёт ping каждые ~30 c → Redis key
presence:{user}с TTL ~45 c. Нет ping → ключ протух → offline. Eventually consistent (лаг до TTL). - Подписка на presence контактов: дорого делать на каждый контакт push. Обычно — pull при открытии чата + редкие апдейты, либо presence-каналы только для активных диалогов.
- Typing — эфемерно, через broker без персиста, с коротким TTL.
Групповые чаты: fan-out write vs read#
- Fan-out on write (push в «почтовый ящик» каждого участника при отправке): быстрое чтение, но дорого для больших групп (1 сообщение в группе 1000 → 1000 записей). Хорошо для маленьких/средних групп и 1:1.
- Fan-out on read (одна копия в канал, читатели подтягивают): дёшево на запись, дороже на чтение, идеально для больших групп/каналов.
- Гибрид: 1:1 и группы до ~N (например 256) — write fan-out (доставка + per-user mailbox для синка); большие группы/broadcast — read fan-out (одна копия в
(channel_id,...), клиенты тянут по seq).
Offline + sync при reconnect#
- Получатель offline → сообщение всё равно персистится; ставится задача в Push Service (APNs/FCM) с превью.
- При reconnect клиент шлёт
last_seen_seqпо каждому каналу → сервер отдаёт diff(seq > last_seen_seq). Это и есть синхронизация и dedup-граница. - Per-device
last_seen_seqдля корректного multi-device.
Хранение#
- Cassandra/ScyllaDB, partition key
(channel_id, time_bucket), clustering keyseq DESC. Time-bucket (например по дню/часу) ограничивает размер партиции и распределяет нагрузку. Чтение «последние N» и «диапазон seq» — эффективно. - RF=3, запись
LOCAL_QUORUM. TTL для эфемерных данных. Холодные данные → дешёвый объектный storage/архив. - Metadata (membership, настройки) → Postgres (транзакции, FK).
Масштабирование и узкие места#
- Stateful WS-коннекты — главный масштаб. Sticky-сессии не обязательны (нода найдётся через registry), но reconnect должен переинициализировать сессию быстро. Деплой нод = массовый разрыв коннектов → нужен graceful drain (медленный сдвиг трафика, клиентский reconnect с jitter-backoff, иначе thundering herd на reconnect).
- Consistent hashing на LB по
user_idснижает перетасовку при добавлении/удалении нод, но при WS чаще достаточно random LB + registry. Consistent hashing полезен для channel-sharding в Chat Service (владелец канала = аллокатор seq). - Hot groups / hot channels — паблик-канал с миллионами подписчиков ломает write fan-out. Решение: read fan-out + кэш «хвоста» сообщений + батчинг доставки + rate limit на канал.
- Presence-стадо (thundering herd) — при массовом реконнекте (деплой, сетевой сбой) лавина heartbeat/lookup в Redis. Митигация: jitter на heartbeat, батч-апдейты presence, отдельный Redis-кластер под presence, локальный кэш на нодах.
- GC-давление в Go — миллионы горутин и буферов. Тюнинг: пул буферов (
sync.Pool),GOGC/GOMEMLIMIT, объединение мелких write через буфер, уменьшение per-conn аллокаций, рассмотреть epoll-based libs (gnet, nbio) при экстремальном числе коннектов вместо goroutine-per-conn. - Лимиты ОС —
ulimit -n,net.ipv4.ip_local_port_range, conntrack, размеры сокет-буферов,SO_REUSEPORT. - Broker как bottleneck — topic-per-node не масштабируется по числу нод линейно; channel-sharded topics + партиционирование Kafka по
channel_idсохраняют порядок внутри партиции бесплатно.
Go: hub + goroutine-per-connection (gorilla/websocket)#
// Client — одна WS-сессия. Две горутины: readPump и writePump.
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte // буфер исходящих; close => writePump завершается
user string
}
type Hub struct {
register chan *Client
unregister chan *Client
// локальные коннекты этой ноды: user_id -> множество устройств
clients map[string]map[*Client]struct{}
deliver chan Envelope // входящие из брокера для доставки
mu sync.RWMutex
}
func (h *Hub) Run() {
for {
select {
case c := <-h.register:
h.mu.Lock()
if h.clients[c.user] == nil {
h.clients[c.user] = map[*Client]struct{}{}
}
h.clients[c.user][c] = struct{}{}
h.mu.Unlock()
// регистрируем в Redis: user -> этот node_id, с TTL
registerSession(c.user, nodeID)
case c := <-h.unregister:
h.mu.Lock()
if set, ok := h.clients[c.user]; ok {
if _, ok := set[c]; ok {
delete(set, c)
close(c.send)
if len(set) == 0 {
delete(h.clients, c.user)
deregisterSession(c.user, nodeID)
}
}
}
h.mu.Unlock()
case env := <-h.deliver: // пришло из брокера: доставить локально
h.mu.RLock()
for c := range h.clients[env.ToUser] {
select {
case c.send <- env.Payload:
default:
// буфер переполнен -> медленный клиент: дропаем/закрываем
// (sync произойдёт по last_seen_seq при reconnect)
}
}
h.mu.RUnlock()
}
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(30 * time.Second) // ping
defer func() { ticker.Stop(); c.conn.Close() }()
for {
select {
case msg, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok { // hub закрыл канал
c.conn.WriteMessage(websocket.CloseMessage, nil)
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return // мёртвый коннект
}
}
}
}
func (c *Client) readPump() {
defer func() { c.hub.unregister <- c }()
c.conn.SetReadDeadline(time.Now().Add(45 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(45 * time.Second))
refreshSessionTTL(c.user) // presence heartbeat
return nil
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
return // разрыв -> unregister в defer
}
// приходящее сообщение: dedup по client_msg_id, в Chat Service,
// присвоение seq, fan-out через брокер
c.hub.onInbound(c.user, data)
}
}Ключевые приёмы: write только из writePump (нельзя писать в один *websocket.Conn из нескольких горутин — gorilla не потокобезопасен на запись), send — буферизированный канал с дропом при переполнении (медленный клиент не должен блокировать hub), ping/pong как heartbeat и для presence TTL.
Вопросы на собеседовании#
В: Как доставить сообщение пользователю, чей коннект живёт на другой ноде?
О: Session registry (Redis) хранит user_id → node_id. Отправляющая нода делает lookup и роутит сообщение на нужную ноду через брокер (topic-per-node) или прямой gRPC. Целевая нода находит локальный коннект в hub и пишет в его send-канал. Для multi-device — множество {device,node} на user.
В: Какие гарантии доставки вы дадите и как достигнете «не теряем / не дублируем»?
О: At-least-once на проводе + идемпотентность по client_msg_id на записи и на клиенте (dedup по seq). Durability — персист до ACK получателю с RF=3 LOCAL_QUORUM. Exactly-once «честный» не делаем — слишком дорого; эквивалент = at-least-once + dedup на обоих концах.
В: Как обеспечить порядок сообщений в чате?
О: Монотонный seq per-channel от единого аллокатора (шард-владелец канала или атомарный counter). Глобальный порядок не нужен и нереалистичен. Клиент сортирует по seq, детектит дыры и дозапрашивает недостающий диапазон. Kafka-партиционирование по channel_id даёт порядок внутри партиции бесплатно.
В: Fan-out on write или on read для групп? О: Гибрид. 1:1 и небольшие/средние группы — write fan-out (быстрое чтение, per-user mailbox для синка). Большие группы/каналы — read fan-out (одна копия в канал, клиенты тянут по seq), иначе hot group убивает запись. Порог переключения по размеру группы.
В: Как работает presence и почему он eventually consistent? О: Heartbeat (ping каждые ~30 c) обновляет Redis-ключ с TTL ~45 c. Нет ping → ключ протух → offline. Лаг до TTL — отсюда eventual consistency. Подписка на presence контактов — pull при открытии чата + точечные апдейты, чтобы не делать fan-out presence на всех.
В: Что происходит при деплое WS-нод и массовом реконнекте? О: Graceful drain: медленно уводим трафик, клиенты реконнектятся с jitter+exponential backoff, чтобы избежать thundering herd. При reconnect клиент шлёт last_seen_seq → сервер отдаёт diff. Registry чистит старые сессии по TTL/explicit deregister.
В: Где боттлнек при 10M коннектов и как масштабироваться? О: Не throughput сообщений, а stateful-коннекты: память/GC на горутины и буферы, лимиты ОС, presence-стадо. Масштаб: ~300k коннектов/нода → 30–40 нод; sync.Pool для буферов, GOMEMLIMIT, батчинг записей, отдельный Redis под presence, при экстриме — epoll-based (gnet/nbio) вместо goroutine-per-conn.
В: Как хранить сообщения и почему wide-column?
О: Cassandra/Scylla, partition (channel_id, time_bucket), clustering seq DESC. Запись-оптимизирована, чтение «последние N / диапазон seq» эффективно, линейно масштабируется, RF=3. Time-bucket ограничивает рост партиции. Metadata (membership) — в Postgres ради транзакций.
В: Что меняется при E2E-шифровании? О: Сервер не видит контент: нет server-side поиска/модерации/превью в push, fan-out усложняется (ключи на участника), история восстанавливается только при наличии ключей у клиента. Сервер становится «слепым» транспортом + key directory (например протокол Signal/X3DH + Double Ratchet).
На что копают на senior+#
- Чёткое различие «масштаб коннектов» vs «масштаб сообщений» — кандидат должен сразу назвать stateful-коннекты главной болью, а не CRUD сообщений.
- Реалистичность гарантий: не обещать exactly-once и глобальный ordering; уметь обосновать at-least-once + dedup и per-channel seq.
- Маршрутизация между нодами: понимание session registry, его TTL/консистентности, что происходит при гонке reconnect (старая нода ещё держит, новая уже зарегалась).
- Backpressure на медленного клиента: что делать с переполненным send-каналом (дроп + sync по seq, а не блокировка hub).
- Thundering herd при реконнектах и presence-стадо — конкретные митигации (jitter, backoff, батчинг, отдельный кластер).
- Hot partition / hot group: как не убить Cassandra одной горячей партицией и как обслужить мега-канал.
- Go-специфика: потоконебезопасность записи в один Conn, GC-давление от миллионов горутин, sync.Pool, GOMEMLIMIT, выбор goroutine-per-conn vs epoll.
- Multi-device sync и per-device read state, корректный last_seen_seq.
- Деплой stateful-сервиса без потери коннектов (graceful drain, connection draining на LB).
- Трейд-офф выбора брокера (Kafka per-channel ordering vs NATS/Redis Streams latency) и почему именно он.