Все уроки
БэкендПродвинутый13 мин чтенияВ моём стеке

Realtime и WebSockets

Доставлять пользователям обновления в тот же момент, когда что-то произошло.

  • WebSockets
  • socket.io
  • Pusher
  • Redis pub/sub
  • SSE
Шаг 1/4
Polling: приложение всё спрашивает «есть что-нибудь новое?» — «нет» — «есть что-нибудь новое?» — «нет»… Сервер очень устаёт.

За 30 секунд

Realtime-функции передают данные с сервера клиенту без обновления страницы: статус заказа, чат, экраны кухни, живые дашборды. Можно делать polling, стримить через Server-Sent Events или держать открытым двусторонний WebSocket. Сложность не в самом соединении, а в масштабировании на несколько серверов, авторизации каналов и обработке переподключений.

Почему я это использую

  • Я использую realtime, потому что клиенты ожидают увидеть, как их заказ переходит из «принят» в «в пути», без обновления страницы.
  • Я использую комнаты сокетов, чтобы экран кухни, касса и дашборд менеджера получали только предназначенные им события.
  • Я использую Redis pub/sub, потому что realtime должен продолжать работать, когда API запущен в нескольких процессах.
  • Я выбираю между облачными (Pusher) и self-hosted сокетами в зависимости от бюджета, масштаба и нужного уровня контроля.

Как это работает

01Polling, long-polling, SSE и WebSockets

Polling — это вопрос «есть что-нибудь новое?» каждые несколько секунд. Он простой и работает везде, но тратит запросы впустую и добавляет задержку. Long-polling держит каждый запрос открытым, пока у сервера не появятся новости или не сработает таймаут, после чего клиент переподключается; меньше потерь, больше сложности. Server-Sent Events (SSE) держат открытым один HTTP-ответ, и сервер стримит по нему текстовые события; это односторонний канал (от сервера к клиенту), он автоматически переподключается и хорошо работает через прокси и HTTP/2. WebSockets открывают полноценный двусторонний канал поверх одного TCP-соединения — идеально, когда клиент тоже часто отправляет сообщения, как в чате, совместном редактировании или играх. Эмпирическое правило: polling для редких обновлений, SSE для потоков от сервера к клиенту вроде уведомлений или стриминга токенов AI, WebSockets для интерактивного двустороннего трафика.

02Как работает WebSocket-соединение

WebSocket начинается как обычный HTTP-запрос с просьбой сменить протокол (upgrade). Если сервер согласен, он отвечает 101 Switching Protocols, и с этого момента то же TCP-соединение передаёт уже не HTTP, а WebSocket-фреймы. Фреймы — это небольшие пакеты: фреймы данных (текстовые или бинарные) и управляющие фреймы для ping, pong и close. Фреймы от клиента к серверу маскируются. Ping и pong поддерживают соединение живым и помогают обнаружить «мёртвых» собеседников. Поскольку upgrade происходит через HTTP, соединение проходит через балансировщики и reverse proxy, но их нужно настроить так, чтобы они разрешали upgrade и долгоживущие соединения. В продакшене всегда используйте wss:// (TLS).

http
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
 
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

03socket.io: namespaces, комнаты и авторизация

socket.io — библиотека поверх WebSockets, которая добавляет автоматическое переподключение, подтверждения (acknowledgements), откат на long-polling и простую модель группировки соединений. Namespace (например, /staff или /customers) — отдельный канал со своими middleware, удобный для разных аудиторий. Комната — группа внутри namespace; сервер добавляет сокеты в комнаты и отправляет событие сразу всей комнате. Типичные комнаты: по одной на тенанта, филиал, терминал и пользователя. Аутентифицируйте в middleware рукопожатия, до того как соединение принято, и решайте о членстве в комнатах на сервере на основе проверенной личности. Никогда не позволяйте клиенту войти в комнату только потому, что он об этом попросил.

ts
io.of('/staff').use(async (socket, next) => {
  try {
    socket.data.user = await verifyAccessToken(socket.handshake.auth.token);
    next();
  } catch {
    next(new Error('unauthorized'));
  }
});
 
