← Tous les cours / Files de messages Cours
00 — VUE D'ENSEMBLE

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.

Producteur
Entité qui publie des messages dans une queue. Il n'attend pas que le consommateur traite — il continue de produire.
Consommateur
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é.

Producteur
Queue
Consommateur

Cas d'usage typiques :

💡 Async par nature : le producteur n'attend pas d'ACK du consommateur. Il envoie et passe à autre chose. Le couplage temporel est rompu — c'est la source principale de résilience de ce modèle.

Clique sur Produire pour ajouter des messages, sur Consommer pour les traiter.

Producteur
Queue (0 / 10)
Consommateur
01 — QUEUE, PUB/SUB, TOPIC

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.

Work Queue (FIFO)
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.
Producteur
Queue
Worker A
Worker B
Pub/Sub (Fan-out)
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.
Producteur
Exchange
Queue A → Consumer A
Queue B → Consumer B
Topic Routing
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
⚠️ Work Queue ≠ Pub/Sub : c'est la confusion la plus courante. Dans une work queue, les workers se partagent le travail — ils ne reçoivent pas tous les mêmes messages. Dans un Pub/Sub, chaque subscriber reçoit tout.
02 — GARANTIES DE LIVRAISON

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.

At-most-once
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.
At-least-once
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).
Exactly-once
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
💡 RabbitMQ : at-least-once avec ACK consommateur + Publisher Confirms. Exactly-once nécessite une logique applicative supplémentaire (déduplication par message ID).
💡 Kafka : at-least-once par défaut (commit d'offset après traitement). Exactly-once avec transactional producers + idempotent producers — disponible depuis Kafka 0.11, activé via enable.idempotence=true.
03 — ACK, NACK ET DLQ

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.

ACK (acknowledgment)
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
        }
    }
);
NACK / Reject
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).
Dead Letter Queue (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
]);
⚠️ Piège classique : si tu NACK avec 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.

04 — BACKPRESSURE

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".

Backpressure
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 :

Solutions côté queue
  • Taille maximale : refuser les nouveaux messages quand la queue est pleine (x-max-length dans 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).
Solutions côté système
  • 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.

💡 Kafka gère la backpressure différemment : le producteur écrit dans le log sans attendre le consommateur. C'est le consommateur qui lit à son rythme via son offset. La backpressure est gérée en dehors de Kafka (monitoring lag du consumer group).

Ajuste les débits pour observer la saturation de la queue. Déplace les curseurs pour voir la backpressure en action.

Taille queue (estimée après 10s) 0 messages
Équilibre — queue stable.
05 — RABBITMQ

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.

Exchange Direct
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.
Exchange Fanout
Ignore la routing key et envoie le message à toutes les queues liées à l'exchange. Parfait pour le broadcast : une queue par service abonné.
Exchange Topic
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 :

Publisher Confirms
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".
⚠️ Exchange Headers : quatrième type d'exchange, route selon les headers AMQP plutôt que la routing key. Rarement utilisé en pratique — le topic couvre 95 % des besoins.
06 — KAFKA

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.

Log partitionné
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).
Producteur
Topic (N partitions)
Consumer Group A (offset propre)
Consumer Group B (offset propre)
Consumer Groups
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 :

💡 Compaction de log : mode alternatif à la retention par TTL. Kafka ne garde que la dernière valeur pour chaque clé de message. Utile pour les "event sourced state stores" où seule la valeur la plus récente compte.
07 — PREFETCH ET QOS

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.

Prefetch (QoS)
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
);
Queue (100 msgs)
Worker A (rapide) — prefetch=1
Worker B (lent) — prefetch=1

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
⚠️ Prefetch trop élevé : si un consumer crashe avec 500 messages non-ACKés dans son buffer, tous ces messages sont remis en queue simultanément, créant un pic de charge. Garde le prefetch à un niveau raisonnable (≤ 50 pour la plupart des cas).

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
08 — IDEMPOTENCE

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.

Idempotence
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.
Consommateur idempotent
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 :

Déduplication par message ID
// 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);
Upsert idempotent (SQL)
-- PostgreSQL — INSERT ou ignore si doublon
INSERT INTO orders (id, amount)
VALUES (:id, :amount)
ON CONFLICT (id) DO NOTHING;

Bonnes pratiques :

💡 Idempotence ≠ déduplication : l'idempotence garantit que ton code produit le bon résultat même avec des doublons. La déduplication empêche l'exécution du code en cas de doublon. Les deux approches sont valables — la déduplication est plus sûre mais coûte un accès DB supplémentaire.
09 — SYNTHÈSE

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
Choisir RabbitMQ si…
  • 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.
Choisir Kafka si…
  • 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.
💡 Symfony Messenger : abstrait le transport. Tu peux commencer avec le transport 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 :