open atlas
↑ К треку
Разборы System Design SDC · 02 · 03

Спроектируй чат

Спроектируй WhatsApp/Messenger: постоянные WebSocket vs long-poll, реестр соединений для маршрутизации, доставка с порядком в диалоге и тремя галочками (отправлено/доставлено/прочитано), presence на масштабе, офлайн-очереди, fan-out групп и хранилище сообщений.

SDC Senior ◷ 34 min
Уровень
ОсновыJuniorMiddleSenior

Команда построила MVP чата на обычном HTTP-поллинге: каждый клиент спрашивал «есть новые сообщения?» каждые две секунды. Демо прошло нормально. На паре сотен тысяч пользователей он рухнул — не от объёма сообщений, который был крошечным, а от неустанных пустых поллов. Девяносто восемь процентов запросов возвращали «ничего нового», но каждый стоил полного TCP/TLS-хендшейка, проверки авторизации и запроса к базе. Серверы были прижаты на 90% CPU, обслуживая ничто. Первая реакция команды — поллить реже, что сделало приложение лаговым, не починив нагрузку. Реальная проблема была архитектурной: чат — это система, где сервер обязан толкать клиенту в момент прихода сообщения, а модель запрос/ответ фундаментально не может этого без расточительного поллинга. Этот урок проектирует push-систему под чатом в стиле WhatsApp — соединения, маршрутизацию, порядок, галочки, presence и хранилище.

Требования

Прежде чем перечислять фичи, спроси себя: что фундаментально отличает чат от обычного API запрос/ответ? Ответ на этот вопрос определяет весь дизайн — tier соединений, реестр, гарантию порядка.

Функциональные: один-на-один и групповая переписка; сообщения доставляются почти в реальном времени, когда получатель онлайн, и ставятся в очередь на доставку, когда офлайн. Галочки доставки — отправлено, доставлено, прочитано (знакомые галочки). Presence — онлайн/был(а) в сети/печатает. Порядок — сообщения в диалоге появляются в консистентном порядке для всех участников. История сообщений персистится и синхронизируется между устройствами пользователя.

Нефункциональные: низкая задержка (сообщение должно прилетать сильно меньше чем за секунду, когда обе стороны онлайн). Надёжная доставка — отправленное сообщение никогда не теряется, даже через дисконнекты, краши и офлайн-получателей; это подразумевает at-least-once доставку плюс дедуп. Огромное число соединений — сотни миллионов устройств, каждое держит долгоживущее соединение, что фундаментально иная задача масштабирования, чем запрос/ответ (ты масштабируешь по одновременным соединениям, а не RPS). Eventual consistency норм для presence и галочек прочтения; порядок внутри диалога должен быть консистентным.

Определяющее свойство: сервер обязан инициировать доставку (push), так что архитектура построена вокруг постоянных соединений и маршрутизации сообщения на тот сервер, что сейчас держит соединение получателя — противоположность stateless HTTP.

Оценка

500M активных в день, большинство держит приложение подключённым, значит порядка сотен миллионов одновременных долгоживущих соединений. Один сервер держит десятки-до-низких-сотен тысяч простаивающих WebSocket (память и файловые дескрипторы на соединение — предел, не CPU), так что нужны тысячи серверов-гейтвеев лишь чтобы терминировать соединения. Объём сообщений: если каждый шлёт ~40 сообщений/день, это 2×10^10 сообщений/день, около 250 000 сообщений/секунду в среднем, кратно на пике. Каждое сообщение мало (сотни байт), так что хранилище доминируется числом, а не размером: ~20 миллиардов сообщений/день по несколько сотен байт — несколько ТБ/день, требуя оптимизированного на запись, горизонтально шардированного хранилища сообщений с политикой хранения.

Вердикт салфетки: ось масштабирования — одновременные соединения, что вынуждает выделенный stateful tier соединений отдельно от stateless бизнес-логики — и способ найти, какой гейтвей держит данного пользователя.

Высокоуровневый дизайн

