L’idempotence dans les systèmes distribués qui fonctionne vraiment

Évitez les effets de bord en double

Sommaire

L’idempotence dans les systèmes distribués est la propriété qui vous sauve lorsque le réseau ment, que la file d’attente réessaie, que le client panique et que l’opérateur appuie sur « rejouer ». Dans les systèmes de production, la livraison en double est normale. Les effets secondaires dupliqués constituent le bug.

HTTP définit une méthode idempotente comme celle pour laquelle plusieurs requêtes identiques ont le même effet intentionnel sur le serveur qu’une seule requête. C’est pourquoi les méthodes PUT, DELETE et les méthodes sûres sont idempotentes selon la sémantique du protocole et peuvent être réessayées automatiquement après une défaillance de communication.

flux de message d’intégration : idempotence

Cette définition est utile, mais elle ne suffit pas. Dans les architectures réelles, l’idempotence n’est pas une simple réponse de trivia HTTP. C’est une garantie commerciale. Si un client clique sur « payer » une fois, vous n’avez pas le droit de facturer deux fois parce qu’une expiration de délai s’est produite entre la validation et la réponse. Si un worker met à jour l’inventaire et plante avant d’acquitter le message, vous n’avez pas le droit de décrémenter les stocks deux fois parce que le courtier a redistribué le message. Voilà la norme.

L’erreur que je vois encore et encore est de traiter l’idempotence comme une fonctionnalité de transport plutôt que comme une propriété du système. La déduplication des files d’attente, les verbes HTTP et les réessais du client aident, mais aucun d’entre eux ne peut sauver une conception qui permet à la même intention commerciale de créer un second effet secondaire. Si vous souhaitez une vision plus large de la manière dont ces décisions d’intégration s’inscrivent dans les limites de service et les compromis de persistance, commencez par Architecture des applications en production : modèles d’intégration, conception du code et accès aux données.

D’où viennent les doublons en production

Les doublons n’apparaissent pas parce que les équipes sont négligentes. Ils apparaissent parce que les systèmes distribués réessayent, réordonnent et rejouent.

Un client peut envoyer une requête de création, le serveur peut la valider, et la réponse peut toujours disparaître sur le fil. C’est exactement pourquoi HTTP distingue les méthodes idempotentes et pourquoi les API de paiement telles que Stripe et PayPal exposent des mécanismes d’idempotence explicites pour les méthodes non sûres comme POST.

Les courtiers de messages rendent le problème encore plus évident. La livraison « au moins une fois » signifie qu’un consommateur peut être invoqué à plusieurs reprises pour le même message, et qu’un gestionnaire peut mettre à jour la base de données avec succès mais échouer avant l’acquittement, ce qui amène le courtier à livrer à nouveau le même message.

Les webhooks ne sont pas différents. GitHub indique que les livraisons de webhooks peuvent arriver dans le désordre, que les livraisons échouées ne sont pas automatiquement redistribuées, et que chaque livraison est accompagnée d’un GUID unique X-GitHub-Delivery que vous devez utiliser pour vous protéger contre les rejouers. Pour une vue architecturale pratique des points de terminaison de chat en tant que frontières d’interaction, consultez Les plateformes de chat comme interfaces système dans les systèmes modernes.

Même les systèmes qui annoncent des garanties plus fortes vous laissent du travail à faire. Kafka peut empêcher les entrées dupliquées dans les journaux Kafka avec des producteurs idempotents et peut fournir une livraison exacte une seule fois pour les flux lire-traire-écrire qui restent dans Kafka avec des transactions et des consommateurs read_committed. Mais les propres documents de conception de Kafka sont clairs : les systèmes externes nécessitent toujours une coordination avec les décalages et les sorties. La livraison exacte une seule fois de Google Cloud Pub/Sub est limitée aux abonnements tirés, au sein d’une région cloud, et nécessite toujours que les clients suivent la progression du traitement jusqu’à ce que l’acquittement réussisse.

Mon résumé subjectif est simple. Supposez que le transport va réessayer. Supposez que les opérateurs vont rejouer. Supposez que les webhooks arriveront en retard. Concevez le chemin en écriture de sorte qu’une intention répétée ne puisse pas créer un second effet commercial. La conception des erreurs est étroitement liée : la manière dont les erreurs sont enveloppées, traduites et classées comme réessayables ou non réessayables fait partie de la même discipline de frontière — Architecture de gestion des erreurs Go : Frontières et modèles couvre la classification des erreurs réessayables, la traduction des frontières et les modèles sentinelle qui permettent à la logique de réessai de prendre des décisions fondées. Lorsque les réessais continuent de toucher une dépendance malsaine, un coupe-circuit à la frontière d’intégration échoue rapidement avant que les tempêtes de réessai n’amplifient le travail dupliqué.

