Event sourcing sans event store : le CDC devient le journal des faits

Sommaire (6 parties)

Un compte bancaire, rangé dans une table accounts avec une colonne balance. Un retrait de 50 euros lance UPDATE accounts SET balance = balance - 50, et le solde d'avant disparaît. Le solde est l'état du compte. La liste de ses opérations est son historique, et l'UPDATE l'efface.

L'event sourcing évite cette perte. On ne garde plus le dernier solde, mais la liste des opérations : dépôt de 100, retrait de 50. Pour connaître le solde, on les additionne. Je pratique cette méthode au quotidien, avec un event store. C'est une base qui ne contient que cette liste, ajoutée à la suite sans jamais être modifiée. Je l'ai décrite dans CQRS en Go, partie 4 : PostgreSQL comme event store.

John Gilbert fait le même constat que moi : « on perd de l'information, on ne garde que le dernier solde ». Pourtant, dans Software Architecture Patterns for Serverless Systems (Packt, 2024), il construit son event sourcing sur cette table-là, sans event store. Le journal des faits du titre vient d'ailleurs : du CDC (change data capture). Ce composant lit le journal interne de la base, où elle note elle-même chaque changement qu'elle fait.

Le livre pose donc une autre réponse à une question que je croyais réglée : où doit vivre l'historique ? Voici les deux réponses côte à côte, ce que le CDC simplifie, et ce qu'il coûte.

Deux endroits où ranger l'historique

Dans l'event sourcing classique, l'état se calcule à partir de l'historique. Une commande (un ordre donné au service) arrive, par exemple « retirer 50 ». Le service relit tous les événements du compte et les rejoue, c'est-à-dire les additionne dans l'ordre, pour retrouver le solde actuel. Il décide, puis ajoute ses nouveaux événements à l'event store. Les vues de lecture, appelées projections, se nourrissent ensuite de cet event store.

Le compte et ses événements forment un agrégat. Chaque événement de l'agrégat porte un numéro de version. Une contrainte UNIQUE (aggregate_id, version) empêche deux écritures simultanées de prendre le même numéro. L'historique vit dans le service, et le service en a besoin pour chaque décision.

Gilbert inverse le montage. Le service garde une table d'état courant : une ligne par compte, mise à jour par un simple UPDATE. Ce n'est plus le service qui note l'historique, c'est la base, dans son journal interne. Le CDC lit ce journal et en sort chaque insertion, mise à jour ou suppression, l'une après l'autre.

Une petite fonction, que le livre appelle trigger, transforme chaque changement en événement et le publie sur le hub. Le hub est un canal partagé : chaque service y écoute ce qui l'intéresse.

L'historique, lui, part en aval, chez les services qui reçoivent les événements. Un lac d'événements écoute tout ce qui passe sur le hub et le range par date dans un stockage objet, sans limite de durée. Pour Gilbert, le lac « sert de journal de transactions à tout le système ». Autrement dit, l'historique n'appartient plus au service qui écrit, mais au système. Les services écoutent le hub en direct, et aucun ne lit le lac pour décider.

Event store rejoué : la commande arrive sur l'agrégat, qui relit ses événements dans l'event store puis y ajoute les nouveaux avec une version ; les projections lisent ensuite l'event store. 1. Event store rejoué Commande Agrégat rejoue tous ses événements ajoute relit Event store ajout seul, version par agrégat Projections vues de lecture État courant, CDC et lac : la commande met à jour une table d'état courant, le CDC lit le journal de la base, le trigger publie chaque changement sur le hub, qui alimente les services en aval et le lac d'événements. 2. État courant + CDC + lac Commande Table d'état courant une ligne par entité CDC + trigger chaque changement devient un événement Hub d'événements Services aval vues locales Lac d'événements historique complet
Schéma 1 : le service a besoin de son historique pour décider. Schéma 2 : il ne garde que l'état courant, et l'historique part en aval, vers le lac.

On ne peut pas écrire dans deux stockages à la fois

Le CDC n'est pas un choix de confort chez Gilbert. Il vient d'une contrainte : « on ne peut pas enregistrer de façon fiable dans plus d'un stockage à la fois ».

Un cas concret, imaginé pour l'occasion : un service de boutique en ligne sauve un achat dans sa base. Puis il publie l'événement « achat créé » pour prévenir les autres services. Ce sont deux écritures. Un crash entre les deux en perd une.

J'ai traité le même problème dans la partie 2 de ma série sur l'idempotence, avec une autre réponse : l'outbox. L'événement est écrit dans une table outbox, dans la même transaction PostgreSQL que l'achat. Une transaction regroupe plusieurs écritures : toutes réussissent, ou aucune. Un worker, un petit programme, lit ensuite la table, publie les événements et marque chaque ligne comme envoyée.