Система делится на stateful tier соединений и stateless ядро маршрутизации/хранилища.

  • Гейтвеи соединений терминируют постоянные WebSocket-соединения. Они stateful (держат сокеты) и существуют лишь чтобы принимать от клиентов и толкать им.
  • Чат-сервис stateless: он персистит сообщение (назначая порядковый номер в диалоге), ищет получателя в реестре и форвардит на доставку.
  • Реестр соединений отображает юзер/устройство, гейтвей, так что любой инстанс чат-сервиса находит, куда слать сообщение. Обновляется на коннект/дисконнект.
  • Хранилище сообщений — долговечная история, шардированная по диалогу, упорядоченная по seq.
  • Офлайн-очередь держит сообщения для отключённых пользователей до их переподключения и синхронизации.

Глубокое погружение

WebSocket vs long-poll и tier соединений

HTTP — запрос/ответ: клиент обязан спросить, прежде чем сервер ответит. Для чата это значит поллинг, и hook показывает, почему поллинг проваливается — пустые поллы доминируют, и каждый несёт полный оверхед запроса. Починки по качеству:

  • Long-polling: клиент делает запрос, который сервер держит открытым, пока не будет сообщения (или таймаута), затем отвечает; клиент сразу перезапрашивает. Это убирает спам пустых поллов, но всё ещё платит новым запросом на сообщение и неловок для всплесков server-push. Это фолбэк для сред без WebSocket.
  • WebSocket: одно TCP-соединение, апгрейженное из HTTP раз, остающееся открытым для двунаправленной переписки с крошечным оверхедом фрейминга на сообщение. Сервер может толкать в момент прихода сообщения. Это основной транспорт современного чата.

Поскольку соединения долгоживущие и stateful, tier соединений — своя сущность: ты масштабируешь его добавлением гейтвеев, а крах гейтвея роняет его соединения (клиенты переподключаются, в идеале к другому гейтвею, с backoff и джиттером, чтобы избежать thundering herd переподключений). Реестр обязан обновляться на каждом коннекте/дисконнекте, потому это быстрое in-memory хранилище (в стиле Redis), а не реляционная таблица — churn соединений постоянен.

Почему это работает

Почему разделять stateful гейтвеи соединений и stateless чат-логику, а не делать всё в одном процессе? Потому что у них противоположные свойства масштабирования и отказа. Tier соединений масштабируется по одновременным соединениям и ограничен памятью/fd; он держит долгоживущее состояние (открытые сокеты) и обязан грациозно переживать деплои (не хочешь, чтобы рутинный деплой ронял 100k сокетов). Чат-логика масштабируется по пропускной способности сообщений, ограничена CPU, stateless, и ты переразвёртываешь её свободно. Сплавить их — значит каждый деплой бизнес-логики отключает всех, и ты не можешь масштабировать две оси независимо. Реестр — шов, позволяющий им быть раздельными: гейтвеи владеют сокетами и регистрируют их, stateless сервис смотрит в реестр для маршрутизации — так ты деплоишь логику, не трогая соединения, и добавляешь ёмкость соединений, не трогая логику.

Доставка сообщений, порядок и три галочки

Надёжная доставка — at-least-once с дедупом. Клиент отправителя назначает клиентский id сообщения; сервер персистит сообщение до ack отправителю (так что крах после ack никогда не теряет его), а получатель дедупит по id сообщения (так что переотправка после переподключения не дублирует). Это даёт пользователю effectively-once.

Порядок — в диалоге, не глобальный — нет осмысленного глобального порядка по всему WhatsApp, лишь «внутри этого чата, в каком порядке произошли сообщения». Чистый способ — назначить монотонный порядковый номер в диалоге при персисте сообщения и рендерить у клиентов по seq. Поскольку все сообщения одного диалога маршрутизируются через и хранятся на одном шарде (шардировано по id диалога), единственный авторитет seq на диалог даёт тотальный порядок без глобальной координации. Клиенты примиряют приход не по порядку (сетевое переупорядочивание, ретраи) по seq.

