Real-Time Backend

WebSocket: анатомия протокола

2013 год. Slack только набирает первые тысячи пользователей. Каждые 3 секунды каждый браузер стучится к серверу: "Есть новые сообщения?" Ответ почти всегда: "Нет." Это long-polling - и это катастрофа при росте. Команда переходит на WebSocket и измеряет нагрузку. Падение в 40 раз. Один постоянный канал вместо тысяч одноразовых запросов. Именно это сделало real-time присутствие - зелёный индикатор онлайн-статуса - возможным в масштабе.

  • **Slack** - 40x снижение нагрузки при переходе с polling на WebSocket в 2013
  • **Figma** - совместное редактирование: каждый курсор, каждое выделение - отдельный WS-фрейм в 2-10 байт вместо HTTP-запроса в 500+ байт
  • **Binance** - биржевой стакан обновляется 10-100 раз в секунду, WebSocket единственный практичный вариант
  • **GitHub Copilot** - стриминг токенов через WebSocket: пользователь видит код пока он генерируется, не после

HTTP Upgrade: как HTTP превращается в WebSocket

2013 год. Slack переходит с polling на WebSocket и фиксирует падение нагрузки на сервер в 40 раз - при том же количестве пользователей. Не в 2 раза. Не в 5. В 40. Каждые 3 секунды браузер больше не отправляет HTTP-запрос в пустоту, надеясь что что-то изменилось.

WebSocket начинается как обычный HTTP/1.1 GET. Это не случайность - это хитрость. Корпоративные прокси, балансировщики, CDN - всё это понимает HTTP. Если бы WebSocket стартовал на собственном порту с собственным протоколом, половина корпоративных сетей его бы заблокировала. Вместо этого - троянский конь: начать как HTTP, попросить переключиться.

Код `101 Switching Protocols` - один из редчайших HTTP-статусов. За всю историю интернета он используется почти исключительно для WebSocket. После этого ответа TCP-соединение не закрывается. Оно перестаёт быть HTTP. Тот же сокет, те же байты - но теперь там другой протокол.

`Sec-WebSocket-Key` - это не шифрование и не аутентификация. Это защита от случайных HTTP-серверов, которые могут ответить `101` не понимая что делают. Клиент генерирует случайный base64-ключ, сервер конкатенирует его с магическим UUID `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`, берёт SHA-1, возвращает base64. Клиент проверяет. Handshake подтверждён.

Важный нюанс: WebSocket работает поверх TCP, не HTTP. После `101` HTTP заканчивается. Никаких заголовков, никаких статус-кодов, никакого `Content-Type`. Только фреймы WebSocket поверх голого TCP-соединения. HTTP был транспортом для установки - и больше не нужен.

Почему WebSocket использует HTTP для начала соединения, а не собственный порт?

Фреймирование: как WebSocket упаковывает данные

После handshake оба конца говорят на языке фреймов. Фрейм WebSocket - минималистичная упаковка: 2-10 байт заголовка плюс payload. Для сравнения: HTTP-запрос несёт 500-800 байт заголовков даже если передаёт одно число. WebSocket несёт 2 байта. Именно эта разница объясняет 40x Slack.

Маскирование - ещё одна деталь против случайных прокси. Без маски последовательность байт могла бы совпасть с HTTP-ответом, и промежуточный кэширующий прокси мог бы её сохранить и отдать другому клиенту. С маской (4 случайных байта XOR с payload) это невозможно. Клиент ОБЯЗАН маскировать. Сервер НИКОГДА не маскирует.

Длина payload кодируется компактно: если <= 125 байт - прямо в 7-битном поле. Если <= 65535 - дополнительные 2 байта. Если больше - дополнительные 8 байт. На практике большинство real-time сообщений (позиции курсора, статусы, чат-сообщения) помещаются в первые два случая.

Fragmentation: одно большое сообщение можно разбить на несколько фреймов. FIN=0 означает "продолжение следует", FIN=1 - "это конец". Это позволяет начать отправку данных, не зная итогового размера - потоковая передача без буферизации на отправляющей стороне. Node.js ws-library и браузерный WebSocket API прячут это за `message` событием - видно только собранное целиком сообщение.

Opcodes делят фреймы на два класса: data (text, binary, continuation) и control (close, ping, pong). Control-фреймы не могут быть фрагментированы и имеют ограничение: payload <= 125 байт. Это важно для ping/pong - их нельзя сделать большими.

