Le modèle de repli : concevoir une dégradation gracieuse dans les microservices
Dans une architecture de microservices, les services forment un réseau d’appels réseau distribués. Bien que cela permette aux équipes de créer et de faire évoluer les services de manière indépendante, cela signifie également que la fiabilité globale de votre système est aussi forte que son maillon le plus faible. Si un service critique tombe en panne ou ne répond plus, cela peut déclencher une panne en cascade qui perturbe l’ensemble de l’application.
Renvoyer une « erreur de serveur interne 500 » générique ou une page blanche aux utilisateurs au moment où une seule dépendance échoue est une mauvaise expérience utilisateur. Au lieu de cela, les systèmes résilients sont conçus pour se dégrader progressivement lorsque les choses tournent mal.
C’est là qu’intervient le modèle de repli. En définissant un chemin d’exécution alternatif et sûr en cas d’échec d’un appel de service principal, vous pouvez maintenir votre application fonctionnelle, même dans un état dégradé.
Dans ce guide, nous explorerons le modèle de repli, les stratégies courantes pour sa mise en œuvre, la façon dont il interagit avec d’autres modèles de résilience et comment écrire une logique de repli en Java (Resilience4j) et Go.
L’analogie avec le monde réel : le plan de sauvegarde du café
Imaginez que vous entrez dans un café local pour acheter un café au lait. Le barista saisit votre commande, mais lorsque vous appuyez sur votre carte, le terminal de paiement affiche une erreur de connexion : le fournisseur d’accès Internet du magasin est en panne.
Le café éteint-il immédiatement les lumières, verrouille-t-il les portes et renvoie-t-il tous les clients chez eux ?
Bien sûr que non. Ils mettent en œuvre une stratégie de repli :
- S’ils ont de l’argent liquide en main, ils vous demandent si vous pouvez payer en espèces.
- Si vous êtes un client régulier, le barista peut écrire votre nom et votre commande dans un registre et vous demander de payer lors de votre prochaine visite.
- Ils peuvent utiliser un lecteur de carte hors ligne qui stocke le jeton de carte localement et traite le paiement plus tard lorsque Internet sera rétabli.
Dans la conception de logiciels :
- La commande de café est la demande du client.
- Le terminal de carte est le principal service en aval (par exemple, une API de passerelle de paiement).
- La panne Internet est une interruption de délai du réseau ou une panne de service.
- Le Ledger / Offline Reader est le chemin d’exécution de secours.
## Stratégies de repli courantes
En fonction de la logique métier et de la criticité du service défaillant, vous pouvez choisir parmi plusieurs stratégies de secours :
1. Valeurs statiques par défaut
La stratégie la plus simple consiste à renvoyer une valeur statique sûre et préconfigurée. Ceci est très efficace pour les fonctionnalités non critiques où l’affichage de données vides ou par défaut est acceptable.
- Exemple : si un service de personnalisation de profil échoue, renvoie une image d’avatar par défaut et un message d’accueil générique.
- Exemple : si un service de recommandation échoue, renvoyez une liste vide ou une liste codée en dur de best-sellers universels plutôt que de générer une erreur.
2. Réponses mises en cache (périmées pendant la revalidation)
Si les données actives ne sont pas disponibles, vous pouvez recourir à des données obsolètes en lecture seule provenant d’un cache local ou d’un magasin de mémoire distribuée rapide comme Redis.
- Exemple : si un service d’inventaire de produits tombe en panne, affichez la quantité en stock mise en cache il y a 5 minutes, ainsi qu’un subtil message d’interface utilisateur indiquant que les données ne sont peut-être pas entièrement à jour.
- Exemple : Si un service de paramètres utilisateur échoue, chargez le profil utilisateur mis en cache au lieu de bloquer son flux de connexion.
3. Service alternatif (multi-fournisseur)
Lors de l’exécution d’une opération critique qui doit réussir, vous pouvez configurer un fournisseur de services secondaire en tant que sauvegarde.
- Exemple : Si votre passerelle de paiement principale (par exemple, Stripe) renvoie une erreur 5xx ou expire, le mécanisme de secours redirige immédiatement la demande de transaction vers une passerelle secondaire (par exemple, PayPal ou Adyen).
- Exemple : si une API de géocodage échoue, faites appel à un fournisseur de cartographie secondaire.
4. File d’attente pour plus tard (tampon asynchrone)
Pour les opérations d’écriture qui ne nécessitent pas de traitement synchrone immédiat, la solution de secours peut mettre la demande en mémoire tampon dans une file d’attente locale ou une base de données pour une nouvelle tentative ultérieure.
- Exemple : si un service de notification par e-mail est en panne, écrivez la charge utile de notification dans une file d’attente de lettres mortes ou dans une table de base de données locale. Un travailleur en arrière-plan lira cette file d’attente et enverra les e-mails une fois que le service de notification sera à nouveau opérationnel.
Le trio de résilience : nouvelle tentative, disjoncteur ou repli
Pour créer une architecture hautement résiliente, vous devez combiner le modèle Fallback avec les modèles Retry et Circuit Breaker. Ils forment une ligne de défense à trois niveaux :
| Modèle | Rôle | Actions | Scénario |
|---|---|---|---|
| Modèle de nouvelle tentative | Résout les problèmes transitoires de courte durée. | Répète la demande après un court délai (backoff + jitter). | Brèves pertes de paquets réseau, réinitialisations de socket. |
| Disjoncteur | Empêche l’épuisement des ressources. | Déplacements ouverts pour bloquer immédiatement les appels vers un service défaillant (fail-fast). | Temps d’arrêt persistant du service, blocages de la base de données. |
| Modèle de secours | Préserve l’expérience utilisateur. | Exécute une action alternative lorsque l’appel principal échoue ou est bloqué. | Lorsque les tentatives sont épuisées ou que le disjoncteur est ouvert. |
Comment ils travaillent ensemble
- Une requête entrante atteint la passerelle de service.
- La demande passe par le Disjoncteur.
- Si le disjoncteur est fermé, la demande est transmise au wrapper Retry, qui effectue l’appel proprement dit.
- Si une erreur passagère se produit, le mécanisme Retry tente de relancer l’appel.
- Si toutes les tentatives échouent ou si le disjoncteur était déjà ouvert (échec rapide de la sauvegarde des ressources), le gestionnaire de secours intercepte l’échec et renvoie la réponse dégradée/mise en cache.
Implémentations de code
Voyons comment implémenter une logique de secours dans Java et Go.
1. Java (Résilience4j)
En Java, Resilience4j est la norme industrielle en matière de tolérance aux pannes. Nous pouvons définir une méthode de secours de manière déclarative à l’aide d’annotations ou par programme.
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
@Service
public class ProductService {
private final InventoryClient inventoryClient;
private final CacheManager cacheManager;
public ProductService(InventoryClient inventoryClient, CacheManager cacheManager) {
this.inventoryClient = inventoryClient;
this.cacheManager = cacheManager;
}
// Bind this method to a circuit breaker. If it fails or is open, route to fallback
@CircuitBreaker(name = "inventoryService", fallbackMethod = "getInventoryFallback")
public List<String> getProductInventory(String category) {
return inventoryClient.fetchStockByCategory(category);
}
// Fallback method must have the same return type and accept the same parameters,
// plus a Throwable parameter containing the error that triggered it
public List<String> getInventoryFallback(String category, Throwable throwable) {
System.err.println("Primary inventory service failed: " + throwable.getMessage());
// Attempt to fetch from local cache (Strategy 2)
List<String> cachedStock = cacheManager.get("inventory:" + category, List.class);
if (cachedStock != null) {
return cachedStock;
}
// Return static empty list if cache is empty (Strategy 1)
return Collections.emptyList();
}
}
2. Allez (Golang)
Dans Go, nous pouvons écrire des décorateurs de secours propres à l’aide de modèles de programmation fonctionnels, qui nous permettent d’encapsuler n’importe quel gestionnaire d’exécution avec un gestionnaire secondaire.
package main
import (
"context"
"errors"
"fmt"
"time"
)
// Request represents a simple payload
type Request struct {
UserID string
}
// Response represents the returned data
type Response struct {
Data string
Status string
}
// ServiceFunc represents our primary function signature
type ServiceFunc func(ctx context.Context, req Request) (Response, error)
// WithFallback wraps a service function with fallback logic
func WithFallback(primary ServiceFunc, fallback ServiceFunc) ServiceFunc {
return func(ctx context.Context, req Request) (Response, error) {
res, err := primary(ctx, req)
if err != nil {
fmt.Printf("[Warning] Primary call failed: %v. Running fallback...\n", err)
return fallback(ctx, req)
}
return res, nil
}
}
func main() {
// 1. Define primary service that occasionally fails
primaryService := func(ctx context.Context, req Request) (Response, error) {
return Response{}, errors.New("database connection timeout (504)")
}
// 2. Define fallback service that retrieves cached data
fallbackService := func(ctx context.Context, req Request) (Response, error) {
// Simulating cached read
return Response{
Data: fmt.Sprintf("Stale cached profile data for user %s", req.UserID),
Status: "DEGRADED (CACHED)",
}, nil
}
// 3. Wrap them together
resilientService := WithFallback(primaryService, fallbackService)
// 4. Execute
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req := Request{UserID: "user_992"}
response, err := resilientService(ctx, req)
if err != nil {
fmt.Printf("Operation completely failed: %v\n", err)
} else {
fmt.Printf("Response Received:\n - Status: %s\n - Data: %s\n", response.Status, response.Data)
}
}
Meilleures pratiques pour le modèle de secours
- Gardez le chemin de secours sans dépendance : La logique de secours ne doit pas s’appuyer sur les mêmes systèmes ou infrastructures en aval qui viennent de tomber en panne. Si votre base de données est en panne, le recours à une autre requête de base de données sur le même serveur échouera probablement également.
- Exécuter rapidement : la logique de secours doit s’exécuter rapidement. Évitez les calculs complexes ou les appels imbriqués lents dans vos chemins de secours. L’objectif est de renvoyer une réponse à l’utilisateur le plus rapidement possible.
- Alerte et surveillance : enregistrez toujours les exécutions de secours et incrémentez les compteurs de télémétrie. Si votre système exécute des chemins de secours, cela signifie qu’il fonctionne dans un état dégradé. Vous avez besoin d’alertes pour savoir quand les taux de repli dépassent les seuils normaux.
- Rendre l’interface utilisateur résiliente : travaillez en étroite collaboration avec les ingénieurs frontend pour vous assurer que l’interface utilisateur est conçue pour accepter les charges utiles de secours (comme les collections vides ou les états partiels) sans rompre la disposition côté client.
Conclusion
Le Fallback Pattern est la police d’assurance ultime pour la fiabilité des microservices. En anticipant les pannes et en fournissant une logique de secours élégante, vous transformez les pannes graves en perturbations subtiles et gérables. La combinaison de ce modèle avec Retries et Circuit Breakers garantit que votre application peut résister à des pannes externes majeures tout en continuant à servir les utilisateurs.