Три галочки — три состояния доставки, текущие обратно отправителю отдельными событиями:

  • Отправлено — сервер персистировал сообщение (одна галочка).
  • Доставлено — устройство получателя приняло его, то есть оно было толкнуто в сокет и подтверждено клиентом (две галочки).
  • Прочитано — получатель открыл диалог (две синие галочки).

Каждая — подтверждение, идущее обратным путём. Галочки доставлено и прочитано сами — малые сообщения, маршрутизируемые обратно отправителю (и могут батчиться/быть eventual — никому не нужна галочка прочтения за 10 мс).

Частая ошибка

Классический баг корректности — подтверждать отправителю до долговечного хранения сообщения — «оптимистично» отвечать «отправлено» в момент, когда гейтвей принял, а персистить асинхронно. Кажется быстрее, но если сервер падает между ack и записью, сообщение пропало, пока UI отправителя показывает уверенную одну галочку. Сообщение тихо потеряно, а это единственное, что чат никогда не должен делать. Правило: персист до ack. Галочка «отправлено» отправителя должна значить «у сервера оно долговечно», а не «сервер его видел». Та же дисциплина на стороне получателя: галочка «доставлено» должна значить, что клиент долговечно принял и подтвердил, так что крах получателя в середине доставки переотправляет при переподключении (дедуп делает это безопасным), а не роняет сообщение. У багов порядка тот же корень — назначай seq на персисте, в единственной точке, где сообщение становится реальным, а не на приёме на гейтвее, который может быть одним из нескольких в гонке.

Presence, офлайн-доставка и групповой чат

Presence (онлайн / был в сети / печатает) — высокочастотное, малоценное на обновление состояние, и трактовать его как сообщения затопило бы систему. Оно живёт в быстром in-memory хранилище по ключу пользователя, обновляется коннектом/дисконнектом гейтвея и хартбитами и читается по требованию. Ловушка масштабирования — fan-out presence: наивно толкать «X вышел онлайн» всем контактам X значит, что событие коннекта популярного пользователя разворачивается в тысячи push. Так что presence обычно тянут (клиент берёт presence контактов, сейчас видимых на экране) или толкают лишь ограниченному заинтересованному набору, и он явно eventual — слегка устаревшее «был в сети» приемлемо, недоставленное сообщение — нет.

Офлайн-доставка: если реестр показывает, что у получателя нет живого соединения, сообщение персистится (оно всегда так) и помечается для офлайн-очереди. При переподключении клиент синхронизирует всё с момента последнего подтверждённого seq на диалог — seq двоится как курсор синхронизации. Потому персистентность безусловна: онлайн-доставка — оптимизация поверх фундамента store-and-sync, а не его замена.

Групповой чат — снова fan-out. Сообщение в группу на 500 участников должно достичь до 500 получателей. Малые группы разворачиваются на записи (push в путь доставки каждого участника); очень большие группы/широковещательные каналы склоняются к fan-out-on-read или гибриду, ровно как лента — та же логика степенного закона. Доставка каждого участника всё равно течёт через реестр на его гейтвей (или в его офлайн-очередь), и каждый держит свою позицию прочтения в диалоге.

Викторина

Твой бэкенд чата прижат на 90% CPU при паре сотен тысяч пользователей, но объём сообщений крошечный и 98% запросов возвращают 'ничего нового'. В чём архитектурная починка?

Викторина

Два пользователя на разных гейтвеях быстро переписываются; сообщения иногда рендерятся не по порядку, и после переподключения одно сообщение дублируется. Как получить консистентный порядок и без дублей?

Закончи аналогию

Чтобы маршрутизировать сообщение онлайн-получателю, stateless чат-сервис ищет получателя в _______ — быстрой in-memory карте от каждого подключённого пользователя/устройства к гейтвею соединений, сейчас держащему его сокет — затем форвардит сообщение тому гейтвею, который толкает его в сокет.

Выбери лучший вариант