io.of('/staff').on('connection', (socket) => {
  const { companyId, branchId, sub } = socket.data.user;
  socket.join(['company:' + companyId, 'branch:' + branchId, 'user:' + sub]);
});
 
// elsewhere, after an order changes:
io.of('/staff').to('branch:' + order.branchId).emit('order.updated', { id: order.id, status: order.status, version: order.version });

04Масштабирование на несколько серверов с Redis

Один процесс знает только о своих подключённых сокетах. Когда вы запускаете четыре экземпляра API, пользователь, подключённый к экземпляру A, не увидит событие, отправленное на экземпляре B. Решение — общая шина сообщений. Redis-адаптер socket.io публикует каждую отправку в комнату в Redis pub/sub; каждый экземпляр подписан и доставляет событие своим локальным сокетам. Если вы используете откат socket.io на long-polling за балансировщиком, включите sticky sessions, чтобы запросы клиента попадали на один и тот же экземпляр; при транспорте только через WebSocket это не нужно. Для очень большого fan-out или гарантированной доставки есть Redis Streams или шардированный адаптер. Отправляйте события и из фоновых воркеров через тот же адаптер (пакет socket.io emitter), чтобы задача в очереди могла напрямую уведомлять пользователей.

ts
import { createClient } from 'redis';
import { createAdapter } from '@socket.io/redis-adapter';
 
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
 
io.adapter(createAdapter(pub, sub));

05Laravel: broadcasting через Pusher, Reverb и Echo

В Laravel broadcasting встроен. Вы помечаете событие интерфейсом ShouldBroadcast, указываете, в какой канал оно уходит, и Laravel отправляет его через драйвер. Pusher — облачный сервис: серверов держать не нужно, платите за соединения и сообщения. Reverb — собственный официальный WebSocket-сервер Laravel, работающий по протоколу Pusher, так что можно хостить его самому с тем же клиентским кодом. php artisan install:broadcasting всё настраивает. На фронтенде на каналы подписывается Laravel Echo. Приватные и presence-каналы авторизуются в routes/channels.php: Echo вызывает эндпоинт /broadcasting/auth, а ваш колбэк решает, может ли этот пользователь слушать канал. Ставьте broadcast-события в очередь, чтобы медленный сокет-сервер никогда не замедлял HTTP-ответ.

php
// routes/channels.php
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
    return Order::whereKey($orderId)->where('customer_id', $user->id)->exists();
});
 
// Frontend (Echo)
echo.private('orders.' + orderId)
    .listen('OrderStatusChanged', (e) => updateStatus(e.status));

06Переподключение, порядок и пропущенные сообщения

Мобильные сети постоянно обрываются, поэтому проектируйте с расчётом на разрывы. Клиенты переподключаются с экспоненциальной задержкой и jitter, чтобы перезапуск сервера не вызвал лавину из тысяч одновременных переподключений. События, отправленные, пока клиент был офлайн, в обычном pub/sub теряются. Относитесь к сокету как к сигналу «что-то изменилось», а REST API считайте источником истины: при переподключении заново запрашивайте текущее состояние. Добавляйте в каждое событие номер версии или временную метку, чтобы клиент мог игнорировать устаревшие или пришедшие не по порядку обновления. Если для одной сущности важен порядок, пускайте её события через один канал и обрабатывайте последовательно. Для критичных данных используйте подтверждения или сохраняемый лог событий с курсором «since».

  • Держите payload маленьким: id и статус, а не объекты целиком.
  • Отправляйте heartbeat; закрывайте и очищайте мёртвые соединения.
  • Настройте таймауты прокси (nginx proxy_read_timeout) больше интервала ping.

Когда применять

  • Отслеживание заказов в реальном времени, экраны кухни и электронной очереди, статус доставки.
  • Чат, переписка с поддержкой и инструменты для совместной работы.
  • Живые дашборды и уведомления в админке, где важны секунды.
  • Стриминг прогресса долгих задач или ответов AI (часто через SSE).

