Le pattern Saga dans les transactions distribuées - Avec des exemples en Go
Transactions dans les microservices avec le pattern Saga
Le motif Saga offre une solution élégante en décomposant les transactions distribuées en une série de transactions locales avec des actions de compensation.
Plutôt que de s’appuyer sur des verrous distribués qui peuvent bloquer les opérations entre services, Saga permet une cohérence éventuelle grâce à une séquence d’étapes réversibles, ce qui le rend idéal pour les processus métier de longue durée.
Dans les architectures microservices, le maintien de la cohérence des données entre les services est l’un des problèmes les plus difficiles. Les transactions ACID traditionnelles ne fonctionnent pas lorsque les opérations s’étendent à plusieurs services avec des bases de données indépendantes, laissant les développeurs chercher des approches alternatives pour assurer l’intégrité des données.
Ce guide démontre l’implémentation du motif Saga en Go avec des exemples pratiques couvrant à la fois les approches d’orchestration et de chorégraphie. Si vous avez besoin d’une référence rapide pour les fondamentaux de Go, la Fiche technique Go fournit un aperçu utile.
Cette belle image est générée par le modèle IA Flux 1 dev.
Comprendre le motif Saga
Le motif Saga a été décrit à l’origine par Hector Garcia-Molina et Kenneth Salem en 1987. Dans le contexte des microservices, il s’agit d’une séquence de transactions locales où chaque transaction met à jour les données au sein d’un seul service. Si une étape échoue, des transactions de compensation sont exécutées pour annuler les effets des étapes précédentes.
Contrairement aux transactions distribuées traditionnelles qui utilisent le commit en deux phases (2PC), Saga ne maintient pas de verrous entre les services, ce qui le rend adapté aux processus métier de longue durée. Le compromis est une cohérence éventuelle plutôt qu’une cohérence forte.
Caractéristiques clés
- Pas de verrous distribués : Chaque service gère sa propre transaction locale
- Actions de compensation : Chaque opération a un mécanisme d’annulation correspondant
- Cohérence éventuelle : Le système atteint finalement un état cohérent
- Longue durée : Convient aux processus qui prennent des secondes, des minutes ou même des heures
Approches d’implémentation de Saga
Il existe deux approches principales pour implémenter le motif Saga : l’orchestration et la chorégraphie.
Motif d’Orchestration
Dans l’orchestration, un coordinateur central (orchestrateur) gère l’ensemble du flux de transaction. L’orchestrateur est responsable de :
- L’appel des services dans le bon ordre
- La gestion des échecs et le déclenchement des compensations
- Le maintien de l’état de la saga
- La coordination des retentatives et des délais d’attente
Avantages :
- Contrôle centralisé et visibilité
- Plus facile à comprendre et à déboguer
- Meilleure gestion des erreurs et récupération
- Tests plus simples du flux global
Inconvénients :
- Point de défaillance unique (bien que cela puisse être atténué)
- Service supplémentaire à maintenir
- Peut devenir un goulot d’étranglement pour les flux complexes
Exemple en Go :
type OrderSagaOrchestrator struct {
orderService OrderService
paymentService PaymentService
inventoryService InventoryService
shippingService ShippingService
}
func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
sagaID := generateSagaID()
// Étape 1 : Créer la commande
orderID, err := o.orderService.Create(order)
if err != nil {
return err
}
// Étape 2 : Réserver l'inventaire
if err := o.inventoryService.Reserve(order.Items); err != nil {
o.orderService.Cancel(orderID) // Compenser
return err
}
// Étape 3 : Traiter le paiement
paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
if err != nil {
o.inventoryService.Release(order.Items) // Compenser
o.orderService.Cancel(orderID) // Compenser
return err
}
// Étape 4 : Créer l'expédition
if err := o.shippingService.CreateShipment(orderID); err != nil {
o.paymentService.Refund(paymentID) // Compenser
o.inventoryService.Release(order.Items) // Compenser
o.orderService.Cancel(orderID) // Compenser
return err
}
return nil
}
Motif de Chorégraphie
Dans la chorégraphie, il n’y a pas de coordinateur central. Chaque service sait quoi faire et communique via des événements. Les services écoutent les événements et réagissent en conséquence. Cette approche pilotée par les événements est particulièrement puissante lorsqu’elle est combinée avec des plateformes de streaming de messages comme AWS Kinesis, qui fournissent une infrastructure évoluable pour la distribution d’événements entre microservices. Pour un guide complet sur l’implémentation de microservices pilotés par les événements avec Kinesis, consultez Construction de Microservices Pilotés par les Événements avec AWS Kinesis.
Avantages :
- Décentralisé et évoluable
- Pas de point de défaillance unique
- Les services restent faiblement couplés
- Adaptation naturelle aux architectures pilotées par les événements
Inconvénients :
- Plus difficile de comprendre le flux global
- Difficile à déboguer et à tracer
- Gestion des erreurs complexe
- Risque de dépendances cycliques
Exemple avec une Architecture Pilotée par les Événements :
// Service de Commande
type OrderService struct {
eventBus EventBus
repo OrderRepository
}
func (s *OrderService) CreateOrder(order Order) (string, error) {
orderID, err := s.repo.Save(order)
if err != nil {
return "", err
}
s.eventBus.Publish("OrderCreated", OrderCreatedEvent{
OrderID: orderID,
CustomerID: order.CustomerID,
Items: order.Items,
Total: order.Total,
})
return orderID, nil
}
// Note : s.repo.Save suivi de s.eventBus.Publish est un double écriture.
// En production, remplacez cela par le motif transactional outbox afin que
// l'événement soit écrit atomiquement avec la ligne de commande et publié par un relais.
func (s *OrderService) HandlePaymentFailed(event PaymentFailedEvent) error {
return s.repo.Cancel(event.OrderID) // Compensation
}
// Service de Paiement
type PaymentService struct {
eventBus EventBus
client PaymentClient
}
func (s *PaymentService) HandleOrderCreated(event OrderCreatedEvent) {
paymentID, err := s.client.Charge(event.CustomerID, event.Total)
if err != nil {
s.eventBus.Publish("PaymentFailed", PaymentFailedEvent{
OrderID: event.OrderID,
})
return
}
s.eventBus.Publish("PaymentSucceeded", PaymentSucceededEvent{
OrderID: event.OrderID,
PaymentID: paymentID,
})
}
func (s *PaymentService) HandleInventoryReservationFailed(event InventoryReservationFailedEvent) error {
// Compensation : rembourser le paiement
return s.client.Refund(event.PaymentID)
}
Stratégies de Compensation
La compensation est le cœur du motif Saga. Chaque opération doit avoir une compensation correspondante capable d’inverser ses effets.
Types de Compensation
-
Opérations Réversibles : Opérations qui peuvent être directement annulées
- Exemple : Libérer l’inventaire réservé, rembourser les paiements
-
Actions de Compensation : Différentes opérations qui atteignent l’effet inverse
- Exemple : Annuler une commande au lieu de la supprimer
-
Compensation Pessimiste : Pré-allouer des ressources qui peuvent être libérées
- Exemple : Réserver l’inventaire avant de charger le paiement
-
Compensation Optimiste : Exécuter les opérations et compenser si nécessaire
- Exemple : Charger le paiement en premier, rembourser si l’inventaire est indisponible
Exigences d’Idempotence
Toutes les opérations et compensations doivent être idempotentes. Cela garantit que la rétentative d’une opération échouée ne cause pas d’effets dupliqués. Il est tout aussi important de s’assurer que chaque participant de la saga publie ses événements de manière fiable après un engagement local — le motif transactional outbox est la méthode standard pour combler cette lacune entre une écriture de base de données et une publication de courtier.
func (s *PaymentService) Refund(paymentID string) error {
// Vérifier si déjà remboursé
payment, err := s.getPayment(paymentID)
if err != nil {
return err
}
if payment.Status == "refunded" {
return nil // Déjà remboursé, idempotent
}
// Traiter le remboursement
return s.processRefund(paymentID)
}
Meilleures Pratiques
1. Gestion de l’État de la Saga
Maintenez l’état de chaque instance de saga pour suivre la progression et permettre la récupération. Lors de la persistance de l’état de la saga dans une base de données, le choix du bon ORM est crucial pour les performances et la maintenabilité. Pour les implémentations basées sur PostgreSQL, considérez la comparaison dans Comparaison des ORM Go pour PostgreSQL : GORM vs Ent vs Bun vs sqlc pour sélectionner le meilleur ajustement pour vos besoins de stockage d’état de saga :
type SagaState struct {
ID string
Status SagaStatus
Steps []SagaStep
CurrentStep int
CreatedAt time.Time
UpdatedAt time.Time
}
type SagaStep struct {
Service string
Operation string
Status StepStatus
Compensated bool
Data map[string]interface{}
}
2. Gestion des Délais d’Attente
Implémentez des délais d’attente pour chaque étape afin d’empêcher les sagas de rester bloquées indéfiniment :
type SagaOrchestrator struct {
timeout time.Duration
}
func (o *SagaOrchestrator) ExecuteWithTimeout(step SagaStep) error {
ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
defer cancel()
done := make(chan error, 1)
go func() {
done <- step.Execute()
}()
select {
case err := <-done:
return err
case <-ctx.Done():
// Délai d'attente dépassé, compenser
if err := step.Compensate(); err != nil {
return fmt.Errorf("compensation échouée : %w", err)
}
return fmt.Errorf("étape %s expirée après %v", step.Name(), o.timeout)
}
}
3. Logique de Retentative
Implémentez un backoff exponentiel pour les échecs transitoires :
func retryWithBackoff(operation func() error, maxRetries int) error {
backoff := time.Second
for i := 0; i < maxRetries; i++ {
err := operation()
if err == nil {
return nil
}
if !isTransientError(err) {
return err
}
time.Sleep(backoff)
backoff *= 2
}
return fmt.Errorf("opération échouée après %d tentatives", maxRetries)
}
4. Event Sourcing pour l’État de la Saga
Utilisez l’event sourcing pour maintenir un journal d’audit complet. Lors de l’implémentation de stores d’événements et de mécanismes de lecture, les génériques Go peuvent aider à créer du code de gestion d’événements sûr et réutilisable. Pour des motifs avancés utilisant les génériques en Go, consultez Génériques Go : Cas d’Utilisation et Motifs.
type SagaEvent struct {
SagaID string
EventType string
Payload []byte
Timestamp time.Time
Version int64
}
type SagaEventStore struct {
store EventRepository
}
func (s *SagaEventStore) AppendEvent(sagaID string, eventType string, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("échec de la sérialisation de la charge utile : %w", err)
}
version, err := s.store.GetNextVersion(sagaID)
if err != nil {
return fmt.Errorf("échec de l'obtention de la version : %w", err)
}
event := SagaEvent{
SagaID: sagaID,
EventType: eventType,
Payload: data,
Timestamp: time.Now(),
Version: version,
}
return s.store.Save(event)
}
func (s *SagaEventStore) ReplaySaga(sagaID string) (*Saga, error) {
events, err := s.store.GetEvents(sagaID)
if err != nil {
return nil, fmt.Errorf("échec de l'obtention des événements : %w", err)
}
saga := NewSaga()
for _, event := range events {
if err := saga.Apply(event); err != nil {
return nil, fmt.Errorf("échec de l'application de l'événement : %w", err)
}
}
return saga, nil
}
5. Surveillance et Observabilité
Implémentez une journalisation et un traçage complets :
func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
span := tracer.StartSpan("saga.create_order")
defer span.Finish()
span.SetTag("saga.id", sagaID)
span.SetTag("order.id", order.ID)
logger.WithFields(log.Fields{
"saga_id": sagaID,
"order_id": order.ID,
"step": "create_order",
}).Info("Saga démarrée")
// ... exécution de la saga
return nil
}
Motifs Courants et Anti-Motifs
Motifs à Suivre
- Motif de Coordinateur de Saga : Utiliser un service dédié pour l’orchestration
- Motif Outbox : Assurer une publication fiable des événements
- Clés d’Idempotence : Utiliser des clés uniques pour toutes les opérations
- Machine à États de Saga : Modéliser la saga comme une machine à états
Anti-Motifs à Éviter
- Compensation Synchronisée : Ne pas attendre que la compensation soit terminée
- Sagas Imbriquées : Éviter que les sagas appellent d’autres sagas (utilisez des sous-sagas à la place)
- État Partagé : Ne pas partager l’état entre les étapes de la saga
- Étapes de Longue Durée : Décomposer les étapes qui prennent trop de temps
Outils et Frameworks
Plusieurs frameworks peuvent aider à implémenter les motifs Saga :
- Temporal : Plateforme d’orchestration de workflows avec support intégré de Saga
- Zeebe : Moteur de workflow pour l’orchestration de microservices
- Eventuate Tram : Framework Saga pour Spring Boot
- AWS Step Functions : Orchestration de workflows serverless
- Apache Camel : Framework d’intégration avec support Saga
Pour les services d’orchestrateur qui nécessitent des interfaces CLI pour la gestion et la surveillance, Construction d’Applications CLI en Go avec Cobra & Viper fournit d’excellents motifs pour créer des outils en ligne de commande pour interagir avec les orchestrateurs de saga.
Lors du déploiement de microservices basés sur Saga dans Kubernetes, l’implémentation d’un maillage de services peut améliorer considérablement l’observabilité, la sécurité et la gestion du trafic. Implémentation de Maillage de Services avec Istio et Linkerd couvre comment les maillages de services complètent les motifs de transactions distribuées en fournissant des préoccupations transversales comme le traçage distribué et la rupture de circuit.
Quand Utiliser le Motif Saga
Utilisez le motif Saga lorsque :
- ✅ Les opérations s’étendent sur plusieurs microservices
- ✅ Processus métier de longue durée
- ✅ La cohérence éventuelle est acceptable
- ✅ Vous devez éviter les verrous distribués
- ✅ Les services ont des bases de données indépendantes
Évitez lorsque :
- ❌ Une cohérence forte est requise
- ❌ Les opérations sont simples et rapides
- ❌ Tous les services partagent la même base de données
- ❌ La logique de compensation est trop complexe
Conclusion
Le motif Saga est essentiel pour gérer les transactions distribuées dans les architectures microservices. Bien qu’il introduise de la complexité, il fournit une solution pratique pour maintenir la cohérence des données entre les limites des services. Choisissez l’orchestration pour un meilleur contrôle et une meilleure visibilité, ou la chorégraphie pour l’évolutivité et le couplage lâche. Assurez-vous toujours que les opérations sont idempotentes, implémentez une logique de compensation appropriée et maintenez une observabilité complète.
La clé d’une implémentation réussie de Saga réside dans la compréhension de vos exigences de cohérence, la conception minutieuse de la logique de compensation et le choix de l’approche adaptée à votre cas d’utilisation. Avec une implémentation appropriée, Saga vous permet de construire des microservices résilients et évolutifs qui maintiennent l’intégrité des données à travers les systèmes distribués.
Liens Utiles
- Microservices Patterns de Chris Richardson
- Motif Saga - Martin Fowler
- Framework Eventuate Tram Saga
- Moteur de Workflow Temporal
- Documentation AWS Step Functions
- Fiche technique Go
- Génériques Go : Cas d’Utilisation et Motifs
- Comparaison des ORM Go pour PostgreSQL : GORM vs Ent vs Bun vs sqlc
- Implémentation de CQRS en Go
- Construction d’Applications CLI en Go avec Cobra & Viper
- Implémentation de Maillage de Services avec Istio et Linkerd
- Construction de Microservices Pilotés par les Événements avec AWS Kinesis