Вы проектируете транспорт реального времени для чат-системы с 500 млн ежедневных активных пользователей. Большинство клиентов остаются подключёнными часами и ожидают доставки сообщений за доли секунды. Какую транспортную модель следует использовать?

Узкие места и компромиссы

Связывающее ограничение — одновременные соединения, а не темп запросов — чат масштабируется по иной оси, чем типичные веб-сервисы, потому ему нужен выделенный stateful tier гейтвеев. Ключевые компромиссы:

  • WebSocket vs long-poll vs поллинг. WebSocket даёт истинный двунаправленный push при минимальной стоимости на сообщение, но нуждается в липких, stateful соединениях и грациозной обработке переподключений; long-poll — фолбэк совместимости; обычный поллинг расточителен и неверен для push (hook).
  • Порядок в диалоге vs глобальный порядок. Seq в диалоге даёт тотальный порядок там, где он важен, без глобальной координации; настаивание на глобальном порядке не покупает ничего и тормозит всё.
  • At-least-once + дедуп vs exactly-once. Надёжная доставка через переподключения значит at-least-once плюс id сообщения для дедупа (персист до ack); истинный exactly-once по ненадёжной сети недостижим.
  • Presence: push vs pull, и eventual. Presence высокочастотен и малоставочен, так что eventual и обычно тянется для видимых контактов; толкать каждое изменение presence катастрофически разворачивается для популярных пользователей.
  • Fan-out групп: запись vs чтение. Малые группы разворачиваются на записи; огромные группы/каналы используют read-side или гибридный fan-out — тот же компромисс степенного закона, что у ленты.

Глубочайший принцип: персистентность безусловна, а онлайн-доставка — оптимизация поверх store-and-sync. Персист до ack; используй seq в диалоге и как порядок, и как курсор синхронизации; и никогда не давай «быстрому пути» (push) стать способом потерять сообщение, что «медленный путь» (хранить, затем синхронизировать при переподключении) сохранил бы.

Вспомните перед уходом
  1. 01
    Почему чату нужны WebSocket и отдельный tier соединений, а не HTTP-поллинг?
  2. 02
    Как достигаются надёжная доставка и консистентный порядок и что за правило персист-до-ack?
  3. 03
    Что за три галочки и как каждая путешествует?
  4. 04
    Как обрабатываются presence и офлайн-доставка на масштабе и почему персистентность безусловна?
Итог

Чат фундаментально server-push, потому поллинг проваливается (hook: пустые поллы прижимают CPU, доставляя ничто) и потому архитектура центрируется на постоянных WebSocket-соединениях. Эти соединения долгоживущие и stateful, так что живут в выделенном tier гейтвеев соединений, масштабируемом по одновременным соединениям, отдельно от stateless, CPU-bound чат-логики; реестр соединений (быстрая in-memory карта от юзера/устройства к гейтвею) — шов, позволяющий stateless сервису маршрутизировать сообщение на единственный сервер, способный его толкнуть, и позволяющий двум tier масштабироваться и деплоиться независимо. Доставка — at-least-once с дедупом по клиентскому id сообщения, и кардинальное правило — персист до ack — галочка «отправлено» должна значить долговечно сохранено. Порядок — в диалоге через монотонный seq, назначенный на персисте на шарде диалога, который также двоится как курсор офлайн-синхронизации. Три галочки (отправлено/доставлено/прочитано) — подтверждения, текущие обратно отправителю. Presence eventual, высокочастотен и тянется, а не разворачивается; офлайн-доставка опирается на безусловную персистентность плюс переподключение-и-синхронизацию; а групповой чат разворачивается на записи для малых групп и на чтении/гибридно для огромных — та же логика степенного закона, что у ленты. Объединяющая идея: онлайн-push — оптимизация поверх долговечного ядра store-and-sync, никогда не его замена. Теперь, когда увидишь сообщения не по порядку или жалобы на дубли после переподключения, ты знаешь, куда смотреть: seq в диалоге, правило персист-до-ack и клиентский id для дедупа.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.