Algorithmes imitant la vie

Depuis mes premiers cours d'informatique à l'université, je me suis intéressé à la façon dont les algorithmes fonctionnent dans le monde réel. Lorsque nous pensons à certaines choses qui se produisent dans le monde réel, nous pouvons proposer des algorithmes qui imitent ces choses. C’est ce que je fais surtout lorsque je suis coincé dans une file d’attente, comme à l’épicerie, dans les embouteillages ou à l'aéroport. J'ai trouvé que s'ennuyer en faisant la file était une excellente occasion de réfléchir à la théorie des files d'attente.

Il y a un peu plus de 10 ans, j'ai passé une journée dans un centre de distribution Amazon. J’étais guidé par un algorithme, prenant des objets sur les étagères, déplaçant des objets d'une boîte à une autre, déplaçant des bacs. En travaillant en parallèle avec tant d'autres personnes, j'ai trouvé cela magnifique de faire partie de ce qui constitue essentiellement un genre de fusion physique incroyablement orchestré.

Dans la théorie des files d'attente, le comportement de ces files lorsqu'elles sont courtes est relativement peu intéressant. Après tout, lorsqu’une file d'attente est courte, tout le monde est heureux. Ce n'est que lorsque la file d'attente est longue, lorsque la file pour un événement sort du bâtiment et atteint le coin de la rue, que les gens commencent à penser au débit et à la hiérarchisation.

Dans cet article, nous discutons des stratégies que nous utilisons chez Amazon pour gérer les scénarios de backlog des files d'attente, des approches de conception que nous adoptons pour éliminer rapidement les files d'attente et hiérarchiser les charges de travail. Plus important encore, je décris comment empêcher l’accumulation des backlogs de files d'attente. Dans la première partie, je décris des scénarios qui conduisent à des backlogs, et dans la seconde, je décris un grand nombre d’approches utilisées par Amazon pour éviter les backlogs ou les traiter avec élégance.

La duplicité des files d'attente

Les files d'attente sont de puissants outils permettant de créer des systèmes asynchrones fiables. Les files d'attente permettent à un système d'accepter un message provenant d'un autre système et de le conserver jusqu'à ce qu'il soit entièrement traité, même en cas de pannes prolongées, de défaillances de serveur ou de problèmes liés à des systèmes dépendants. Plutôt que de supprimer des messages en cas d'échec, la file d'attente les rediffuse jusqu'à ce qu’ils soient traités. En fin de compte, une file d'attente augmente la durabilité et la disponibilité du système, au prix d'une latence parfois accrue en raison des tentatives.
 
Chez Amazon, nous créons de nombreux systèmes asynchrones qui tirent parti des files d'attente. Certains de ces systèmes traitent des flux de travail qui peuvent être chronophages et qui impliquent de déplacer des éléments physiques dans le monde, comme des commandes passées sur amazon.com. D'autres systèmes coordonnent des étapes qui peuvent prendre un temps considérable. Par exemple, Amazon RDS demande des instances EC2, attend leur lancement, puis configure vos bases de données pour vous. D'autres systèmes tirent parti du traitement par lots. Par exemple, les systèmes impliqués dans l'acquisition de métriques et de journaux CloudWatch extraient un ensemble de données, puis les agrègent et les « aplatissent » en tranches.
 
S'il est facile de voir les avantages d'une file d'attente pour le traitement des messages de manière asynchrone, les risques liés à l'utilisation d'une file d'attente sont plus subtils. Au fil des ans, nous avons constaté que la mise en file d'attente sensée améliorer la disponibilité pouvait se retourner contre nous. En fait, cela peut considérablement augmenter le temps de reprise après une panne.
 
Dans un système basé sur une file d'attente, lorsque le traitement est interrompu, mais que les messages continuent d'arriver, la dette de messages peut s'accumuler dans un backlog important, ce qui augmente le temps de traitement. Les travaux peuvent être terminés trop tard pour que les résultats soient utiles, ce qui est essentiellement à l'origine de la perte de disponibilité que la mise en file d'attente était sensée protéger.
 
En d'autres termes, un système basé sur une file d'attente possède deux modes de fonctionnement, ou de comportement bimodal. Lorsqu'il n'y a pas de backlog dans la file d'attente, la latence du système est faible et le système est en mode rapide. Mais si le taux d’arrivée dépasse celui du traitement en raison d’une défaillance ou d’un modèle de charge inattendu, on bascule rapidement vers un mode de fonctionnement plus sinistre. Dans ce mode, la latence de bout en bout augmente de plus en plus et le traitement du backlog peut prendre beaucoup de temps pour revenir au mode rapide.