Gilbert propose deux variantes :

  • Stream-first (le flux d'abord) : le service publie d'abord sur le hub, et un service en aval stocke la donnée.
  • Database-first : le service écrit seulement sa table, et le CDC de cette table publie l'événement.

La règle de choix de Gilbert est simple. Si l'utilisateur doit retrouver tout de suite ce qu'il vient de créer, on écrit d'abord en base, ce qui exige une base avec CDC. Les services sans écran, comme les passerelles vers des systèmes externes, publient d'abord.

Le mot outbox n'apparaît jamais dans le livre au sens transactionnel. Pourtant, tel que je le lis, le database-first est une outbox dont la table serait le journal interne de la base elle-même. Le gain est concret : plus de table outbox, plus de colonne published à mettre à jour, plus de worker écrit par toi. Le service lit et écrit un seul modèle, sa table.

Ce travail ne disparaît pourtant pas : quelqu'un doit exploiter le CDC. Avec PostgreSQL, un outil comme Debezium lit le journal interne, et c'est un service de plus à faire tourner. Gilbert s'appuie plutôt sur le CDC d'une base gérée par le fournisseur cloud, comme DynamoDB Streams.

Un détail du livre vaut d'être repris. Le trigger publie d'abord chaque changement tel quel, avec la ligne avant et la ligne après. Ensuite seulement, une règle publie un événement métier propre, par exemple thing-submitted quand le champ status passe à submitted. Les changements bruts servent de piste d'audit et aident à découvrir les vrais événements métier d'un sous-système qui démarre.

Idempotence et ordre sans numéro de version

Un cas concret, imaginé pour l'occasion : le message « débite 50 euros » arrive deux fois, parce que l'expéditeur l'a renvoyé après une panne. Si on débite deux fois, le client perd 100 euros au lieu de 50. Une opération est idempotente quand la jouer dix fois donne le même résultat que la jouer une fois. Rendre le débit idempotent, c'est reconnaître le second message et l'ignorer.

Sans event store, les consommateurs perdent le filet que je connais bien : la version par agrégat. Un consommateur est un service qui lit les événements pour mettre à jour sa propre vue. Or il reçoit des doublons, et des événements dans le désordre. Gilbert l'assume dès le départ : « la livraison exactement une fois est irréaliste ».

Les doublons ont trois sources :

  • une requête client renvoyée après un timeout ;
  • un lot d'événements qui échoue au milieu et repart en entier ;
  • un renvoi depuis le lac.

Le désordre vient d'un producteur en panne, d'un pic de trafic ou d'un événement mis de côté puis renvoyé.

Première défense : des identifiants déterministes, c'est-à-dire recalculés à l'identique à chaque nouvel essai. Le plus simple réutilise l'identifiant du changement fourni par le CDC, stable d'un essai à l'autre. Si un même changement produit deux événements métier, on ajoute un suffixe fixe par type.

Seconde défense : le verrouillage optimiste inversé, inverse optimistic locking dans le livre. Le verrouillage optimiste classique refuse une écriture quand la donnée a changé depuis la lecture, et l'utilisateur doit relire. Côté flux, Gilbert fait l'inverse. La ligne garde dans un champ, l'oplock, l'horodatage du dernier événement appliqué, c'est-à-dire sa date et son heure. Sa règle : « on jette les événements entrants dont l'horodatage est plus ancien ou égal à la valeur d'oplock en base ».

Pour te représenter la chose : l'oplock de la ligne indique 14h00. Un événement daté de 13h59 arrive : plus ancien, on l'ignore. Un événement de 14h00 : déjà vu, on l'ignore aussi. Un événement de 14h01 : on l'applique.

Le livre ajoute une finesse. Une vue qui rassemble dans une même ligne les données de plusieurs types d'événements garde un oplock par type. Sinon, un order-created en retard serait jeté à cause d'un order-submitted plus récent, alors qu'il apporte des champs que l'autre n'a pas. Voici la traduction en Go et PostgreSQL :

// Exemple écrit pour cet article, pas tiré du livre (son code est en JavaScript sur DynamoDB).
// Vue « commande » jointe à partir de deux types d'événements, reçus dans n'importe quel ordre.
// Un oplock par type : created_at_ms et submitted_at_ms (horodatage métier en millisecondes).

const applyCreated = `
INSERT INTO order_view (id, customer, created_at_ms)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE
   SET customer = EXCLUDED.customer, created_at_ms = EXCLUDED.created_at_ms
 WHERE order_view.created_at_ms IS NULL
    OR order_view.created_at_ms < EXCLUDED.created_at_ms`

const applySubmitted = `
INSERT INTO order_view (id, status, submitted_at_ms)
VALUES ($1, 'submitted', $2)
ON CONFLICT (id) DO UPDATE
   SET status = 'submitted', submitted_at_ms = EXCLUDED.submitted_at_ms
 WHERE order_view.submitted_at_ms IS NULL
    OR order_view.submitted_at_ms < EXCLUDED.submitted_at_ms`

func (v *OrderView) Apply(ctx context.Context, e Event) error {
    var res sql.Result
    var err error
    switch e.Type {
    case "order-created":
        res, err = v.db.ExecContext(ctx, applyCreated, e.EntityID, e.Customer, e.Timestamp)
    case "order-submitted":
        res, err = v.db.ExecContext(ctx, applySubmitted, e.EntityID, e.Timestamp)
    default:
        return nil // type ignoré par cette vue
    }
    if err != nil {
        return fmt.Errorf("apply %s: %w", e.ID, err)
    }
    if n, _ := res.RowsAffected(); n == 0 {
        v.dropped.Add(1) // doublon ou événement en retard : jeté, mais compté
    }
    return nil
}

Chaque requête est un upsert : elle crée la ligne si elle n'existe pas, sinon la met à jour. Dans ce cas, elle ne le fait que si l'horodatage reçu est plus récent. Le premier événement reçu crée le squelette de la ligne, les suivants la complètent, dans n'importe quel ordre. Le compteur dropped suit un conseil du livre : mesurer ce qu'on ignore, pour voir le jour où on ignore trop.

En relisant mon propre article sur l'idempotence, j'ai retrouvé la même idée sans le nom. Ma projection de solde faisait un upsert avec WHERE account_balances.last_event_id < EXCLUDED.last_event_id, pour ne pas écraser un état récent avec un événement ancien. C'est un oplock inversé : l'écriture est ignorée si elle est plus ancienne que la ligne. Seule la valeur comparée change. Chez moi, c'est la position d'un événement dans un event store ordonné. Chez Gilbert, c'est l'heure à laquelle l'utilisateur a agi.

Ce que le CDC coûte

Le modèle de Gilbert retire de la plomberie au service qui écrit. La facture arrive chez les consommateurs, et elle tient en trois cas, en plus de l'exploitation du CDC vue plus haut.

Deux événements dans la même milliseconde

Dans le format d'événement du livre, l'horodatage est un nombre de millisecondes, pris quand l'utilisateur a agi. Imagine deux changements d'adresse sur le même achat, horodatés tous les deux 1574100988321. Le second arrive avec un horodatage égal à l'oplock de la ligne. Il est jeté comme un doublon, et la vue garde l'ancienne adresse jusqu'au prochain changement.

Le livre ne traite pas ce cas dans sa section sur l'oplock inversé. Il s'en approche ailleurs, en choisissant comment trier les événements. L'horodatage y est « souvent la meilleure option, tant qu'il ne produit pas de doublons ».

Une piste qui vient de moi, pas du livre. Le service qui écrit, en amont du hub, a déjà son propre verrou optimiste classique : un champ qui sert à refuser une écriture périmée. Gilbert précise qu'il peut contenir « un horodatage ou un compteur ». Choisis le compteur, publie-le dans l'événement, et compare dessus en aval. Tu retrouves une version par entité, sans event store.

L'événement en retard après un snapshot

Un snapshot est un état déjà calculé qu'on réutilise comme point de départ, au lieu de tout recalculer depuis le début. Gilbert garde des calculs sur l'historique, mais il les sort du service qui écrit.

Un service intermédiaire, qu'il appelle control service, collecte les dépôts et les retraits, calcule le solde et publie un événement balance. Cet événement joue le rôle de snapshot : le calcul suivant part du dernier solde publié et ajoute les événements plus récents. Son exemple montre les mêmes dépôts et retraits reçus dans deux ordres différents, avec le même solde final de 170.

Le livre reconnaît la limite. Son exemple « ignore les cas limites, comme un événement détaillé plus ancien qui arrive après un snapshot ». L'auteur les laisse « en exercice ». Par événement détaillé, il entend un dépôt ou un retrait, par opposition au solde. Son code suppose que les événements anciens sont « déjà comptés dans le dernier snapshot ».

Tel que je le lis, voici ce qui peut arriver :

  1. le control service publie un solde, donc un snapshot ;
  2. un dépôt plus ancien que ce snapshot arrive après lui ;
  3. le calcul suivant part du snapshot et ne voit jamais ce dépôt.

Gilbert donne une piste sans la coder : chaque snapshot liste les identifiants des événements qui l'ont produit. On peut alors vérifier après coup et émettre un ajustement.

Dans un event store, ce cas n'existe pas pour l'agrégat. La contrainte de version force les écritures à passer l'une après l'autre. Un snapshot pris à la version 100 se complète avec les événements de version 101 et plus. Aucun n'arrive en retard. Le désordre ne touche que les projections, où l'upsert conditionnel suffit.

Suppression logique et durée de vie des lignes

Dans un système où les événements arrivent en retard, un DELETE casse l'idempotence. Si un événement updated arrive après le deleted, la ligne ressuscite. Gilbert supprime donc logiquement, sans effacer la ligne. La commande pose deleted = true. Le CDC voit une simple mise à jour, et le trigger publie thing-deleted. Les requêtes filtrent sur le champ deleted.

Les consommateurs, eux, restent maigres grâce à un TTL : une durée de vie posée sur chaque ligne, calculée sur l'horodatage de l'événement. Le livre propose 33 jours par défaut. Le TTL a un effet de bord à connaître : il borne la durée pendant laquelle un doublon est reconnu. Un événement renvoyé après expiration est traité une seconde fois. D'où l'avertissement du livre : il faut veiller à ce que le TTL « ne soit pas trop court ».

Le renvoi consiste à envoyer d'anciens événements à un consommateur qui doit rattraper son retard. On ne les republie jamais sur le hub : Gilbert note que cela les diffuserait « à tous les consommateurs ». On les envoie depuis le lac à ce seul consommateur. Il doit donc absorber doublons et désordre, ce qui ramène à l'oplock inversé.

Event store ou CDC : comment choisir

Les deux approches répondent à des besoins différents. Mon critère : de quoi la commande a-t-elle besoin pour décider, de l'historique ou de l'état présent ? Décider, c'est par exemple accepter ou refuser un retrait.

  • L'event store, quand la décision dépend de l'historique de l'agrégat. Un solde qui ne doit jamais passer sous zéro en est un cas. Autre cas : une machine à états, c'est-à-dire des étapes à franchir dans un ordre exact. La version par agrégat te donne cet ordre gratuitement, et le calcul de l'état reste dans le service.
  • L'état courant avec CDC, quand la décision ne regarde que l'état présent. L'historique sert alors surtout à l'audit, au renvoi et au démarrage de nouveaux services. C'est le cas de la plupart des écrans de saisie.
  • Les deux dans le même système, ce que Gilbert fait lui-même. Aux bords du système, des tables ordinaires avec CDC. Au milieu, des services qui calculent sur l'historique. Son event sourcing ne disparaît pas, il change de place.

Une condition passe avant tout le reste : sans base qui expose son journal interne, le database-first n'existe pas. Il reste alors le stream-first, ou une outbox écrite à la main.

Conclusion

Gilbert raconte son premier système e-commerce dans le cloud, synchronisé par des flux et des fonctions. Voici son aveu : « je ne m'en rendais pas compte à l'époque, mais j'implémentais les patterns Event Sourcing et CQRS ». J'ai fait le chemin inverse : j'écrivais un oplock inversé dans une clause WHERE sans savoir que le mot existait.

Les mécanismes des deux écoles se ressemblent donc plus qu'on ne le dit : outbox ou CDC, upsert conditionnel, identifiants stables. Le livre m'a appris que je cherchais la différence au mauvais endroit. Elle ne tient pas à l'outil mais à la question que le service pose avant d'écrire.

Pour aller plus loin. Le livre de John Gilbert a sa fiche de lecture Software Architecture Patterns for Serverless Systems. Pour l'event sourcing par agrégat, voir ma fiche sur Understanding Eventsourcing et l'ordre dans lequel lire les livres sur l'Event Sourcing et le CQRS. Les bases de l'idempotence côté HTTP sont dans la partie 1 de la série sur l'idempotence.

Lu sur ExpertEdge (lien affilié) Le livre de John Gilbert fait partie de l'abonnement ExpertEdge de Packt. Avec le code ODILON50, l'abonnement annuel est à -50 % : Software Architecture Patterns for Serverless Systems. Je touche une commission si tu t'abonnes avec ce code. Mes notes n'en dépendent pas.

Dans le grand livre du développeur

Un livre gratuit où chaque idée est racontée une seule fois, à sa place, du silicium au jugement humain.

Commentaires (0)