WebSocket: full-duplex поверх одного TCP
SSE даёт server push, но только в одну сторону. gRPC bidirectional требует HTTP/2 и сложного toolchain-а. Когда нужен симметричный двусторонний канал между браузером и сервером — стандартом остаётся WebSocket, описанный в RFC 6455 с 2011 года.
WebSocket — это протокол, который начинается как обычный HTTP-запрос, но после handshake-а превращает TCP-соединение в двунаправленный поток frame-ов. Дальше HTTP-логики нет — только бинарный протокол с очень простым форматом.
Зачем понадобился WebSocket
До WebSocket в браузерах были два workaround-а для real-time:
- Long polling. Клиент делает GET, сервер не отвечает, пока не появится событие; тогда возвращает ответ и клиент сразу делает следующий GET. Latency приемлемая, но overhead на каждый цикл.
- Comet / forever frame / SSE. Сервер пишет в долгий ответ. В одну сторону работает, для двусторонней связи нужно отдельное соединение для отправки.
WebSocket убрал эти костыли: один TCP, обе стороны равноправны. Идеально для chat-ов, multiplayer-игр, collaborative editing-а (Figma, Notion, Google Docs), trading dashboard-ов.
TCP сокеты: клиент-сервер на уровне BSD socketsUpgrade handshake
WebSocket «маскируется» под HTTP, чтобы пройти через файрволы. Запрос:
GET /ws/chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat.v1, chat.v2
Origin: https://app.example.com
Сервер отвечает:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat.v2
Магия в Sec-WebSocket-Accept: сервер берёт клиентский Sec-WebSocket-Key, конкатенирует с фиксированным GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, вычисляет SHA-1 и кодирует в base64. Этот алгоритм описан в RFC и нужен, чтобы сервер случайно не поверил, что клиент — это WebSocket, когда на самом деле это просто кеш-ответ старого прокси.
После 101 Switching Protocols TCP-соединение перестаёт быть HTTP. Дальше летят WebSocket frame-ы.
Анатомия frame-а
Frame состоит из 2-14 байт заголовка + payload. Минимум:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
- FIN — последний frame в логическом сообщении (можно фрагментировать большое сообщение на несколько frame-ов).
- Opcode — тип frame-а:
0x1text,0x2binary,0x8close,0x9ping,0xApong. - MASK — клиент к серверу обязан XOR-ить payload случайной 4-байтовой маской (защита от cache poisoning старых прокси). Сервер к клиенту маску не использует.
- Payload length — 7 бит (до 125), либо 16 бит (до 65535), либо 64 бита (до 2^63).
В реальности с этим работают библиотеки — никто frame-ы руками не парсит.
Subprotocols
Заголовок Sec-WebSocket-Protocol позволяет клиенту и серверу договориться о формате сообщений поверх WebSocket. Это просто строка-имя: клиент шлёт список приемлемых протоколов, сервер выбирает один.
Известные subprotocols:
mqtt— MQTT 3.1/5 поверх WebSocket для IoT.graphql-ws— спецификация GraphQL subscriptions.wamp.2.json— Web Application Messaging Protocol для RPC.- Кастомный — большинство приложений придумывает свой
chat.v1.
Это просто соглашение о формате payload-а; WebSocket frame-ы остаются прежними.
Heartbeats
В отличие от SSE, WebSocket имеет встроенные ping/pong frame-ы. Сервер периодически шлёт 0x9 — клиент обязан ответить 0xA. Это позволяет:
- Засечь мёртвое соединение (NAT забыл mapping, мобильная сеть оборвалась).
- Поддерживать соединение через прокси с idle-таймаутом.
Хорошие библиотеки делают ping каждые 20-30 секунд автоматически. В Python websockets это ping_interval=20, ping_timeout=20.
Python: библиотека websockets
websockets — каноническая Python-библиотека (asyncio-нативная). Сервер:
import asyncio
import websockets
from websockets.server import WebSocketServerProtocol
connections: set[WebSocketServerProtocol] = set()
async def chat_handler(ws: WebSocketServerProtocol):
connections.add(ws)
try:
async for message in ws:
# Broadcast: рассылка всем подключённым
for peer in connections:
if peer is not ws and peer.open:
await peer.send(message)
finally:
connections.remove(ws)
async def main():
async with websockets.serve(
chat_handler,
"0.0.0.0",
8765,
ping_interval=20,
ping_timeout=20,
max_size=1_000_000, # лимит на сообщение, защита от DoS
):
await asyncio.Future() # run forever
asyncio.run(main())
Клиент:
import asyncio
import websockets
async def chat_client():
uri = "wss://api.example.com/ws/chat"
async with websockets.connect(uri, extra_headers={"Authorization": "Bearer ..."}) as ws:
await ws.send("hello server")
async for message in ws:
print("received:", message)
asyncio.run(chat_client())
Несколько важных моментов production-кода:
wss://обязательно в production (зашифрованный WebSocket поверх TLS).- Авторизация — заголовок
Authorizationдоступен только при handshake-е. После него ничего не передаётся, нужна ваша protocol-level аутентификация (первое сообщение с токеном). - Лимит размера сообщений (
max_size) — иначе один клиент может прислать 1 GB payload и положить сервер.
Scaling: stateful-coupling
Главная боль WebSocket-ов — stateful. Каждое соединение привязано к конкретному серверу. Если у вас 10 нод и 100 000 пользователей в чате, и Alice сидит на ноде 3, а Bob на ноде 7, нужен механизм межсерверного broadcast-а.
Типовые решения:
- Sticky sessions на load balancer-е — каждый клиент всегда попадает на ту же ноду. Решает проблему reconnect-а на ту же ноду, но не межсерверный broadcast.
- Pub/sub backbone — Redis pub/sub, NATS, Kafka. При получении сообщения нода публикует в канал, остальные получают и рассылают своим клиентам.
- Managed WebSocket gateway — Pusher, Ably, AWS API Gateway WebSocket. Они держат TCP-coupling, ваш backend публикует через REST.
В serverless-среде (Lambda, Cloud Run scale-to-zero) WebSocket работает плохо — функция не может удерживать долгое соединение. Поэтому AWS придумал API Gateway WebSocket: gateway держит соединение, а Lambda вызывается на каждое сообщение.
Backpressure
Если клиент медленнее сервера (мобильный 3G), сообщения копятся в буфере отправки на сервере. Без контроля — память течёт.
Решения:
- Drop old messages — отбрасывать устаревшие данные (для тиковых данных биржи это норма).
- Pause producer — приостанавливать генерацию данных, пока буфер не опустеет.
- Coalescing — заменять серию обновлений одним актуальным состоянием.
В Python websockets можно проверять ws.transport.get_write_buffer_size() или ловить websockets.exceptions.ConnectionClosed при переполнении.
WebSocket — мощный, но избыточный для server -> client scenario. Если у вас нет сообщений от клиента к серверу (кроме periodic heartbeat-ов) — SSE даст ту же функциональность с в 5 раз меньшим объёмом серверного кода. Не используйте WS только потому, что «он круче».
WebSocket в DE-сценариях
- Live аналитика — биржевые тиковые данные (Binance, Coinbase публикуют WS-фиды), real-time дашборды.
- Collaborative editing — Jupyter notebook collaboration, Mode Analytics.
- Streaming source ingestion — pipeline принимает WS-фид, парсит сообщения, кладёт в Kafka.
- Notifications между data tools — Airflow -> UI, dbt cloud -> IDE.
Junior DE чаще всего сталкивается с WS как клиент: подключиться к Binance, парсить тики, складывать в локальный буфер. Серверная сторона WS — типичная задача backend-команды, но базовое понимание помогает в дискуссиях.
Когда выбрать WebSocket
| Сценарий | Подходит? |
|---|---|
| Двусторонний chat | Да |
| Multiplayer game | Да |
| Collaborative editing | Да |
| Биржевые тики | Да (биржи отдают через WS) |
| Прогресс операции | Лучше SSE |
| LLM streaming | Лучше SSE |
| Push-уведомления | Лучше SSE / FCM |
| Webhook от внешнего сервиса | Лучше HTTPS webhook |