L'Event Sourcing est un puissant modèle architectural qui traite l'état de l'application comme une séquence d'événements. Bien que cela offre une piste d'audit immuable et des capacités de rejou puissantes, il introduit un défi de performance majeur : reconstruire l'état actuel d'une entité en rejouant chaque événement est coûteux en calcul et intensif en E/S. Pour les développeurs intermédiaires à avancés construisant des systèmes sur des bases de données relationnelles, la solution réside dans une stratégie robuste impliquant des instantanés (snapshots) et des projections.
Le défi de la rejouabilité des événements
Dans une implémentation pure d'Event Sourcing, pour charger l'état d'une entité, vous devez récupérer tous les événements associés à son ID unique et les appliquer séquentiellement. Si une entité a accumulé des milliers d'événements au fil de mois ou d'années, ce processus devient un goulot d'étranglement. Vous reconstruisez essentiellement un objet complexe à partir de zéro à chaque exécution de requête.
Pour atténuer ce problème, nous introduisons les instantanés. Un instantané est un enregistrement persistant de l'état de l'entité à un moment donné. Lors du chargement d'une entité, le système récupère l'instantané le plus récent, puis rejoue uniquement les événements survenus après cet instantané. Cela réduit considérablement le nombre de lectures de base de données et les cycles CPU requis pour reconstruire l'état.
Mise en œuvre des instantanés dans les bases de données relationnelles
Bien que les magasins de documents NoSQL soient souvent salués pour leur flexibilité avec les données dénormalisées, les bases de données relationnelles peuvent gérer efficacement les instantanés grâce à une structure de table simple. La clé est de lier l'instantané à l'ID de la racine de l'agrégat et de suivre sa version.
Voici un exemple pratique de la manière de structurer une table d'instantanés en SQL, conçue pour fonctionner aux côtés d'une table d'événements :
CREATE TABLE AggregateSnapshots (
AggregateId UNIQUEIDENTIFIER NOT NULL,
EventType VARCHAR(255) NOT NULL,
SnapshotData NVARCHAR(MAX) NOT NULL,
Version BIGINT NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
CONSTRAINT PK_AggregateSnapshots PRIMARY KEY (AggregateId)
);
CREATE INDEX IX_AggregateSnapshots_Version
ON AggregateSnapshots (AggregateId, Version DESC);
Dans ce schéma, SnapshotData stocke l'état sérialisé de l'entité (souvent sous forme de JSON dans les instances modernes de SQL Server ou PostgreSQL). Le champ Version est critique ; il permet à la logique de l'application de déterminer si l'instantané est plus ancien que le flux d'événements actuel et doit être mis à jour.
Verrouillage optimiste et cohérence
Une des complexités subtiles de la mise à jour des instantanés dans une base de données relationnelle est la gestion de la concurrence. Vous ne pouvez pas simplement écraser un instantané, car cela créerait des conditions de course où les écritures concurrentes pourraient perdre des données. Au lieu de cela, vous devez utiliser le verrouillage optimiste.
Lors de la sauvegarde d'un instantané, le code de votre application doit vérifier si la version actuelle dans la base de données correspond à la version de l'événement qui a déclenché la sauvegarde de l'instantané. Si les versions diffèrent, un autre processus a modifié l'état, et vous devez réessayer ou rejeter l'opération. Cela garantit l'intégrité des données sans nécessiter de verrous transactionnels lourds qui pourraient dégrader les performances.
Projections : le côté lecture de l'Event Sourcing
Les instantanés optimisent le côté écriture et la reconstruction de l'état de l'entité. Cependant, ils n'aident pas avec les requêtes de lecture complexes, telles que « Montrez-moi tous les clients qui ont dépensé plus de 1000 $ au cours de l'année dernière ». Pour cela, nous avons besoin de Projections.
Les projections sont des modèles optimisés pour la lecture dérivés du flux d'événements. Elles découplent le modèle d'écriture du modèle de lecture. Dans un contexte relationnel, cela signifie souvent créer des tables dénormalisées qui sont mises à jour par des gestionnaires d'événements (ou un moteur de projection séparé) au fur et à mesure que de nouveaux événements sont persistés.
CREATE TABLE CustomerSpendingTotals (
CustomerId UNIQUEIDENTIFIER PRIMARY KEY,
TotalSpent DECIMAL(18, 2) DEFAULT 0.00,
LastOrderDate DATETIME2,
UpdatedVersion BIGINT
);
-- Exemple de logique de gestionnaire d'événements (Pseudocode)
public void Handle(OrderPlacedEvent @event) {
// 1. Mettre à jour la logique de l'instantané de l'entité ici...
// 2. Mettre à jour la projection
UPDATE CustomerSpendingTotals
SET TotalSpent = TotalSpent + @event.OrderValue,
LastOrderDate = @event.OrderDate
WHERE CustomerId = @event.CustomerId;
En maintenant ces tables de projection, votre application peut servir des rapports et des tableaux de bord complexes directement depuis SQL sans avoir à rejouer le flux d'événements à chaque demande.
Conclusion
Combiner l'Event Sourcing avec des bases de données relationnelles nécessite une approche réfléchie du stockage des données. Les instantanés fournissent l'augmentation de performance nécessaire pour la reconstruction de l'état des entités, tandis que les projections permettent des requêtes complexes et efficaces. En tirant parti des capacités JSON de SQL Server ou PostgreSQL et en mettant en œuvre un contrôle de concurrence optimiste, vous pouvez construire des systèmes évolutifs et maintenables qui conservent les avantages de l'Event Sourcing sans sacrifier les performances en lecture. Au fur et à mesure que votre application grandit, rappelez-vous que la séparation des modèles d'écriture et de lecture n'est pas seulement un choix architectural, c'est une nécessité pour les performances à long terme.