Когда не стоит

  • Данные, которые меняются раз в несколько минут; polling или кнопка обновления проще.
  • Односторонние уведомления в закрытое приложение; используйте push-уведомления (FCM/APNs).
  • Когда облачный сервис обойдётся дешевле, чем время на поддержку и масштабирование собственных сокет-серверов.

Альтернативы

  • Server-Sent Events

    Односторонний стриминг поверх HTTP; автоматическое переподключение, очень просто.

  • Pusher / Ably

    Облачный realtime; без инфраструктуры, оплата по использованию.

  • Laravel Reverb

    Официальный self-hosted WebSocket-сервер для Laravel на протоколе Pusher.

  • Push-уведомления

    FCM и APNs доходят до пользователей, даже когда приложение закрыто.

Лучшие практики

  1. Аутентифицируйте при рукопожатии и назначайте комнаты на сервере.
  2. Подключайте Redis-адаптер (или облачный сервис), как только запускаете больше одного экземпляра.
  3. Считайте события подсказками и при переподключении заново получайте состояние из API.
  4. Версионируйте события, чтобы клиенты могли отбрасывать устаревшие и дублирующиеся обновления.
  5. Переподключайтесь с экспоненциальной задержкой и jitter.
  6. Делайте broadcast из очередей, чтобы медленные сокеты никогда не блокировали HTTP-запросы.

Частые ошибки

  • Разрешать клиентам подписываться на любую комнату или канал, имя которых они пришлют.
  • Масштабироваться на несколько экземпляров и удивляться, почему половина пользователей пропускает события.
  • Предполагать, что каждое сообщение приходит по порядку и ровно один раз.
  • Отправлять в broadcast-payload полные записи из базы, включая приватные поля.
  • Таймауты прокси короче интервала heartbeat, из-за чего соединения постоянно переподключаются.

Глубже

07Ёмкость: файловые дескрипторы, прокси и heartbeats

Каждый открытый WebSocket — это открытый файловый дескриптор на сокет-сервере и на каждом прокси перед ним. Linux ограничивает их число на процесс, часто до 1,024 по умолчанию, поэтому первая стена при масштабировании — обычно «too many open files», а не CPU. Поднимите лимит для сервиса (ulimit -n, LimitNOFILE в systemd, minfds в supervisor), а в nginx увеличьте worker_connections и worker_rlimit_nofile, помня, что проксируемый сокет занимает в nginx два соединения: со стороны клиента и со стороны upstream. Следующий предел — память на соединение, поэтому измеряйте её под нагрузкой. Прокси закрывают простаивающие соединения: proxy_read_timeout в nginx по умолчанию 60 секунд, а socket.io по умолчанию пингует каждые 25 секунд, поэтому таймаут должен оставаться больше интервала пинга. У Laravel Reverb есть особая ловушка: его цикл stream_select по умолчанию ограничен примерно 1,024 соединениями, и сверх этого нужно расширение ext-uv.

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
 
server {
    location /socket.io/ {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 75s;   # above the 25 s ping interval plus timeout
        proxy_send_timeout 75s;
    }
}

08Гарантии доставки: подтверждения и восстановление состояния

Обычный emit работает по принципу «отправил и забыл»: если сокет оборвался мгновение назад, сообщение пропало. socket.io предлагает два инструмента. Acknowledgements позволяют получателю подтвердить обработку, а emitWithAck с таймаутом превращает это в Promise, который можно ждать через await, так что сервер знает, напечатала ли касса чек на самом деле, и может повторить через очередь, если нет. Connection state recovery позволяет клиенту, переподключившемуся в пределах окна (по умолчанию две минуты), вернуть свой id, комнаты и пропущенные пакеты. У этого есть ограничения: покрываются только короткие разрывы, поддерживают это не все адаптеры (классический Redis pub/sub адаптер — нет, адаптер Redis Streams — да), и по умолчанию при восстановлении пропускается ваш auth middleware, что, возможно, стоит отключить. Для всего, что нельзя терять никогда, надёжным ответом остаётся сохраняемый журнал событий с курсором.

