Producteur/Consommateur — le découplage asynchrone
Le producteur envoie, le consommateur traite — sans attendre l'un l'autre. La queue est le tampon.
Imagine une boulangerie : le boulanger fabrique des croissants et les pose dans la vitrine. Les clients viennent les prendre quand ils veulent. Le boulanger ne les attend pas. C'est exactement le modèle producteur/consommateur.
Entité qui publie des messages dans une queue. Il n'attend pas que le consommateur traite — il continue de produire.
Entité qui lit et traite les messages depuis la queue. Il peut s'exécuter sur une autre machine, dans un autre processus, à un autre moment.
Le découplage est la propriété centrale : producteur et consommateur n'ont pas besoin de s'exécuter en même temps. Si le consommateur tombe, les messages s'accumulent dans la queue. Quand il revient, il reprend là où il s'était arrêté.
Cas d'usage typiques :
- Envoi d'e-mails : la requête HTTP renvoie immédiatement, l'e-mail est envoyé en arrière-plan.
- Traitement d'images : l'upload se termine vite, le redimensionnement prend du temps — on délègue.
- Synchronisation de données entre microservices : chaque service publie ses événements sur une queue centrale.
Clique sur Produire pour ajouter des messages, sur Consommer pour les traiter.
Queue, Pub/Sub, Topic — les patterns de distribution
Work queue (1 message, 1 consumer), Pub/Sub (1 message, N consumers), Topic routing (messages routés par clé).
Tous les systèmes de messaging ne distribuent pas les messages de la même façon. Trois patterns couvrent l'essentiel des besoins.
Un producteur envoie des messages dans une queue. Plusieurs consommateurs lisent depuis la même queue — mais chaque message n'est traité que par un seul consommateur. C'est du load balancing naturel.
Un producteur publie sur un topic ou un exchange fanout. Chaque consommateur abonné reçoit une copie du message. Si 5 consommateurs sont abonnés, le message est livré 5 fois — une fois à chacun.
Un message porte une routing key (ex. :
order.paid, order.shipped). Chaque consommateur déclare les patterns qui l'intéressent (order.*, *.paid). L'exchange route selon les correspondances.
| Pattern | Livraison | Cas d'usage |
|---|---|---|
| Work Queue | 1 message → 1 consumer | Traitement de tâches, jobs d'arrière-plan |
| Pub/Sub | 1 message → N consumers | Notifications, invalidation de cache multi-service |
| Topic Routing | 1 message → consomers matchant la clé | Événements métier avec filtrage fin |
Garanties de livraison — at-most, at-least, exactly-once
Trois niveaux de garantie, trois niveaux de complexité — comprendre lequel tu as vraiment.
Quand on parle de garanties de livraison, on parle de ce qui se passe en cas de panne — réseau coupé, consumer qui crashe, broker qui redémarre. Il existe trois niveaux.
Le message est envoyé une fois, sans confirmation. S'il se perd, personne ne le sait. Le producteur ne réessaie pas.
Exemple : logs non critiques, métriques d'analytics où perdre quelques points est acceptable.
Le message est livré au moins une fois. En cas d'échec, on réessaie — mais le consommateur peut donc recevoir des doublons.
Exemple : envoi d'e-mail de confirmation (idempotent si on déduplique côté consommateur).
Le message est livré exactement une fois, ni perdu ni dupliqué. Très difficile à garantir — nécessite des transactions distribuées.
Exemple : paiements financiers, débit de compte.
En pratique, at-least-once + consommateur idempotent est le compromis le plus courant. Exactly-once coûte cher en latence et complexité.
| Garantie | Perte possible | Doublon possible | Complexité |
|---|---|---|---|
at-most-once |
Oui | Non | Faible |
at-least-once |
Non | Oui | Moyenne |
exactly-once |
Non | Non | Élevée |
enable.idempotence=true.
ACK, NACK et Dead Letter Queue
L'ACK confirme le traitement. Le NACK signale l'échec. La DLQ recueille les messages qui ne passent pas.
Le mécanisme d'ACK est ce qui distingue at-least-once de at-most-once. Sans ACK, le broker supprime le message dès qu'il l'envoie — si le consumer crashe entre la réception et le traitement, le message est perdu.
Le consommateur envoie un ACK au broker pour confirmer qu'il a traité le message. Le broker ne supprime le message de la queue qu'à la réception de l'ACK.
// RabbitMQ — PHP AMQP lib $channel->basic_consume( 'ma-queue', '', // consumer tag false, // no_local false, // no_ack — false = ACK manuel obligatoire false, // exclusive false, // nowait function ($msg) { try { traiter($msg->body); $msg->ack(); // succès → broker supprime le message } catch (Exception $e) { $msg->nack(false); // échec sans requeue → DLQ } } );
Le consommateur signale un échec. Deux options :
requeue=true (le message retourne dans la queue pour être retraité) ou requeue=false (le message est supprimé ou envoyé dans la DLQ).
Queue spéciale qui recueille les messages "morts" : NACK sans requeue, TTL expiré, max-retries atteint. La DLQ permet d'inspecter les messages échoués et de les rejouer manuellement.
Configuration d'une DLQ dans RabbitMQ :
// Déclarer la DLQ $channel->queue_declare('ma-queue.dlq', false, true, false, false); // Déclarer la queue principale avec pointeur vers la DLQ $channel->queue_declare('ma-queue', false, true, false, false, false, [ 'x-dead-letter-exchange' => ['S', ''], 'x-dead-letter-routing-key' => ['S', 'ma-queue.dlq'], 'x-message-ttl' => ['I', 60000], // TTL 60s ]);
requeue=true sur un message qui échoue systématiquement, il reboucle indéfiniment et occupe tous tes workers. Toujours implémenter un compteur de retry maximal avant d'envoyer en DLQ.
Dans Symfony Messenger, la DLQ est gérée automatiquement via le transport failed : les messages qui ont dépassé le nombre de retries sont routés vers ce transport, inspectables avec bin/console messenger:failed:show.
Backpressure — quand les consommateurs ne suivent pas
Si les producteurs sont plus rapides que les consommateurs, la queue explose. Backpressure = le signal qui dit « ralentis ».
Imagine un tuyau d'arrosage branché sur une citerne. Si tu ouvres le robinet plus vite que le tuyau ne peut écouler l'eau, la citerne déborde. C'est exactement la backpressure : le signal que le système envoie en amont pour dire "tu vas trop vite".
Mécanisme par lequel un consommateur (ou un tampon intermédiaire) signale au producteur qu'il doit ralentir sa production, sous peine de saturation.
Dans les systèmes de messaging, le symptôme est visible : la queue grossit sans arrêt. Solutions :
- Taille maximale : refuser les nouveaux messages quand la queue est pleine (
x-max-lengthdans RabbitMQ). Le producteur reçoit une erreur. - Drop silencieux : les messages les plus anciens ou les plus récents sont supprimés quand la limite est atteinte (
x-overflow: drop-head).
- Scale horizontal : ajouter des consommateurs pour absorber le débit.
- Rate limiting : limiter le débit du producteur à la source (token bucket, leaky bucket).
- Reactive Streams : protocole de backpressure pull-based — le consommateur demande N éléments, le producteur n'en produit pas plus.
Dans Node.js streams, la backpressure est intégrée : writable.write() retourne false quand le buffer interne est plein, signalant au producteur de s'arrêter jusqu'à l'événement drain. TCP utilise le même mécanisme via la fenêtre de congestion.
Ajuste les débits pour observer la saturation de la queue. Déplace les curseurs pour voir la backpressure en action.
RabbitMQ — exchanges, bindings et routing
Exchange direct, fanout, topic — la flexibilité de RabbitMQ vient de sa séparation entre exchange et queue.
RabbitMQ s'appuie sur le protocole AMQP. Sa flexibilité vient d'une architecture en deux niveaux : les exchanges reçoivent les messages des producteurs, les queues stockent les messages pour les consommateurs. Un binding relie les deux.
Route les messages vers les queues dont la routing key correspond exactement à celle du message. Un message avec la clé
order.paid n'ira que dans les queues bindées sur order.paid.
Ignore la routing key et envoie le message à toutes les queues liées à l'exchange. Parfait pour le broadcast : une queue par service abonné.
Routing key avec patterns :
* correspond à un mot, # correspond à zéro ou plusieurs mots. order.* matche order.paid et order.shipped. #.error matche tout ce qui finit par .error.
// Déclarer un exchange topic et binder une queue $channel->exchange_declare('events', 'topic', false, true, false); $channel->queue_declare('billing-events', false, true, false, false); $channel->queue_bind('billing-events', 'events', 'order.*'); // Publier un message $msg = new AMQPMessage( json_encode(['order_id' => 42]), ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT] ); $channel->basic_publish($msg, 'events', 'order.paid');
Durabilité — trois conditions pour qu'un message survive à un redémarrage de RabbitMQ :
- L'exchange doit être déclaré durable.
- La queue doit être déclarée durable.
- Le message doit avoir
delivery_mode = 2(persistent).
Mode dans lequel RabbitMQ envoie un ACK au producteur pour confirmer que le message a bien été persisté sur disque. Active-le avec
$channel->confirm_select(). Sans ça, la publication est "fire and forget".
Kafka — le log distribué immuable
Kafka ne supprime pas les messages après consommation — il stocke un log partitionné que chaque consumer group lit à son rythme.
RabbitMQ est un broker de messages : il distribue et supprime. Kafka est un log distribué : il stocke et laisse lire. Cette différence fondamentale change tout.
Kafka stocke les messages dans des partitions, qui sont des fichiers séquentiels immuables. Chaque message a un offset unique dans sa partition. Les messages ne sont jamais supprimés à la consommation — ils persistent selon la retention policy (TTL ou taille).
Un consumer group est un ensemble de consommateurs qui se partagent les partitions d'un topic. Chaque partition n'est assignée qu'à un seul consumer du groupe — c'est ce qui garantit l'ordre et permet le parallélisme. Deux groupes différents lisent le topic de façon indépendante.
// Consommer depuis Kafka (php-rdkafka) $conf = new RdKafka\Conf(); $conf->set('group.id', 'billing-service'); $conf->set('auto.offset.reset', 'earliest'); $conf->set('enable.auto.commit', 'false'); // commit manuel = at-least-once $consumer = new RdKafka\KafkaConsumer($conf); $consumer->subscribe(['orders']); while (true) { $message = $consumer->consume(10000); if ($message->err === RD_KAFKA_RESP_ERR_NO_ERROR) { traiter($message->payload); $consumer->commit($message); // commit après traitement } }
Propriétés clés à retenir :
- Ordre garanti par partition : les messages d'une même partition sont lus dans l'ordre d'écriture. Pas d'ordre garanti entre partitions.
- Parallélisme = nombre de partitions : si un topic a 6 partitions, on peut avoir au maximum 6 consommateurs actifs par consumer group.
- Replay : comme les messages ne sont pas supprimés, tu peux "rembobiner" l'offset et relire l'historique — utile pour rejouer des événements après un bug.
Prefetch et QoS — éviter de surcharger un consumer
Sans limite de prefetch, RabbitMQ envoie tous les messages au premier consumer disponible — même s'il est déjà débordé.
Sans configuration de prefetch, RabbitMQ applique le round-robin "naïf" : il envoie les messages aux consommateurs à tour de rôle, sans tenir compte de leur charge réelle. Un consumer lent qui a 100 messages non traités peut continuer d'en recevoir de nouveaux.
Nombre maximum de messages non-ACKés qu'un consommateur peut avoir en transit simultanément. RabbitMQ cesse d'envoyer des messages à un consumer qui a atteint son quota.
// Limiter à 1 message en cours de traitement par consumer $channel->basic_qos( 0, // prefetch_size — ignoré par RabbitMQ (toujours 0) 1, // prefetch_count — 1 = traiter un message à la fois false // global — false = par consumer, true = par channel );
Avec prefetch_count=1, Worker A va traiter la majorité des messages puisqu'il est disponible plus souvent. Worker B ne reçoit un nouveau message que quand il a fini le précédent. C'est du load balancing fair.
| Prefetch | Comportement | Quand l'utiliser |
|---|---|---|
1 |
Un message à la fois, max équité | Workers hétérogènes, tâches longues |
10–50 |
Batch modéré, bon débit | Tâches courtes, workers homogènes |
0 (infini) |
Tous les messages envoyés d'un coup | À éviter sauf cas très spécifiques |
Dans Symfony Messenger, le prefetch est configuré dans le DSN du transport AMQP :
# config/packages/messenger.yaml framework: messenger: transports: async: dsn: 'amqp://guest:guest@rabbitmq:5672/%2f/messages' options: prefetch_count: 5
Idempotence — traiter deux fois sans conséquence
Avec at-least-once, ton handler peut être appelé deux fois pour le même message. L'idempotence garantit que ça ne change rien.
Avec at-least-once, ton handler peut être appelé plusieurs fois pour le même message. Un crash après le traitement mais avant l'ACK, une reconnexion réseau, un timeout côté broker — tous ces scénarios entraînent une relivraison.
Une opération est idempotente si l'appliquer plusieurs fois produit le même résultat que l'appliquer une seule fois.
x = 5 est idempotent. x += 1 ne l'est pas.
Un handler qui peut traiter le même message deux fois sans effet secondaire indésirable. En pratique : on stocke les IDs des messages traités, et on skip si l'ID est déjà connu.
Stratégies concrètes :
// Chaque message a un ID unique // généré par le producteur if ($this->processed->contains($msg->id)) { return; // déjà traité, skip } traiter($msg); $this->processed->add($msg->id);
-- PostgreSQL — INSERT ou ignore si doublon INSERT INTO orders (id, amount) VALUES (:id, :amount) ON CONFLICT (id) DO NOTHING;
Bonnes pratiques :
- Message ID unique côté producteur : génère un UUID v4/v7 à la publication et inclus-le dans le message. Ne te fie jamais à un ID généré côté consommateur.
- Table d'idempotence : une table
processed_messages(id, processed_at)avec une contrainte d'unicité surid. L'insert lève une exception si doublon. - Fenêtre temporelle : si les doublons ne peuvent arriver que dans un délai court (ex. : 24h), nettoie la table d'idempotence régulièrement pour éviter sa croissance infinie.
Synthèse — RabbitMQ ou Kafka ?
Latence vs débit, push vs pull, suppression vs retention — le guide de choix entre les deux.
RabbitMQ et Kafka sont souvent présentés comme concurrents. En réalité, ils répondent à des besoins différents. Le choix dépend de ta question centrale : veux-tu distribuer du travail ou conserver un historique d'événements ?
| Critère | RabbitMQ | Kafka |
|---|---|---|
| Modèle | Broker push — distribue et supprime | Log pull — stocke, le consumer lit |
| Latence | Très basse (ms) | Faible à moyenne (ms à dizaines de ms) |
| Débit | Modéré (centaines de milliers/s) | Très élevé (millions/s par partition) |
| Retention | Supprimé après ACK | Configurable (TTL ou taille) |
| Replay | Non | Oui |
| Ordre | Par queue (best effort) | Garanti par partition |
| Routing | Très flexible (exchanges) | Basique (topic + partition key) |
| Opérationnel | Simple à opérer | Complexe (ZooKeeper / KRaft, partitions…) |
| Cas typique | Tâches asynchrones, microservices | Event sourcing, pipelines de données, audit log |
- Tu veux distribuer des tâches entre workers (envoi d'e-mails, traitement d'images).
- Tu as besoin de routing flexible (exchanges topic/direct/fanout).
- Ta latence doit être très basse.
- Tu veux une DLQ native et des retry configurables sans code.
- Tu as besoin de rejouer des événements passés (audit, débogage, migration).
- Plusieurs services consomment le même flux indépendamment.
- Ton débit est très élevé (millions de messages/s).
- Tu fais de l'event sourcing ou du CQRS avec historique complet.
doctrine (messages en base PostgreSQL), passer à RabbitMQ quand le volume augmente, puis migrer vers Kafka si nécessaire — sans changer tes handlers.
Points essentiels à retenir :
- Le pattern producteur/consommateur découple temporellement les composants — la queue est le tampon de résilience.
- ACK/NACK et la DLQ sont les deux mécanismes centraux de la fiabilité côté consommateur.
- At-least-once + idempotence est le duo gagnant pour 80 % des cas — plus simple qu'exactly-once.
- La backpressure doit être anticipée à la conception, pas rattrapée en urgence quand la queue explose.
- Le prefetch est le premier levier à ajuster si tu as des workers hétérogènes.