Le contrat API sur lequel je fais vraiment confiance

Comment les clés d’idempotence empêchent-elles les requêtes API dupliquées

Le seul contrat API sur lequel je me fie pour les opérations de mutation est l’intention fournie par l’appelant plus la persistance côté serveur.

AWS recommande un identifiant de requête fourni par l’appelant et met en garde contre le fait que le service doit enregistrer atomiquement le jeton d’idempotence avec le travail de mutation. Stripe stocke le premier code d’état et le corps de la réponse pour une clé, compare les paramètres ultérieurs à la requête originale et renvoie le même résultat pour les réessais. PayPal utilise PayPal-Request-Id sur les API POST prises en charge et renvoie le dernier état de la requête précédente avec ce même en-tête.

Cela conduit à un contrat pratique :

  1. Le client génère une clé d’idempotence pour une opération commerciale.
  2. Le serveur met cette clé à portée en fonction du locataire et du nom de l’opération.
  3. Le serveur stocke un hachage de requête afin que la même clé ne puisse pas être réutilisée pour une charge utile différente.
  4. Le serveur enregistre l’état tel que pending, completed ou failed.
  5. Les réessais avec la même clé renvoient soit le résultat stocké, soit un pointeur stable vers celui-ci.
  6. Les réessais avec la même clé et une charge utile différente échuent brutalement.

Il existe un projet de norme IETF Idempotency-Key, mais au 09/05/2026, il est toujours répertorié dans l’IETF Datatracker comme un brouillon Internet expiré plutôt que comme une RFC publiée. En pratique, le nom d’en-tête reste largement utile en tant que convention de facto, mais vous devez documenter le contrat dans votre propre API au lieu de prétendre que la norme est terminée.

Que doit représenter la clé ? L’intention. Pas une tentative HTTP. Pas une connexion TCP. Pas un compteur de réessai. Si l’utilisateur veut dire « créer la commande 123 une fois », chaque réessai pour cette même commande doit réutiliser la même clé. Si l’utilisateur veut dire « passer une deuxième commande », cela doit utiliser une clé différente.

Un identifiant de requête sert au traçage. Une clé d’idempotence sert à l’exactitude. Si vous les confondez, vos tableaux de bord semblent rangés pendant que votre argent bouge deux fois.

Pourquoi PUT ne suffit pas

Non, HTTP PUT ne suffit pas pour rendre une opération idempotente.

Oui, la RFC 9110 donne à PUT une sémantique idempotente. Mais si votre gestionnaire PUT émet un nouvel événement en aval, envoie un email à chaque réessai ou facture à nouveau un fournisseur externe, alors votre implémentation a violé le contrat commercial même si le nom de votre route semble respectable.

Le choix du verbe aide les clients à comprendre l’intention. Il ne met pas en œuvre l’intention pour vous.

Utilisez PUT lorsque le modèle de ressource correspond véritablement à une opération de remplacement complet ou d’upsert. Utilisez POST lorsque vous créez des commandes ou des actions. Mais pour toute mutation qui pourrait être réessayée au-delà des frontières réseau, documentez un contrat d’idempotence explicite. Si vos actions de mutation sont déclenchées à partir de flux de travail de chat, le même contrat s’applique dans Modèles d’intégration Slack pour les alertes et les flux de travail et Modèle d’intégration Discord pour les alertes et les boucles de contrôle. Les effets secondaires cachés sont l’endroit où l’architecture vient mourir.

Combien de temps une clé d’idempotence doit-elle être stockée

Plus longtemps que votre équipe de transport ne le souhaite.

Stripe dit que les clés peuvent être purgées après au moins 24 heures. PayPal dit que la rétention est spécifique à l’API et donne des exemples qui peuvent durer jusqu’à 45 jours. Amazon SQS FIFO ne déduplique que dans une fenêtre de 5 minutes. GitHub conserve les récentes livraisons pendant 3 jours pour une redistribution manuelle. Ces chiffres sont extrêmement différents parce que la bonne période de rétention est une décision commerciale, pas un défaut de protocole.

