Migrations de base de données sans interruption avec Rails : un guide pratique
Pourquoi les migrations qui paraissent anodines sont celles qui cassent la production, et la séquence que nous suivons pour modifier un schéma sans verrouiller une table très sollicitée.
Publié le 15 octobre 2023 · 5 min de lecture
Les migrations qui cassent la production sont rarement celles qui changent beaucoup de choses. Ce sont celles qui paraissent anodines : ajouter une colonne NOT NULL, renommer un élément encore référencé par un job en arrière-plan, ou ajouter un index de la manière évidente. Voici le guide que nous appliquons par défaut sur les applications Rails qui ne peuvent pas se permettre une fenêtre de maintenance.
Le vrai ennemi, ce sont les verrous, pas la taille
Postgres comme MySQL doivent brièvement verrouiller une table pour certaines opérations DDL. Sur une table de quelques centaines de lignes, ce verrou est invisible. Sur une table de quarante millions de lignes avec un débit d'écriture soutenu, ce même verrou met en file d'attente toutes les écritures derrière lui, puis toutes les requêtes derrière celles-ci. La migration elle-même peut durer deux secondes ; l'incident qu'elle provoque peut durer plusieurs minutes, le temps que la file de verrous se vide.
Ajouter une colonne en toute sécurité
Ajouter une colonne nullable sans valeur par défaut est peu coûteux sur un Postgres récent : c'est une modification de métadonnées uniquement. Dès que vous ajoutez une valeur par défaut sur une ancienne version de Postgres, ou directement une contrainte NOT NULL, vous forcez une réécriture complète de la table. La séquence sûre est toujours la même : ajouter la colonne en nullable, rattraper les données par lots, puis ajouter la contrainte une fois que chaque ligne la respecte déjà.
# migration 1
add_column :invoices, :currency, :string
# backfill, in a rake task or a one-off job, batched
Invoice.in_batches(of: 5_000) { |batch| batch.update_all(currency: "EUR") }
# migration 2, once the backfill is done
change_column_null :invoices, :currency, false
Des index sans bloquer les écritures
Un simple add_index prend un verrou qui bloque les écritures pendant toute la durée de la construction. Sur toute table qui compte en production, utilisez plutôt une construction concurrente, et désactivez l'enveloppe transactionnelle que Rails ajoute par défaut, afin que l'instruction puisse réellement s'exécuter hors d'une transaction.
class AddIndexToInvoicesOnStatus < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_index :invoices, :status, algorithm: :concurrently
end
end
Un renommage n'est jamais vraiment un renommage
Renommer une colonne dans une migration et livrer la modification du code dans le même déploiement garantit une fenêtre pendant laquelle l'ancien code applicatif et le nouveau schéma sont en désaccord. Nous séparons donc l'opération en plusieurs déploiements : ajouter la nouvelle colonne et écrire dans les deux (dual-write), rattraper les données, basculer les lectures vers la nouvelle colonne, et ne supprimer l'ancienne que lors d'un dernier déploiement distinct, une fois que plus rien ne la référence.
Rien de tout cela n'est exotique. C'est plus lent qu'une migration unique, volontairement, et c'est ce qui fait la différence entre un déploiement que personne ne remarque et un canal d'incident qui s'illumine un vendredi soir.
Articles liés
Comment rattraper des millions de lignes dans une base Rails sans interruption de service
Rattraper une table de quarante millions de lignes n'est pas le même problème que d'en rattraper une de quarante mille. Ce qui change, et comment nous le régulons en toute sécurité.
Les transactions de base de données dans Rails : ce que signifie vraiment ACID pour votre code
ActiveRecord rend les transactions très faciles à utiliser. Ce que promet réellement chacune des quatre garanties ACID, et où les développeurs Rails se trompent sur l'isolation.