php

Server-Sent Events на PHP: live-обновления без WebSocket

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

Архитектура Server-Sent Events: браузер, PHP-скрипт, источник данных
Поток событий идёт в одну сторону — от сервера к клиенту через одно долгоживущее HTTP-соединение

Что такое 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.