Skip to main content
Назад к блогу
6 мин чтения

Real-time WebSocket паттерны для тикетинга и чата: продакшн-плейбук

Паттерны из двух продакшн-систем: тикетинг-платформа с 12K одновременных выборов мест и командный чат с индикаторами присутствия. Что работает, что нет, и архитектура за обоими.

next.jsperformancecase-study

Я отгрузил две WebSocket-тяжёлые продакшн-системы: тикетинг-платформу E7 (обновления карты мест в реальном времени при высоконагруженных продажах) и командный инструмент с живым присутствием и мессенджером. Оба научили разному об архитектуре реального времени.

Этот пост покрывает паттерны, которые пережили продакшн — не учебниковые паттерны, а те, что реально работают, когда 3,000 человек борются за одни и те же места или 200 членов команды нуждаются в мгновенной доставке сообщений.

Паттерн 1: Модель комнат

Обе системы используют комнаты. Комната — логическая группировка WebSocket-соединений, разделяющих состояние.

Тикетинг: Одна комната на продажу мероприятия. Когда пользователь открывает карту мест для Мероприятия #42, он вступает в комнату event:42. Каждая блокировка, разблокировка и покупка места в этом мероприятии рассылается всем в комнате.

Чат: Одна комната на канал. Когда пользователь открывает #engineering, он вступает в комнату channel:engineering. Каждое сообщение, редактирование и индикатор набора рассылается всем в комнате.

Модель комнат решает проблему fan-out: ты отправляешь обновления только тем соединениям, которым это нужно. Без комнат каждая блокировка места рассылалась бы каждому подключённому пользователю каждого мероприятия — 99% пустой траты bandwidth.

// Упрощённое управление комнатами
const rooms = new Map<string, Set<WebSocket>>()

function joinRoom(ws: WebSocket, roomId: string) {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set())
  rooms.get(roomId)!.add(ws)
}

function broadcastToRoom(roomId: string, message: object, exclude?: WebSocket) {
  const room = rooms.get(roomId)
  if (!room) return
  const payload = JSON.stringify(message)
  for (const ws of room) {
    if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
      ws.send(payload)
    }
  }
}

Урок: Держи комнаты маленькими. E7 имел мероприятия с 3,000 одновременными пользователями в одной комнате. В таком масштабе одна блокировка места генерирует 3,000 исходящих сообщений. Мы оптимизировали батчингом: вместо рассылки каждой блокировки отдельно, мы батчим обновления каждые 200ms и шлём одно сообщение со всеми изменениями за это окно. Это сократило исходящие сообщения на ~80% во время пиковых продаж.

Паттерн 2: Оптимистичные обновления с серверной сверкой

Обе системы используют одинаковый клиентский паттерн: применить изменение немедленно в UI, отправить на сервер, сверить, если сервер не согласен.

Блокировка места:

  1. Пользователь тапает место → UI показывает его «вашим» (зелёный) немедленно
  2. Клиент шлёт { type: "lock_seat", seatId: "A-15" } через WebSocket
  3. Сервер проверяет: A-15 доступно? Если да → подтверждает, рассылает. Если нет → шлёт отказ запрашивающему
  4. При отказе → клиент возвращает место в «доступные» с хаптик-вибрацией

Сообщение чата:

  1. Пользователь шлёт сообщение → UI рендерит немедленно с индикатором «отправка»
  2. Клиент шлёт через WebSocket
  3. Сервер сохраняет, назначает ID и timestamp, рассылает
  4. Клиент получает подтверждённую версию и обновляет локальное сообщение

Ключевой инсайт: оптимистичные обновления скрывают задержку, серверная сверка обеспечивает корректность.

Что пошло не так изначально: В тикетинге у нас не было таймаута на оптимистичную блокировку. Пользователь тапал место, WebSocket-сообщение тихо проваливалось (соединение упало), и место оставалось зелёным на экране вечно — заблокированное на клиенте, доступное на сервере. Фикс: каждое оптимистичное обновление имеет 3-секундный таймаут. Если подтверждение от сервера не приходит — клиент откатывает обновление и показывает баннер «соединение потеряно».

Паттерн 3: Heartbeat и переподключение

WebSocket-соединения умирают тихо. TCP-соединение может быть полу-открытым (сервер думает, что живое, клиент ушёл).

Обе системы используют heartbeat:

