Модуль: 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 поиска).

Оценки нагрузки#

Возьмём средний по размеру мессенджер.

ПараметрЗначениеРасчёт
DAU50Mдано
Concurrent connections~10M~20% DAU онлайн одновременно
Сообщений на пользователя/день40дано
Messages/day2B50M × 40
Avg write QPS~23k2B / 86400
Peak write QPS~115k×5 пик
Fan-out factor (avg)~31:1 и средние группы
Delivery QPS (peak)~350k115k × 3
Размер сообщения (метаданные + текст)~1 KBpayload+headers
Storage/day~2 TB2B × 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–4010M / 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.

  1. N1 (через Chat Service) персистит сообщение и присваивает seq.
  2. Lookup в session registry: B → N2.
  3. Публикация в broker (topic ноды N2 или прямой gRPC N1→N2).
  4. N2 находит локальный коннект B и пишет в его write-горутину. Session registry — Redis с TTL; при разрыве коннекта TTL чистит запись (или explicit deregister). Multi-device: user_idset активных {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 key seq 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) и почему именно он.