Read in English · Leer en español · Auf Deutsch lesen
Section 9.4 sur 9.8 · Avancé · 32 min de lecture
Délais d’expiration, nouvelles tentatives, disjoncteurs, cloisonnement et dégradation gracieuse
Leçon vidéo
5 min · narré en anglais, avec sous-titresÉcouter seulement (audio)
Lire la transcription
Bienvenue dans la section neuf point quatre. Dans un système distribué, il y a toujours une dépendance lente ou défaillante. La question n’est pas de savoir comment éviter la défaillance, mais comment empêcher une défaillance locale de devenir globale.
Imaginez que vous appelez un fournisseur qui ne décroche jamais. Si vous restez en ligne toute la journée, vous ne faites rien d’autre. Si chaque collègue fait de même, tout le bureau s’arrête. Un bureau bien organisé raccroche après quelques sonneries. C’est un délai d’expiration. Il rappelle un peu plus tard, mais pas indéfiniment. C’est une nouvelle tentative avec une pause. Il cesse d’appeler un fournisseur manifestement fermé, et vérifie de nouveau une heure plus tard. C’est un disjoncteur. Il veille à ce qu’un seul fournisseur ne puisse pas occuper tous les téléphones. C’est un cloisonnement. Et il a un plan B, comme la liste de prix de la veille. C’est une solution de repli. Les services logiciels ont besoin exactement des mêmes habitudes.
Voici pourquoi c’est important. Le service de commandes de la plateforme de livraison de repas dispose de mille deux cents threads répartis sur six instances. À midi, d’après la loi de Little, seuls cinquante sont occupés : trente-cinq pour les validations de commande, qui appellent le prestataire de paiement, et quinze pour tout le reste. À midi trente et une, le prestataire de paiement cesse de répondre mais garde les connexions ouvertes. Le client de paiement n’a pas de délai d’expiration, donc chaque validation de commande monopolise son thread indéfiniment. Cent threads de plus se retrouvent bloqués chaque seconde. En douze secondes environ, tous les threads attendent le prestataire de paiement. Désormais, l’historique des commandes, le panier et le suivi échouent aussi, alors qu’ils ne touchent jamais aux paiements. Une défaillance touchant vingt-cinq pour cent du trafic a mis hors service cent pour cent du trafic. Avec un délai d’expiration de deux secondes, la validation de commande utilise au plus deux cents threads, ce qui en laisse environ neuf cent quatre-vingt-cinq pour tout le reste. Seule la validation de commande échoue, et elle échoue clairement. Un cloisonnement va plus loin. Limiter les appels de paiement à quarante threads par instance garantit au moins neuf cent soixante threads pour tout le reste, quels que soient les délais d’expiration.
Les délais d’expiration doivent raccourcir à mesure que l’on descend dans une chaîne d’appels. Une échéance rend cela automatique. L’application mobile fixe une échéance de mille millisecondes pour l’ensemble de la requête. L’API gateway consomme vingt millisecondes et transmet le temps restant : neuf cent quatre-vingts. Le service de commandes en consomme cinquante, puis trois cent vingt pour d’autres appels, et transmet six cent dix à la tarification. La tarification a besoin d’environ huit cents millisecondes, elle échoue donc rapidement, tout de suite, au lieu de faire un travail que personne n’attendra.
Les nouvelles tentatives transforment de brefs incidents en succès. Mais les nouvelles tentatives sont aussi de la charge. Ne réessayez que les erreurs transitoires, comme les délais d’expiration ou une erreur cinq cent trois, et seulement les opérations idempotentes, car une requête qui a expiré a peut-être réussi. Réessayez deux ou trois fois, en attendant plus longtemps à chaque fois : c’est le backoff exponentiel, jusqu’à un plafond. Ajoutez du jitter, une attente aléatoire. Sinon, mille clients qui ont échoué ensemble réessaient ensemble, en vagues qui maintiennent la dépendance à terre. Et les nouvelles tentatives se multiplient d’une couche à l’autre. Trois tentatives à trois couches, cela fait vingt-sept appels. Ne réessayez donc qu’à une seule couche, et plafonnez les nouvelles tentatives à environ dix pour cent des requêtes.
Un disjoncteur enveloppe les appels à une dépendance. Tant qu’il est fermé, les appels passent et les défaillances sont comptées. Quand les défaillances franchissent un seuil, par exemple la moitié des vingt derniers appels, le disjoncteur s’ouvre. Dès lors, chaque appel échoue rapidement, sans toucher au réseau. Cela protège les threads de l’appelant et laisse à la dépendance le temps de se rétablir. Après une attente, par exemple trente secondes, il devient semi-ouvert et laisse passer quelques appels d’essai. S’ils réussissent, il se referme. Si un appel d’essai échoue, il s’ouvre pour une nouvelle période.
Le cloisonnement tire son nom des compartiments étanches d’un navire. Ici, chaque dépendance dispose de son propre pool de threads borné. Le prestataire de paiement est lent, donc son pool se remplit, et les appels de paiement suivants sont rejetés immédiatement. Mais les appels aux restaurants et aux livreurs continuent normalement. Un délai d’expiration limite combien de temps un appel peut retenir des ressources. Un cloisonnement limite combien d’appels peuvent en retenir à la fois.
Quand un appel échoue, l’appelant doit tout de même répondre. Une solution de repli peut être la dernière carte connue, un délai de livraison estimé, des plats populaires au lieu de recommandations personnalisées, ou une erreur claire et rapide. C’est la dégradation gracieuse : le parcours principal continue de fonctionner tandis que les extras s’effacent. Une solution de repli doit être simple, indépendante de la partie défaillante, et testée régulièrement. La sécurité est l’exception. Si le service d’autorisation est injoignable, le repli sûr consiste à refuser. Enfin, vérifiez qu’une opération est idempotente avant de la réessayer. Une politique de nouvelles tentatives sur un appel de paiement sans clé d’idempotence n’est pas un patron de résilience. C’est un générateur de doubles débits.
La section complète ajoute les requêtes de couverture et une liste de contrôle pour chaque appel distant. Ensuite, dans la section neuf point cinq, nous verrons à partir de quand un service est assez bon. À très vite.
Ce qu’il faut savoir
- Les défaillances en cascade se produisent quand les appelants attendent une dépendance lente jusqu’à épuiser leurs propres threads et connexions ; chaque point d’intégration a besoin d’une protection délibérée (Nygard, Release It!).
- Fixez des délais d’expiration explicites de connexion et de requête à partir des percentiles de latence mesurés, de plus en plus courts en descendant la chaîne d’appels, et propagez l’échéance restante pour qu’aucun service ne travaille sur une requête abandonnée.
- Ne réessayez que les erreurs transitoires sur des opérations idempotentes, quelques fois, avec un backoff exponentiel plafonné et un jitter complet (Brooker, 2015).
- Les nouvelles tentatives se multiplient d’une couche à l’autre (trois tentatives à trois niveaux donnent 27 appels) ; réessayez à une seule couche et plafonnez les nouvelles tentatives avec un budget, par exemple 10 % des requêtes.
- Un disjoncteur échoue rapidement tant qu’une dépendance est en panne : il passe de l’état fermé à l’état ouvert en cas de défaillances, puis revient à l’état fermé en passant par des appels d’essai à l’état semi-ouvert.
- Le cloisonnement donne à chaque dépendance ou à chaque appelant son propre pool borné, et les solutions de repli maintiennent le parcours principal en état de marche, mais elles doivent être simples, indépendantes de la partie défaillante, testées, et échouer en mode fermé pour la sécurité.
- L’idempotence permet de réessayer, de couvrir et de rejouer sans risque ; les requêtes de couverture réduisent la latence de queue des lectures idempotentes, pour un faible surcroît de charge, budgété.
La défaillance est normale ; sa propagation est facultative
Dans un système distribué, il y a toujours une dépendance lente ou défaillante : un prestataire de paiement passe une mauvaise minute, une base de données bascule sur une autre, un lien réseau perd des paquets, un déploiement tourne mal. La section 6.1 a montré pourquoi ces défaillances partielles ne peuvent pas être évitées et pourquoi un appelant ne peut souvent même pas savoir ce qui s’est passé. La question architecturale n’est donc pas de savoir comment éviter la défaillance, mais comment empêcher une défaillance locale de devenir globale. Une défaillance en cascade est la façon typique dont les systèmes meurent : une dépendance lente ralentit ses appelants, leurs ressources se remplissent de requêtes en attente, ils deviennent à leur tour lents pour leurs propres appelants, et en quelques secondes un problème dans un composant mineur a fait tomber toute la plateforme.
Michael Nygard a catalogué les patrons qui interrompent de telles chaînes dans Release It! (2007 ; deuxième édition 2018), en s’appuyant sur des pannes qu’il avait étudiées. Son observation centrale est que les points d’intégration, tous les endroits où un système en appelle un autre, sont la principale source d’instabilité, et que chacun a besoin d’une protection délibérée. Les patrons de cette section (délais d’expiration, échéances, nouvelles tentatives avec backoff, disjoncteurs, cloisonnement, solutions de repli, idempotence et couverture) constituent la boîte à outils standard. Ce sont des tactiques au sens de la section 2.5 : chacune est une décision unique visant la disponibilité, et chacune a un coût.
Délais d’expiration : le premier et le plus important des patrons
Un délai d’expiration (timeout) borne la durée d’attente d’un appelant. Sans lui, un appel à une dépendance qui ne répond jamais monopolise un thread, une connexion et de la mémoire aussi longtemps que le système d’exploitation garde la connexion ouverte, ce qui peut durer de nombreuses minutes. Beaucoup de bibliothèques clientes n’ont pas de délai d’expiration par défaut (la très répandue bibliothèque Python requests attend indéfiniment sauf indication contraire), si bien que l’absence de délai d’expiration est une décision de conception prise par omission. L’exemple commenté ci-dessous en montre la conséquence.
Deux délais d’expiration comptent généralement. Le délai d’expiration de connexion borne l’établissement d’une connexion et peut être court, quelques centaines de millisecondes à l’intérieur d’un centre de données, car un serveur sain accepte les connexions presque immédiatement. Le délai d’expiration de requête borne l’échange complet et doit venir de la mesure : légèrement au-dessus de la latence de la dépendance à un percentile élevé (p99 ou p99,9) sous charge normale, de sorte qu’il coupe les attentes pathologiques sans faire échouer les requêtes simplement lentes. Il doit aussi tenir dans le propre budget de l’appelant. Si l’API gateway abandonne au bout de 2 secondes mais que le service de commandes attend 10 secondes la tarification, le service de commandes continue de travailler pendant 8 secondes sur une requête que personne n’attend plus. Les délais d’expiration doivent donc raccourcir à mesure que l’on descend dans une chaîne d’appels, et c’est l’idée qui sous-tend les échéances.
Les échéances et leur propagation
Une échéance est un instant absolu auquel toute la requête doit être terminée. La propagation des échéances (deadline propagation) transmet le temps restant à chaque appel aval, de sorte que chaque service sait combien de budget il lui reste au lieu d’appliquer son propre délai d’expiration fixe. gRPC l’intègre nativement : un client fixe une échéance, le temps restant voyage avec l’appel, et un serveur peut vérifier si l’échéance est dépassée et arrêter le travail. Sur HTTP, les équipes propagent un en-tête contenant les millisecondes restantes ou l’échéance absolue, idéalement via le même middleware que celui qui propage le contexte de traçage (section 9.6).
La propagation apporte deux avantages. Un service peut échouer rapidement quand le temps restant est manifestement trop court pour le travail, plutôt que de lancer une requête de base de données qui ne pourra pas se terminer à temps. Et quand l’utilisateur abandonne, le travail en aval s’arrête aussi, au lieu de continuer à consommer de la capacité précisément pendant la surcharge qui a causé le retard. C’est du délestage de charge appliqué aux requêtes mortes, l’une des protections les plus efficaces dont puisse disposer un système sous contrainte.
Une échéance propagée le long d’une chaîne d’appels
Exemple commenté
Exemple commenté : comment un seul délai d’expiration manquant devient une panne totale
Le service de commandes de la plateforme de livraison de repas tourne sur 6 instances de 200 threads de travail chacune : 1200 threads. À midi, il reçoit 400 requêtes par seconde : 100 validations de commande, qui appellent le prestataire de paiement (normalement 300 ms, si bien qu’une validation de commande occupe son thread environ 350 ms au total), et 300 autres requêtes (historique des commandes, panier, suivi) qui prennent 50 ms. Le client de paiement n’a aucun délai d’expiration configuré. À 12:31, le prestataire de paiement cesse de répondre mais garde les connexions ouvertes.
| Threads occupés en temps normal (loi de Little) | Validation de commande 100 × 0,35 s = 35 ; autre trafic 300 × 0,05 s = 15 ; total 50 sur 1200 (environ 4 %) |
|---|---|
| Après 12:31 | Chaque validation de commande monopolise désormais son thread indéfiniment : 100 threads de plus se retrouvent bloqués chaque seconde |
| Délai avant épuisement | (1200 − 15 − 35) ÷ 100 ≈ 11,5 secondes : en 12 secondes environ, tous les threads attendent le prestataire de paiement |
| Conséquence | L’historique des commandes, le panier et le suivi, qui n’appellent jamais les paiements, font maintenant la queue pour obtenir des threads et expirent au niveau de l’API gateway. Une défaillance touchant 25 % du trafic a mis hors service 100 % du trafic |
| Et en amont | Les connexions de l’API gateway vers le service de commandes s’accumulent de la même façon ; les contrôles de santé expirent, les instances sont marquées comme non saines et, si la sonde de vivacité est touchée, elles sont redémarrées, à froid, dans les mêmes conditions |
| Avec un délai d’expiration de 2 secondes | Threads utilisés par la validation de commande : 100 × 2 s = 200 sur l’ensemble du parc (loi de Little). Il reste environ 985 threads pour le reste du trafic : seule la validation de commande échoue, et elle échoue avec une erreur claire au bout de 2 s |
| Ajout d’un disjoncteur | Dès que, disons, la moitié des 20 derniers appels ont échoué, le disjoncteur s’ouvre : les validations de commande échouent en quelques millisecondes et n’utilisent presque aucun thread ; un appel d’essai toutes les 30 s détecte le rétablissement |
| Ajout d’un cloisonnement | Limiter les appels de paiement à 40 threads par instance (240 au total) garantit au moins 960 threads pour tout le reste, quels que soient les délais d’expiration définis |
| Ajout d’une solution de repli | La validation de commande propose au client un autre moyen de paiement, ou explique que les paiements par carte sont retardés, au lieu d’afficher une erreur générique |
Le calcul repose encore sur la loi de Little : threads utilisés = débit de requêtes × durée pendant laquelle chaque requête occupe un thread. Sans délai d’expiration, la durée d’occupation n’est pas bornée, donc le nombre de threads nécessaires ne l’est pas non plus.
Les délais d’expiration limitent la durée ; le cloisonnement limite le nombre ; les disjoncteurs cessent complètement d’appeler. Chacun comble une lacune différente, c’est pourquoi on les utilise ensemble.
Release It! de Nygard décrit de vraies pannes qui ont suivi exactement ce schéma, souvent déclenchées par un point d’intégration que personne ne jugeait critique.
Épuisement du pool de threads
Nouvelles tentatives avec backoff exponentiel et jitter
Beaucoup de défaillances sont transitoires : un paquet perdu, une connexion réinitialisée pendant un basculement, un bref 503 renvoyé par une instance surchargée. Réessayer les transforme en succès que l’utilisateur ne voit jamais. Mais les nouvelles tentatives sont aussi de la charge, et des nouvelles tentatives mal conçues comptent parmi les causes de pannes les plus courantes. Quatre règles les rendent sûres. Ne réessayez que les erreurs transitoires (délais d’expiration, échecs de connexion, 502, 503, 504, et 429 après le délai indiqué), jamais des erreurs qui se reproduiront, comme 400 ou 404. Ne réessayez que les opérations idempotentes ou celles qui portent une clé d’idempotence, car une requête qui a expiré a peut-être réussi. Réessayez un petit nombre de fois, en général deux ou trois. Et attendez entre les tentatives, avec un backoff exponentiel : un délai qui double à chaque tentative, jusqu’à un plafond.
Le backoff seul ne suffit pas. Si mille clients échouent au même moment, comme c’est le cas quand une dépendance a un raté, ils réessaient tous après 100 ms, puis tous après 200 ms, et arrivent en vagues synchronisées qui peuvent maintenir la dépendance à terre. L’article de Marc Brooker Exponential Backoff And Jitter (AWS Architecture Blog, 2015) a simulé ce phénomène et montré qu’ajouter de l’aléatoire, le jitter (gigue aléatoire), réduit fortement à la fois le travail total et le temps nécessaire pour terminer. Le jitter complet
(full jitter) qu’il recommande attend un temps aléatoire compris entre zéro et la borne exponentielle : sleep = random(0, min(cap, base × 2attempt)). Le jitter a sa place partout où de nombreux clients agissent en même temps : nouvelles tentatives, reconnexions, dates d’expiration du cache et tâches planifiées.
import random, time
class TransientError(Exception):
"""Timeouts, connection resets, 502, 503 and 504 responses."""
def call_with_retries(fn, attempts=3, base=0.1, cap=2.0):
for attempt in range(attempts):
try:
return fn()
except TransientError:
if attempt == attempts - 1:
raise # give up: let the caller decide
ceiling = min(cap, base * 2 ** attempt)
time.sleep(random.uniform(0, ceiling)) # full jitterTempêtes de nouvelles tentatives et budgets de nouvelles tentatives
Les nouvelles tentatives se multiplient d’une couche à l’autre. Supposons qu’un toucher dans l’application mobile passe par l’API gateway, le service de commandes et le client de paiement, et que chacun des trois fasse jusqu’à trois tentatives. Si le prestataire de paiement est en panne, une seule action de l’utilisateur produit 3 × 3 × 3 = 27 appels vers lui, et une panne qui aurait dû se traduire par un taux d’erreur stable devient une tempête de nouvelles tentatives (retry storm) de 27 fois la charge normale, qui maintient le prestataire à terre même après la correction de la cause initiale. Les utilisateurs qui touchent de nouveau l’écran ajoutent une quatrième couche.
Trois défenses fonctionnent ensemble. D’abord, ne réessayez qu’à une seule couche, généralement la plus proche de la dépendance défaillante, et laissez les autres couches faire remonter l’erreur ; le livre SRE de Google recommande qu’un serveur surchargé le signale explicitement, pour que les appelants situés au-dessus ne réessaient pas. Ensuite, utilisez un budget de nouvelles tentatives (retry budget) : le livre SRE décrit le fait de n’autoriser les nouvelles tentatives que tant qu’elles restent sous 10 % des requêtes d’un client, ce qui plafonne la charge supplémentaire due aux nouvelles tentatives à environ 1,1 fois la normale, au lieu de jusqu’à trois fois. Les SDK d’AWS implémentent un quota similaire de jetons de nouvelle tentative, et la politique de nouvelles tentatives de gRPC inclut une régulation (throttling) dans le même but. Enfin, combinez les nouvelles tentatives avec un disjoncteur, pour qu’une dépendance que l’on sait en panne ne soit pas réessayée du tout.
Disjoncteurs
Un disjoncteur (circuit breaker), nommé ainsi par Nygard d’après l’appareil électrique, enveloppe les appels à une dépendance et observe leurs résultats. Tant que la dépendance est saine, le disjoncteur est fermé et les appels passent, tandis que les défaillances et les expirations de délai sont comptées. Quand les défaillances franchissent un seuil, par exemple la moitié des 20 derniers appels, le disjoncteur s’ouvre : pendant une période définie, chaque appel échoue immédiatement sans toucher au réseau, ce qui protège les threads de l’appelant et laisse à la dépendance le temps de se rétablir. Quand la période expire, le disjoncteur devient semi-ouvert et laisse passer quelques appels d’essai. S’ils réussissent, il se ferme ; si l’un d’eux échoue, il s’ouvre de nouveau pour une autre période. La description qu’en a donnée Martin Fowler en 2014 a fait largement connaître le patron, et la bibliothèque Hystrix de Netflix (désormais en mode maintenance) l’a popularisé dans les microservices ; Resilience4j pour Java et Polly pour .NET sont aujourd’hui les bibliothèques courantes, et les service meshes offrent des protections apparentées comme les limites de concurrence et la détection des instances aberrantes (dans Envoy, celles-ci s’appellent circuit breaking, bien qu’elles fonctionnent différemment).
La machine à états du disjoncteur
Les réglages méritent réflexion. Qu’est-ce qui compte comme une défaillance ? Les expirations de délai et les réponses 5xx, oui ; les réponses 4xx, généralement non, car elles reflètent la requête de l’appelant plutôt que la santé de la dépendance. Les succès lents peuvent aussi compter, pour qu’une dépendance qui répond en 5 secondes soit traitée comme défaillante. La fenêtre doit être assez grande pour éviter un déclenchement sur deux ou trois appels malchanceux (les bibliothèques exigent un nombre minimal d’appels), et la période d’ouverture assez longue pour permettre le rétablissement, mais assez courte pour ne pas prolonger une panne. Les disjoncteurs doivent être définis par dépendance, et parfois par endpoint, pour qu’un endpoint de reporting défaillant ne coupe pas la prise de commandes. Les nouvelles tentatives doivent traiter un disjoncteur ouvert comme une réponse définitive plutôt que comme quelque chose à réessayer. Enfin, chaque changement d’état doit être journalisé et représenté sur un graphique, car un disjoncteur ouvert est un symptôme que quelqu’un doit voir.
import time
class CircuitOpen(Exception):
pass
class CircuitBreaker:
def __init__(self, failure_threshold=5, reset_after=30.0):
self.failure_threshold = failure_threshold # failures in a row that trip it
self.reset_after = reset_after # seconds to stay open
self.failures, self.state, self.opened_at = 0, "closed", 0.0
def call(self, fn, *args, **kwargs):
if self.state == "open":
if time.monotonic() - self.opened_at < self.reset_after:
raise CircuitOpen("failing fast") # no remote call at all
self.state = "half-open" # let a trial call through
try:
result = fn(*args, **kwargs)
except Exception:
self.failures += 1
if self.state == "half-open" or self.failures >= self.failure_threshold:
self.state, self.opened_at = "open", time.monotonic()
raise
self.failures, self.state = 0, "closed" # success closes the circuit
return result
payments = CircuitBreaker()
# payments.call(provider.authorise, order_id, amount, timeout=2.0)Cloisonnement
La coque d’un navire est divisée par des cloisons étanches en compartiments, de sorte qu’une brèche inonde un seul compartiment au lieu de couler le navire. En logiciel, un cloisonnement (bulkhead) partitionne les ressources pour qu’une partie défaillante ou surchargée ne puisse pas tout consommer. La forme la plus courante donne à chaque dépendance son propre pool de threads, pool de connexions ou sémaphore borné : les paiements peuvent utiliser au plus 40 appels simultanés par instance, la consultation des restaurants 40, l’affectation des livreurs 40. Quand les paiements ralentissent, leur pool se remplit et les appels de paiement suivants sont rejetés immédiatement, mais les appels vers les restaurants et les livreurs continuent. Un délai d’expiration limite combien de temps chaque appel peut retenir des ressources ; un cloisonnement limite combien d’appels peuvent en retenir à la fois, si bien qu’il protège même quand les délais d’expiration sont trop longs.
Le cloisonnement fonctionne aussi à plus grande échelle. Des déploiements distincts du même service pour différents appelants (un pool d’instances du service de commandes pour l’application grand public, un autre pour l’API des restaurants partenaires) empêchent une intégration partenaire défaillante d’affamer les clients. Les architectures cellulaires, l’axe Z du cube de scalabilité, font de chaque cellule un cloisonnement pour une tranche de clients, et le shuffle sharding, décrit par des ingénieurs d’AWS, attribue à chaque client une petite combinaison aléatoire d’instances, de sorte que le trafic empoisonné d’un client n’affecte que très peu d’autres clients. Le coût de tout cloisonnement est le taux d’utilisation : la capacité réservée à un compartiment ne peut pas être prêtée à un autre, donc les pools doivent être dimensionnés à partir de la demande mesurée.
Le cloisonnement à l’intérieur d’un service
Solutions de repli et dégradation gracieuse
Quand un appel échoue ou qu’un disjoncteur est ouvert, l’appelant doit tout de même répondre. Une solution de repli (fallback) décide de ce qu’il renvoie. Les options, de la meilleure à la pire pour l’utilisateur, sont : une valeur en cache ou périmée (la dernière carte connue, avec des prix confirmés à l’étape de paiement) ; une valeur par défaut raisonnable (un délai de livraison estimé de 30 à 45 minutes au lieu d’une estimation en direct) ; une fonctionnalité réduite (des plats populaires au lieu de recommandations personnalisées) ; le report du travail (accepter un avis et le publier quand le service d’avis se rétablit) ; et une erreur claire et rapide qui indique à l’utilisateur quoi faire. Concevoir ces options fonctionnalité par fonctionnalité, avec le responsable produit, voilà ce que la dégradation gracieuse signifie en pratique : le parcours principal (parcourir, commander, payer, suivre) continue de fonctionner tandis que les fonctionnalités non essentielles s’effacent. La même réflexion guide le délestage de charge (section 9.3) : sachez à l’avance quelles fonctionnalités désactiver en premier.
Les décisions de sécurité sont l’exception : si le service d’autorisation est injoignable, le repli sûr est de refuser (échouer en mode fermé, fail closed), pas d’autoriser. Les praticiens ne sont d’ailleurs pas d’accord sur les solutions de repli en général. Nygard et les ingénieurs de Netflix les préconisent largement. Des ingénieurs d’Amazon ont soutenu, dans l’article de la Builders’ Library Avoiding fallback in distributed systems, que les chemins de repli sont rarement exercés, difficiles à tester, et peuvent eux-mêmes déclencher des pannes en faisant basculer un grand parc vers un mode non testé au pire moment ; ils préfèrent rendre le chemin principal plus fiable et échouer rapidement. Les deux positions s’accordent sur la règle pratique : une solution de repli doit être plus simple que ce qu’elle remplace, ne doit pas dépendre du composant défaillant, et doit être exercée régulièrement, par exemple lors de game days (section 9.8), faute de quoi elle ne devrait pas exister.
Idempotence : ce qui rend sûres les nouvelles tentatives et la couverture
L’idempotence est la propriété selon laquelle faire quelque chose deux fois a le même effet que le faire une seule fois. Tous les patrons de cette section qui répètent du travail en dépendent : les nouvelles tentatives après un délai d’expiration, les requêtes de couverture, la nouvelle livraison de messages après le plantage d’un consommateur, et les rejeux depuis une file de lettres mortes. La section 6.5 a montré comment une clé d’idempotence fait de POST /orders une nouvelle tentative sans risque, et la section 6.4 comment les consommateurs idempotents dédupliquent les messages. La règle architecturale est simple : avant d’ajouter une nouvelle tentative, vérifiez que l’opération est idempotente et, si elle ne l’est pas, ajoutez d’abord une clé ou une détection des doublons. Une politique de nouvelles tentatives sur un appel de paiement non idempotent n’est pas un patron de résilience ; c’est un générateur de doubles débits.
Requêtes de couverture
Les nouvelles tentatives traitent les défaillances ; les requêtes de couverture (hedged requests) traitent la lenteur. La section 2.4 les a présentées à partir de l’article The Tail at Scale de Dean et Barroso (2013) : envoyez une requête à une réplique et, si elle n’a pas répondu dans un délai égal, par exemple, à la latence du 95e percentile, envoyez une copie à une seconde réplique et utilisez la première réponse reçue, en annulant l’autre. Comme seuls les quelques pour cent de requêtes les plus lentes sont dupliqués, la charge supplémentaire est faible, tandis que la queue se réduit de façon spectaculaire, comme l’a montré le banc d’essai cité à la section 2.4.
La couverture a des préconditions. L’opération doit être idempotente, normalement une lecture. Il doit y avoir des répliques indépendantes, pour que la seconde copie ait peu de chances d’être lente pour la même raison. Les requêtes supplémentaires doivent être budgétées et désactivées en cas de surcharge, car quand toutes les répliques sont lentes, la couverture double la charge d’un système déjà en difficulté, exactement comme une nouvelle tentative non budgétée. Utilisée avec soin sur des chemins en fan-out à forte intensité de lecture, comme la recherche et la consultation des cartes, c’est l’un des outils les plus efficaces contre la latence de queue.
Les patrons en un coup d’œil
| Patron | Protège contre | Principal coût ou risque |
|---|---|---|
| Délai d’expiration (timeout) | Attente indéfinie ; ressources retenues par des appels morts | Trop court, il fait échouer des appels sains mais lents ; trop long, il ne protège rien |
| Propagation des échéances (deadline propagation) | Travail qui se poursuit après l’abandon de l’appelant | Nécessite une prise en charge dans chaque service et chaque bibliothèque du chemin |
| Nouvelle tentative avec backoff et jitter | Défaillances transitoires | Charge supplémentaire ; doublons sauf idempotence |
| Budget de nouvelles tentatives (retry budget) | Tempêtes de nouvelles tentatives qui multiplient la charge | Certaines défaillances transitoires ne sont pas réessayées pendant les mauvaises périodes |
| Disjoncteur (circuit breaker) | Appels répétés à une dépendance en panne | Réglage ; peut couper une dépendance qui n’est que partiellement défaillante |
| Cloisonnement (bulkhead) | Une dépendance ou un appelant qui consomme toutes les ressources | La capacité réservée ne peut pas être partagée |
| Repli et dégradation | Défaillance totale d’une fonctionnalité | Chemins de code non testés ; résultats périmés ou approximatifs |
| Requête de couverture (hedged request) | Latence de queue due aux répliques lentes | Charge supplémentaire ; lectures uniquement |
Liste de contrôle : chaque appel distant
Passez en revue chaque point d’intégration, y compris les bases de données, les caches, les courtiers et les API tierces, à l’aide de cette liste.
- Les délais d’expiration de connexion et de requête sont définis explicitementChoisis à partir des percentiles élevés mesurés de la dépendance et de la propre échéance de l’appelant, jamais laissés à la valeur par défaut d’une bibliothèque.
- L’échéance restante est propagéeLes appels aval reçoivent le temps restant, et le travail s’arrête quand celui-ci est écoulé.
- Les nouvelles tentatives sont limitées et sûresUniquement les erreurs transitoires, uniquement les opérations idempotentes ou celles qui ont une clé d’idempotence, à une seule couche, avec un backoff exponentiel plafonné et du jitter.
- Un budget de nouvelles tentatives plafonne la charge supplémentaireLes nouvelles tentatives restent une petite fraction du trafic (par exemple 10 %), si bien qu’une panne ne peut pas multiplier la charge.
- Un disjoncteur protège la dépendanceLes seuils, le temps d’attente et ce qui compte comme une défaillance sont réglés, et son état est exporté sous forme de métrique.
- La concurrence vers la dépendance est bornéeUn cloisonnement (pool dédié ou sémaphore) empêche cette dépendance de consommer tous les threads ou toutes les connexions.
- Le comportement en cas de défaillance est décidéUne solution de repli, une réponse dégradée ou une erreur claire, convenue avec le responsable produit, et un échec en mode fermé pour les décisions de sécurité.
- Les délais d’expiration, nouvelles tentatives, déclenchements du disjoncteur et replis sont observablesComptés, représentés sur des graphiques et assortis d’alertes, pour qu’une dégradation silencieuse soit remarquée (section 9.6).
- Le chemin de défaillance est testéL’injection de fautes ou un game day montre que les délais d’expiration, le disjoncteur et la solution de repli fonctionnent réellement (section 9.8).
Vérifiez votre compréhension
Un service reçoit 50 requêtes par seconde, chacune appelant une dépendance avec un délai d’expiration de 4 secondes. Si la dépendance se bloque, combien de threads ces appels peuvent-ils monopoliser ?
D’après la loi de Little, environ 50 × 4 = 200 threads ; c’est pourquoi le délai d’expiration et la limite du cloisonnement doivent être compatibles avec la taille du pool.
Pourquoi le jitter complet est-il meilleur qu’un simple backoff exponentiel quand de nombreux clients échouent en même temps ?
Un backoff simple fait réessayer tous les clients aux mêmes instants, en vagues synchronisées ; des délais aléatoires étalent les nouvelles tentatives, si bien que la dépendance voit une charge régulière et plus faible.
Dans quel état un disjoncteur ne fait-il aucun appel distant, et qu’est-ce qui met fin à cet état ?
Dans l’état ouvert ; quand la période d’attente expire, il devient semi-ouvert et laisse passer des appels d’essai.
Pourquoi un contrôle d’autorisation ne doit-il pas se replier sur l’autorisation de l’accès quand le service d’autorisation est en panne ?
Les décisions de sécurité doivent échouer en mode fermé : autoriser l’accès pendant une panne laisserait entrer n’importe qui chaque fois que le service tomberait en panne ou serait attaqué.
Sources et lectures complémentaires
Une question sur cette section ? ArchitectBot, l’assistant d’étude IA du cours, répond d’abord à partir du cours.
Demander : « Notre étape de paiement appelle trois services externes de façon synchrone. Avec quels délais d’expiration, quelles nouvelles tentatives et quels réglages de disjoncteur devrions-nous commencer, et comment empêcher une tempête de nouvelles tentatives ? »