Когда нужно показать пользователю прогресс задачи, новые комментарии или биржевые котировки в реальном времени, первая мысль — WebSocket. Но если поток данных идёт только от сервера к клиенту, WebSocket избыточен: тащить отдельную библиотеку, настраивать апгрейд протокола, открывать дополнительный порт. Server-Sent Events (SSE) — простой стандарт W3C, работающий поверх обычного HTTP. Один fopen на клиенте, цикл с echo и flush() на сервере, и у вас живой канал. В этой статье разберём SSE на PHP с нуля: устройство протокола, готовый скрипт, интеграцию с Redis, типичные грабли с Nginx и буферизацией.

Что такое SSE и когда он лучше WebSocket
SSE — это формат потоковой передачи событий через обычный HTTP-ответ. Браузер делает GET, сервер отвечает заголовком Content-Type: text/event-stream и держит соединение открытым, периодически дописывая данные. Браузер парсит их через встроенный объект EventSource и отдаёт в JavaScript-обработчик.
Ключевые отличия от WebSocket:
- Однонаправленный — только сервер пишет клиенту. Если нужно слать сообщения в обе стороны, SSE не подходит.
- Текстовый протокол — данные в UTF-8, бинарь нужно кодировать в base64.
- Автоматический reconnect — браузер сам переподключится при разрыве и пришлёт
Last-Event-ID, чтобы догнать пропущенные события. - Работает через прокси и CDN — это обычный HTTP, никакого
Upgradeзаголовка. - Простота на сервере — нужен только PHP с возможностью держать долгий процесс.
Используйте SSE для уведомлений, лога деплоя, прогресс-баров, лайв-фидов, котировок, обновления счётчиков. Не используйте для чатов с типом «пишет…» и онлайн-игр — там нужен дуплекс.
Формат протокола: четыре поля и одно правило
Поток SSE — это текст в UTF-8, где события разделены пустой строкой (двумя переводами строки \n\n). Внутри события возможны четыре поля:
# Простое сообщение
data: Привет, мир
# Многострочные данные (склеются через \n)
data: первая строка
data: вторая строка
# Именованное событие — попадёт в addEventListener('progress', ...)
event: progress
data: {"percent": 42}
id: 1747
# Подсказка браузеру: переподключаться через 5 секунд
retry: 5000
Главное правило: после каждого блока — обязательно пустая строка. Без неё браузер будет копить буфер и не вызовет обработчик.
Минимальный SSE-сервер на PHP
Создаём sse.php — он будет каждую секунду слать текущее время. Это болванка для понимания механики:
<?php
// Отключаем все буферизации — критично для SSE
while (ob_get_level() > 0) {
ob_end_clean();
}
// SSE-заголовки
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // отключает буферизацию Nginx
// Браузеру разрешаем держать соединение долго
set_time_limit(0);
ignore_user_abort(false);
// Начинаем с id последнего полученного клиентом события
$lastId = (int)($_SERVER['HTTP_LAST_EVENT_ID'] ?? 0);
while (true) {
// Если клиент закрыл вкладку — выходим, иначе скрипт висит зомби
if (connection_aborted()) {
break;
}
$lastId++;
$payload = json_encode([
'time' => date('H:i:s'),
'memory' => memory_get_usage(true),
]);
echo "id: {$lastId}\n";
echo "event: tick\n";
echo "data: {$payload}\n\n";
// Пушим в сокет
@ob_flush();
flush();
sleep(1);
}
На стороне браузера приём этих событий — это пять строк:
// Подключаемся к потоку
const es = new EventSource('/sse.php');
// Именованное событие "tick"
es.addEventListener('tick', (e) => {
const payload = JSON.parse(e.data);
document.getElementById('clock').textContent = payload.time;
});
// Любое событие без поля event — попадёт сюда
es.onmessage = (e) => console.log('message:', e.data);
// Ошибки сети (браузер сам переподключится)
es.onerror = (e) => console.warn('reconnect...', e);
Откройте страницу, посмотрите DevTools → Network → вкладку sse.php и нажмите EventStream. Увидите события в виде красивой таблицы — это встроенный SSE-парсер Chrome.
Реальный пример: live-канал из Redis
Цикл со sleep(1) хорош для демо, но в продакшене так не делают — это пустой polling. Правильно: подписаться на сообщения через Redis Pub/Sub, и пробрасывать их в SSE по мере поступления. Один процесс публикует в канал, все подключённые SSE-клиенты получают мгновенно.
<?php
// sse-redis.php — стримит все сообщения из канала "notifications"
while (ob_get_level() > 0) ob_end_clean();
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
set_time_limit(0);
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// Таймаут чтения 25 сек — чтобы периодически слать heartbeat
$redis->setOption(Redis::OPT_READ_TIMEOUT, 25);
$id = 0;
$userId = (int)($_GET['user'] ?? 0);
$channel = "user.{$userId}.notifications";
try {
$redis->subscribe([$channel], function ($redis, $chan, $msg) use (&$id) {
if (connection_aborted()) {
$redis->close();
exit;
}
$id++;
echo "id: {$id}\n";
echo "event: notification\n";
echo "data: {$msg}\n\n";
@ob_flush();
flush();
});
} catch (RedisException $e) {
// Таймаут — это нормально, шлём heartbeat и даём браузеру переподключиться
echo ": heartbeat\n\n";
@ob_flush();
flush();
}
Публиковать события из любого скрипта — одна строка:
<?php
// publisher.php — вызывается, например, из контроллера после создания комментария
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->publish("user.42.notifications", json_encode([
'type' => 'comment',
'text' => 'Новый комментарий в посте #123',
'url' => '/posts/123#c456',
]));
В клиенте слушаем именованное событие:
const es = new EventSource('/sse-redis.php?user=42');
es.addEventListener('notification', (e) => {
const note = JSON.parse(e.data);
showToast(note.text, note.url);
});
Строка : heartbeat — это комментарий в формате SSE (начинается с двоеточия). Браузер его игнорирует, но соединение остаётся живым, что важно для прокси с таймаутом простоя.
Грабли: буферизация, FastCGI и Nginx
Самая частая боль — события не приходят, пока скрипт не завершится. Это значит, что данные где-то буферизуются. Виновников трое:
1. Output buffer PHP. Любые ob_start() в auto_prepend_file или фреймворке копят вывод. Решение — отключить всё в начале скрипта:
while (ob_get_level() > 0) {
ob_end_clean();
}
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', 'off');
2. FastCGI buffering в Nginx. Nginx по умолчанию ждёт, пока FastCGI вернёт «достаточно» данных. Отключаем для конкретного location:
# /etc/nginx/conf.d/sse.conf
location ~ ^/sse.*\.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Критично: отключаем буферизацию
fastcgi_buffering off;
fastcgi_read_timeout 24h;
# Подсказка для proxy и CDN не буферизовать
proxy_buffering off;
# Отключаем gzip — он тоже буферизует
gzip off;
}
Заголовок X-Accel-Buffering: no из PHP делает то же самое, но конфиг надёжнее.
3. PHP-FPM и process_idle_timeout. Долгоживущий SSE-скрипт занимает воркер на всё время соединения. Если у вас 10 воркеров и 1000 пользователей, всё ляжет. Варианты:
- Поднять
pm.max_childrenи переключитьpm = ondemand. - Выделить отдельный пул PHP-FPM только под SSE с большим числом воркеров.
- Использовать ReactPHP / Swoole для асинхронной обработки тысяч соединений в одном процессе — но это уже не «обычный PHP».
Last-Event-ID: догоняем пропущенные события
Если клиент отвалился на 10 секунд, при reconnect он пришлёт заголовок Last-Event-ID: 47. Сервер должен отдать все события с id > 47. Реализуется через таблицу-журнал событий:
CREATE TABLE sse_events (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
event VARCHAR(32) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id, id)
);
На старте скрипта догоняем пропущенное, затем переходим в режим Pub/Sub:
<?php
$lastId = (int)($_SERVER['HTTP_LAST_EVENT_ID'] ?? 0);
$userId = (int)($_GET['user'] ?? 0);
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', 'user', 'pass');
// 1. Добиваем backlog
$stmt = $pdo->prepare(
'SELECT id, event, payload FROM sse_events
WHERE user_id = ? AND id > ? ORDER BY id ASC LIMIT 100'
);
$stmt->execute([$userId, $lastId]);
foreach ($stmt as $row) {
echo "id: {$row['id']}\n";
echo "event: {$row['event']}\n";
echo "data: {$row['payload']}\n\n";
$lastId = $row['id'];
}
@ob_flush();
flush();
// 2. Дальше — Pub/Sub или polling по этому же $lastId
Так пользователь не пропустит уведомления, даже если у него прыгал Wi-Fi.
Закрываем за собой ресурсы
SSE-скрипт — это долгоживущий процесс с открытыми соединениями к Redis, MySQL, файлам. Если клиент закрыл вкладку, нужно это засечь и аккуратно выйти, иначе ресурсы текут.
<?php
// Включаем падение при разрыве клиента
ignore_user_abort(false);
// Регистрируем уборку в любом случае выхода
register_shutdown_function(function () use (&$redis, &$pdo) {
if (isset($redis)) $redis->close();
if (isset($pdo)) $pdo = null;
});
while (true) {
if (connection_aborted()) break;
// ... ваш цикл
}
Проверка connection_aborted() срабатывает только при попытке вывода — поэтому heartbeat-комментарий каждые 15–25 секунд нужен не только для прокси, но и для своевременного выхода скрипта.
Чеклист для продакшена
- Отключите все буферизации — PHP, Nginx, FastCGI, gzip.
- Не делайте бесконечный
sleep— подписывайтесь на Redis Pub/Sub или ставьте умный backoff. - Шлите heartbeat каждые 15–25 секунд:
: ping\n\n. Без этого упадут на прокси с idle-таймаутом. - Поддержите
Last-Event-ID— иначе при reconnect клиент пропустит события. - Лимитируйте соединения на пользователя через Nginx
limit_connили ваш Auth-слой — иначе DoS. - Отдельный пул PHP-FPM для SSE, чтобы основной сайт не зажимало воркерами.
- Не отдавайте сырой JSON, если есть переводы строк — экранируйте через
json_encodeсJSON_UNESCAPED_UNICODE. - Тестируйте через DevTools → Network → EventStream — там видно структуру событий и тайминги.
SSE — это «WebSocket для бедных», но для 90% задач реального времени этого достаточно: проще написать, проще отдебажить, не нужны новые библиотеки на клиенте. Если поймали себя на мысли «сейчас прикручу Socket.IO», сначала прикиньте — может, хватит трёх десятков строк PHP и встроенного EventSource.
