Files d'attente PHP à grande échelle : les 4 goulots d'étranglement que personne ne documente
Votre système de queue fonctionne parfaitement en production. Redis tourne, les workers consomment les jobs sans accroc, les métriques sont au vert. Puis, six mois plus tard, le volume explose — un million de jobs par jour — et c'est le vendredi après-midi que tout part en vrille.
C'est exactement le scénario documenté par Gabriel Anhaia sur dev.to, à partir d'une expérience réelle avec Laravel. Les mécanismes sous-jacents sont quasi-identiques dans l'écosystème Symfony/Messenger, et les leçons sont universelles pour tout développeur PHP travaillant avec des systèmes de messagerie asynchrone.
Ce qui rend ces problèmes particulièrement pernicieux : ils apparaissent dans un ordre précis. Corriger le mauvais en premier ne fait que masquer le suivant.
Goulot n°1 : la sérialisation qui explose à l'échelle
Lorsqu'un job est poussé dans la queue, il est sérialisé — converti en chaîne de caractères pour être stocké dans Redis. À faible volume, personne ne remarque ce coût. À 1M jobs/jour, la sérialisation devient le premier mur.
Le problème classique : sérialiser des objets entiers (entités Doctrine, modèles Eloquent) avec toutes leurs relations chargées. Un objet User avec ses rôles, ses permissions et ses préférences peut facilement peser plusieurs kilo-octets une fois sérialisé — multiplié par des milliers de jobs simultanés, Redis se retrouve engorgé et la latence de dispatch grimpe.
La bonne pratique : ne sérialiser que les identifiants scalaires, et recharger l'objet depuis la base de données dans le handle() du job.
// ❌ Mauvais : on sérialise l'entité entière
class SendInvoiceJob
{
public function __construct(private User $user, private Invoice $invoice) {}
}
// ✅ Bon : on ne sérialise que les IDs
class SendInvoiceJob
{
public function __construct(private int $userId, private int $invoiceId) {}
public function handle(UserRepository $users, InvoiceRepository $invoices): void
{
$user = $users->find($this->userId);
$invoice = $invoices->find($this->invoiceId);
// ...
}
}
Avec Symfony Messenger, le principe est identique : vos messages (Message objects) doivent rester de simples DTOs légers.
Goulot n°2 : la fuite mémoire des workers long-lived
Une fois la sérialisation optimisée, vous allez naturellement ajouter des workers. Et là apparaît le deuxième problème : la mémoire d'un worker augmente continuellement jusqu'au crash.
Les workers PHP sont des processus long-lived — ils traitent des milliers de jobs sans redémarrer. Chaque job peut laisser des références en mémoire : des services instanciés, des connexions non fermées, des caches statiques qui grossissent. Doctrine/ORM est particulièrement connu pour ce comportement si l'EntityManager n'est pas correctement remis à zéro entre les jobs.
Les points de vigilance en Symfony :
- Appeler
$entityManager->clear()après chaque job pour vider l'Identity Map de Doctrine - Configurer
--memory-limitsurmessenger:consumepour forcer un redémarrage propre - Utiliser Supervisor avec
autorestart=truepour garantir la relance automatique - Surveiller la consommation mémoire via des métriques (Prometheus + Grafana, ou simplement des logs)
# supervisor.conf
[program:messenger-consume]
command=php bin/console messenger:consume async --memory-limit=128M --time-limit=3600
autorestart=true
stdout_logfile=/var/log/messenger.log
Le --time-limit est votre filet de sécurité : même si une fuite mémoire subtile existe, le worker redémarre proprement toutes les heures.
Goulot n°3 : la table des jobs échoués devient un goulot d'écriture
Avec le volume qui monte, le taux d'échec absolu augmente mécaniquement — même si le taux relatif reste stable. Si 0,1% de vos jobs échouent et que vous en traitez 1M par jour, vous écrivez 1 000 lignes par jour dans votre table failed_jobs (ou messenger_messages en état failed avec Symfony).
Le problème : cette table grossit sans fin, les index se dégradent, et chaque écriture d'échec devient lente — ce qui ralentit le worker lui-même au moment où il tente de logger l'échec.
Solutions concrètes :
- Mettre en place une purge automatique des jobs échoués anciens (> 30 jours par exemple)
- Partitionner ou archiver la table si le volume est très élevé
- Monitorer activement les types d'erreurs plutôt que de laisser la table s'accumuler
- Configurer les retry intelligemment pour ne pas multiplier les échecs sur des erreurs non récupérables
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
failed:
dsn: 'doctrine://default?queue_name=failed'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
Avec Symfony Messenger, le transport failed est dédié — pensez à le consommer et purger régulièrement via messenger:failed:remove --all.
Goulot n°4 : la latence de dispatch sous contention Redis
Le quatrième goulot est souvent le plus surprenant : la latence d'ajout d'un job dans la queue. Ce qui prenait 1ms se retrouve à 40ms sous charge.
Redis est single-threadé pour les opérations de données. Sous forte contention — beaucoup de workers qui lisent, beaucoup de processus web qui écrivent — les commandes LPUSH et BRPOP s'accumulent en file d'attente côté Redis. S'y ajoute parfois une configuration réseau sous-optimale : pas de connection pooling, reconnexions fréquentes, timeout trop courts.
Pistes d'optimisation :
- Séparer les connexions Redis : une instance dédiée aux queues, distincte du cache applicatif
- Utiliser des queues prioritaires pour isoler les jobs critiques des jobs de fond
- Monitorer les métriques Redis :
redis-cli INFO stats, latence, connexions actives - Envisager la montée en charge Redis : Redis Cluster si le débit est vraiment très élevé
# Séparer cache et queue dans Symfony
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # Redis dédié queue
framework:
cache:
default_redis_provider: '%env(CACHE_REDIS_DSN)%' # Redis dédié cache
Conclusion : anticiper avant que la croissance ne vous rattrape
L'enseignement principal de cette analyse — et c'est ce qui en fait la valeur — est que ces quatre problèmes arrivent dans un ordre prévisible. Ajouter des workers sans corriger la sérialisation aggrave la contention. Corriger la sérialisation sans gérer la mémoire déplace juste le problème. Chaque optimisation prématurée sur le mauvais goulot vous fait perdre du temps.
La bonne approche est de construire ces fondations dès que le volume commence à croître : messages légers, workers avec limites de ressources, purge de la table d'échecs, et monitoring Redis actif.
Chez MulerTech, nous aidons nos clients à dimensionner leur infrastructure Symfony pour absorber la croissance sans surprises. Si votre système de queue commence à montrer des signes de tension, c'est le bon moment pour adresser ces points — avant le vendredi à 15h.
Source originale : Laravel Queues at 1M Jobs/Day — Gabriel Anhaia, dev.to