Я отгрузил две 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, отправить на сервер, сверить, если сервер не согласен.
Блокировка места:
- Пользователь тапает место → UI показывает его «вашим» (зелёный) немедленно
- Клиент шлёт
{ type: "lock_seat", seatId: "A-15" }через WebSocket - Сервер проверяет: A-15 доступно? Если да → подтверждает, рассылает. Если нет → шлёт отказ запрашивающему
- При отказе → клиент возвращает место в «доступные» с хаптик-вибрацией
Сообщение чата:
- Пользователь шлёт сообщение → UI рендерит немедленно с индикатором «отправка»
- Клиент шлёт через WebSocket
- Сервер сохраняет, назначает ID и timestamp, рассылает
- Клиент получает подтверждённую версию и обновляет локальное сообщение
Ключевой инсайт: оптимистичные обновления скрывают задержку, серверная сверка обеспечивает корректность.
Что пошло не так изначально: В тикетинге у нас не было таймаута на оптимистичную блокировку. Пользователь тапал место, 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-секундным интервалом.