Apprenez les transactions Postgres pour les workflows multi‑étapes : regrouper les mises à jour, éviter les écritures partielles, gérer les reprises et maintenir la cohérence des données.

La plupart des fonctionnalités réelles ne se réduisent pas à une seule mise à jour en base. Elles forment une courte chaîne : insérer une ligne, mettre à jour un solde, marquer un statut, écrire un enregistrement d'audit, peut‑être mettre un job en file. Une écriture partielle survient quand seulement certaines de ces étapes atteignent la base.
On le remarque généralement quand quelque chose interrompt la chaîne : une erreur serveur, un timeout entre votre appli et Postgres, un plantage après l'étape 2, ou une reprise qui réexécute l'étape 1. Chaque instruction peut être correcte isolément. Le flux se casse quand il s'arrête en cours de route.
On peut souvent le repérer rapidement :
Un exemple concret : une montée en gamme met à jour le plan du client, ajoute un enregistrement de paiement et augmente les crédits disponibles. Si l'application plante après avoir enregistré le paiement mais avant d'ajouter les crédits, le support voit "payé" dans une table et "pas de crédits" dans une autre. Si le client relance, vous pouvez même enregistrer le paiement deux fois.
L'objectif est simple : traiter le workflow comme un interrupteur unique. Soit chaque étape réussit, soit aucune ne le fait, afin de ne jamais stocker un travail à moitié fini.
Une transaction est la façon dont la base de données dit : traitez ces étapes comme une seule unité de travail. Soit tous les changements ont lieu, soit aucun d'entre eux. Cela compte chaque fois que votre workflow nécessite plus d'une mise à jour, comme créer une ligne, mettre à jour un solde et écrire un journal d'audit.
Pensez au transfert d'argent entre deux comptes. Il faut soustraire du compte A et ajouter au compte B. Si l'application plante après la première étape, vous ne voulez pas que le système "se souvienne" uniquement de la soustraction.
Quand vous committez, vous dites à Postgres : conservez tout ce que j'ai fait dans cette transaction. Tous les changements deviennent permanents et visibles par les autres sessions.
Quand vous rollbackez, vous dites à Postgres : oubliez tout ce que j'ai fait dans cette transaction. Postgres annule les changements comme si la transaction n'avait jamais eu lieu.
À l'intérieur d'une transaction, Postgres garantit que vous n'exposerez pas des résultats à moitié finis aux autres sessions avant votre commit. Si quelque chose échoue et que vous faites rollback, la base nettoie les écritures de cette transaction.
Une transaction ne remplace pas une mauvaise conception du workflow. Si vous soustrayez le mauvais montant, utilisez le mauvais ID utilisateur ou sautez une vérification nécessaire, Postgres commitera fidèlement le mauvais résultat. Les transactions n'empêchent pas non plus automatiquement tous les conflits métier (comme la survente de stock) à moins de les associer aux bonnes contraintes, verrous ou niveaux d'isolation.
Chaque fois que vous mettez à jour plus d'une table (ou plus d'une ligne) pour réaliser une seule action réelle, vous avez un candidat pour une transaction. L'idée reste la même : ou tout est fait, ou rien ne l'est.
Le flux de commande est le cas classique. Vous pouvez créer une ligne de commande, réserver du stock, prélever un paiement, puis marquer la commande comme payée. Si le paiement réussit mais que la mise à jour du statut échoue, vous avez de l'argent capturé pour une commande qui semble toujours impayée. Si la ligne de commande est créée mais que le stock n'est pas réservé, vous pouvez vendre des articles que vous n'avez pas réellement.
L'onboarding utilisateur casse silencieusement de la même manière. Créer l'utilisateur, insérer un profil, attribuer des rôles et enregistrer l'envoi d'un e‑mail de bienvenue constituent une seule action logique. Sans regroupement, vous pouvez aboutir à un utilisateur qui peut se connecter mais sans permissions, ou à un profil existant sans utilisateur.
Les actions back‑office exigent souvent un comportement strict de "piste d'audit + changement d'état". Approuver une demande, écrire une entrée d'audit et mettre à jour un solde doivent réussir ensemble. Si le solde change mais que le journal d'audit manque, vous perdez la preuve de qui a changé quoi et pourquoi.
Les jobs en arrière‑plan en profitent aussi, surtout quand vous traitez un élément avec plusieurs étapes : réclamer l'item pour éviter que deux workers ne le fassent, appliquer la mise à jour métier, enregistrer un résultat pour le reporting et les reprises, puis marquer l'item comme terminé (ou échoué avec un motif). Si ces étapes se dispersent, les reprises et la concurrence créent un désordre.
Les fonctionnalités multi‑étapes cassent quand vous les traitez comme un tas de mises à jour indépendantes. Avant d'ouvrir un client de base de données, rédigez le workflow comme une courte histoire avec une seule ligne d'arrivée claire : qu'est‑ce qui compte exactement comme "terminé" pour l'utilisateur ?
Commencez par lister les étapes en langage courant, puis définissez une condition de succès unique. Par exemple : "La commande est créée, le stock est réservé et l'utilisateur voit un numéro de confirmation de commande." Tout ce qui est en dessous n'est pas un succès, même si certaines tables ont été mises à jour.
Ensuite, tracez une ligne nette entre le travail en base et le travail externe. Les étapes en base sont celles que vous pouvez protéger par des transactions. Les appels externes comme les paiements par carte, l'envoi d'e‑mails ou l'appel d'API tierces peuvent échouer de façon lente et imprévisible, et vous ne pouvez généralement pas les annuler.
Une approche simple de planification : séparer les étapes en (1) doivent être tout-ou-rien, (2) peuvent se produire après le commit.
À l'intérieur de la transaction, ne gardez que les étapes qui doivent rester cohérentes ensemble :
Déplacez les effets secondaires à l'extérieur. Par exemple, committez d'abord la commande, puis envoyez l'e‑mail de confirmation à partir d'un enregistrement outbox.
Pour chaque étape, écrivez ce qui doit se produire si l'étape suivante échoue. "Rollback" peut signifier un rollback de base, ou une action compensatoire.
Exemple : si le paiement réussit mais que la réservation de stock échoue, décidez à l'avance si vous remboursez immédiatement, ou si vous marquez la commande comme "paiement capturé, en attente de stock" et gérez cela de façon asynchrone.
Une transaction dit à Postgres : traitez ces étapes comme une unité. Soit elles ont toutes lieu, soit aucune. C'est la façon la plus simple d'éviter les écritures partielles.
Utilisez une seule connexion de base de données (une session) du début à la fin. Si vous répartissez les étapes sur différentes connexions, Postgres ne peut pas garantir le résultat tout‑ou‑rien.
La séquence est simple : begin, exécutez les lectures et écritures nécessaires, commit si tout réussit, sinon rollback et renvoyez une erreur claire.
Voici un exemple minimal en SQL :
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Les transactions détiennent des verrous pendant leur exécution. Plus vous les gardez ouvertes longtemps, plus vous bloquez les autres travaux et plus vous risquez des timeouts ou des deadlocks. Faites l'essentiel dans la transaction, et déplacez les tâches lentes (envoi d'e‑mails, appels à des prestataires de paiement, génération de PDF) à l'extérieur.
Quand quelque chose échoue, loggez suffisamment de contexte pour reproduire le problème sans divulguer de données sensibles : nom du workflow, order_id ou user_id, paramètres clés (montant, devise) et le code d'erreur Postgres. Évitez de logger des payloads complets, des données de carte ou des informations personnelles.
La concurrence, c'est juste deux choses qui se passent en même temps. Imaginez deux clients qui essaient d'acheter le dernier billet de concert. Les deux écrans affichent "1 restant", les deux cliquent sur Payer, et maintenant votre appli doit décider qui l'obtient.
Sans protection, les deux requêtes peuvent lire la même valeur et écrire une mise à jour. C'est ainsi que l'on se retrouve avec un stock négatif, des réservations dupliquées ou un paiement sans commande.
Les verrous de ligne sont la garde‑fou la plus simple. Vous bloquez la ligne spécifique que vous allez modifier, effectuez vos vérifications, puis la mettez à jour. Les autres transactions qui touchent la même ligne doivent attendre que vous commitiez ou fassiez rollback, ce qui empêche les mises à jour doubles.
Un motif courant : démarrer une transaction, sélectionner la ligne d'inventaire avec FOR UPDATE, vérifier qu'il y a du stock, la décrémenter, puis insérer la commande. Cela "retient la porte" pendant que vous terminez les étapes critiques.
Les niveaux d'isolation contrôlent combien d'anomalies vous tolérez quand des transactions concurrentes s'exécutent. Le compromis est généralement sécurité vs rapidité :
Gardez les verrous courts. Si une transaction reste ouverte pendant que vous appelez une API externe ou attendez une action utilisateur, vous créerez des attentes longues et des timeouts. Préférez un chemin d'échec clair : définissez un lock_timeout, attrapez l'erreur et renvoyez "merci de réessayer" plutôt que de laisser les requêtes bloquer.
Si vous devez faire du travail hors base (comme débiter une carte), scindez le workflow : réservez rapidement, committez, faites la partie lente, puis finalisez avec une autre courte transaction.
Les reprises sont normales dans les applications autour de Postgres. Une requête peut échouer même si votre code est correct : deadlocks, timeouts de statement, coupures réseau brèves ou erreur de sérialisation sous un niveau d'isolation élevé. Si vous relancez simplement le même handler, vous risquez de créer une seconde commande, débiter deux fois ou insérer des lignes d'"événement" en double.
La solution est l'idempotence : l'opération doit être sûre à exécuter deux fois avec la même entrée. La base doit être capable de reconnaître "c'est la même requête" et répondre de façon cohérente.
Un motif pratique consiste à joindre une clé d'idempotence (souvent un request_id généré côté client) à chaque workflow multi‑étapes et à la stocker sur l'enregistrement principal, puis à ajouter une contrainte unique sur cette clé.
Par exemple : au checkout, générez request_id quand l'utilisateur clique sur Payer, puis insérez la commande avec ce request_id. Si une reprise survient, la seconde tentative heurte la contrainte unique et vous renvoyez la commande existante au lieu d'en créer une nouvelle.
Ce qui compte généralement :
Gardez la boucle de reprise en dehors de la transaction. Chaque tentative doit démarrer une nouvelle transaction et réexécuter l'unité de travail depuis le début. Retenter à l'intérieur d'une transaction échouée ne sert à rien car Postgres marque la transaction comme abortée.
Un petit exemple : votre appli a essayé de créer une commande et de réserver du stock, mais a timeouté juste après le COMMIT. Le client relance. Avec une clé d'idempotence, la seconde requête renvoie la commande déjà créée et évite une seconde réservation au lieu de doubler le travail.
Les transactions gardent un workflow multi‑étapes uni, mais elles ne rendent pas automatiquement les données correctes. Un moyen puissant d'éviter les conséquences des écritures partielles est de rendre les états "incorrects" difficiles ou impossibles au niveau de la base, même si un bug s'infiltre dans le code applicatif.
Commencez par des rails de sécurité de base. Les clés étrangères s'assurent que les références sont réelles (une ligne de commande ne peut pas pointer vers une commande manquante). NOT NULL empêche les lignes incomplètes. Les contraintes CHECK rejettent des valeurs absurdes (par exemple quantity > 0, total_cents >= 0). Ces règles s'exécutent à chaque écriture, quel que soit le service ou le script qui touche la base.
Pour les workflows plus longs, modélisez explicitement les changements d'état. Au lieu de plusieurs flags booléens, utilisez une colonne status unique (pending, paid, shipped, canceled) et n'autorisez que des transitions valides. Vous pouvez faire respecter cela avec des contraintes ou des triggers afin que la base refuse des sauts illégaux comme shipped -> pending.
L'unicité est une autre forme de correction. Ajoutez des contraintes uniques là où les doublons casseraient votre workflow : order_number, invoice_number ou une idempotency_key utilisée pour les reprises. Alors, si votre appli relance la même requête, Postgres bloque la deuxième insertion et vous pouvez revenir "déjà traité" au lieu de créer une seconde commande.
Quand vous avez besoin de traçabilité, stockez‑la explicitement. Une table d'audit (ou d'historique) qui enregistre qui a changé quoi et quand transforme les "mises à jour mystérieuses" en faits que vous pouvez interroger lors d'incidents.
La plupart des écritures partielles ne viennent pas d'un "mauvais SQL". Elles proviennent de décisions de workflow qui rendent facile la validation d'une moitié de l'histoire.
accounts puis orders, mais qu'une autre fait l'inverse, vous augmentez le risque de deadlocks sous charge.Un exemple concret : au checkout, vous réservez du stock, créez une commande, puis débitez une carte. Si vous débitez la carte à l'intérieur de la même transaction, vous pouvez maintenir un verrou sur le stock en attendant le réseau. Si le débit réussit mais que votre transaction fait ensuite rollback, vous avez débité le client sans commande.
Un schéma plus sûr : concentrez la transaction sur l'état en base (réserver le stock, créer la commande, enregistrer un paiement en attente), committez, appelez ensuite l'API externe, puis écrivez le résultat dans une nouvelle courte transaction. Beaucoup d'équipes implémentent cela avec un statut pending simple et un job en arrière‑plan.
Quand un workflow a plusieurs étapes (insert, update, charge, envoi), l'objectif est simple : soit tout est enregistré, soit rien.
Mettez toutes les écritures nécessaires dans une seule transaction. Si une étape échoue, faites rollback et laissez les données telles qu'elles étaient.
Rendez la condition de succès explicite. Par exemple : "La commande est créée, le stock est réservé et le statut du paiement est enregistré." Tout le reste doit faire échouer la transaction.
BEGIN ... COMMIT.ROLLBACK, et l'appelant reçoit un résultat d'échec clair.Supposez qu'une même requête peut être relancée. La base doit vous aider à appliquer des règles "une seule fois".
Faites le minimum à l'intérieur de la transaction et évitez d'attendre des appels réseau tout en tenant des verrous.
Si vous ne voyez pas où ça casse, vous resterez à deviner.
Un checkout comporte plusieurs étapes qui doivent évoluer ensemble : créer la commande, réserver le stock, enregistrer la tentative de paiement, puis marquer le statut de la commande.
Imaginez qu'un utilisateur clique sur Acheter pour 1 article.
Dans une seule transaction, effectuez uniquement les changements en base :
orders avec le statut pending_payment.inventory.available ou créez une ligne reservations).payment_intents avec une idempotency_key fournie par le client (unique).outbox comme "order_created".Si une instruction échoue (rupture de stock, violation de contrainte, plantage), Postgres rollbackera toute la transaction. Vous n'obtenez pas une commande sans réservation, ni une réservation sans commande.
Le fournisseur de paiement est hors de la base, traitez‑le comme une étape séparée.
Si l'appel au fournisseur échoue avant votre commit, annulez la transaction et rien n'est écrit. Si l'appel échoue après votre commit, lancez une nouvelle transaction qui marque la tentative de paiement comme échouée, libère la réservation et met le statut de la commande à canceled.
Demandez au client d'envoyer une idempotency_key par tentative de checkout. Faites‑la respecter par un index unique sur payment_intents(idempotency_key) (ou sur orders si vous préférez). À la reprise, votre code recherche les lignes existantes et poursuit au lieu d'insérer une nouvelle commande.
N'envoyez pas d'e‑mails dans la transaction. Écrivez un enregistrement outbox dans la même transaction, puis laissez un worker l'envoyer après le commit. Ainsi vous n'envoyez jamais d'e‑mail pour une commande qui a été rollbackée.
Choisissez un workflow qui touche plus d'une table : signup + envoi de bienvenue, checkout + inventaire, facture + écriture en ledger, ou création de projet + paramètres par défaut.
Rédigez d'abord les étapes, puis définissez les règles qui doivent toujours être vraies (vos invariants). Exemple : "Une commande est soit entièrement payée et réservée, soit non payée et non réservée. Jamais à moitié réservée." Transformez ces règles en une unité tout‑ou‑rien.
Un plan simple :
Puis testez volontairement les cas moches. Simulez un crash après l'étape 2, un timeout juste avant le commit et un double‑submit depuis l'UI. L'objectif est d'obtenir des résultats ennuyeux : pas de lignes orphelines, pas de doubles prélèvements, pas de statuts en attente indéfiniment.
Si vous prototypez rapidement, il est utile de dessiner le workflow dans un outil de planification avant de générer handlers et schéma. Par exemple, Koder.ai (koder.ai) propose un Planning Mode et prend en charge snapshots et rollback, ce qui peut être pratique pendant que vous itérez sur les frontières de transaction et les contraintes.
Faites cela pour un workflow cette semaine. Le second ira beaucoup plus vite.