Systèmes basés sur des files d'attente

Pour illustrer les systèmes basés sur des files d'attente dans cet article, je vais expliquer en détail comment deux services AWS fonctionnent : AWS Lambda, un service qui exécute votre code en réponse à des événements sans vous soucier de l'infrastructure sur laquelle il s'exécute ; et AWS IoT Core, un service géré qui permet aux périphériques connectés d'interagir de manière simple et sécurisée avec les applications cloud et d'autres appareils.

Avec AWS Lambda, vous téléchargez votre code de fonction, puis appelez vos fonctions de deux manières :

• Synchrone : auquel cas la sortie de votre fonction vous est renvoyée dans la réponse HTTP.
• Asynchrone : la réponse HTTP est renvoyée immédiatement et votre fonction est exécutée et réessayée en arrière-plan.

Lambda s'assure que votre fonction est exécutée, même en cas de défaillance du serveur. Elle a donc besoin d'une file d'attente durable dans laquelle stocker votre demande. Avec une file d'attente durable, votre demande peut être reconduite si votre fonction échoue la première fois.

Avec AWS IoT Core, vos appareils et applications se connectent et peuvent s'abonner aux rubriques de messages PubSub. Lorsqu'un appareil ou une application publie un message, les applications avec les abonnements correspondants reçoivent leur propre copie du message. Une grande partie de cet envoi de messages PubSub est asynchrone, car un appareil IoT contraint ne souhaite pas utiliser ses ressources limitées, attendant de s'assurer que tous les appareils, applications et systèmes souscrits en reçoivent une copie. Ceci est particulièrement important, car un appareil abonné peut être hors ligne lorsqu'un autre publie un message qui l'intéresse. Lorsque l'appareil hors connexion se reconnecte, il s'attend à retrouver une pleine vitesse, puis de voir ses messages livrés par la suite (pour plus d'informations sur le codage de votre système pour gérer la livraison des messages après la reconnexion, consultez la section Sessions permanentes MQTT dans le Guide du développeur AWS IoT). Pour ce faire, une variété de types de persistance et de traitement asynchrone se déroulent en arrière-plan.

Les systèmes basés sur des files d'attente comme ceux-ci sont souvent mis en œuvre avec une file d'attente durable. SQS offre une sémantique de livraison de messages durable, évolutive et « au moins une fois », afin que les équipes Amazon, y compris Lambda et IoT, l'utilisent régulièrement lors de la construction de leurs systèmes asynchrones évolutifs. Dans les systèmes basés sur des files d'attente, un composant produit des données en plaçant des messages dans la file d'attente, et un autre composant consomme ces données en demandant régulièrement des messages, en les traitant, puis en les supprimant une fois leur traitement terminé.

Échecs dans les systèmes asynchrones

Dans AWS Lambda, si l'appel de votre fonction est plus lent que la normale (par exemple, en raison d'une dépendance), ou en cas d'échec temporaire, aucune donnée n'est perdue et Lambda réessaie votre fonction. Lambda met en file d'attente vos appels et, lorsque la fonction recommence à fonctionner, Lambda résout le backlog de votre fonction. Mais considérons le temps qu'il faut pour régler le backlog et revenir à la normale.

Imaginez un système qui subit une panne d'une heure lors du traitement des messages. Quels que soient le débit et la capacité de traitement donnés, la reprise après la panne nécessite le double de la capacité du système pendant une heure après la reprise. En pratique, la capacité disponible du système pourrait être plus que doublée, en particulier avec des services Elastic tels que Lambda, et la récupération pourrait être plus rapide. D'autre part, d'autres systèmes avec lesquels votre fonction interagit peuvent ne pas être prêts à gérer une augmentation considérable du traitement tandis que vous résolvez le problème du backlog. Lorsque cela se produit, le retour à la normale peut prendre encore plus de temps. Les services asynchrones accumulent des backlogs de traitement pendant les pannes, ce qui génère de longs temps de reprise, contrairement aux services synchrones qui abandonnent les demandes pendant les pannes, mais qui ont des temps de reprise plus courts.

