Zero-downtime database migrations
Les migrations de base de données sont souvent la partie la plus risquée d'un déploiement. Voici comment les réaliser sans interruption.
Le code applicatif se déploie facilement sans coupure : on démarre la nouvelle version, on bascule le trafic, on arrête l'ancienne. La base de données, elle, est partagée par les deux versions pendant cette bascule. Deux risques en découlent : un schéma incompatible avec l'une des versions du code, et des verrous qui bloquent les requêtes le temps d'un ALTER TABLE. Une migration sans interruption doit éviter les deux.
Pourquoi le schéma doit rester compatible
Pendant un déploiement blue-green ou progressif, l'ancienne et la nouvelle version du code tournent en même temps, parfois pendant plusieurs minutes. Si la migration renomme une colonne, l'ancienne version plante dès qu'elle la lit. Si elle ajoute une colonne NOT NULL sans valeur par défaut, l'ancienne version plante dès qu'elle insère une ligne. La règle d'or : chaque migration doit être compatible avec le code actuellement en production et avec le code qui va le remplacer. Le rollback doit rester possible à tout moment.
Règles fondamentales
- Ne jamais supprimer une colonne utilisée par le code en production
- Toujours ajouter les nouvelles colonnes comme nullable
- Séparer les migrations de schéma des migrations de données
- Tester les migrations sur une copie de la base de production
Une colonne peut aussi être ajoutée avec une valeur par défaut, comme dans l'exemple ci-dessous : l'essentiel est que l'ancien code puisse continuer à insérer des lignes sans la connaître. Le test sur une copie de production est indispensable, car une migration instantanée sur une base de développement de quelques milliers de lignes peut prendre de longues minutes sur une table de plusieurs dizaines de millions de lignes.
Pattern Expand-Contract
Le pattern Expand-Contract (ou parallel change) découpe tout changement incompatible en étapes compatibles, réparties sur plusieurs déploiements.
Phase 1 - Expand : Ajouter la nouvelle structure
-- Migration 1 : Ajouter la nouvelle colonne
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
-- Migration 2 : Remplir les données
UPDATE users SET email_verified = TRUE WHERE verified_at IS NOT NULL;
Phase 2 - Déployer le code utilisant les deux colonnes
Pendant cette phase, le code écrit dans les deux colonnes et lit la nouvelle. Les lignes créées par l'ancienne version pendant la bascule n'ont pas encore la bonne valeur : relancez le remplissage une fois le déploiement terminé pour les rattraper.
Phase 3 - Contract : Supprimer l'ancienne structure
-- Migration 3 : Supprimer l'ancienne colonne
ALTER TABLE users DROP COLUMN verified_at;
Cette dernière migration part dans un déploiement ultérieur, une fois que plus aucune version en production ne lit l'ancienne colonne. Retirez-la aussi du mapping Doctrine avant de la supprimer, sinon l'ORM continuera de l'inclure dans ses requêtes.
Le même principe s'applique pour renommer une colonne, qu'on ne renomme jamais directement :
- ajouter la nouvelle colonne ;
- déployer un code qui écrit dans les deux colonnes ;
- copier les données existantes par lots ;
- déployer un code qui lit la nouvelle colonne ;
- déployer un code qui n'écrit plus dans l'ancienne ;
- supprimer l'ancienne colonne.
Remplir les données par lots
Un UPDATE unique sur une grosse table ouvre une longue transaction, verrouille de nombreuses lignes et fait grossir le retard des réplicas. Sur MySQL, qui accepte LIMIT dans un UPDATE, on traite plutôt les lignes par petits lots :
$batchSize = 1000;
do {
$affected = $connection->executeStatement(
'UPDATE users SET email_verified = TRUE
WHERE verified_at IS NOT NULL AND email_verified = FALSE
LIMIT ' . $batchSize
);
// Laisser respirer la base et les réplicas
usleep(100_000);
} while ($affected > 0);
Chaque lot est une transaction courte. Le script peut être interrompu et relancé sans risque, car la condition email_verified = FALSE exclut les lignes déjà traitées. Placez ce code dans une commande Symfony plutôt que dans une migration Doctrine : il peut tourner longtemps et doit pouvoir être relancé indépendamment du schéma.
Migrations Doctrine optimisées
public function up(Schema $schema): void
{
// Utiliser des opérations non-bloquantes
$this->addSql('ALTER TABLE orders ADD COLUMN status VARCHAR(50) DEFAULT NULL');
// Pour les grandes tables, utiliser pt-online-schema-change
// ou gh-ost pour éviter les locks
}
Sur MySQL 8.0, l'ajout d'une colonne se fait le plus souvent avec l'algorithme INSTANT, sans copie de la table. Pour les autres opérations, demandez explicitement un algorithme sans verrou : si MySQL ne peut pas le respecter, la requête échoue immédiatement au lieu de bloquer la table.
ALTER TABLE orders ADD INDEX idx_orders_status (status), ALGORITHM=INPLACE, LOCK=NONE;
Sur PostgreSQL, un index se crée sans bloquer les écritures avec CREATE INDEX CONCURRENTLY, qui ne peut pas s'exécuter dans une transaction. Il faut donc désactiver la transaction englobante de Doctrine Migrations pour cette migration :
final class Version20241012120000 extends AbstractMigration
{
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
$this->addSql('SET lock_timeout = \'5s\'');
$this->addSql('CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status)');
}
}
Le lock_timeout protège d'un piège classique : un ALTER TABLE, même rapide, doit attendre la fin des transactions en cours sur la table, et toutes les requêtes suivantes s'accumulent derrière lui. Avec un délai court, la migration échoue proprement et peut être relancée, au lieu de bloquer toute l'application. L'équivalent MySQL est SET SESSION lock_wait_timeout = 5.
Les outils de migration en ligne
Quand MySQL doit reconstruire la table (changement de type de colonne, par exemple), pt-online-schema-change et gh-ost créent une copie de la table avec le nouveau schéma, la synchronisent pendant la copie, puis échangent les deux tables en une opération très brève :
# Percona Toolkit : tester, puis exécuter
pt-online-schema-change --alter "MODIFY status VARCHAR(100) DEFAULT NULL" D=app,t=orders --dry-run
pt-online-schema-change --alter "MODIFY status VARCHAR(100) DEFAULT NULL" D=app,t=orders --execute
# gh-ost : s'appuie sur le binlog plutôt que sur des triggers
gh-ost --host=db.internal --user=app --ask-pass --database=app --table=orders \
--alter="MODIFY status VARCHAR(100) DEFAULT NULL" --allow-on-master --execute
Outils recommandés
- pt-online-schema-change : migrations sans lock pour MySQL
- gh-ost : alternative GitHub pour les migrations en ligne
- Doctrine Migrations : gestion versionnée des migrations
- Flyway : outil de migration multi-base
Pièges courants
doctrine:schema:update --forceen production : cette commande peut supprimer ou renommer des colonnes sans prévenir. En production, seules les migrations versionnées et relues doivent toucher au schéma.- Relire le SQL généré :
doctrine:migrations:diffproduit parfois unDROPsuivi d'unADDlà où vous attendiez un renommage. - Clés étrangères et index sur de grosses tables : leur création peut être longue. Mesurez sur la copie de production.
- Méthode
down(): ne comptez pas dessus pour un rollback en production. Grâce à l'Expand-Contract, revenir à la version précédente du code suffit, sans toucher au schéma.
Checklist avant chaque migration
- La migration est-elle compatible avec le code actuel et avec le nouveau ?
- A-t-elle été chronométrée sur une copie de la base de production ?
- Les opérations lourdes sont-elles faites en ligne ou par lots ?
- Un délai d'attente de verrou est-il défini ?
- La suppression des anciennes structures est-elle reportée à un déploiement ultérieur ?
Ces règles demandent un peu plus de déploiements pour un même changement, mais chacun devient banal, réversible et sans impact pour les utilisateurs.