L'API tourne sur un VPS 4 Go RAM, Nginx devant, PHP-FPM configuré à 50 workers. Un pic de trafic — rien d'exceptionnel, une campagne marketing — et en 8 secondes le pool est saturé. CPU à 30 %, serveur down. Le monitoring remonte des 502 et des 504 en rafale. Le goulot d'étranglement n'était pas le CPU. C'était la RAM et le pool épuisé.
Six mois plus tard, migration vers Go. Pas par effet de mode, mais parce qu'on avait compris le modèle. La différence de comportement sous charge n'était pas une question de vitesse brute — c'était une question de comment chaque runtime gère la concurrence.
Note : si vous cherchez un comparatif général des langages, j'ai écrit un article dédié. Celui-ci descend un niveau en dessous : la mécanique.
Le modèle PHP-FPM en une image
Nginx (ou Apache) agit comme reverse proxy : il gère le TLS, sert les fichiers statiques, bufférise les requêtes entrantes. Il ne fait pas tourner PHP. PHP-FPM, lui, maintient un pool de processus OS forké prêts à exécuter du PHP.
Le flux est simple :
client → Nginx → PHP-FPM queue → [worker pool]
↑
si pool plein : queue → timeout → 502/504
Chaque worker est un processus OS indépendant. Il consomme entre 30 et 60 Mo de RAM au minimum, selon ce que l'application charge en mémoire. Cette mémoire n'est pas partagée entre workers — chacun a son propre espace mémoire. Le pool est dimensionné à la configuration, pas à la charge réelle.
La saturation n'est pas un bug. C'est le comportement attendu du modèle.
[www]
pm = dynamic
pm.max_children = 50 ; 50 workers × 50 MB = 2.5 GB RAM réservée
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500 ; Recycle les workers après 500 req (évite les leaks mémoire)
Avec cette configuration sur un VPS 4 Go, PHP-FPM peut consommer jusqu'à 2,5 Go de RAM avant même que l'application démarre réellement à traiter des données.
Ce qui se passe quand le pool est plein
Séquence précise : une requête arrive, Nginx la bufférise, PHP-FPM essaie d'assigner un worker.
Si pm.max_children est atteint, la requête attend en queue.
Si la queue est pleine ou que fastcgi_read_timeout expire, Nginx retourne un 504.
Les clients voient une erreur. Le CPU, lui, est tranquille.
Le calcul est brutal : 50 workers × 50 Mo = 2,5 Go de RAM consommée rien que pour le pool PHP, avant les logs, le cache, Nginx lui-même. La concurrence est bornée par la RAM, pas par la puissance de calcul.
Ce modèle a une conséquence directe sur les connexions persistantes. Une SSE ou une WebSocket maintient un worker occupé pendant toute sa durée de vie. 50 connexions SSE simultanées = 50 workers bloqués = pool saturé pour tout le reste.
ps --no-headers -o rss -C php-fpm | awk '{sum+=$1} END {print sum/1024 " MB"}'
Cette commande donne la consommation RAM réelle de tous les processus php-fpm en production. Utile à faire tourner avant de dimensionner le pool.
Le modèle Go — goroutines et scheduling M:N
Go ne fork pas de processus. Il ne maintient pas de pool de threads.
Son runtime implémente un scheduler M:N : N goroutines multiplexées sur M threads OS,
où M correspond à GOMAXPROCS (par défaut le nombre de cœurs).
Une goroutine démarre avec une stack de 8 Ko — contre environ 8 Mo pour un thread OS. Cette stack croît dynamiquement si besoin, mais reste légère tant que la goroutine est bloquée en I/O. Le runtime la place en attente et utilise le thread OS pour autre chose.
Avec net/http, chaque connexion entrante spawn une goroutine.
10 000 connexions simultanées ≈ 80 Mo de stacks de goroutines.
Sur un PHP-FPM à 50 workers, vous seriez à 2,5 Go et en 502 depuis longtemps.
Il n'y a pas de PHP-FPM, pas de pool de workers. Le binaire Go est le serveur. Nginx peut rester devant pour le TLS, la compression, le cache des fichiers statiques, le rate-limiting — mais pas pour gérer la concurrence applicative.
Sur le cycle de vie des goroutines et les risques de fuite, j'ai détaillé les patterns à éviter dans l'article Goroutine leaks en Go : détecter, comprendre, corriger.
package main
import (
"log"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Println("Listening on :8080")
log.Fatal(srv.ListenAndServe())
}
Les timeouts sur le serveur sont non-négociables en production. Sans ReadTimeout,
un client lent peut maintenir une connexion ouverte indéfiniment,
et la goroutine associée ne se libère jamais.
La dégradation sous charge — comportement comparé
PHP-FPM : dégradation en falaise
En dessous de max_children, tout fonctionne normalement.
Au-delà : queue, timeout, 502. La dégradation est binaire — le service répond ou non.
Il n'y a pas de demi-mesure, pas de latence qui monte progressivement.
Le serveur passe de "opérationnel" à "en erreur" en quelques secondes.
Go : dégradation progressive
Les goroutines s'accumulent en mémoire. La latence monte linéairement.
Il n'y a pas de "pool plein" — tant que la RAM le permet, Go continue d'accepter des connexions.
Avec context.WithTimeout correctement propagé,
les requêtes lentes libèrent leurs goroutines proprement à l'expiration.
Sans timeout — goroutine potentiellement orpheline :
func handler(w http.ResponseWriter, r *http.Request) {
result := fetchFromDB() // peut prendre 30 secondes
w.Write(result)
// si le client déconnecte, la goroutine continue quand même
}
Avec context timeout — libération propre :
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := fetchFromDBCtx(ctx)
if err != nil {
http.Error(w, "timeout", http.StatusGatewayTimeout)
return
}
w.Write(result)
// quand le client déconnecte, r.Context() est annulé → ctx aussi → fetchFromDBCtx sort proprement
}
La différence de comportement sous charge entre les deux modèles tient souvent à cette propagation du contexte. En Go, une goroutine mal ancrée à un contexte devient une goroutine leak — invisible jusqu'à ce que la RAM sature.
Les conséquences opérationnelles
Le choix n'est pas "PHP est lent, Go est rapide". C'est une question d'adéquation entre le modèle de concurrence et les contraintes du projet.
PHP + Nginx gagne quand :
- Hébergement mutualisé, CMS (WordPress, Drupal), écosystème Composer déjà en place
- Trafic inférieur à ~1 000 req/min, équipe PHP existante, code legacy
- Déploiement FTP ou Git push simple sans accès au système
Go gagne quand :
- APIs haute fréquence (> 10 000 req/min), WebSockets, SSE en volume
- Budget VPS contraint : 512 Mo de RAM peuvent tenir des milliers de connexions Go légères
- Microservices à faible empreinte, binaire unique à déployer
Pour le cas concret des SSE avec PHP et les contournements nécessaires, j'ai détaillé l'approche dans l'article SSE, PHP-FPM et chatbox : travailler avec les workers.
| Critère | PHP + Nginx/FPM | Go (net/http) |
|---|---|---|
| Unité de concurrence | Process OS (~50 Mo) | Goroutine (~8 Ko) |
| Limite naturelle | Taille du pool (RAM) | RAM totale (dégradation progressive) |
| Comportement à saturation | Queue + timeout + 502 | Latence monte, connexions tenues |
| Connexions persistantes (SSE, WS) | 1 worker bloqué | 1 goroutine dormante (~8 Ko) |
| VPS 2 Go RAM | ~30-40 workers max | ~100k connexions légères |
| Déploiement | FTP, mutualisé, CMS ready | Binaire unique, systemd |
| Hébergement mutualisé | Oui (partout) | Non (VPS minimum) |
| Écosystème CMS/libs | Énorme | Minimal côté web classique |
| Idéal pour | Sites, CMS, API < 1k req/min | API haute fréquence, temps réel, microservices |
Conclusion
La plupart des migrations vers Go que j'ai vues — ou faites — partent d'une mauvaise surprise avec PHP-FPM sous charge. Une surprise qui était évitable avec un test de charge en amont, avant la mise en production.
PHP-FPM est robuste et prévisible. Son seul vrai défaut : il est opaque jusqu'au moment où le pool est plein. Pas d'avertissement progressif, pas de dégradation gracieuse. Une fois que vous avez compris cette mécanique, vous choisissez en connaissance de cause — et vous restez souvent sur PHP, mieux dimensionné.
Le bon outil n'est pas celui qui résiste le mieux. C'est celui dont vous comprenez le point de rupture.