Au fil des ans, en réfléchissant aux files d'attente, nous avons parfois été tentés de penser que la latence n'était pas importante pour les systèmes asynchrones. Ces systèmes sont souvent conçus pour la durabilité ou pour isoler l'appelant immédiat de la latence. Toutefois, dans la pratique, nous avons constaté que le temps de traitement comptait beaucoup et que même les systèmes asynchrones sont supposés avoir une latence inférieure à la seconde ou meilleure. Lorsque les files d'attente sont introduites pour une meilleure durabilité, il n'est pas facile de trouver le compromis pour obtenir une latence de traitement aussi élevée face à un backlog. Le risque que cachent les systèmes asynchrones est de traiter des backlogs considérables.

Comment nous mesurons la disponibilité et la latence

Cette discussion sur la réduction de la latence en contrepartie d'une meilleure disponibilité soulève une question intéressante : comment mesurons-nous et définissons-nous des objectifs en matière de latence et de disponibilité pour un service asynchrone ? Mesurer les taux d'erreur du point de vue des producteurs nous donne une partie de la disponibilité, mais pas beaucoup. La disponibilité du producteur est proportionnelle à la disponibilité de la file d'attente du système que nous utilisons. Ainsi, lorsque nous créons sur SQS, la disponibilité de nos producteurs correspond à celle de SQS.

D'un autre côté, si nous mesurons la disponibilité du côté du consommateur, la disponibilité du système risque de paraître moins bonne qu'elle ne l'est réellement, car les échecs risquent de se reproduire et de réussir à la prochaine tentative.

Nous obtenons également des mesures de disponibilité à partir des files d'attente de lettres mortes (DLQ). Si un message est à court d'essais, il est supprimé ou placé dans une DLQ. Une DLQ est simplement une file d'attente distincte utilisée pour stocker des messages qui ne peuvent pas être traités pour une enquête et une intervention ultérieures. Le taux de messages abandonnés ou de DLQ est une bonne mesure de disponibilité, mais il peut détecter le problème trop tard. Même si mettre en place des alertes sur les volumes DLQ est une bonne idée, les informations des DLQ arriveraient trop tard pour que nous puissions nous en servir exclusivement pour détecter les problèmes.

Qu'en est-il de la latence ? Encore une fois, la latence observée par les producteurs reflète celle de notre service de file d'attente lui-même. Par conséquent, nous nous concentrons davantage sur la mesure de l'âge des messages dans la file d'attente. Cela intercepte rapidement les cas où les systèmes sont en retard, ou génèrent fréquemment des erreurs et de nouvelles tentatives. Des services tels que SQS fournissent l'horodatage du moment où chaque message a atteint la file d'attente. Avec les informations d'horodatage, chaque fois que nous retirons un message de la file d'attente, nous pouvons enregistrer et générer des métriques indiquant le niveau de retard de nos systèmes.

Le problème de latence peut être toutefois un peu plus nuancé. Après tout, des backlogs sont à prévoir et, en fait, acceptables pour certains messages. Par exemple, dans AWS IoT, il est parfois prévu qu'un appareil se déconnecte ou ralentisse la lecture de ses messages. En effet, de nombreux appareils IoT présente une faible puissance et disposent d'une connectivité Internet inégale. En tant qu'opérateurs d'AWS IoT Core, nous devons être en mesure de faire la différence entre un petit backlog attendu dû à des appareils hors ligne ou choisissant de lire les messages lentement, et un backlog inattendu à l'échelle du système.

Dans AWS IoT, nous avons instrumenté le service avec une autre métrique : AgeOfFirstAttempt. Cette mesure enregistre le moment présent moins le temps de mise en file d'attente des messages, mais uniquement s'il s'agissait de la première fois qu'AWS IoT tentait de livrer un message à un appareil. Ainsi, lorsque les appareils sont sauvegardés, nous disposons d'une métrique propre qui n'est pas polluée par les appareils qui réessayent de livrer des messages ou de les mettre en file d'attente. Pour rendre la métrique encore plus propre, nous émettons une deuxième métrique : AgeOfFirstSubscriberFirstAttempt. Dans un système PubSub tel qu'AWS IoT, il n'y a pas de limite pratique au nombre d'appareils ou d'applications pouvant s'abonner à une rubrique spécifique. La latence est donc plus élevée lors de l'envoi du message à un million d'appareils que lors de l'envoi à un seul appareil. Pour obtenir une métrique stable, nous émettons une métrique chronomètre lors de la première tentative de publication d'un message au premier abonné de cette rubrique. Nous disposons ensuite d'autres mesures pour évaluer la progression du système en ce qui concerne la publication des messages restants.

