Курс «Архитектура мессенджера» · Часть 2 · Соединение и доставка

Раздел 3. Как клиент держит связь с сервером

Постоянное соединение (WebSocket / long-lived TCP) против polling; gateway- и connection-серверы; как сервер понимает, к какому узлу подключён получатель.

В Разделе 1 мы выяснили: мессенджеру нужна постоянная связь, потому что сервер должен сам толкать сообщения тому, кто не спрашивает. Этот раздел — про то, как именно клиент держит эту связь: почему не годится опрос, что такое WebSocket, как устроены серверы соединений и как сервер вообще находит, куда доставлять сообщение.

Почему не опрос (polling)

Наивная идея — пусть клиент сам периодически спрашивает «нет ли мне новых сообщений». Это называется опрос (polling), и он плох в обоих вариантах:

  • Частый опрос (short polling): клиент дёргает сервер каждую секунду. Получается лавина пустых запросов — почти всегда «ничего нового», а сеть, процессор и батарея тратятся впустую. На миллионах пользователей это неподъёмно.
  • Редкий опрос: дёргаем реже — экономим ресурсы, но получаем задержку до интервала опроса. Сообщение пришло сразу после запроса — пользователь увидит его только через минуту.
  • Long polling: компромисс — запрос «висит», пока не появятся данные. По задержке лучше, но соединение одноразовое (пришёл ответ — нужен новый запрос), инициатива по-прежнему у клиента, и держать массу висящих запросов дорого.

Вывод: опрос не решает задачу. Нужен канал, по которому сервер сам пушит данные, как только они появились.

WebSocket: постоянный двусторонний канал

Этот канал и даёт WebSocket. Технически это «апгрейд» обычного HTTP-соединения: клиент и сервер один раз делают рукопожатие, после чего поверх одного TCP-соединения открывается постоянный двусторонний (full-duplex) канал. Дальше обе стороны шлют друг другу данные в любой момент, не открывая соединение заново.

Есть родственная технология — SSE (Server-Sent Events), но она односторонняя (только сервер → клиент). Чату нужен обмен в обе стороны, поэтому берут WebSocket.

Аналогия из Раздела 1: опрос — это бумажные письма «есть что-нибудь? — нет», а WebSocket — телефонный звонок: соединился один раз и говоришь в обе стороны, пока не положил трубку.

Мобильная обвязка: heartbeat и переподключения

На мобильных соединение нестабильно, поэтому вокруг WebSocket навешивают обвязку:

  • Heartbeat (ping-pong): устройство и сервер обмениваются короткими сигналами «я жив». Пропали сигналы — связь считают мёртвой и закрывают. (Из этого же факта бесплатно получается онлайн-статус — Раздел 7.)
  • Переподключение с backoff и jitter: связь рвётся регулярно; клиент переподключается, но не мгновенно и не все разом — с нарастающей паузой (backoff) и случайным разбросом (jitter), чтобы не устроить «шторм».
  • Догрузка пропущенного: после переподключения клиент забирает из очереди то, что пришло, пока он был offline.

И важная граница: живая связь существует, только пока приложение открыто. Свернул или закрыл — операционная система рвёт соединение ради экономии батареи, и тогда в дело вступают push-уведомления (Раздел 5).

Серверы соединений: stateful против stateless

Соединения держит не Message-сервис, а отдельный слой — серверы соединений (Conn-серверы, gateway). Они держат открытые сокеты и гоняют по ним кадры, а вся бизнес-логика живёт в Message-сервисе.

Зачем такое разделение? Потому что у этих двух частей разная природа:

  • сервер соединений stateful — он привязан к конкретным живым сокетам, и его тяжело масштабировать и перезапускать;
  • логику хотят держать stateless — её легко размножать и обновлять, любой узел обрабатывает любой запрос.

Разделив их, мы масштабируем тяжёлый «держатель соединений» отдельно от лёгкой логики.