ts
const io = new Server(httpServer, {
  connectionStateRecovery: {
    maxDisconnectionDuration: 2 * 60 * 1000,
    skipMiddlewares: false, // re-run auth on recovered connections
  },
});
 
try {
  const replies = await io.to('terminal:' + terminalId)
    .timeout(5_000)
    .emitWithAck('receipt.print', { orderId });
  logger.info({ replies }, 'terminal confirmed');
} catch {
  await printQueue.add('retry', { orderId, terminalId }); // no ack in time
}

09Fan-out, горячие комнаты и троттлинг обновлений

Fan-out — это одно событие, доставленное множеству сокетов. Комната с 50,000 зрителей живого дашборда превращает один emit в 50,000 записей, умноженных адаптером на число экземпляров. Три техники держат это в рамках. Объединяйте: вместо отправки каждого изменения храните последнее состояние по каждой сущности и сбрасывайте не чаще, чем раз в несколько сотен миллисекунд, так что 40 обновлений остатков в секунду превращаются в два сообщения. Используйте volatile emits для данных, где важно только самое свежее значение, например позиции курсора или живые метрики: socket.volatile.emit отбрасывает пакет, если клиент не готов, а не буферизует его. Держите payload маленькими — только id и изменившиеся поля — и для большого объёма рассмотрите бинарный парсер вроде msgpack. Для множества экземпляров sharded Redis адаптер socket.io использует sharded pub/sub из Redis 7, так что трафик распределяется по Redis Cluster, а не каждый узел получает каждое сообщение.

  • Измеряйте сообщения в секунду на комнату и на экземпляр, а не только количество соединений.
  • Сжатие perMessageDeflate в socket.io по умолчанию выключено; оно стоит памяти на каждый сокет, поэтому включайте его, только когда payload большие.
  • Очень горячие комнаты можно делить по регионам или шардам, а клиенты подключаются к одному шарду.

10Безопасность долгоживущих соединений

WebSockets обходят часть защит, на которые вы полагаетесь в HTTP. Браузеры отправляют cookies с WebSocket handshake, но CORS к нему не применяется, поэтому вредоносная страница может открыть сокет к вашему серверу с cookies пользователя: cross-site WebSocket hijacking. При аутентификации через cookie всегда проверяйте заголовок Origin по allow-list. Токен, проверенный при подключении, может истечь за часы до закрытия соединения, поэтому планируйте отключение в момент истечения токена или требуйте, чтобы клиент обновил токен и заново аутентифицировался через сокет. Относитесь к каждому входящему сообщению как к телу HTTP-запроса: валидируйте его схемой, проверяйте права на конкретное действие и ограничивайте частоту сообщений на сокет. Ограничьте размер сообщения: по умолчанию socket.io принимает до 1 MB на сообщение, а это гораздо больше, чем нужно большинству приложений.

ts
const io = new Server(httpServer, {
  maxHttpBufferSize: 64 * 1024, // default is 1 MB
  allowRequest: (req, cb) => cb(null, ALLOWED_ORIGINS.has(req.headers.origin ?? '')),
});
 
io.on('connection', (socket) => {
  const expiresInMs = socket.data.user.exp * 1000 - Date.now();
  const timer = setTimeout(() => socket.disconnect(true), expiresInMs);
  socket.on('disconnect', () => clearTimeout(timer));
 
  socket.on('cart.update', (raw, ack) => {
    const msg = CartUpdateSchema.safeParse(raw);
    if (!msg.success) return ack?.({ error: 'invalid_payload' });
    // permission check, then handle msg.data
  });
});

Реальные ситуации

01

Кухонный экран пропускает заказы после обрыва Wi-Fi

Проблема

Сотрудники ресторана жалуются, что новые заказы иногда так и не появляются на кухонном экране. Это всегда происходит после кратковременного обрыва Wi-Fi на планшете.

Как решить