La métrique AgeOfFirstAttempt sert d'avertissement préalable pour un problème touchant l'ensemble du système, en grande partie parce qu'elle élimine le bruit des appareils qui choisissent de lire leurs messages plus lentement. Il convient de noter que les systèmes comme AWS IoT sont dotés de beaucoup plus de métriques que cela. Toutefois, avec toutes les métriques disponibles en matière de latence, la stratégie consistant à classer la latence des premières tentatives séparément de la latence des nouvelles tentatives est couramment utilisée avec Amazon.

Mesurer la latence et la disponibilité des systèmes asynchrones est un exercice difficile et le débogage peut également s'avérer délicat, car les demandes rebondissent entre les serveurs et peuvent être retardées en dehors de chaque système. Pour vous aider avec le traçage distribué, nous propageons un identifiant de demande dans nos messages placés en file d'attente afin de pouvoir tout réunir. Nous utilisons couramment des systèmes tels que X-Ray pour nous aider également à ces fins.

Backlogs dans des systèmes asynchrones multi-locataires

De nombreux systèmes asynchrones sont multi-locataires et gèrent le travail pour le compte de nombreux clients différents. Cela ajoute une certaine complexité à la gestion de la latence et de la disponibilité. L'avantage de la multi-location est qu'elle nous évite la surcharge opérationnelle liée à l'exploitation séparée de plusieurs flottes et nous permet d'exécuter des charges de travail combinées avec une utilisation des ressources beaucoup plus importante. Toutefois, les clients s'attendent à ce qu'elle se comporte comme leur propre système à locataire unique, avec une latence prévisible et une haute disponibilité, quelles que soient les charges de travail des autres clients.

Les services AWS n'exposent pas directement leurs files d'attente internes pour que les appelants y placent des messages. Au lieu de cela, ils implémentent des API légères pour authentifier les appelants et ajoutent les informations de ces appelants à chaque message avant leur mise en file d'attente. Ceci s'apparente à l'architecture Lambda décrite précédemment : lorsque vous appelez une fonction de manière asynchrone, Lambda place votre message dans une file d'attente appartenant à Lambda et renvoie immédiatement une réponse, plutôt que de vous exposer directement les files d'attente internes de Lambda.

Ces API légères nous permettent également d'ajouter une limitation d'impartialité. L'impartialité dans un système multi-locataires est importante pour qu'aucune charge de travail du client n'affecte un autre client. AWS applique généralement l'impartialité en définissant des limites basées sur les tarifs par client, avec une certaine flexibilité pour les rafales. Dans un grand nombre de nos systèmes, par exemple, dans SQS lui-même, nous augmentons les limites par client à mesure que les clients évoluent de manière organique. Les limites servent de garde-fous pour les pics inattendus, nous ainsi laissant le temps de faire des ajustements de provisionnement en arrière-plan.

D'une certaine manière, l'impartialité dans les systèmes asynchrones fonctionne comme une limitation dans les systèmes synchrones. Toutefois, nous pensons qu'il est encore plus important de penser aux systèmes asynchrones en raison des arriérés importants qui peuvent s'accumuler si rapidement.

Pour illustrer cela, imaginons ce qui se produirait si un système asynchrone n'avait pas assez de protections contre les voisins bruyants intégrées. Si un client du système augmentait soudainement le trafic alors non limité et générait un backlog à l'échelle du système, 30 minutes auraient été nécessaires pour qu'un opérateur intervienne, comprenne la situation et atténue le problème. Pendant ces 30 minutes, le côté producteur du système a peut-être pu bien se mettre à l'échelle et mettre tous les messages en file d'attente. Toutefois, si le volume des messages en file d'attente correspond à 10 fois la capacité du client après sa mise à l'échelle, cela signifie qu'il faudrait 300 minutes au système pour traiter le backlog et récupérer. Même des pics de charge courts peuvent entraîner des temps de reprise de plusieurs heures et, par conséquent, des pannes de plusieurs heures.

En pratique, les systèmes AWS ont de nombreux facteurs compensateurs pour minimiser ou prévenir les impacts négatifs des backlogs de files d'attente. Par exemple, la mise à l'échelle automatique permet d'atténuer certains problèmes lorsque la charge augmente. Mais il est utile d'examiner uniquement les effets de la mise en file d'attente, sans prendre en compte les facteurs de compensation, car cela permet de concevoir des systèmes fiables sur plusieurs couches. Voici quelques modèles que nous avons trouvés qui peuvent aider à éviter les backlogs de files d'attente considérables et les longs délais de reprise :