Масштаб тут серьёзный: задача «держать много открытых соединений» известна как C10K, а в больших мессенджерах это уже C10M — миллионы соединений. Каждое соединение ест память и файловый дескриптор, поэтому используют эффективные механизмы вроде epoll и тюнят ОС. Один узел тянет примерно 500 тыс — 1 млн соединений, так что на пик в десятки миллионов нужны десятки-сотни узлов.

Как найти получателя: Реестр сессий и pub/sub

Соединение Боба висит на каком-то одном узле, а сообщение пришло на узел Алисы. Как Message-сервис узнаёт, куда доставлять? Двумя инструментами:

  • Реестр сессий — справочник адресов. При подключении узел записывает «пользователь/устройство → на каком узле он сейчас висит» в быстрое общее хранилище (типа Redis, живёт в памяти), с TTL и обновлением при переподключении. При отправке Message-сервис смотрит в реестр, находит узел получателя и пересылает сообщение туда по внутренней сети. К реестру обращаются на каждое сообщение, поэтому он сам — нагруженный компонент, его тоже шардируют и реплицируют.
  • Шина публикаций-подписок (pub/sub) — для рассылки многим. Когда получателей много (группа, канал), искать каждого в реестре невыгодно; удобнее, чтобы узлы подписывались на «каналы» своих пользователей, а отправитель публиковал сообщение один раз — и все нужные узлы получали его сами.

Коротко: реестр — для точечной адресации одному, шина — для рассылки многим. На практике их сочетают (подробнее об операционной стороне — Раздел 12).

Балансировка постоянных соединений

Обычные запросы балансировщик раскидывает по одному, а постоянное соединение так не получится: его нельзя перекидывать на другой узел на каждом кадре. Поэтому соединение балансируют один раз при установке и держат «прилипшим» (sticky) к своему узлу на всё время жизни. Работает это на транспортном уровне (L4) — балансировщику не нужно понимать содержимое, только распределить соединение.

Опасный момент — шторм переподключений: когда узел падает или его выкатывают, все его соединения рвутся разом и клиенты ломятся заново все сразу. Защита: плавный вывод узла (drain) при выкатке, разброс и нарастающая пауза (jitter + backoff) у клиентов, и расчёт на то, что данные не теряются — пропущенное лежит в очереди и догрузится после переподключения.

Вопросы интервьюера (с разбором)

«Почему для мессенджера не подходит опрос (polling), и что используешь вместо него?» Образец: «Частый опрос — это лавина почти всегда пустых запросов, бьёт по сети, процессору и батарее; редкий опрос экономит ресурсы, но даёт задержку до интервала. Long polling лучше по задержке, но соединение одноразовое и инициатива всё равно у клиента. Мне же нужно, чтобы сервер сам толкал данные, как только они появились, поэтому беру постоянное двустороннее соединение — WebSocket: одно рукопожатие, дальше full-duplex канал.»

«Зачем выделять серверы соединений отдельно от Message-сервиса?» Образец: «Потому что у них разная природа. Сервер соединений stateful — он держит конкретные живые сокеты, его тяжело масштабировать и перезапускать. Логику же хочу держать stateless, чтобы легко размножать и обновлять. Разделив их, я масштабирую тяжёлый держатель соединений отдельно от лёгкой логики. Плюс держать миллионы соединений (C10M) — это отдельная инженерная задача с памятью и дескрипторами на каждое, её удобно решать в выделенном слое.»

«Как сервер понимает, к какому узлу подключён получатель?» Образец: «Через Реестр сессий — быстрое общее хранилище в памяти (типа Redis), где при подключении пишется, на каком узле сейчас висит пользователь или устройство. При отправке Message-сервис смотрит в реестр, находит узел получателя и пересылает сообщение туда по внутренней сети. Для рассылки многим вместо поиска каждого использую шину публикаций-подписок: узлы подписаны на своих пользователей, отправитель публикует один раз. Реестр — для адресации одному, шина — для многих.»


#мессенджер #websocket #постоянное-соединение #connection-серверы #маршрутизация #балансировка #подготовка-к-интервью