Écrire des migrations de base de données sûres et idempotentes
Une migration qui s'est bien passée en préproduction puis échoue à mi-chemin en production vous place dans la pire situation possible : un schéma qui n'est ni l'ancienne version ni la nouvelle, un déploiement qui ne peut pas avancer et un retour arrière qui n'annulera peut-être pas ce qui s'est déjà produit. Écrire les migrations de sorte que les relancer soit toujours sans danger (idempotentes, au sens strict) transforme « la migration a échoué partiellement » d'un incident en non-événement.
Ce qui tourne réellement mal
Un script de migration typique :
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP;
CREATE INDEX idx_users_last_login ON users (last_login_at);
UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL;
Si la création de l'index expire sur une grande table (ou que le processus de déploiement est tué, ou que la connexion tombe), vous vous retrouvez avec la colonne ajoutée mais sans index et sans remplissage rétroactif. Relancer tout le script depuis le début échoue maintenant dès la première ligne (column "last_login_at" already exists), alors que ce qu'il vous faut réellement, c'est que le script reprenne là où il s'était arrêté.
Rendre chaque instruction sûre à relancer
La plupart des instructions de changement de schéma ont une forme IF EXISTS / IF NOT EXISTS. Utilisez-la partout.
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users (last_login_at);
UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL;
Désormais, tout le script peut être relancé après n'importe quel échec partiel. Les appels ADD COLUMN et CREATE INDEX deviennent sans effet s'ils sont déjà appliqués, et le UPDATE est déjà naturellement idempotent : WHERE last_login_at IS NULL ne touche que les lignes qui en ont encore besoin, donc l'exécuter deux fois ne fait aucun travail supplémentaire la seconde fois.
Ce dernier point se généralise : un UPDATE protégé par une condition qui décrit « a encore besoin de ce changement » plutôt que « applique ce changement » est idempotent par construction. Comparez :
-- NOT idempotent: running this twice doubles every balance
UPDATE accounts SET balance = balance * 1.05;
-- idempotent: running this any number of times has the same effect
UPDATE accounts SET balance = base_balance * 1.05 WHERE balance != base_balance * 1.05;
La première décrit une action ; la seconde décrit un état cible. Préférez décrire l'état cible chaque fois que la migration fait autre chose qu'un pur changement de schéma.
Les suppressions demandent le même traitement
DROP TABLE IF EXISTS legacy_sessions;
ALTER TABLE orders DROP COLUMN IF EXISTS legacy_status;
Symétrique du cas additif : sans IF EXISTS, relancer une migration qui a déjà réussi à supprimer quelque chose échoue bruyamment sur une instruction qui a, en réalité, déjà fait son travail.
Les remplissages rétroactifs doivent être reprenables, pas seulement idempotents
Un UPDATE unique sur une énorme table est un verrou tenu pendant toute la durée et une unité de travail unique, tout ou rien. Découpez-le en lots, pour qu'un échec en cours de route fasse perdre quelques minutes de progression et non tout le travail :
-- repeat until this returns 0 rows affected
UPDATE users
SET last_login_at = created_at
WHERE id IN (
SELECT id FROM users
WHERE last_login_at IS NULL
LIMIT 1000
);
async function backfillLastLogin(db) {
let updated;
do {
const result = await db.query(`
UPDATE users SET last_login_at = created_at
WHERE id IN (SELECT id FROM users WHERE last_login_at IS NULL LIMIT 1000)
`);
updated = result.rowCount;
} while (updated > 0);
}
Chaque lot est une petite transaction à part. Si le processus meurt après le lot 40, le redémarrer reprend simplement là où last_login_at IS NULL renvoie encore des lignes : aucune table de suivi n'est nécessaire, car la clause WHERE est le suivi de progression.
Séparer la partie sûre de la partie destructive
ADD COLUMN se réessaie sans danger. DROP COLUMN n'est pas réversible une fois les données perdues. Scinder un changement de schéma comme « renommer status en order_status » en déploiements distincts garde chaque migration idempotente et sûre en elle-même :
- Ajouter la nouvelle colonne, la remplir à partir de l'ancienne, déployer du code qui écrit dans les deux.
- Déployer du code qui lit uniquement la nouvelle colonne.
- Dans une migration ultérieure et distincte (après avoir vérifié que l'étape 2 fonctionne sans heurt depuis un moment), supprimer l'ancienne colonne.
Si la migration de l'étape 3 échoue à mi-chemin, vous n'avez rien perdu d'irremplaçable : l'ancienne colonne est toujours là pour une nouvelle tentative. Regrouper les trois étapes en une seule migration signifie qu'un échec partiel peut vous laisser avec des données écrites dans une colonne que votre code applicatif revenu en arrière ne lit plus.
Les outils de migration en font déjà une partie : sachez laquelle
Les frameworks comme les migrations ActiveRecord de Rails ou le système de migrations de Django enregistrent dans une table de métadonnées quelles migrations ont déjà été exécutées, de sorte que relancer la commande de migration ne ré-exécutera pas une migration entièrement terminée. Ce dont ils ne peuvent généralement pas vous protéger, c'est d'un fichier de migration qui échoue à mi-chemin de son propre corps : c'est toujours à vous de l'écrire de façon défensive, avec les schémas IF EXISTS / état cible ci-dessus, à l'intérieur de chaque migration.
À retenir
Écrivez chaque instruction de migration comme « assure-toi que ceci est vrai » plutôt que « fais cette action », et une migration qui échoue à mi-chemin devient quelque chose que l'on relance, et non quelque chose pour lequel on appelle quelqu'un à 2 h du matin. Le coût, ce sont quelques clauses IF EXISTS et conditions WHERE de plus ; le bénéfice, c'est que l'échec partiel cesse d'être un cas particulier auquel il faut réfléchir sous pression.