Schéma Saga dans les transactions distribuées - Exemples en Go
Transactions en microservices avec le modèle Saga
Le pattern Saga
offre une solution élégante en décomposant les transactions distribuées en une série de transactions locales avec des actions compensatoires.
Au lieu de s’appuyer sur des verrous distribués qui peuvent bloquer les opérations à travers les services, le pattern Saga permet une cohérence finale grâce à une séquence d’étapes réversibles, ce qui le rend idéal pour les processus métier longue durée.
Dans les architectures en microservices, maintenir la cohérence des données à travers 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 à la recherche d’approches alternatives pour assurer l’intégrité des données.
Ce guide démontre l’implémentation du pattern Saga en Go avec des exemples pratiques couvrant à la fois les approches d’orchestration et de chorégraphie. Si vous avez besoin d’un rappel rapide sur les fondamentaux de Go, la Feuille de rappel Go fournit un aperçu utile.
Cette belle image est générée par le modèle AI Flux 1 dev.
Comprendre le pattern Saga
Le pattern Saga a été initialement décrit 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 compensatoires sont exécutées pour annuler les effets des étapes précédentes.
Contrairement aux transactions distribuées traditionnelles qui utilisent le commit à deux phases (2PC), le pattern Saga ne bloque pas les services, ce qui le rend adapté aux processus métier longue durée. Le compromis est une cohérence finale plutôt qu’une cohérence forte.
Caractéristiques clés
- Aucun verrou distribué : Chaque service gère sa propre transaction locale
- Actions compensatoires : Chaque opération a un mécanisme de remboursement correspondant
- Cohérence finale : Le système atteint finalement un état cohérent
- Longue durée : Adapté aux processus qui prennent des secondes, des minutes, voire des heures
Approches d’implémentation du pattern Saga
Il existe deux approches principales pour implémenter le pattern Saga : l’orchestration et la chorégraphie.
Pattern d’orchestration
Dans l’orchestration, un coordinateur central (orchestrateur) gère l’ensemble du flux de transaction. L’orchestrateur est responsable de :
- Appeler les services dans l’ordre correct
- Gérer les échecs et déclencher les compensations
- Maintenir l’état du Saga
- Coordonner les réessais et les délais
Avantages :
- Contrôle et visibilité centralisés
- Plus facile à comprendre et à déboguer
- Meilleure gestion des erreurs et récupération
- Test plus simple du flux global
Inconvénients :
- Point unique de défaillance (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
}
Pattern de chorégraphie
Dans la chorégraphie, il n’y a pas de coordinateur central. Chaque service sait ce qu’il doit faire et communique via des événements. Les services écoutent les événements et réagissent en conséquence. Cette approche basée sur les événements est particulièrement puissante lorsqu’elle est combinée à des plateformes de streaming d’événements comme AWS Kinesis, qui offrent une infrastructure échelle pour la distribution d’événements à travers les microservices. Pour un guide complet sur la mise en œuvre de microservices événementiels avec Kinesis, voir Construire des microservices événementiels avec AWS Kinesis.
Avantages :
- Décentralisé et échelle
- Aucun point unique de défaillance
- Les services restent faiblement couplés
- Adapté naturellement aux architectures événementielles
Inconvénients :
- Plus difficile à comprendre le flux global
- Difficile à déboguer et à tracer
- Gestion complexe des erreurs
- Risque de dépendances cycliques
Exemple avec une architecture événementielle :
// 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
}
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 : remboursement du paiement
return s.client.Refund(event.PaymentID)
}
Stratégies de compensation
La compensation est au cœur du pattern Saga. Chaque opération doit avoir une compensation correspondante capable de réverser 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 compensatoires : Opérations différentes qui atteignent l’effet inverse
- Exemple : Annuler une commande au lieu de la supprimer
-
Compensation pessimiste : 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 d’abord, rembourser si l’inventaire n’est pas disponible
Exigences d’idempotence
Toutes les opérations et compensations doivent être idempotentes. Cela garantit que le redémarrage d’une opération échouée ne provoque pas d’effets en double.
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 == "remboursé" {
return nil // Déjà remboursé, idempotent
}
// Traiter le remboursement
return s.processRefund(paymentID)
}
Bonnes pratiques
1. Gestion de l’état du 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 du Saga dans une base de données, le choix de l’ORM approprié est crucial pour les performances et la maintenabilité. Pour les implémentations basées sur PostgreSQL, consultez la comparaison dans Comparaison des ORMs Go pour PostgreSQL : GORM vs Ent vs Bun vs sqlc pour choisir le meilleur adapté à vos besoins de stockage de l’état du 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
Implémentez des délais pour chaque étape afin d’éviter que les Saga ne restent 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épassé, compenser
if err := step.Compensate(); err != nil {
return fmt.Errorf("compensation échouée : %w", err)
}
return fmt.Errorf("étape %s a dépassé le délai après %v", step.Name(), o.timeout)
}
}
3. Logique de réessai
Implémentez un backoff exponentiel pour les échecs temporaires :
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 réessais", maxRetries)
}
4. Journalisation et observabilité
Implémentez une journalisation et une observabilité complètes :
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 du Saga
return nil
}
Schémas courants et anti-schémas
Schémas à suivre
- Pattern du coordinateur Saga : Utilisez un service dédié pour l’orchestration
- Pattern de la boîte de sortie : Assurez-vous d’une publication fiable des événements
- Clés d’idempotence : Utilisez des clés uniques pour toutes les opérations
- Machine à états Saga : Modélisez le Saga comme une machine à états
Anti-schémas à éviter
- Compensation synchrone : Ne pas attendre que la compensation se termine
- Sagas imbriqués : Évitez que les Saga appelle d’autres Saga (utilisez des sous-Saga à la place)
- État partagé : Ne partagez pas l’état entre les étapes du Saga
- Étapes longues : Découpez les étapes qui prennent trop longtemps
Outils et cadres
Plusieurs cadres peuvent aider à implémenter le pattern Saga :
- Temporal : Plateforme d’orchestration de workflow avec un support intégré du pattern Saga
- Zeebe : moteur de workflow pour l’orchestration de microservices
- Eventuate Tram : framework Saga pour Spring Boot
- AWS Step Functions : orchestration de workflow serverless
- Apache Camel : framework d’intégration avec un support Saga
Pour les services d’orchestrateur qui nécessitent des interfaces CLI pour la gestion et le suivi, Construire des applications CLI en Go avec Cobra & Viper fournit d’excellents schémas pour créer des outils de ligne de commande pour interagir avec les orchestrateurs Saga.
Lors du déploiement de microservices basés sur Saga dans Kubernetes, l’implémentation d’un maillage de services peut considérablement améliorer l’observabilité, la sécurité et la gestion du trafic. Implémentation d’un maillage de services avec Istio et Linkerd couvre comment les maillages de services complètent les schémas de transactions distribuées en fournissant des préoccupations transversales comme le traçage distribué et le circuit breaker.
Quand utiliser le pattern Saga
Utilisez le pattern Saga lorsque :
- ✅ Les opérations s’étendent à plusieurs microservices
- ✅ Processus métier longue durée
- ✅ Une cohérence finale est acceptable
- ✅ Vous souhaitez éviter les verrous distribués
- ✅ Les services ont des bases de données indépendantes
Évitez lorsqu’ :
- ❌ 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 pattern Saga est essentiel pour gérer les transactions distribuées dans les architectures en microservices. Bien qu’il introduise de la complexité, il fournit une solution pratique pour maintenir la cohérence des données à travers les limites des services. Choisissez l’orchestration pour un meilleur contrôle et visibilité, ou la chorégraphie pour l’échelle et le couplage faible. 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 mise en œuvre réussie du pattern Saga est de comprendre vos exigences de cohérence, de concevoir soigneusement la logique de compensation et de choisir l’approche adaptée à votre cas d’utilisation. Avec une mise en œuvre correcte, le pattern Saga vous permet de construire des microservices résilients et échelonnables qui maintiennent l’intégrité des données dans les systèmes distribués.
Liens utiles
- Microservices Patterns by Chris Richardson
- Saga Pattern - Martin Fowler
- Eventuate Tram Saga Framework
- Temporal Workflow Engine
- AWS Step Functions Documentation
- Go Cheat Sheet
- Go Generics: Use Cases and Patterns
- Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc
- Building CLI Applications in Go with Cobra & Viper
- Implementing Service Mesh with Istio and Linkerd
- Building Event-Driven Microservices with AWS Kinesis