Courtier contre journal
Le chapitre précédent traitait la boîte aux lettres comme une boîte noire. On l'ouvre. Il y a deux façons de la construire, et toute la différence tient à une question : qui se souvient de ce qui a déjà été lu ?
Au chapitre précédent, on a appris à nommer ce qu’on s’envoie : des commandes, des requêtes, des événements. Mais on a traité « la boîte aux lettres » qui les transporte comme une boîte noire qui accepte les dépôts et les distribue. Reprends le scénario : ton service de commande publie l’événement « Commande payée ». Que devient ce message une fois déposé ? Est-il remis à un lecteur puis détruit, ou conservé et relisible plus tard ? Et qui, du système ou du lecteur, se souvient de ce qui a déjà été lu ? La réponse partage le monde du messaging en deux familles d’outils, et ce chapitre ouvre la boîte pour les confronter.
Le facteur et le registre
Au chapitre un, on a esquissé l’image en une phrase. Déroule-la maintenant.
Imagine d’abord un facteur. Tu lui confies une lettre, il la porte à un destinataire, le destinataire signe, et le facteur jette sa copie : sa mission est finie, il ne garde aucune trace. Si quelqu’un d’autre voulait lire cette lettre demain, c’est trop tard, elle a été remise une fois puis oubliée. Le facteur sait seulement ce qu’il lui reste à distribuer ; une fois distribué, plus rien.
Imagine maintenant un grand registre relié, posé sur une table. Chaque message qui arrive y est recopié à la suite, sur une nouvelle ligne numérotée, et rien n’est jamais effacé. N’importe qui peut s’asseoir, poser un doigt sur une ligne, lire vers le bas à son rythme, et revenir en arrière s’il le souhaite. Deux lecteurs avancent indépendamment : l’un peut être à la ligne 40, l’autre encore à la ligne 12. Le registre, lui, ne retient pas qui a lu quoi : c’est chaque lecteur qui se souvient de l’endroit où il en est.
Ces deux images ne sont pas des décorations : ce sont les deux grandes architectures du messaging. Le facteur, c’est le courtier de messages Courtier de messages Intermédiaire qui reçoit les messages, les range dans des files et remet chacun à un consommateur, puis l'efface une fois qu'il a été acquitté. L'archetype est RabbitMQ : un message livré et acquitté disparaît, il n'est pas conservé pour être relu. L'état de progression (ce qui reste à distribuer) vit dans le courtier, pas chez le lecteur. Source : Hohpe & Woolf, 2003 , dont RabbitMQ est l’archétype. Le registre, c’est le journal de messages Journal de messages Suite ordonnée et append-only de messages que l'on conserve au lieu de les effacer après lecture. L'archetype est Kafka : chaque message reçoit une position fixe, et plusieurs lecteurs peuvent le relire indépendamment, chacun à son rythme. À l'inverse du courtier, l'état de lecture ne vit pas dans le journal mais chez le consommateur, sous la forme d'un offset. Source : Kleppmann, 2017 , dont Kafka est l’archétype. Tout le chapitre consiste à comprendre pourquoi ce choix d’image change profondément ce que le système sait faire.
Le courtier : distribuer puis oublier
Commençons par le facteur. Un courtier gère des files. Tu publies un message, il le range dans une file, et il le remet à un consommateur qui s’y est branché. Dès que le consommateur confirme qu’il l’a bien reçu et traité, le courtier retire le message de la file. Il a disparu. Personne ne le relira.
Que se passe-t-il quand plusieurs consommateurs se branchent sur la même file ? Le courtier les sert chacun à tour de rôle : le premier message à l’un, le suivant à un autre, et ainsi de suite. Chaque message ne va donc qu’à un seul consommateur, et le travail se répartit tout seul entre eux. C’est le motif des consommateurs concurrents : pour traiter plus vite, on ajoute des consommateurs sur la même file, et le courtier équilibre la charge. C’est exactement ce qu’on veut pour un pool de travailleurs qui prélèvent des paiements : chaque paiement doit être traité une fois et une seule, par n’importe lequel d’entre eux.
Le point capital est ailleurs. Où vit l’information « ce message reste-t-il à traiter ? » Elle vit dans le courtier. C’est lui qui tient la file, lui qui sait ce qui reste, lui qui efface après confirmation. Le consommateur, lui, ne retient rien : il reçoit, il traite, il confirme, il oublie. Conséquence directe et lourde : un consommateur qui arrive trop tard ne verra jamais les messages déjà distribués et effacés. Ils n’existent plus nulle part.
Le journal : conserver et laisser relire
Passons au registre. Un journal est une suite de messages écrits les uns à la suite des autres, et jamais effacés : on ne fait qu’ajouter à la fin. C’est ce qu’on appelle une structure en ajout seul. Chaque message reçoit en y entrant un numéro de position définitif, croissant : le premier message est à la position 0, le suivant à 1, et ainsi de suite.
Comment un consommateur lit-il ce journal ? Il garde un curseur : le numéro du prochain message qu’il compte lire. Ce curseur porte un nom, l’ offset Offset Position de lecture d'un consommateur dans un journal de messages : le numéro du prochain message qu'il lira. C'est le consommateur qui détient et fait avancer son offset, pas le journal. Deux lecteurs du même journal ont donc des offsets indépendants, et rembobiner un offset à une position antérieure suffit pour relire l'historique. Source : Kleppmann, 2017 , et voici la bascule de tout le chapitre : l’offset appartient au consommateur, pas au journal. Le journal ne sait pas, et n’a pas à savoir, qui a lu jusqu’où. C’est chaque lecteur qui tient son offset et le fait avancer à mesure qu’il lit.
De là découle tout ce qui distingue le journal du courtier. Comme l’offset vit chez le lecteur et que le message reste, un nouveau lecteur peut très bien remettre son offset à 0 et relire tout l’historique depuis le début. Et plusieurs lecteurs indépendants peuvent lire le même journal sans se gêner, chacun à sa position. Pour organiser ces lecteurs, on les réunit en groupes de consommateurs Groupe de consommateurs Ensemble de consommateurs qui partagent un même offset pour se répartir la lecture d'un journal : au sein du groupe, chaque message n'est traité qu'une fois. Plusieurs groupes distincts lisent le même journal indépendamment, chacun avec son propre offset, si bien qu'un message est relu autant de fois qu'il y a de groupes. C'est l'équivalent côté journal des consommateurs concurrents d'un courtier. Source : Kleppmann, 2017 : à l’intérieur d’un groupe, les membres se partagent le travail et chaque message n’est traité qu’une fois, exactement comme les consommateurs concurrents d’un courtier ; mais deux groupes distincts ont chacun leur propre offset, si bien qu’un même message est lu une fois par chaque groupe. Trois groupes branchés sur le journal, c’est trois lectures complètes et indépendantes du même flux.
Reste une nuance honnête : « jamais effacé » a une limite pratique. Un journal conserve les messages pendant une durée, ou jusqu’à une taille, que l’on configure : c’est la rétention. Tant que la rétention couvre la période voulue, on peut relire ; au-delà, les vieux messages finissent par être recyclés. La relecture est donc possible, mais bornée par ce qu’on a décidé de garder.
La bascule : qui détient la position de lecture ?
On peut maintenant mettre les deux mondes face à face. La vraie différence n’est pas « file contre suite de lignes » : c’est l’endroit où vit la position de lecture, et tout le reste en découle.
| Critère | Courtier (file) | Journal (log) |
|---|---|---|
| Qui détient la position de lecture | Le courtier | Le consommateur (son offset) |
| Sort d’un message lu | Effacé après acquittement | Conservé, relisible |
| Un même message peut être lu par | Un seul consommateur | Autant de groupes que l’on veut |
| Plusieurs consommateurs sur le flux | Se partagent (consommateurs concurrents) | Se partagent dans un groupe, plusieurs groupes en parallèle |
| Relire l’historique | Impossible (effacé) | Possible (rembobiner l’offset), borné par la rétention |
| Un lecteur tardif voit le passé | Non | Oui, tant que la rétention le couvre |
| Archétype | RabbitMQ | Kafka |
Lis la troisième colonne comme une cascade : parce que l’offset vit chez le consommateur et que le message reste, on peut relire, on peut rembobiner, on peut brancher autant de groupes qu’on veut. Et lis la deuxième de même : parce que le courtier détient la position et efface, il sert vite et simplement un travail à répartir, mais il ne sait pas revenir en arrière. Aucun des deux n’est « meilleur » : ils répondent à deux besoins différents, qu’on va apprendre à reconnaître.
Observe la bascule toi-même
Le composant ci-dessous fait tourner le même flux de messages dans les deux modèles, côte à côte. Produis quelques messages, puis joue.
Côté courtier, distribue les messages à des consommateurs concurrents et regarde la file se vider : chaque message part vers un seul consommateur, puis disparaît. Côté journal, fais avancer l’offset des groupes et regarde que rien ne s’efface. Enfin, le geste révélateur : ajoute un lecteur tardif. Côté courtier, il arrive devant une file vide, il n’a rien à lire. Côté journal, il repart de l’offset 0 et relit tout l’historique. C’est toute la différence, en un bouton.
Produis des messages, puis distribue-les côté courtier et fais avancer les offsets côté journal. Pour finir, ajoute un lecteur tardif et compare ce qu'il voit de chaque côté.
Courtier (file)
File d'attente
file vide : rien à distribuer
Consommateurs concurrents
- A0 traités
- B0 traités
Journal (log)
journal vide
- facturationoffset: 0à jour
- fidélitéoffset: 0à jour
Trois questions à te poser en jouant :
- Après avoir tout distribué côté courtier, ajoute un consommateur puis clique « Distribuer le suivant ». Pourquoi ne reçoit-il rien, alors qu’un nouveau groupe côté journal, lui, peut tout relire ?
- Fais avancer l’offset du groupe « facturation » sans toucher à « fidélité ». Qu’est-ce que cela montre sur l’indépendance des deux offsets ?
- Côté courtier, le compteur « traités » de tes consommateurs augmente, mais la file se vide. Côté journal, les offsets avancent mais le nombre de messages ne baisse jamais. Quelle propriété de chaque modèle cette asymétrie résume-t-elle ?
Côté code : un courtier dans l’âme, un journal en face
Voyons comment ces deux modèles apparaissent dans du vrai code. Et c’est ici qu’un aveu utile s’impose.
Hexeract, le framework Rust de messaging qui nous sert de fil rouge, est un courtier dans l’âme. Son bus s’appuie sur RabbitMQ : on publie un message sur une file, un travailleur la consomme, l’acquitte, et le message disparaît. Plusieurs travailleurs sur la même file forment des consommateurs concurrents, et RabbitMQ répartit entre eux.
// Publier : le message part vers une file, sous une clé de routage.
let transport = RabbitMqTransport::new(&amqp_url).await?;
transport
.publish("orders.order-paid", &OrderPaid { order_id })
.await?;
// Consommer : un travailleur lit la file et traite chaque message.
// Lancer plusieurs travailleurs sur la MEME file, c'est des consommateurs
// concurrents : RabbitMQ les sert à tour de rôle, chaque message à un seul.
let worker = RabbitMqWorkerBuilder::new(connection)
.queue("orders.order-paid")
.register_handler::<OrderPaid, _>(NotifyShippingHandler)
.build()?;
worker.run(cancel).await?;
Cherche un offset, un groupe de consommateurs, une relecture dans Hexeract : tu n’en trouveras pas, et c’est volontaire. Un message acquitté y est retiré, point. Loin d’être un manque, c’est la preuve par le code de ce que dit ce chapitre : un courtier efface, il ne conserve pas. Pour obtenir un journal, il faut un autre modèle, pas une option de plus sur le même.
Wolverine, son équivalent en .NET, est instructif parce qu’il sait parler aux deux mondes. Avec un transport RabbitMQ, on règle le nombre de consommateurs concurrents sur une file. Avec un transport Kafka, on rejoint un groupe de consommateurs et l’on choisit où poser l’offset, y compris au tout début pour relire.
// Côté courtier (RabbitMQ) : plusieurs consommateurs se partagent la file.
opts.ListenToRabbitQueue("orders.order-paid")
.ListenerCount(5); // cinq consommateurs concurrents sur la même file
// Côté journal (Kafka) : un groupe lit le topic et tient son offset.
// AutoOffsetReset.Earliest dit : si ce groupe est nouveau, repars du début
// et relis tout l'historique conservé.
opts.ListenToKafkaTopic("orders.order-paid")
.ConfigureConsumer(config =>
{
config.GroupId = "fraud-detection";
config.AutoOffsetReset = AutoOffsetReset.Earliest;
});
Deux lignes résument la bascule : ListenerCount ajoute des consommateurs qui se partagent une file que le courtier vide ; GroupId plus AutoOffsetReset.Earliest crée un lecteur qui détient son offset et peut rembobiner. Le même message « Commande payée », selon la boîte qu’on choisit, est soit distribué une fois puis oublié, soit conservé et relu par autant de groupes qu’on veut.
Exercices
Prends une feuille et un crayon. Les corrigés sont juste en dessous, à ne regarder qu’après avoir essayé.
Exercice 1 : choisir la bonne boîte
Pour notre commande e-commerce, deux besoins distincts. (a) Un pool de travailleurs prélève les paiements : chaque paiement doit être traité une fois et une seule, par n’importe quel travailleur libre. (b) L’événement « Commande payée » doit être reçu par trois services qui en font des choses différentes : la facturation, la fidélité, et l’analytique ; et chacun doit pouvoir rejouer les événements après une panne. Pour chaque besoin, dis si un courtier ou un journal convient mieux, et justifie en une phrase à partir de la position de lecture.
Exercice 2 : le service qui arrive en retard
Un nouveau service de détection de fraude doit analyser tous les paiements des sept derniers jours, y compris ceux survenus avant son déploiement. Première question : avec un courtier, pourquoi est-ce tout simplement impossible ? Deuxième question : avec un journal, comment t’y prends-tu concrètement, et de quel réglage la réussite dépend-elle ?
En une phrase
Un courtier distribue chaque message à un seul consommateur puis l’efface, en gardant lui-même la position de lecture ; un journal conserve les messages et laisse chaque consommateur tenir son propre offset, ce qui autorise plusieurs lecteurs indépendants et la relecture de l’historique.
1. Quelle est la vraie différence de fond entre un courtier et un journal ?
2. Pourquoi trois groupes de consommateurs branchés sur le même journal lisent-ils chacun tout le flux, alors que trois consommateurs concurrents sur une file de courtier se le partagent ?
3. Un nouveau service doit traiter des messages produits avant son démarrage. Que peut-on dire ?
Vers le chapitre suivant
On sait maintenant ce que devient un message : effacé après acquittement chez le courtier, conservé et relisible chez le journal. Mais on a glissé sur un mot lourd de conséquences : l’acquittement. Reviens au facteur. Acquitte-t-il la lettre au moment où il te la tend, ou seulement après que tu l’as signée ? S’il jette sa copie en te la tendant et que tu la fais tomber dans une flaque, elle est perdue : personne ne la rejouera. S’il attend ta signature et que la foudre tombe juste après que tu as signé mais avant qu’il ne note ta signature, il reviendra demain te livrer la même lettre : tu la recevras deux fois. Perdre une fois, ou recevoir deux fois : ce dilemme n’est pas un détail technique, c’est le choix des sémantiques de livraison. Le chapitre trois place l’instant de l’acquittement sous le microscope et montre quelles garanties, at-most-once, at-least-once, effectively-once, on peut réellement tenir.
Sources
- Hohpe, G. & Woolf, B. (2003). Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions. Addison-Wesley. Motifs « Message Broker », « Competing Consumers », « Point-to-Point Channel » et « Publish-Subscribe Channel ». Référence de l’éditeur
- Kleppmann, M. (2017). Designing Data-Intensive Applications, chapitre 11 « Stream Processing » (courtiers de messages classiques contre journaux). O’Reilly. Référence de l’éditeur
- Apache Kafka. Documentation : Consumers, Consumer Groups, Offsets. kafka.apache.org
- RabbitMQ. Consumers and Acknowledgements. rabbitmq.com
- Wolverine. Kafka Transport et RabbitMQ Listeners. Documentation officielle. Kafka, RabbitMQ