Что такое realtime в PHP
Realtime в веб-приложении — это не магия «мгновенного PHP», а выбранный способ доставлять событие в браузер без ручного обновления страницы. Пользователь ждёт статус экспорта, админ смотрит live-лог, менеджер видит новый заказ, чат получает сообщение. Во всех этих случаях обычный request-response из SAPI и суперглобалы уже неудобен: событие появляется на сервере позже, чем был открыт экран.
В PHP обычно встречаются четыре подхода: WebSocket, Server-Sent Events, long polling и отдельный broadcast server. Они решают похожую продуктовую задачу, но по-разному нагружают рантайм, прокси, load balancer и воркеры.
flowchart LR
Browser[Браузер\nWebSocket / SSE / long polling] --> Proxy[NGINX / CDN / Load balancer]
Proxy --> Gateway[Realtime gateway\nCentrifugo / Mercure / OpenSwoole / RoadRunner]
Browser --> Api[Обычный HTTP API\nPHP-FPM / RoadRunner]
Api --> DB[(База данных)]
Api --> Queue[Queue backend]
Queue --> Worker[PHP worker]
Worker --> DB
Api --> Broker[Pub/Sub broker\nRedis / NATS / Postgres]
Worker --> Broker
Broker --> Gateway
Gateway --> BrowserWebSocket: двусторонний канал
WebSocket открывает постоянное соединение между браузером и сервером. После HTTP-handshake с Upgrade соединение переходит в другой режим: клиент и сервер могут отправлять сообщения друг другу, пока канал жив. В браузере это выглядит просто:
const socket = new WebSocket('wss://example.com/ws');
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ type: 'subscribe', topic: 'orders' }));
});
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
console.log('update', message);
});WebSocket хорошо подходит для чата, совместного редактирования, multiplayer-сценариев, терминалов в браузере, live-dashboard с частыми командами от клиента. Если клиент не только слушает, но и часто говорит с сервером, WebSocket естественнее SSE.
Главный подвох для классического PHP — соединение долго живёт. Если держать WebSocket как обычный PHP-FPM request, каждый открытый браузер будет занимать процесс или поток слишком надолго. Поэтому WebSocket в production обычно выносят в runtime, рассчитанный на постоянные соединения: Swoole и OpenSwoole, RoadRunner с Centrifugo, Ratchet/ReactPHP, отдельный Node/Go-сервис или managed realtime-платформа. Смысл тот же, что в PHP-FPM, RoadRunner и долгоживущие воркеры: код больше не умирает после каждого запроса, значит появляются риски stale state, утечек памяти и неправильного хранения пользовательского состояния.
SSE: сервер говорит, браузер слушает
Server-Sent Events проще: браузер открывает HTTP-запрос, а сервер постепенно пишет в ответ события в формате text/event-stream. Канал односторонний: сервер → клиент. Если клиенту нужно отправить команду, он делает обычный POST рядом.
Минимальный PHP endpoint выглядит так:
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
while (true) {
echo "event: progress\n";
echo 'data: ' . json_encode(['percent' => 42], JSON_THROW_ON_ERROR) . "\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
if (connection_aborted()) {
break;
}
sleep(1);
}На клиенте:
const stream = new EventSource('/events/export/4821');
stream.addEventListener('progress', (event) => {
const data = JSON.parse(event.data);
console.log(`${data.percent}%`);
});
stream.onerror = () => {
console.log('stream reconnecting or failed');
};У SSE есть приятные детали: браузер умеет переподключаться, события могут иметь id, поле retry управляет задержкой reconnect, а строка-комментарий может служить heartbeat. Но это всё ещё долгий HTTP response. На PHP-FPM такой endpoint занимает worker, пока вкладка открыта. Для одного внутреннего dashboard это может быть нормально; для тысяч пользователей — уже архитектурная ошибка.
SSE особенно хорош для прогресса фоновой задачи из Очереди, фоновые задачи и воркеры, уведомлений, live-лент, статуса импорта, AI-streaming ответа. Если поток только от сервера к браузеру, SSE часто дешевле и понятнее, чем WebSocket.
Long polling: fallback, а не мечта
Long polling — старый, но всё ещё полезный компромисс. Браузер делает запрос /updates?since=123, сервер держит его открытым, пока не появится событие или не истечёт timeout. После ответа клиент сразу открывает следующий запрос.
<?php
$deadline = time() + 25;
$lastSeenId = (int) ($_GET['since'] ?? 0);
while (time() < $deadline) {
$events = $repository->findEventsAfter($lastSeenId);
if ($events !== []) {
header('Content-Type: application/json');
echo json_encode(['events' => $events], JSON_THROW_ON_ERROR);
return;
}
usleep(250_000);
}
header('Content-Type: application/json');
echo json_encode(['events' => []], JSON_THROW_ON_ERROR);Long polling проще проходит через старые прокси и shared hosting, потому что это всё ещё обычные HTTP-запросы. Минусы очевидны: лишние reconnect, задержка до следующего запроса, больше шума в логах, больше нагрузки на PHP-FPM. Его стоит рассматривать как fallback или временную меру, а не как основу высоконагруженного realtime.
Broadcast server и pub/sub
Production-схема чаще выглядит не так: «PHP держит все браузеры», а так: PHP обрабатывает бизнес-событие и публикует сообщение в realtime-слой. Соединения держит отдельный gateway: Centrifugo, Mercure, Pusher-compatible сервер, OpenSwoole server, RoadRunner Centrifuge plugin или managed-сервис.
Например, пользователь запускает экспорт. HTTP request кладёт job в очередь. Worker завершает CSV и публикует событие export.finished в канал пользователя. Realtime gateway доставляет его в браузер через WebSocket или SSE. PHP-код остаётся бизнес-слоем, а не менеджером тысяч TCP-соединений.
Это важное разделение ответственности. У gateway есть свои задачи: авторизация подписок, fan-out по каналам, presence, reconnect, хранение короткой истории, backpressure, heartbeat, лимиты. У PHP-приложения — транзакции, права, доменные события, шаблоны сообщений и интеграция с HTTP-заголовки, ответы и редиректы там, где это обычный HTTP.
Как выбирать
Если нужен двусторонний канал и частые сообщения от клиента — WebSocket. Если сервер просто пушит обновления в браузер — SSE. Если инфраструктура бедная или нужен совместимый fallback — long polling. Если пользователей много, вкладок много и события идут из разных частей системы — отдельный broadcast server почти всегда чище, чем попытка держать всё в PHP-FPM.
Хороший realtime-дизайн начинается не с протокола, а с вопроса: «Какое событие пользователь должен увидеть, кто имеет право его видеть, и что случится после reconnect?» После этого уже выбирают WebSocket, SSE или очередь плюс gateway.
См. также
- Асинхронный PHP и event loop — почему постоянные соединения требуют неблокирующего мышления.
- Swoole и OpenSwoole — WebSocket server, coroutine runtime и долгоживущие процессы в PHP.
- PHP-FPM, RoadRunner и долгоживущие воркеры — почему request isolation меняется на worker model.
- Очереди, фоновые задачи и воркеры — как realtime часто показывает статус job, а не выполняет работу сам.
- HTTP-заголовки, ответы и редиректы —
Content-Type, streaming response, redirect и ограничения вывода. - CLI и встроенный сервер — локальные эксперименты с SSE и worker-командами удобнее запускать из CLI.
- Наблюдаемость и эксплуатация PHP-сервисов — метрики соединений, reconnect rate, latency, memory usage и worker restarts.
Источники
- MDN Web Docs — WebSocket API
- MDN Web Docs — Using server-sent events
- PHP Manual — ignore_user_abort
- NGINX Documentation — WebSocket proxying
- OpenSwoole Documentation — WebSocket Server
- RoadRunner Documentation — Centrifuge WebSockets plugin
- Centrifugo — Scalable real-time messaging server
- Symfony Docs — Pushing Data to Clients Using the Mercure Protocol