Skip to main content
Назад до блогу
5 хв читання

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

Патерни з двох продакшн-систем: тикетинг-платформа з 12K одночасних виборів місць та командний чат з індикаторами присутності. Що працює, що ні, і архітектура за обома.

next.jsperformancecase-study

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

Цей пост покриває патерни, що пережили продакшн — не підручникові патерни, а ті, що реально працюють, коли 3,000 людей борються за одні й ті самі місця або 200 членів команди потребують миттєвої доставки повідомлень.

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

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

Тикетинг: Одна кімната на продаж заходу. Коли користувач відкриває карту місць для Заходу #42, він вступає в кімнату event:42. Кожне блокування, розблокування та покупка місця розсилається всім у кімнаті.

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

Модель кімнат вирішує проблему fan-out: ти надсилаєш оновлення лише тим з'єднанням, яким це потрібно.

// Спрощене управління кімнатами
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-з'єднання вмирають тихо.

Обидві системи використовують heartbeat з серверним ping кожні 30 секунд та клієнтське перепідключення з експоненціальним 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 секунди. Близько 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-секундним інтервалом.