SSE et PHP-FPM ne font pas bon ménage

Je construisais une chatbox sur mon serveur dédié — un espace partagé où des membres peuvent s'échanger des données et communiquer en temps réel. Côté serveur, j'implémente Server-Sent Events. Propre, simple, natif HTTP. En développement local, tout fonctionne parfaitement. Je pousse en production. Pendant deux jours, rien à signaler.

Puis un soir, une dizaine de personnes utilisent le chat en même temps. La page d'accueil du site commence à répondre lentement. À vingt utilisateurs connectés simultanément, le serveur ne répond plus du tout aux autres requêtes. 504 Gateway Timeout partout. Le serveur tourne, PHP tourne, mais plus rien ne passe. J'ai mis une heure à comprendre ce qui se passait.

L'implémentation naïve qui semble marcher

Le code SSE côté serveur est bref et standard. Voici ce que j'avais écrit :

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');

while (true) {
    $messages = getNewMessages();
    if (!empty($messages)) {
        foreach ($messages as $msg) {
            echo "data: " . json_encode($msg) . "\n\n";
            ob_flush();
            flush();
        }
    }
    sleep(1);
}

C'est exactement ce que recommande la documentation SSE. Le Content-Type: text/event-stream signale au navigateur qu'il s'agit d'un flux d'événements. Le X-Accel-Buffering: no désactive la mise en tampon d'Nginx pour que les données partent immédiatement. La boucle infinie vérifie régulièrement les nouveaux messages et les envoie dès qu'ils arrivent. En isolation, c'est parfaitement fonctionnel.

Le problème n'est pas dans le code. Il est dans ce que ce code fait au niveau du serveur quand plusieurs personnes l'exécutent en même temps.

Le diagnostic — le pool de workers PHP-FPM

PHP-FPM (FastCGI Process Manager) est le gestionnaire de processus PHP derrière Apache ou Nginx dans la très grande majorité des serveurs PHP de production. Son fonctionnement est simple : il maintient un pool de workers — des processus PHP en attente. Quand une requête HTTP arrive, Apache ou Nginx la passe à un worker disponible. Le worker l'exécute, renvoie la réponse, puis se libère pour la requête suivante.

La taille de ce pool est finie. Selon la configuration et la mémoire disponible, on tourne généralement entre 20 et 50 workers. C'est amplement suffisant pour des pages PHP normales qui s'exécutent en quelques dizaines de millisecondes et libèrent le worker immédiatement.

Mais un endpoint SSE, ce n'est pas une page normale. C'est une connexion qui reste ouverte aussi longtemps que l'utilisateur est connecté. Voici ce qui se passe concrètement :

[worker 1]  → user Alice  (boucle infinie, bloqué)
[worker 2]  → user Bob    (boucle infinie, bloqué)
[worker 3]  → user Carol  (boucle infinie, bloqué)
[worker 4]  → user David  (boucle infinie, bloqué)
[worker 5]  → user Emma   (boucle infinie, bloqué)
...
[worker 20] → user Théo   (boucle infinie, bloqué)

