Quand les migrations de base de données cassent les applications en production
Votre équipe vient de déployer une nouvelle fonctionnalité. Le déploiement semble parfait. Mais cinq minutes plus tard, l'ingénieur d'astreance envoie une capture d'écran des logs d'erreur. Les anciennes instances de l'application plantent avec des erreurs de base de données. La requête SELECT * FROM users WHERE status = 'active' échoue soudainement. Que s'est-il passé ?
Vous avez changé la colonne status de VARCHAR à INT dans la migration. Le nouveau code de l'application gère les entiers sans problème. Mais lors d'une mise à jour progressive, les anciennes et les nouvelles instances de l'application s'exécutent côte à côte. Les anciennes instances attendent toujours des chaînes de caractères. Le schéma de la base de données a changé sous leurs pieds, et elles ont cassé.
C'est la tension centrale des migrations de base de données dans les déploiements modernes : la base de données est partagée, mais les versions de l'application ne le sont pas.
Le problème de la base de données partagée
Lorsque vous déployez avec des mises à jour progressives, des déploiements blue-green ou des versions canaries, plusieurs versions de votre application s'exécutent simultanément. Elles se connectent toutes à la même base de données. Mais chaque version a des attentes différentes concernant le schéma.
L'ancienne application s'attend à certaines colonnes, types de données et contraintes. La nouvelle application s'attend à une structure légèrement différente. Les deux doivent fonctionner correctement pendant la période de transition. Si votre migration casse la compatibilité avec l'ancienne application, vous obtenez des erreurs en production.
Ce n'est pas un problème théorique. Cela arrive chaque fois qu'une migration modifie quelque chose dont dépend le code en cours d'exécution.
Compatibilité ascendante : la règle non négociable
La règle fondamentale est simple : chaque migration doit être compatible avec l'ancienne application. L'ancien code doit pouvoir lire et écrire des données sans erreur, même après l'exécution de la migration.
Considérez ces deux migrations SQL pour voir la différence :
-- Sûre : Ajouter une colonne nullable avec une valeur par défaut
-- L'ancienne application peut toujours faire INSERT sans spécifier phone_number
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) DEFAULT NULL;
-- Cassante : Changer le type de colonne de VARCHAR à INT
-- Le SELECT * FROM users WHERE status = 'active' de l'ancienne application échouera
-- car 'active' est une chaîne, pas un entier
ALTER TABLE users ALTER COLUMN status TYPE INT USING status::integer;
Certaines modifications sont naturellement rétrocompatibles. Ajouter une colonne nullable avec une valeur par défaut, par exemple, ne casse pas les requêtes existantes. L'INSERT INTO users (name, email) de l'ancienne application fonctionne toujours car la nouvelle colonne phone accepte les valeurs nulles.
D'autres modifications cassent la compatibilité immédiatement. Changer un type de colonne, renommer une colonne, ajouter une contrainte NOT NULL à une colonne déjà remplie, ou ajouter une clé étrangère que les données existantes ne peuvent pas satisfaire provoquera des erreurs dans les anciennes instances de l'application.
La règle n'est pas optionnelle. Si vous ne pouvez pas garantir la compatibilité ascendante, vous ne pouvez pas déployer en toute sécurité avec zéro temps d'arrêt.
Le pattern Expand-Contract
La façon la plus sûre de gérer les changements cassants est le pattern expand-contract, parfois appelé double écriture. L'idée est d'effectuer les modifications par phases, sans jamais supprimer les anciennes structures tant que toutes les instances de l'application n'ont pas été mises à jour.
Le diagramme de séquence suivant illustre la chronologie du pattern expand-contract, montrant comment les anciennes et nouvelles instances de l'application interagissent avec la base de données pendant chaque phase.
Phase 1 : Expand. Ajoutez la nouvelle structure sans supprimer l'ancienne. Si vous voulez remplacer status (VARCHAR) par status_id (INT), ajoutez la nouvelle colonne tout en conservant l'ancienne. La nouvelle application écrit dans les deux colonnes. L'ancienne application continue d'utiliser status. Les deux fonctionnent.
Phase 2 : Migrer les données. Remplissez la nouvelle colonne avec les valeurs converties de l'ancienne colonne. Cela peut s'exécuter comme un job en arrière-plan ou une étape de migration séparée.
Phase 3 : Mettre à jour le code de l'application. Déployez la nouvelle version de l'application sur toutes les instances. Maintenant, chaque instance en cours d'exécution connaît les deux colonnes.
Phase 4 : Contract. Dans un déploiement séparé, supprimez l'ancienne colonne. À ce stade, aucune application en cours d'exécution n'en dépend.
Le pattern ajoute de la complexité. Votre code d'application doit gérer la logique de double écriture pendant la transition. Vous avez des colonnes supplémentaires à maintenir temporairement. Mais c'est le prix à payer pour éviter les temps d'arrêt et les erreurs pendant le déploiement.
Compatibilité descendante : l'autre direction
La compatibilité ascendante protège l'ancien code de l'application. La compatibilité descendante protège le nouveau code de l'application lorsque la base de données n'a pas encore été entièrement migrée.
Considérez un scénario où vous déployez d'abord la nouvelle application, mais la migration n'a pas été exécutée sur tous les réplicas de la base de données. Le nouveau code doit gérer à la fois les anciens et les nouveaux formats de schéma. S'il lit status comme VARCHAR mais s'attend à INT, il doit gérer la conversion avec élégance.
La compatibilité descendante est plus difficile à atteindre et a généralement des limites. Cela signifie que votre nouveau code doit être défensif concernant les données qu'il lit. Il ne doit pas supposer que le schéma a déjà changé. Cela implique souvent d'ajouter une logique de repli ou une conversion de données dans la couche applicative jusqu'à ce que la migration soit terminée.
Au-delà des colonnes : index, contraintes et clés étrangères
La compatibilité ne concerne pas seulement les colonnes et les types de données. Les index, les contraintes et les clés étrangères peuvent également casser les applications en cours d'exécution.
L'ajout d'une nouvelle contrainte de clé étrangère peut faire échouer les requêtes INSERT ou UPDATE existantes si les données référencées n'existent pas. L'ajout d'une contrainte UNIQUE à une colonne qui autorisait auparavant les doublons cassera toute requête qui tente d'insérer des valeurs en double. Même l'ajout d'un index peut causer des problèmes de performance si la base de données verrouille la table pendant la création de l'index.
Chaque modification de schéma doit être évaluée pour son impact sur le code de l'application en cours d'exécution. Demandez-vous : cette modification va-t-elle faire échouer une requête de l'ancienne application ? Va-t-elle changer le comportement d'une manière que l'ancien code n'attend pas ?
Liste de contrôle pratique pour des migrations sûres
Avant d'exécuter une migration en production, vérifiez ces points :
- L'ancienne application peut-elle lire toutes les données existantes sans erreur après la migration ?
- L'ancienne application peut-elle écrire de nouvelles données sans erreur après la migration ?
- Toutes les nouvelles colonnes sont-elles nullables ou ont-elles des valeurs par défaut ?
- Les nouvelles contraintes sont-elles déjà vérifiées pour les données existantes ?
- La migration va-t-elle provoquer des verrous de table qui bloquent les requêtes ?
- Existe-t-il un plan de rollback si quelque chose tourne mal ?
L'essentiel à retenir
Les migrations de base de données lors de déploiements sans temps d'arrêt exigent de traiter le schéma comme une interface partagée entre les versions de l'application. Chaque migration doit être compatible avec l'ancien code. Les changements cassants nécessitent le pattern expand-contract, en ajoutant d'abord de nouvelles structures et en supprimant les anciennes uniquement après que toutes les instances ont été mises à jour.
La base de données est la source unique de vérité que toutes les versions de l'application partagent. Si vous la modifiez négligemment, vous cassez le code en cours d'exécution. Concevez vos migrations comme des ponts que les anciennes et les nouvelles applications peuvent traverser en toute sécurité. Ce n'est qu'après que chaque instance est passée du nouveau côté que vous devez modifier le pont lui-même.