Mon précédent article de cette série abordait les problèmes de fiabilité dont souffrent de nombreux systèmes reposant sur la messagerie, ainsi que la manière d'y remédier en mettant en œuvre le pattern outbox. Ce nouvel article déplace le curseur de la publication vers le traitement des messages publiés par les différents composants d'un système. Comme précédemment, je commencerai par poser le problème à résoudre.
Le problème
Revenons une fois encore sur le pattern outbox. Il répond à un problème courant : la publication des messages après validation de la transaction métier sous-jacente, avec le risque que les événements ne soient finalement jamais publiés. Ce pattern garantit que tous les événements seront publiés — au moins une fois. Permettez-moi de revenir sur ce dernier point.

Figure 1 : le pattern outbox
Le relais de publication récupère les événements depuis la base opérationnelle et, une fois publiés, ceux-ci sont acquittés, soit par suppression des enregistrements, soit en les marquant comme publiés. Mais que se passe-t-il si le processus échoue précisément entre la publication et le marquage d'un événement comme publié ? L'événement sera tout simplement republié à la tentative suivante, et les abonnés recevront le même événement plusieurs fois. Est-ce vraiment problématique ? Pas du tout.
Livraison at-least-once
Si vous utilisez AWS SQS pour traiter les événements auxquels un service est abonné (et vous devriez), sachez que ce service offre de toute façon une garantie de livraison at-least-once :
Amazon SQS stocke des copies de vos messages sur plusieurs serveurs, à des fins de redondance et de haute disponibilité. Dans de rares cas, l'un des serveurs hébergeant une copie d'un message peut être indisponible au moment où vous recevez ou supprimez ce message… Si cela se produit, vous pourriez recevoir à nouveau cette copie du message lors d'une réception ultérieure.
Cela vaut non seulement pour AWS SQS, mais pour tout bus de messages distribué (honnête). À ce stade, vous vous demandez peut-être : qu'en est-il des files FIFO de SQS ? D'après la documentation, elles sont censées résoudre ce problème :
Contrairement aux files standard, les files FIFO n'introduisent pas de messages dupliqués. Elles vous aident à éviter d'envoyer des doublons dans une file. Si vous renouvelez l'action SendMessage dans l'intervalle de déduplication de 5 minutes, Amazon SQS n'introduit aucun doublon dans la file.
Le problème n'est résolu qu'en partie. Certes, si l'outbox publie le même message plusieurs fois (en moins de 5 minutes), une file FIFO identifiera le doublon et l'ignorera. Mais penchons-nous sur le côté consommateur. Le traitement de messages issus d'une file distribuée comporte trois étapes :
- Récupérer le prochain message disponible.
- Traiter le message.
- Acquitter le message en le marquant comme traité, ou le supprimer de la file dans le cas de SQS.
Maintenant, enfilez votre casquette de pessimiste invétéré et reposez-vous les trois questions déjà posées dans l'article précédent :
- Qu'est-ce qui peut mal tourner ici ?
- Quelles en sont les conséquences ?
- Peut-on dire que ce flux de traitement des messages est fiable ?
Faites une pause et réfléchissez-y avant de poursuivre la lecture.
Bien, comparons nos réponses.
Si quelque chose tourne mal entre le traitement d'un message et son acquittement, celui-ci sera récupéré à nouveau lors de l'exécution suivante : il sera donc traité plusieurs fois. Même si les files FIFO promettent un traitement exactly-once, cette garantie ne porte que sur le traitement des messages côté SQS, pas côté application. Lequel est le plus important pour vous ? Le second, évidemment. Pour concevoir un système distribué fiable, vous devez donc partir du principe que tout message peut être livré aux abonnés plusieurs fois.
La solution
Si l'on revient à la page de documentation SQS qui reconnaît la possibilité de livraisons en double, elle indique également comment résoudre le problème :
Concevez vos applications de manière idempotente (elles ne doivent pas être affectées négativement par le traitement répété d'un même message).
Excellent conseil. Si transmettre plusieurs fois le même événement ou la même commande à votre logique applicative aboutit toujours au même résultat, vous n'avez plus à vous soucier des cas limites engendrant des doublons.
Est-il facile de mettre en place une logique de traitement idempotente, fiable et à toute épreuve ? Pas du tout ! Mais avant d'expliquer comment faire, je voudrais dire quelques mots sur la manière de ne pas le faire.
Traitement idempotent des événements : la mauvaise méthode (la facile)
Beaucoup cherchent à atteindre l'idempotence en mettant en œuvre le pattern idempotent consumer. L'idée derrière ce pattern est tout à fait valable, mais la grande majorité de ses implémentations passe à côté de la raison même de son existence. Voici comment on le met en place le plus souvent :
D'abord, chaque commande ou événement traité par votre application doit se voir attribuer un identifiant unique. Ensuite, il faut un idempotency store — une base clé/valeur qui associe les ID de requêtes à leurs résultats (Redis, DynamoDB, des maps en mémoire, etc.).
Une fois ces deux prérequis en place, le traitement d'une requête entrante suit une logique simple :
À la réception d'une requête, vérifiez d'abord si son ID figure déjà dans l'idempotency store. Si oui, retournez le résultat enregistré dans la table ; aucun traitement n'est nécessaire. Sinon :
- Exécutez la requête.
- Persistez l'ID et le résultat dans l'idempotency store.
Remettez votre casquette de pessimiste et dites-moi ce qui peut mal tourner ici.
Bien sûr, si pour une raison quelconque le processus échoue juste après la fin du traitement mais avant que le résultat ne soit stocké dans l'idempotency store, l'opération sera exécutée plusieurs fois.
Vous doutez peut-être qu'une solution aussi naïve soit réellement utilisée. Jetez donc un œil à ce blog sur AWS Lambda Powertools.
Il existe une version un peu plus avancée de cet algorithme, qui marque d'abord une requête comme in process avec un timeout après un certain délai, mais elle souffre du même défaut : si le processus échoue après la fin du traitement mais avant la mise à jour de l'idempotency store, la requête sera traitée à nouveau.
De manière générale, dès lors que l'opération s'appuie sur une base de données qui ne participe pas à la même transaction que l'idempotency store, il existe un risque qu'une requête soit traitée plusieurs fois.
Vous pourriez objecter à ce stade : ce cas limite paraît si rare, pourquoi devrais-je m'en préoccuper ? On pourrait dire la même chose du problème initial que l'on cherche à résoudre. C'est peut-être acceptable pour votre système. Auquel cas, pourquoi ajouter des pièces mobiles supplémentaires ? Quoi qu'il en soit, savoir si un traitement en double est acceptable ou non reste une décision de conception qu'il faut prendre en pleine conscience.
Alors, comment mettre en place une logique de traitement idempotente fiable et à toute épreuve ?
Traitement idempotent des événements : la bonne méthode (la difficile)
Comme pour le pattern outbox, une mise en œuvre fiable du pattern idempotent consumer requiert qu'une transaction atomique englobe à la fois la logique métier et le suivi des événements traités. Et comme pour l'outbox, l'implémentation dépend de la stack technologique.
Idempotence avec transactions multi-enregistrements
Si votre base opérationnelle prend en charge les transactions multi-enregistrements, créez une table dédiée aux clés d'idempotence — les ID des requêtes entrantes (je préfère l'appeler inbox). Une fois la requête exécutée, mettez à jour les données opérationnelles et insérez la clé d'idempotence dans une seule transaction atomique. L'insertion de la clé doit toutefois être conditionnée : elle ne doit réussir que si cette valeur n'existe pas déjà dans la table. Si elle existe, l'opération doit échouer ; vous saurez alors que la requête entrante a déjà été traitée.
...
response = dynamodb.transact_write_items(
TransactItems=[\
{\
'Put': {\
'TableName': 'operational_data',\
'Item': {\
...\
}\
}\
},\
{\
'Put': {\
'TableName': 'inbox',\
'Item': {\
'request_id': {'S': event_id},\
},\
'ConditionExpression': 'attribute_not_exists(request_id)'\
}\
}\
]
)
...
Optimisation supplémentaire : vous pouvez vérifier l'existence de la clé d'idempotence avant d'exécuter la logique de traitement.
Idempotence avec contrôle de concurrence optimiste
Si la base de données que vous utilisez ne prend pas en charge les transactions multi-enregistrements, vous pouvez tout de même mettre en place un traitement idempotent fiable en vous appuyant sur le contrôle de concurrence optimiste :
- Chaque opération porte sur la modification d'un seul enregistrement de la table (par exemple un document JSON).
- Chaque enregistrement possède un champ de version pour gérer les exceptions de concurrence.
- Chaque mise à jour incrémente la valeur de cette version.
- Lors de la mise à jour, la base doit s'assurer que la version de l'enregistrement écrasé correspond à celle qui avait été lue initialement.
Une fois le contrôle de concurrence optimiste en place, l'enregistrement géré peut être enrichi d'un champ supplémentaire : processed_events (tableau). Avant d'exécuter une requête, vérifiez si son ID figure dans le tableau processed_events. Si ce n'est pas le cas, traitez l'événement et ajoutez son ID au tableau.
Articles de la série
- Architecture événementielle sur AWS, partie I : les bases
- Architecture événementielle sur AWS, partie II : les bases avancées
- Architecture événementielle sur AWS, partie III : les fondamentaux complexes (article actuel)
Initialement publié sur https://vladikk.com.
Cet article clôt ma série de trois publications consacrées aux fondamentaux de la conception de systèmes basés sur une architecture événementielle sur AWS :
- Dans le premier article, vous avez appris à exploiter les services managés d'AWS pour séparer, au niveau architectural, les responsabilités de publication et d'abonnement aux événements.
- Le deuxième article a exploré les problèmes courants pouvant survenir lors de la publication d'événements et la manière de les traiter via le pattern outbox.
- Enfin, le présent article a montré comment fiabiliser votre système en s'appuyant sur l'idempotence dans la logique de traitement des événements.
Pour résumer, lorsque vous concevez un système distribué, gardez en tête non seulement les erreurs classiques de l'informatique distribuée, mais aussi la loi de Murphy. Partez toujours du principe que tout ce qui peut mal tourner finira par mal tourner. Intégrez-le dans l'architecture de votre système. Anticiper tous les scénarios d'échec rendra vos week-ends et vos jours fériés bien plus paisibles ;)