La protection à chaque couche est importante dans les systèmes asynchrones. Comme les systèmes synchrones ne tendent pas à accumuler des backlogs, nous les protégeons avec un contrôle des admissions et une limitation de la porte d'entrée. Dans les systèmes asynchrones, chaque composant de nos systèmes doit se protéger de la surcharge et empêcher une charge de travail de consommer une part inéquitable des ressources. Il y aura toujours une charge de travail qui contournera le contrôle des admissions de la porte d'entrée. Nous avons donc besoin d'une ceinture, de bretelles et d'un protecteur de poche pour éviter que les services ne soient surchargés.
L'utilisation de plusieurs files d'attente contribue à façonner le trafic. À certains égards, une seule file d'attente et une multi-location sont en contradiction. Au moment où le travail est mis en file d'attente dans une file d'attente partagée, il est difficile d'isoler une charge de travail d'une autre.
Les systèmes en temps réel sont souvent implémentés avec des files d'attente FIFO, mais préfèrent un comportement LIFO. Nos clients nous expliquent que, lorsqu'ils sont face à un backlog, ils préfèrent voir leurs nouvelles données traitées immédiatement. Toutes les données accumulées pendant une panne ou une surtension peuvent ensuite être traitées en fonction de la capacité disponible.

Stratégies d'Amazon pour la création de systèmes asynchrones multi-locataires résilients

Amazon utilise plusieurs modèles pour rendre ses systèmes asynchrones multi-locataires résilients aux modifications des charges de travail. Ce sont beaucoup de techniques, mais il y a également beaucoup de systèmes utilisés dans Amazon, chacun avec ses propres exigences de vie et de durabilité. Dans la section suivante, je décris certains des modèles que nous utilisons et que les clients AWS nous disent utiliser dans leurs systèmes.

Diviser les charges de travail en files d'attente distinctes

Au lieu de partager une file d'attente entre tous les clients, dans certains systèmes, nous donnons à chaque client sa propre file d'attente. L'ajout d'une file d'attente pour chaque client ou charge de travail n'est pas toujours rentable, car les services devront dépenser des ressources pour interroger toutes les files d'attente. Mais dans les systèmes avec un petit nombre de clients ou de systèmes adjacents, cette solution simple peut s'avérer utile. D'autre part, si un système compte même des dizaines, voire des centaines de clients, des files d'attente distinctes peuvent commencer à devenir encombrantes. Par exemple, AWS IoT n'utilise pas de file d'attente distincte pour chaque appareil IoT de l'univers. Les coûts liés aux interrogations ne seraient pas bien proportionnés dans ce cas.

Partitionnement aléatoire

AWS Lambda est un exemple de système dans lequel interroger une file d'attente distincte pour chaque client Lambda serait trop coûteux. Toutefois, le fait d'avoir une seule file d'attente peut entraîner certains des problèmes décrits dans cet article. Ainsi, plutôt que d'utiliser une seule file d'attente, AWS Lambda met en service un nombre fixe de files d'attente et divise chaque client en un petit nombre de files d'attente. Avant de mettre un message en file d'attente, il vérifie quelle file d'attente contient le moins de messages avant d'y placer le message. Lorsque la charge de travail d'un client augmente, il génère un backlog dans les files d'attente mappées, mais les autres charges de travail sont automatiquement acheminées en dehors de ces files. La création d'une isolation des ressources magiques ne requiert pas un grand nombre de files d'attente. Ce n'est que l'une des nombreuses protections intégrées à Lambda, mais c'est une technique également utilisée dans d'autres services sur Amazon.

Mettre de côté le trafic en excès dans une file d'attente distincte

À certains égards, lorsqu'un backlog s'est développé dans une file d'attente, il est trop tard pour hiérarchiser le trafic. Toutefois, si le traitement du message est relativement coûteux ou chronophage, il peut toujours être intéressant de déplacer les messages dans une file d'attente distincte. Dans certains systèmes d'Amazon, le service consommateur implémente la limitation distribuée et, lorsqu'il retire de la file d'attente des messages destinés à un client dépassant un débit défini, il met en file d'attente ces messages excédentaires dans des files d'attente de débordement distinctes et les supprime de la file d'attente principale. Le système fonctionne toujours sur les messages de la file d'attente de débordement dès que des ressources sont disponibles. Cela se rapproche essentiellement d'une file d'attente prioritaire. Une logique similaire est parfois mise en œuvre du côté du producteur. Ainsi, si un système accepte un grand volume de demandes d'une seule charge de travail, celui-ci ne supplante pas les autres charges de travail de la file d'attente du chemin réactif.