События, отправленные, пока планшет был офлайн, не были доставлены никому. Исправляйте с обеих сторон. При переподключении клиент должен заново загрузить список открытых заказов из REST API, который является источником истины, и слить их по id и версии заказа. Добавьте периодическое фоновое обновление, например каждые 60 секунд, как страховку. На сервере держите payload маленькими и версионированными, чтобы устаревшие обновления игнорировались. Если задержка критична, включите connection state recovery с адаптером, который её поддерживает, или пусть экран подтверждает новые заказы, а касса предупреждает персонал, если заказ остаётся неподтверждённым минуту.

02

Половина пользователей перестала получать события после масштабирования

Проблема

Команда масштабировала сокет-сервер с одного до трёх экземпляров. Теперь одни пользователи получают живые обновления, а другие нет, и в логах иногда появляются ошибки «Session ID unknown».

Как решить

Это две отдельные проблемы. Пропавшие события означают, что экземпляры не делят emits: добавьте Redis адаптер socket.io или sharded адаптер для кластеров Redis 7, чтобы emit на одном экземпляре доходил до сокетов на других, и пусть фоновые воркеры тоже отправляют через Redis emitter. Ошибки «Session ID unknown» идут от транспорта HTTP long-polling, чьи запросы должны попадать на один и тот же экземпляр: включите sticky sessions на балансировщике, например по cookie или IP hash, или настройте клиентов с transports: ['websocket'], если фолбэк на polling не нужен. Затем проведите нагрузочный тест с несколькими экземплярами до следующего релиза.

03

Reverb падает примерно на тысяче соединений

Проблема

Приложение на Laravel переехало с Pusher на self-hosted Reverb. В тестах всё работало, но в продакшене соединения начинают падать, когда онлайн примерно тысяча пользователей.

Как решить

Это число — лимит цикла событий по умолчанию: Reverb использует stream_select, если не установлен ext-uv, а stream_select справляется примерно с 1,024 открытыми файлами. Установите расширение uv через PECL, чтобы Reverb автоматически переключил цикл. Затем поднимите все файловые лимиты в цепочке: лимит nofile пользователя в limits.conf или unit-файле systemd, minfds в supervisord.conf и worker_connections плюс worker_rlimit_nofile в nginx. Проверьте диапазон локальных портов на хосте прокси. Мониторьте соединения через интеграцию с Pulse. Когда одного сервера мало, задайте REVERB_SCALING_ENABLED=true, направьте все узлы на общий Redis и поставьте их за балансировщик.

Шпаргалка

Подключиться к WebSocket из терминала
npx wscat -c wss://api.example.com/ws
Подробные логи сервера socket.io
DEBUG=socket.io* node dist/main.js
Клиент без фолбэка на long-polling
io('https://api.example.com', { transports: ['websocket'] })
Отправить, только если клиент готов
socket.volatile.emit('metrics', snapshot)
Проверить лимит открытых файлов
ulimit -n
Запустить Reverb
php artisan reverb:start --host=0.0.0.0 --port=8080
Мягко перезапустить Reverb при деплое
php artisan reverb:restart
Запустить Reverb на нескольких серверах
REVERB_SCALING_ENABLED=true
Заголовки ответа SSE
Content-Type: text/event-stream; Cache-Control: no-cache; X-Accel-Buffering: no
Возобновить поток SSE
Сервер отправляет 'id: 42' с каждым событием; браузер переподключается с Last-Event-ID: 42

В моих реальных проектах

  • В мультитенантном POS SaaS комнаты socket.io разделены по компании, филиалу, терминалу и пользователю, а fan-out между экземплярами идёт через Redis pub/sub.
  • На платформе для сети ресторанов живые обновления заказов доходят до клиентов через Pusher и Laravel Echo.
  • Админка beauty-магазина получает обновления в реальном времени через Pusher.

Вопросы и ответы

Q1WebSockets или Server-Sent Events: как вы выбираете?

