BUILDING TO SCALE
FR | EN
Parler de votre projet

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.