Si vous ne conservez les clés que pendant cinq minutes parce que votre file d’attente le fait, vous ne concevez pas l’idempotence. Vous copiez une limitation de transport dans votre couche commerciale.

Conservez les enregistrements d’idempotence pendant au moins le maximum de ces fenêtres :

  • horizon de réessai du client
  • horizon de redistribution de file d’attente
  • horizon de rejouer des webhooks
  • horizon de rejouer des opérateurs
  • horizon de règlement ou de compensation pour les opérations de déplacement d’argent

Pour les paiements, les réservations et la mise à disposition, cela signifie souvent des heures ou des jours, pas des minutes.

AWS souligne également deux anti-modèles avec lesquels je suis totalement d’accord. N’utilisez pas d’horodatages comme clé, car les écarts d’horloge et les collisions les rendent peu fiables. Ne stockez pas aveuglément l’intégralité des charges utiles de requête en tant qu’enregistrement de déduplication pour chaque requête, car cela nuit aux performances et à l’évolutivité. Stockez un hachage de requute normalisé plus l’état de réponse minimal dont vous avez besoin pour rejouer en toute sécurité. Si vous devez reproduire le premier octet de la réponse bit pour bit, stockez le corps de réponse canonique comme le fait Stripe.

Les modèles de base de données qui rendent l’idempotence réelle

L’idempotence devient réelle lorsque la couche de persistance peut gagner une course exactement une fois.

PostgreSQL vous offre deux primitives critiques ici. Les contraintes uniques assurent l’unicité sur une ou plusieurs colonnes, et INSERT ... ON CONFLICT vous permet de définir une action alternative au lieu d’échouer sur une violation d’unicité. PostgreSQL documente également que ON CONFLICT DO UPDATE garantit un résultat atomique d’insertion ou de mise à jour sous concurrence.

Cela signifie que votre couche d’idempotence devrait généralement commencer avec une table comme celle-ci :

create table api_idempotency (
    tenant_id text not null,
    operation text not null,
    idempotency_key text not null,
    request_hash text not null,
    state text not null,
    status_code integer,
    response_body jsonb,
    resource_type text,
    resource_id text,
    created_at timestamptz not null default now(),
    expires_at timestamptz not null,
    primary key (tenant_id, operation, idempotency_key)
);

Et le flux de gestion devrait ressembler à ceci :

begin transaction

try insert (tenant_id, operation, idempotency_key, request_hash, state='pending')
on conflict do nothing

load row for (tenant_id, operation, idempotency_key) for update

if row.request_hash != incoming_request_hash
    fail with conflict or validation error

if row.state = 'completed'
    return stored response

if row.state = 'pending' and row was created by another live request
    either wait briefly, or fail fast with a retryable response

perform local business mutation

store stable result in idempotency row
set state = 'completed'

commit
return result

La partie importante n’est pas la syntaxe. La partie importante est l’atomicité. L’enregistrement de la clé et la réalisation de la mutation doivent réussir ou échouer ensemble. AWS le dit explicitement pour l’idempotence des API, et la même règle s’applique aux services basés sur SQL.

Ne faites pas une séquence naïve de vérification-then-act comme « sélectionner la clé ; si absente alors insérer la commande ». Sous concurrence, deux requêtes peuvent passer la vérification et toutes deux créer l’effet secondaire. Une contrainte unique n’est pas optionnelle. C’est le mécanisme qui transforme votre architecture d’un folklore optimiste en quelque chose que vous pouvez prouver sous charge.

Voici la règle que j’utilise dans les revues. Si la décision de déduplication n’est pas protégée par la même frontière transactionnelle que la mutation, vous n’avez pas d’idempotence. Vous avez de l’espoir.

Les messages, événements et webhooks ont besoin de leur propre frontière

Comment les consommateurs gèrent-ils les événements et messages dupliqués

Pour les consommateurs de messages, le modèle classique est toujours le bon. Enregistrez les ID de message traités dans la même transaction de base de données que la mise à jour commerciale. Chris Richardson décrit l’approche de la table PROCESSED_MESSAGES directement, en utilisant une clé primaire sur l’abonné et l’ID de message afin que les doublons échuent proprement et puissent être ignorés.

De nombreuses équipes appellent ce magasin explicite processed_messages une table d’inbox. L’étiquette importe moins que la règle. Le récepteur doit persister la preuve qu’il a déjà traité le message avant qu’un réessai ne puisse faire en toute sécurité rien du tout.

Une forme minimale ressemble à ceci :