Mettre de côté l'ancien trafic dans une file d'attente distincte

Tout comme mettre de côté la surcharge de trafic, nous pouvons également mettre de côté l'ancien trafic. Lorsque nous retirons un message d'une file d'attente, nous pouvons vérifier son âge. Plutôt que de simplement enregistrer l'âge, nous pouvons utiliser les informations pour décider de déplacer ou non le message dans une file d'attente avec backlog que nous traiterons uniquement une fois que nous aurons consulté la file d'attente dynamique. Si un pic de charge survient et implique que nous ingérions une grande quantité de données et que nous prenons du retard, nous pouvons mettre de côté cette vague de trafic dans une file d'attente distincte aussi rapidement que nous pouvons également la retirer de la file d'attente et l'y replacer. Cela libère les ressources des consommateurs pour leur permettre de travailler plus rapidement sur de nouveaux messages que si nous avions simplement traité le backlog dans l'ordre. C'est un moyen d'estimer le classement LIFO.

Suppression d'anciens messages (durée de vie des messages)

Certains systèmes peuvent tolérer que des messages très anciens soient supprimés. Par exemple, certains systèmes traitent rapidement les deltas en systèmes, mais effectuent également une synchronisation complète régulièrement. Nous appelons souvent ces systèmes de synchronisation périodique des nettoyeurs anti-entropie. Dans ces cas-là, au lieu de mettre de côté l'ancien trafic mis en file d'attente, nous pouvons le supprimer à moindre coût s'il est arrivé avant le dernier nettoyage.

Limiter les threads (et autres ressources) par charge de travail

Tout comme dans nos services synchrones, nous concevons nos systèmes asynchrones pour empêcher une charge de travail d'utiliser plus que sa juste part de threads. Le moteur de règles est un aspect d'AWS IoT dont nous n'avons pas encore parlé. Les clients peuvent configurer AWS IoT pour acheminer les messages depuis leurs appareils vers un cluster Amazon Elasticsearch appartenant au client, Kinesis Stream, etc. Si la latence de ces ressources appartenant au client devient lente, mais que le débit des messages entrants reste constant, la quantité de simultanéité dans le système augmente. Et comme la quantité de simultanéité qu'un système peut gérer est limitée à tout moment, le moteur de règles empêche toute charge de travail de consommer plus que sa juste part de ressources liées à la simultanéité.

La force à l'œuvre est décrite dans la Loi de Little, qui stipule que la concurrence dans un système est égale au taux d'arrivée multiplié par le temps de latence moyen de chaque demande. Par exemple, si un serveur traitait 100 messages par seconde à une moyenne de 100 ms, il utiliserait en moyenne 10 threads. Si la latence augmentait soudainement à 10 secondes, elle utiliserait soudainement 1 000 threads (en moyenne, ce chiffre pourrait donc être supérieur en pratique), ce qui pourrait facilement épuiser un pool de threads.

Le moteur de règles utilise plusieurs techniques pour empêcher cela. Il utilise des E/S non bloquantes pour éviter l'épuisement des threads, bien qu'il y ait encore d'autres limites à la quantité de travail d'un serveur donné (par exemple, la mémoire et les descripteurs de fichier lorsque le client traite rapidement des connexions et que la dépendance a expiré). Une deuxième protection contre la concurrence qui peut être utilisée est un sémaphore qui mesure et limite la quantité de concurrence qui peut être utilisée pour une charge de travail unique à un instant donné. Le moteur de règles utilise également une limitation d'équité basée sur la fréquence. Toutefois, comme il est tout à fait normal que les charges de travail évoluent dans le temps, le moteur de règles adapte automatiquement les limites au fil du temps pour s'adapter à l'évolution des charges de travail. Et comme le moteur de règles est basé sur les files d'attente, il sert de tampon entre les appareils IoT, la mise à l'échelle automatique des ressources et les limites préventives en arrière-plan.

Sur l'ensemble des services d'Amazon, nous utilisons des pools de threads distincts pour chaque charge de travail afin d'éviter qu'une seule charge de travail ne consomme tous les threads disponibles. Nous utilisons également AtomicInteger pour chaque charge de travail afin de limiter la simultanéité autorisée pour chaque charge, ainsi que des approches de limitation basées sur la fréquence pour l'isolation des ressources basées sur la fréquence.

Envoi de contre-pression en amont