Я смотрю на направление трафика. Если сервер в основном отправляет данные клиенту — уведомления, живой статус или стриминг ответа AI, — SSE проще: это обычный HTTP, он сам переподключается, хорошо работает с HTTP/2 и прокси, а действия клиент по-прежнему отправляет обычными вызовами API. Если клиент тоже часто отправляет сообщения — как в чате, совместном редактировании, играх или POS-терминале, который сообщает своё состояние, — лучше подходят WebSockets, потому что одно соединение с минимальными накладными расходами несёт оба направления. SSE передаёт только текст, так что для бинарных данных выгоднее WebSockets. Оба варианта требуют внимания к прокси и таймаутам.

Q2Как масштабировать WebSockets на несколько серверов?

Каждый сервер знает только о своих соединениях, поэтому нужна общая шина. В socket.io Redis-адаптер публикует отправки в комнаты в Redis pub/sub, и каждый экземпляр доставляет их своим локальным участникам. Фоновые воркеры отправляют события через ту же шину. Если включён откат на long-polling, балансировщику нужны sticky sessions; при транспорте только через WebSocket — нет. Я также слежу за числом соединений на экземпляр, лимитами файловых дескрипторов и памятью на сокет и размазываю переподключения с помощью jitter. При очень большом масштабе облачный сервис вроде Pusher или Ably или отдельный слой шлюзов снимают эту работу с API-серверов.

Q3Как вы авторизуете приватные каналы?

В два шага. Сначала аутентифицировать само соединение: проверить токен или сессию во время рукопожатия и отклонить неаутентифицированные сокеты. Затем авторизовать каждый канал или комнату на сервере. В socket.io я вычисляю членство в комнатах из проверенного пользователя — например, по его тенанту, филиалу и id пользователя, — а не доверяю имени комнаты, присланному клиентом. В Laravel приватные и presence-каналы проходят через routes/channels.php, где колбэк проверяет, что пользователь владеет ресурсом или имеет право его видеть. При этом payload всё равно должен содержать только то, что этой аудитории разрешено видеть, а токены нужно повторно проверять при переподключении.

Q4Что происходит с сообщениями, отправленными, пока клиент отключён?

В обычном pub/sub они пропадают, потому что сервер доставляет события только сокетам, подключённым в этот момент. Поэтому я проектирую сокет как канал уведомлений, а не как базу данных. При переподключении клиент заново запрашивает текущее состояние из REST API, который является источником истины. События содержат версию или updated-at, чтобы клиент мог отбрасывать устаревшие обновления. Там, где пропуск сообщения действительно критичен, я использую подтверждения с повторами или сохраняемый лог событий, который клиент читает со своего последнего курсора. Connection state recovery в socket.io помогает только при коротких разрывах.

Q5Pusher, Reverb или собственный сервер на socket.io?

Pusher — самый быстрый путь: никаких серверов, глобальная инфраструктура, хорошие клиентские библиотеки. Минусы — стоимость растёт вместе с числом соединений и сообщений, а данные проходят через третью сторону. Reverb — хорошая золотая середина для Laravel-приложений: self-hosted, совместим с протоколом Pusher, так что Echo работает без изменений, и может масштабироваться горизонтально через Redis. Собственный сервер на socket.io имеет смысл в Node-бэкенде, которому нужна богатая двусторонняя логика, свои комнаты и подтверждения, как в POS с терминалами. Я выбираю по стеку команды, ожидаемому числу соединений, бюджету и тому, сможет ли команда эксплуатации надёжно поддерживать ещё один сервис.

Вопросы уровня senior

S1Как бы вы спроектировали realtime для 100,000 одновременных соединений?

Отделяю сокет-слой от API. Выделенный парк gateway-экземпляров держит соединения и почти не содержит бизнес-логики: аутентифицирует при handshake, подключает к комнатам и пересылает действия клиентов в API или очередь. Экземпляры делят emits через sharded Redis адаптер или шину сообщений, а фоновые воркеры публикуют события в эту шину. Каждый gateway-узел получает повышенные файловые лимиты и тюнинг ядра, и я провожу нагрузочные тесты, чтобы найти реалистичное число соединений на узел — часто десятки тысяч при лёгком трафике. Переподключения используют экспоненциальный backoff с jitter, чтобы деплой не порождал thundering herd, а деплои постепенно освобождают узлы. Payload маленькие и объединённые. Альтернатива — hosted-сервис вроде Ably или Pusher: стоимость за соединение в обмен на нулевую эксплуатацию.