create table processed_messages (
    subscriber_id text not null,
    message_id text not null,
    processed_at timestamptz not null default now(),
    primary key (subscriber_id, message_id)
);

Et le flux du consommateur est tout aussi strict que le flux HTTP :

begin transaction

insert into processed_messages (subscriber_id, message_id)
values (?, ?)
on conflict do nothing

if no row inserted
    rollback
    ack and ignore duplicate

apply business mutation

commit
ack message

Ce modèle est ennuyeux. Bien. L’idempotence doit être ennuyeuse.

Il est également généralement préférable d’essayer de s’appuyer sur les termes marketing des courtiers. Le support exact une seule fois de Kafka est excellent lorsque vous restez dans le modèle transactionnel propre de Kafka, mais les documents de Kafka mettent toujours en garde contre le fait que les destinations externes nécessitent une coopération. SQS FIFO réduit les envois dupliqués uniquement dans sa fenêtre de déduplication de 5 minutes. Pub/Sub exact une fois s’attend toujours à ce que l’abonné suive la progression et évite le travail dupliqué lorsque les accusés de réception échouent.

Exactement une fois est généralement une optimisation locale. Les effets secondaires idempotents sont la garantie du système.

Associer la déduplication avec le modèle outbox

Si votre service met à jour l’état local et publie également un événement, la consommation idempotente seule ne suffit pas. Vous avez également besoin d’une méthode sûre pour faire sortir l’événement après la validation de la transaction locale.

C’est pourquoi le modèle outbox transactionnel importe. Chris Richardson décrit l’idée de base comme l’écriture de l’événement dans une table outbox dans la même transaction que la mise à jour commerciale, puis sa publication asynchrone. Debezium dit que le modèle outbox évite les incohérences entre l’état interne d’un service et les événements consommés par d’autres services. NServiceBus va plus loin et montre comment le traitement outbox déduplique les messages entrants et évite les enregistrements zombies et les messages fantômes.

Voici l’architecture que je recommande pour les services qui possèdent des données et publient des événements d’intégration :

  1. Validez et persistez la commande sous une clé d’idempotence.
  2. Écrivez l’état commercial et l’événement outbox dans une seule transaction locale.
  3. Laissez le CDC ou un dispatcher outbox publier l’événement.
  4. Rendez les consommateurs en aval également idempotents.

Outbox ne supprime pas le besoin de consommateurs idempotents. Il supprime le besoin de prétendre qu’une validation de base de données et une publication de courtier peuvent être une transaction distribuée magique unique lorsqu’elles ne peuvent généralement pas l’être.

Les webhooks ne sont que des messages avec une meilleure marque

Traitez les webhooks entrants exactement comme les messages provenant d’une extrémité de réseau non fiable.

GitHub documente que les livraisons peuvent arriver dans le désordre, recommande d’utiliser X-Hub-Signature-256 pour vérifier l’authenticité, et fournit X-GitHub-Delivery comme identifiant de livraison unique. Il note également que les redistributions réutilisent le même identifiant de livraison.

L’architecture est donc simple :

  • vérifiez la signature en premier
  • utilisez le GUID de livraison comme clé de déduplication
  • persistez la réception avant les effets secondaires
  • rendez les gestionnaires sensibles à l’ordre plutôt que de supposer l’ordre d’arrivée
  • mettez au file d’attente le travail lourd et retournez rapidement

Si votre gestionnaire de webhook écrit directement dans les tables commerciales avant d’enregistrer la réception, il n’est pas prêt pour la production. Il fait simplement plus vite des erreurs dupliquées.

Les sagas et les moteurs de flux de travail ont toujours besoin d’idempotence

Les sagas et les moteurs de flux de travail durables ne suppriment pas le problème. Ils le rendent visible.

Temporal recommande d’écrire les Activités pour être idempotentes car les Activités peuvent être réessayées après des échecs ou des expirations de délai. Ses documents soulignent même le cas limite où un worker termine un effet secondaire externe avec succès mais plante avant de signaler la fin, ce qui amène l’Activité à s’exécuter à nouveau. Temporal suggère également d’utiliser une combinaison de Workflow Run ID et Activity ID en tant que clé d’idempotence stable lors de l’appel de services en aval. Si vous appliquez cela dans l’orchestration de service, Microservices Go pour l’orchestration IA/ML couvre les compromis plus larges du flux de travail.

C’est exactement le bon modèle mental. Un moteur de flux de travail peut préserver l’historique d’exécution et coordonner les réessais. Il ne peut pas rétroactivement annuler un paiement sur une carte ou renvoyer un email sauf si votre application lui donne des étapes idempotentes et des compensations idempotentes.