[page d'accueil]  → 504 Gateway Timeout ← plus aucun worker disponible
[API /health]     → 504 Gateway Timeout ← idem
[n'importe quoi]  → 504 Gateway Timeout ← idem

Chaque utilisateur connecté au chat immobilise un worker pour toute la durée de sa session. À vingt utilisateurs simultanés sur un pool de vingt workers, il n'en reste aucun de disponible. Le site entier est mort. Ce n'est pas un bug, pas un memory leak, pas une requête lente mal optimisée. C'est structurel.

Le worker ne peut pas se libérer : il est en train d'exécuter une boucle infinie qui attend les messages. Il ne rendra la main qu'à la déconnexion de l'utilisateur — c'est-à-dire exactement quand le navigateur ferme la connexion SSE, ce qui peut prendre des heures.

Pourquoi Node.js ou Go n'ont pas ce problème

Node.js et Go gèrent les connexions longue durée sans effort parce qu'ils reposent sur un modèle radicalement différent. Node.js utilise une event loop non-bloquante : un seul thread peut gérer des milliers de connexions SSE ouvertes simultanément, parce qu'une connexion en attente de données ne consomme pas de thread — elle est simplement enregistrée comme un descripteur de fichier à surveiller. Go fait de même avec ses goroutines, qui sont des threads légers multiplexés sur quelques threads OS. Tenir mille connexions SSE ouvertes en Go coûte quelques mégaoctets de mémoire, pas mille workers. Ce n'est pas un bug PHP — c'est une différence de modèle architectural fondamentale. PHP-FPM = un worker bloqué par connexion active. Node.js/Go = un thread pour toutes les connexions, concurrence non-bloquante. Pour une comparaison détaillée du comportement sous charge entre les deux modèles, voir PHP-FPM, workers et goroutines : ce qui se passe vraiment sous la charge.

La solution canonique PHP : le long-poll

Le long-poll renverse la logique. Au lieu de garder une connexion ouverte indéfiniment, le serveur répond dès qu'il a quelque chose à dire — ou au bout d'un timeout si rien ne s'est passé. La connexion se ferme. Le client reboucle immédiatement en ouvrant une nouvelle requête.

Du point de vue de l'utilisateur, le chat est en temps réel : les messages arrivent en moins d'une seconde. Du point de vue du serveur, chaque connexion dure au maximum 30 secondes avant de se libérer. Le worker retourne dans le pool. Les autres requêtes passent.

Voici le fichier chat-poll.php :

<?php
header('Content-Type: application/json');
header('Cache-Control: no-cache');

$lastId  = (int)($_GET['last_id'] ?? 0);
$timeout = 30; // secondes avant de rendre la main quoi qu'il arrive
$start   = time();

$messagesFile = __DIR__ . '/messages.json';

while (time() - $start < $timeout) {
    clearstatcache(true, $messagesFile);

    if (file_exists($messagesFile)) {
        $all  = json_decode(file_get_contents($messagesFile), true) ?? [];
        $news = array_values(array_filter($all, fn($m) => $m['id'] > $lastId));

        if (!empty($news)) {
            echo json_encode($news);
            exit;
        }
    }

    usleep(300_000); // polling toutes les 300ms
}

// Timeout : rien de nouveau, on rend la main, le client reboucle
echo json_encode([]);

Le principe est simple : on surveille un fichier JSON toutes les 300ms. Dès que de nouveaux messages apparaissent (identifiés par leur id supérieur au dernier vu par le client), on les renvoie et on sort. Si rien n'arrive pendant 30 secondes, on renvoie un tableau vide. Dans les deux cas, le worker est libéré.

Côté client, la boucle JavaScript :

async function pollChat(lastId = 0) {
    try {
        const res = await fetch(`/chat-poll.php?last_id=${lastId}`);
        const messages = await res.json();

        if (messages.length > 0) {
            messages.forEach(appendMessage);
            lastId = messages.at(-1).id;
        }
    } catch (e) {
        // Erreur réseau : attendre avant de reboucler
        await new Promise(r => setTimeout(r, 2000));
    }

    // Reboucle immédiatement — connexion courte, worker libéré
    pollChat(lastId);
}

pollChat();

La fonction pollChat est récursive mais asynchrone : elle attend la réponse du serveur, traite les messages, puis se rappelle elle-même. Comme chaque appel est dans une Promise résolue, il n'y a pas d'accumulation sur la pile d'appels. C'est le pattern standard pour du polling infini en JS.

Le gain est immédiat. Avec ce schéma, un worker est tenu au maximum 30 secondes (en l'absence de messages), et souvent moins de 300ms si des messages arrivent rapidement. Avec un pool de 20 workers et un timeout de 30 secondes, on peut en théorie gérer des centaines d'utilisateurs en rotation : la plupart du temps, les workers sont libres.

Bonus : inotify pour éviter le polling actif

Le polling toutes les 300ms fonctionne bien, mais il consomme un peu de CPU inutilement quand il n'y a rien à lire. L'extension PHP inotify permet de remplacer cette boucle d'attente par une notification système : le processus se met en veille et est réveillé dès que le fichier de messages est modifié.

<?php
// Avec l'extension inotify installée (pecl install inotify)
$fd = inotify_init();
inotify_add_watch($fd, $messagesFile, IN_MODIFY | IN_CREATE);

// Attendre jusqu'au timeout ou jusqu'à une modification
$read = [$fd];
$write = $except = [];
$remaining = $timeout - (time() - $start);

if (stream_select($read, $write, $except, $remaining) > 0) {
    inotify_read($fd); // vider la file d'événements
    // lire les nouveaux messages...
}

fclose($fd);

Résultat : latence quasi-nulle pour l'utilisateur, zéro CPU en attente. Cela dit, inotify n'est pas installé partout et ajoute une dépendance d'extension. Pour une chatbox, le polling à 300ms est largement suffisant — la différence perçue par l'utilisateur est imperceptible.

Conclusion

SSE est un bon protocole. Simple, natif, bien supporté par les navigateurs, pas besoin de bibliothèque. Le problème ne vient pas de SSE — il vient de ce que PHP-FPM fait de chaque connexion active. Un worker par connexion, c'est le modèle de PHP. Ce modèle est parfaitement adapté à des milliers de pages courtes. Il est fondamentalement inadapté à des connexions persistantes. Si vous avez besoin du vrai push côté navigateur avec une API Fetch et des streams lisibles, l'implémentation SSE avec ReadableStream et authentification par clé API couvre la partie client en détail.

Le long-poll n'est pas aussi élégant qu'un vrai push. Il y a des aller-retours, les timeouts, la reconnexion côté client à gérer. Mais il respecte le modèle PHP, il passe à l'échelle, et il ne nécessite aucune dépendance externe — pas de ReactPHP, pas de Ratchet, pas de Swoole. Un fichier PHP et une boucle JavaScript. Si la question de l'async PHP vous intéresse plus généralement, l'article sur PHP async, event loop et Fibers explore jusqu'où PHP peut aller sur un seul thread.

La leçon que j'en retiens : connaître les contraintes de son stack, c'est ce qui distingue "ça marche en dev" de "ça tient en prod". Le code SSE naïf était correct. Ce qui manquait, c'était la compréhension de comment PHP-FPM alloue ses ressources. Un quart d'heure de lecture de la documentation PHP-FPM m'aurait évité la panne.

Commentaires (0)