Si une charge de travail génère un backlog déraisonnable que le consommateur est incapable de suivre, beaucoup de nos systèmes commencent automatiquement à rejeter le travail de manière plus agressive chez le producteur. Il est facile de créer un backlog journalier pour une charge de travail. Même si cette charge de travail est isolée, cela peut être accidentel et coûteux de la traiter rapidement. Une implémentation de cette approche peut être aussi simple que de mesurer occasionnellement la profondeur de la file d'attente d'une charge de travail (en supposant qu'une charge de travail se trouve dans sa propre file d'attente) et de redimensionner une limite de limitation entrante (inversement) proportionnellement à la taille du backlog.

Dans les cas où nous partageons une file d'attente SQS pour plusieurs charges de travail, cette approche devient délicate. Bien qu'une API SQS renvoie le nombre de messages dans la file d'attente, aucune API ne peut renvoyer le nombre de messages dans la file d'attente avec un attribut particulier. Nous pouvions toujours mesurer la profondeur de la file d'attente et appliquer une contre-pression en conséquence, mais cela créerait injustement une contre-pression sur des charges de travail innocentes partageant la même file d'attente. D'autres systèmes, tels qu'Amazon MQ, offrent une visibilité de backlog plus précise.

La contre-pression ne convient pas à tous les systèmes d'Amazon. Par exemple, dans les systèmes qui traitent les commandes pour amazon.com, nous avons tendance à préférer accepter ces commandes même si un backlog apparaît, plutôt que d'empêcher de nouvelles commandes d'être acceptées. Bien entendu, cela s'accompagne de nombreuses priorités en arrière-plan afin que les commandes les plus urgentes soient traitées en premier.

Utilisation de files d'attente de retard pour reporter le travail à plus tard

Lorsque les systèmes ont le sentiment que le débit d'une charge de travail particulière doit être réduit, nous essayons d'utiliser une stratégie de retrait pour cette charge de travail. Pour mettre cela en œuvre, nous utilisons souvent une fonctionnalité SQS qui reporte la livraison d'un message à plus tard. Lorsque nous traitons un message et décidons de le sauvegarder pour plus tard, nous le remettons parfois dans une file d'attente distincte, mais nous définissons le paramètre delay pour que le message reste masqué pendant plusieurs minutes dans la file d'attente. Cela donne au système une chance de travailler sur des données plus récentes à la place.

Éviter d'avoir un trop grand nombre de messages en vol

Certains services de file d'attente tels que SQS ont des limites quant au nombre de messages en vol pouvant être livrés au consommateur de la file d'attente. Cela est différent du nombre de messages pouvant être placés dans la file d'attente (pour lesquels il n'y a pas de limite pratique), mais il s'agit plutôt du nombre de messages sur lesquels la flotte de consommateurs travaille en même temps. Ce nombre peut être gonflé si un système tente de supprimer des messages de la file d'attente, mais qu'il ne parvient pas à les supprimer. Par exemple, nous avons vu des bogues où le code ne parvient pas à intercepter une exception lors du traitement d'un message et oublie de supprimer le message. Dans ces cas, le message reste en vol du point de vue de SQS pour le paramètre VisibilityTimeout du message. Lors de la conception de notre stratégie de traitement des erreurs et de surcharge, nous tenons compte de ces limites et avons tendance à privilégier le transfert des messages en excès dans une file d'attente différente au lieu de les laisser visibles.

Les files d'attente SQS FIFO ont une limite similaire, mais subtile. Avec SQS FIFO, les systèmes consomment vos messages dans l'ordre d'un groupe de messages donné, mais les messages de différents groupes sont traités dans n'importe quel ordre. Donc, si nous développons un petit backlog dans un groupe de messages, nous continuons à traiter les messages dans d'autres groupes. Toutefois, SQS FIFO interroge uniquement les derniers 20 000 messages non traités. Ainsi, s'il y a plus de 20 000 messages non traités dans un sous-ensemble de groupes de messages, les autres groupes de messages contenant de nouveaux messages seront supprimés.

Utilisation de files d'attente de lettres mortes pour les messages impossibles à traiter

Les messages qui ne peuvent pas être traités peuvent contribuer à surcharger le système. Si un système met en file d'attente un message qui ne peut pas être traité (peut-être parce qu'il déclenche un cas de périphérie de validation des entrées), SQS peut vous aider en déplaçant automatiquement ces messages dans une file d'attente séparée dotée de la fonction DLQ (dead-letter queue). Nous vous alertons s'il y a des messages dans cette file d'attente, car cela signifie que nous avons un bogue que nous devons corriger. L'avantage de la DLQ est qu'elle nous permet de traiter à nouveau les messages une fois le bogue corrigé.