S2Как гарантировать порядок realtime-событий?

В распределённой системе порядок можно гарантировать только там, где есть единая последовательность. Одно WebSocket-соединение упорядочено, но события могут приходить от нескольких экземпляров API и воркеров, поэтому могут отправляться не по порядку. Я даю каждой сущности монотонно растущую версию, которую устанавливает база внутри той же транзакции, что и изменение, и включаю её в каждое событие. Клиенты хранят наибольшую версию по каждой сущности и игнорируют всё более старое, а при обнаружении пропуска заново загружают эту сущность из API. Когда важен строгий порядок обработки, как с командами терминалу, я направляю все сообщения для этой сущности через одну партицию очереди или один воркер. Компромисс — пропускная способность: порядок в пределах сущности дешёв, глобальный порядок дорог и редко нужен.

S3Как вы обрабатываете аутентификацию на соединениях, которые открыты часами?

Проверяю токен при handshake, но не доверяю ему вечно. Короткоживущие access токены истекают задолго до того, как кухонный экран отключится. Я планирую отключение в момент истечения токена, а клиент обновляет токен через обычный API и переподключается; как вариант, клиент отправляет свежий токен через сокет, а сервер его перепроверяет и продлевает срок. Отзыв тоже должен доходить до живых соединений: когда пользователь отключён или его сессии отозваны, публикуется событие, по которому каждый gateway отключает сокеты этого пользователя. При аутентификации через cookie я проверяю Origin по allow-list при handshake. Каждое сообщение всё равно проходит авторизацию на конкретное действие, потому что валидное соединение не означает, что разрешено любое действие.

S4Должны ли бизнес-действия идти через WebSocket или через REST API?

По умолчанию я использую REST для команд, а сокет — для уведомлений. REST бесплатно даёт статус-коды, idempotency keys, middleware, rate limiting, логирование и тесты, а тот же эндпоинт обслуживает клиентов без сокета. Сокет затем объявляет «заказ 42 изменился, версия 7». Отправлять действия через сокет имеет смысл, когда задержка и частота высокие, а сообщения маленькие, например совместные курсоры, игры, индикаторы набора текста в чате или POS-терминал, стримящий статус, потому что это избавляет от HTTP-запроса на каждое сообщение. В этом случае я явно воссоздаю HTTP-дисциплину: валидацию схемой, проверки прав, подтверждения с кодами результата и idempotency id для повторов. Компромисс — меньшая задержка против потери хорошо понятного HTTP-инструментария.

Глоссарий

Frame
Единица данных в WebSocket: текстовые, бинарные или управляющие фреймы, например ping, pong и close.
Heartbeat
Регулярные сообщения ping и pong, которые поддерживают соединение живым и выявляют молча исчезнувших собеседников.
Room
Серверная группа сокетов в socket.io; emit в комнату доходит до каждого участника.
Adapter
Компонент socket.io, который отслеживает комнаты и, вместе с Redis, пересылает emits между экземплярами сервера.
Sticky session
Маршрутизация балансировщика, при которой запросы одного клиента попадают на один и тот же бэкенд-экземпляр.
Fan-out
Доставка одного события множеству получателей, например каждому сокету в комнате.
Presence channel
Канал, который также отслеживает, кто подписан; используется для функций «кто онлайн».
Volatile emit
Сообщение, которое может быть отброшено, если клиент не готов; подходит для постоянно обновляемых данных.
CSWSH
Cross-site WebSocket hijacking: вредоносная страница открывает сокет с cookies жертвы; предотвращается проверкой Origin.

Есть идея продукта?Давайте создадим его.

Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.