Почему клиент ОБЯЗАН маскировать фреймы, а сервер - нет?

Ping/Pong, heartbeat и жизненный цикл соединения

TCP-соединение может умереть незаметно. NAT-роутер через 5 минут тишины выбрасывает запись из таблицы состояний. Мобильная сеть переключает базовую станцию. Провайдер перезагружает оборудование. С точки зрения ОС соединение всё ещё "живо" - но пакеты уходят в никуда. WebSocket решает это механизмом ping/pong.

Браузерный WebSocket API не даёт доступа к ping/pong - это уровень протокола, браузер обрабатывает сам. На сервере - полный контроль. Типичный паттерн: ping каждые 30 секунд, если pong не пришёл за следующие 30 - соединение мёртвое, `terminate()`.

Разница между `ws.close()` и `ws.terminate()`: `close()` отправляет Close-фрейм (opcode 0x8) и ждёт ответного Close от другой стороны - graceful shutdown. `terminate()` немедленно уничтожает TCP-соединение без уведомления. Для мёртвых соединений только `terminate()` - Close-фрейм некому получать.

Жизненный цикл WebSocket-соединения проходит через четыре чётких состояния. `CONNECTING` (0): handshake в процессе. `OPEN` (1): соединение установлено, можно слать данные. `CLOSING` (2): Close-фрейм отправлен или получен, идёт graceful shutdown. `CLOSED` (3): соединение закрыто.

Close codes 4000-4999 зарезервированы для приложений. Это стандартный способ передать причину закрытия на прикладном уровне: 4001 - не аутентифицирован, 4002 - превышен rate limit, 4003 - комната закрыта. Клиент видит код и принимает решение: переподключиться, показать ошибку, или завершить сессию.

WebSocket ping/pong - это то же самое что ICMP ping (утилита ping в терминале)

WebSocket ping/pong - application-level механизм RFC 6455 поверх TCP. ICMP ping - network-level протокол. Они не связаны

ICMP работает на уровне IP (L3), WebSocket ping/pong - на уровне приложения (L7). WebSocket ping не покажет что сеть жива - только что другой конец WebSocket-соединения жив и читает данные

Сервер отправляет ping каждые 30 секунд. Клиент перестал отвечать. Как правильно закрыть такое соединение?

Ключевые идеи

  • **HTTP Upgrade** - троянский конь: начать как HTTP, чтобы пройти через прокси, потом переключиться на WS поверх того же TCP-сокета
  • **Фрейм = 2-10 байт заголовка** + payload. HTTP-запрос несёт 500+ байт заголовков в пустоту. Отсюда 40x Slack
  • **Маска** - не шифрование, а защита от прокси-кэширования. XOR с 4 байтами, публичная, клиент обязан, сервер никогда
  • **Ping/pong** - единственный способ обнаружить мёртвое TCP-соединение раньше чем через 2 часа (TCP keepalive по умолчанию)
  • **terminate() vs close()** - мёртвое соединение нужно terminate(), живое закрывают close() с кодом 1000-4999

Связанные темы

WebSocket-протокол - слой под любым real-time приложением:

  • HTTP и его ограничения — WebSocket - прямой ответ на request-response модель HTTP
  • Server-Sent Events — Альтернатива для однонаправленного стриминга без смены протокола
  • Сравнение подходов — Когда WebSocket, SSE, long-polling - критерии выбора
  • Модель OSI — WebSocket handshake - пример взаимодействия L4 и L7 в реальной системе

Вопросы для размышления

  • WebSocket handshake использует HTTP/1.1, а не HTTP/2. Что изменилось бы если бы WebSocket мог работать поверх HTTP/2?
  • Почему маскирование клиентских фреймов защищает от прокси, но маскирование серверных фреймов было бы бесполезно?
  • В какой ситуации graceful close (ws.close()) опаснее terminate() - когда именно стоит предпочесть жёсткое закрытие?

Связанные уроки

  • rt-04 — RFC 6455 - основа, без которой handshake непонятен
  • rt-03-sse — SSE - однонаправленный аналог, сравнение даёт глубину
  • rt-06 — Сравнение подходов строится на знании внутренностей WS
  • net-02-osi-overview — TCP/IP-слои объясняют почему Upgrade работает именно так
  • rt-02-http-limits — WebSocket - прямой ответ на ограничения HTTP
  • net-36-websocket
WebSocket: анатомия протокола

0

1

Войти