Be Software Architecture Wise
Be Wise Apps

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

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

Une requête part avec une échéance de 1000 ms ; chaque service transmet le temps restant, et un service qui ne peut pas finir dans le temps restant échoue aussitôt au lieu de commencer le travailApp mobileéchéance 1000 msAPI gatewayreste 980Serv. commandesreste 930Tarif.reste 610envoienvoienvoi20 ms passées50 ms passéespuis 320 ms enautres appelsil lui faut ~800 ms :échoue aussitôtChaque saut transmet le temps restant, pas un nouveau délai à lui
Chaque saut transmet le temps restant ; un service qui ne peut pas finir dans le temps restant échoue aussitôt plutôt que de faire un travail que personne n’attendra.

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:31Chaque 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équenceL’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 amontLes 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 secondesThreads 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 disjoncteurDè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 cloisonnementLimiter 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 repliLa 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

Un prestataire de paiement lent monopolise tous les threads du service de commandes : les requêtes sans paiement, et les appels à une base de données saine, échouent aussiValidationHistorique,suiviServ. commandesles 200 threads attendentles paiementsPrest. de paiementne répond plus ;d’habitude 0,3 sBDsaine, mais aucunthread libre pour l’appeleréchouent :0 thread libre
Sans délai d’expiration, un prestataire de paiement lent monopolise tous les threads du service de commandes, si bien que même les requêtes qui ne touchent jamais aux paiements échouent.

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 jitter
Réessayer une défaillance transitoire avec un backoff exponentiel plafonné et un jitter complet (Brooker, 2015). Seules les opérations idempotentes, ou celles qui portent une clé d’idempotence, doivent être réessayées.

Tempê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

États du disjoncteur : fermé, il laisse passer les appels et compte les échecs ; trop d’échecs l’ouvrent et les appels échouent vite ; après une attente, semi-ouvert, il laisse passer des appels d’essai et se ferme s’ils réussissent, sinon se rouvreFerméles appels passent ;échecs comptésOuvertéchec rapide, sansappel distantSemi-ouvertquelques appelsd’essai autoriséstaux d’échec au-dessus du seuil(ex. 50 % des 20 derniers appels)après l’attente(ex. 30 s)un essai échoueessaisréussis
Fermé compte les défaillances, ouvert échoue rapidement, et semi-ouvert teste le rétablissement avec quelques appels d’essai.

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)
Un disjoncteur minimal. Les bibliothèques de production comme Resilience4j (Java) et Polly (.NET) ajoutent des fenêtres de taux d’échec, la détection des appels lents, des limites sur les appels d’essai simultanés, la sûreté des threads (thread safety) et des métriques.

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

Cloisonnement : le service de commandes donne à chaque dépendance un petit pool de threads ; un prestataire de paiement lent ne remplit que le sien, et les appels aux restaurants et livreurs continuentRequêtesServ. commandespool paiements pleinpool restaurantspool livreursPaiementslentsRestaurantssainsLivreurssains
Avec un pool distinct par dépendance, un prestataire de paiement lent ne remplit que le pool des paiements ; les appels aux restaurants et aux livreurs se poursuivent normalement.

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

PatronProtège contrePrincipal coût ou risque
Délai d’expiration (timeout)Attente indéfinie ; ressources retenues par des appels mortsTrop 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’appelantNécessite une prise en charge dans chaque service et chaque bibliothèque du chemin
Nouvelle tentative avec backoff et jitterDéfaillances transitoiresCharge supplémentaire ; doublons sauf idempotence
Budget de nouvelles tentatives (retry budget)Tempêtes de nouvelles tentatives qui multiplient la chargeCertaines 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 panneRé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 ressourcesLa capacité réservée ne peut pas être partagée
Repli et dégradationDé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 lentesCharge 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é.

Suivant · 9.5SLO et budgets d’erreurComment mesurer la fiabilité du point de vue de l’utilisateur avec des SLI, fixer des cibles de SLO, les transformer en budgets d’erreur et en politiques, déclencher des alertes sur le taux de consommation et garder les SLA prudemment plus souples que les objectifs internes.Continuer →

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 ? »