// Серверный heartbeat
const HEARTBEAT_INTERVAL = 30_000
const HEARTBEAT_TIMEOUT = 10_000

function setupHeartbeat(ws: WebSocket) {
  let isAlive = true
  ws.on('pong', () => {
    isAlive = true
  })
  const interval = setInterval(() => {
    if (!isAlive) {
      ws.terminate()
      return
    }
    isAlive = false
    ws.ping()
  }, HEARTBEAT_INTERVAL)
  ws.on('close', () => clearInterval(interval))
}

Клиентское переподключение с экспоненциальным backoff и jitter:

function connect() {
  const ws = new WebSocket(url)
  ws.onclose = () => {
    const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000)
    const jitter = delay * 0.5 * Math.random()
    setTimeout(connect, delay + jitter)
    reconnectAttempts++
  }
  ws.onopen = () => {
    reconnectAttempts = 0
    rejoinRooms()
  }
}

Jitter критичен. Без него, если сервер перезагружается и 3,000 клиентов переподключаются одновременно — они все ретраят с одинаковыми интервалами, создавая thundering herd, который роняет сервер снова.

Паттерн 4: Присутствие

Чат показывает, кто онлайн и кто печатает.

Индикатор набора: Клиент шлёт { type: "typing" }, когда пользователь начинает печатать. Без события «перестал печатать». Вместо этого индикатор на клиенте имеет 3-секундный таймаут. Если новое typing-событие не приходит — индикатор исчезает. Проще и надёжнее, чем трекать остановку набора.

Подводный камень: Присутствие требует единого источника правды для «кто подключён». Если запускаешь несколько WebSocket-серверов за балансировщиком — каждый инстанс знает только о своих соединениях. Нужно общее хранилище (Redis pub/sub) для агрегации.

Для E7 — один WebSocket-сервер (паттерн трафика пульсирующий: высокий во время продаж, около нуля в остальное время). Для чата — Redis pub/sub для fan-out через 3 инстанса.

Паттерн 5: Порядок сообщений и дедупликация

Сообщения могут приходить не в порядке или дублироваться.

Порядок: Каждое сообщение получает серверный порядковый номер. Клиент рендерит в порядке номеров, не в порядке прибытия.

Дедупликация: Каждое клиентское сообщение включает idempotencyKey (UUID). Сервер проверяет ключ перед обработкой. Если уже обработано — возвращает существующий результат.

Тикетинг-система не нуждается в порядке (состояние мест идемпотентно), но критически нуждается в дедупликации (двойной тап не должен создавать два запроса блокировки).

Паттерн 6: Грейсфул деградация

WebSocket падают. Файрволы блокируют. Корпоративные прокси срезают Upgrade-заголовок.

Тикетинг: Фоллбэк на HTTP polling каждые 2 секунды. Карта мест всё ещё обновляется, просто с 2-секундной задержкой. Около 3% пользователей попадают на polling — в основном корпоративные сети.

Чат: Фоллбэк на long-polling. Менее эффективен, но работает через любой HTTP-прокси.

MVP-шорткат: Для MVP E7 мы отгрузили polling-only. Никаких WebSocket вообще. Карта мест обновлялась каждые 3 секунды через HTTP. Этого хватило на первые 3 месяца. WebSocket добавили, когда размеры мероприятий выросли и 3-секундные задержки начали вызывать двойные бронирования.

Реальный трейдофф: polling — решение уровня MVP. WebSocket — решение уровня масштаба. Не строй real-time инфраструктуру до того, как она нужна.

Что бы сделал иначе

Использовал бы Server-Sent Events (SSE) для тикетинга вместо WebSocket. Карта мест — однонаправленный поток: сервер пушит обновления клиентам. Клиенты шлют блокировки через обычные HTTP POST. SSE проще (нет upgrade handshake, работает через прокси, автоматическое переподключение встроено в браузер).

Серьёзнее рассмотрел бы Socket.IO для чата. Избегал из-за размера бандла и предвзятости «просто используй ws». Но Socket.IO обрабатывает переподключение, комнаты и мультиплексирование из коробки — всё, что я построил вручную.


Собираешь что-то, что требует обновлений в реальном времени? Давай разберёмся с правильной архитектурой — иногда это WebSocket, иногда SSE, иногда просто polling с 2-секундным интервалом.