La même chose s’applique aux sagas. Les propres conseils de saga de Temporal décrivent des actions de compensation qui s’exécutent lorsqu’une étape échoue. Ces compensations doivent être idempotentes également. Si « rembourser le paiement » s’exécute deux fois, vous pouvez avoir résolu le bug original en en créant un nouveau.

Ma règle ici est brutale et simple. Chaque Activité, chaque gestionnaire de commande, et chaque compensation qui touche le monde extérieur devrait soit être naturellement idempotente, soit porter une véritable clé d’idempotence au système en aval.

Comment tester l’idempotence avant la production

La plupart des équipes testent les chemins heureux puis agissent surprises lorsque les réessais se produisent. Cela ne suffit pas. Pour les équipes Go, Tester le code Go concurrent avec testing/synctest couvre comment écrire des tests rapides et déterministes pour les boucles de réessai et le comportement de délai d’attente du contexte sans dormir à travers des délais artificiels.

Vous devriez avoir des tests automatisés pour au moins ces cas :

  • le serveur valide la mutation mais la réponse n’atteint jamais le client
  • deux requêtes identiques se disputent avec la même clé d’idempotence
  • la même clé est réutilisée avec une charge utile différente
  • un consommateur valide son travail de base de données et plante avant l’acquittement
  • un webhook est rejoué avec le même identifiant de livraison
  • un dispatcher outbox publie le même événement plus d’une fois
  • une Activité de flux de travail termine l’appel externe et plante avant que la fin ne soit signalée
  • un enregistrement d’idempotence expire et un véritable réessai tardif arrive

AWS recommande explicitement des suites de tests complètes qui incluent des requêtes réussies, des requêtes échouées et des requêtes dupliquées. Ce conseil est banal et absolument correct.

J’ajouterais un autre exercice de défaillance. Vérifiez que la réponse rejouée est sémantiquement équivalente au premier résultat. AWS discute des réessais arrivant tardivement et plaide pour des réponses qui préservent la signification originale même après que l’état sous-jacent a changé. C’est la différence entre « aucun effet secondaire supplémentaire ne s’est produit » et « l’appelant a toujours un contrat cohérent ».

Règles subjectives qui sauvent de vrais systèmes

Voici les règles que je forcerais dans une revue d’architecture.

Premièrement, les clés d’idempotence appartiennent à l’intention commerciale, pas aux tentatives de transport.

Deuxièmement, mettez chaque clé à portée par locataire et par opération. Les espaces de clés globales sont la façon dont des requêtes non liées entrent en collision.

Troisièmement, persistez la décision de déduplication atomiquement avec la mutation. Si ce n’est pas le cas, la conception est erronée.

Quatrièmement, rejetez les réessais de même clé mais de charge utile différente. Stripe et AWS font tous deux cela pour de bonnes raisons.

Cinquièmement, conservez les clés pour l’horizon complet de rejouer du processus commercial, pas pour la plus courte fenêtre de file d’attente.

Sixièmement, associez les producteurs à un outbox et les consommateurs à un suivi des ID de message. Un côté sans l’autre est une demi-conception.

Septièmement, propagez la même identité d’opération en aval lorsque l’action commerciale est la même. AWS recommande explicitement de transmettre le jeton d’idempotence le long de la chaîne de traitement.

Huitièmement, ne supposez jamais que le marketing exact une fois supprime le besoin d’effets secondaires idempotents.

Si cela semble strict, tant mieux. L’idempotence est l’endroit où l’architecture optimiste rencontre la réalité de la production. Vous n’avez pas besoin de complexité partout. Mais partout où des effets secondaires dupliqués pourraient nuire à l’argent, à l’état ou à la confiance, l’idempotence devrait être une partie première classe du contrat.

Ces mêmes règles s’appliquent directement aux agents IA en arrière-plan. Les agents de sondage qui prétendent des tâches, émettent des notifications ou déclenchent des appels d’outils ont besoin de clés de déduplication et de protocoles de revendication idempotents tout autant que les API de paiement. Pour savoir comment le modèle de revendication et de déduplication fonctionne à l’intérieur des assistants IA de production, consultez Agents de sondage dans les assistants IA : 11 modèles d’implémentation.

Liens utiles

S'abonner

Recevez de nouveaux articles sur les systèmes, l'infrastructure et l'ingénierie IA.