Assurer une mémoire tampon supplémentaire dans les threads d'interrogation par charge de travail

Si une charge de travail génère un débit suffisant à un point tel que les threads d'interrogation sont occupés en permanence, même en état stable, le système peut alors avoir atteint un point où il n'y a plus de mémoire tampon pour absorber une augmentation du trafic. Dans cet état, un léger pic du trafic entrant entraînera un backlog non traité, avec pour conséquence une latence plus élevée. Nous prévoyons un tampon supplémentaire dans les threads d'interrogation afin d'absorber de telles rafales. L'une des mesures consiste à suivre le nombre de tentatives d'interrogation ayant pour résultat des réponses vides. Si chaque tentative d'interrogation récupère un message supplémentaire, nous avons soit le nombre correct de threads d'interrogation, soit peut-être pas assez pour suivre le trafic entrant.

Messages de pulsation de longue durée

Lorsqu'un système traite un message SQS, SQS lui donne un certain temps pour terminer le traitement du message avant de supposer que le système est tombé en panne et pour transmettre ce message à un autre consommateur pour retenter l'opération. Si le code continue à s'exécuter et oublie cette échéance, le même message peut être livré plusieurs fois en parallèle. Tandis que le premier processeur est toujours en train de supprimer un message après son délai d'expiration, un second processeur le récupère et, de la même façon, le traite après le délai d'expiration, puis un troisième, et ainsi de suite. C'est pourquoi nous avons mis en œuvre notre logique de traitement des messages pour arrêter le travail à la date d'expiration du message ou pour continuer à faire battre ce message pour rappeler à SQS que nous y travaillons toujours. Ce concept est similaire aux locations lors de l'élection du leader.

C'est un problème insidieux, car nous voyons que la latence d'un système risque d'augmenter en cas de surcharge, peut-être aussi longtemps que les requêtes d'une base de données sont longues, ou que les serveurs prennent tout simplement plus de travail qu'ils ne peuvent en gérer. Lorsque le temps de latence du système dépasse le seuil du paramètre VisibilityTimeout, un service déjà surchargé se fork-bomb lui-même.

Planifier le débogage entre hôtes

Comprendre les échecs dans un système distribué est déjà difficile. L'article connexe sur l'instrumentation décrit plusieurs de nos approches pour instrumenter des systèmes asynchrones, depuis l'enregistrement périodique des profondeurs de files d'attente jusqu'à la propagation d'« identifiants de suivi » et l'intégration avec X-Ray. Ou, lorsque nos systèmes ont un flux de travail asynchrone complexe au-delà d'une file d'attente SQS triviale, nous utilisons souvent un service de flux de travail asynchrone différent du type Step Functions, qui fournit une visibilité sur le flux de travail et simplifie le débogage distribué.

Conclusion

Dans un système asynchrone, il est facile d'oublier à quel point il est important de penser à la latence. Après tout, les systèmes asynchrones sont parfois supposés prendre plus de temps, car ils sont précédés d'une file d'attente permettant d'effectuer des tentatives fiables. Toutefois, les scénarios de surcharge et d'échec peuvent générer d'énormes arriérés insurmontables à partir desquels un service ne peut pas récupérer dans un délai raisonnable. Ces backlogs peuvent provenir d'une charge de travail ou d'une mise en file d'attente du client à un rythme inattendu, de charges de travail devenant plus onéreuses que prévu à traiter, de latence ou d'échecs dans une dépendance.

Lors de la création d'un système asynchrone, nous devons nous concentrer sur ces scénarios d'arrière-plan, les anticiper et les minimiser à l'aide de techniques telles que la hiérarchisation, la mise à l'écart et la contre-pression.

Lectures complémentaires

À propos de l'auteur

David Yanacek est ingénieur en chef senior et travaille sur AWS Lambda. David est développeur logiciel chez Amazon depuis 2006. Il a auparavant travaillé sur Amazon DynamoDB et AWS IoT, ainsi que sur les cadres de service Web internes et les systèmes d'automatisation d'opération des flottes. L'une des activités professionnelles préférées de David est d'effectuer des analyses de journaux et de parcourir des métriques opérationnelles dans le but de trouver des solutions pour que les systèmes s'exécutent de mieux en mieux au fil du temps.

Sélection d’un leader dans les systèmes distribués Instrumenter les systèmes distribués pour une visibilité opérationnelle