Defacto propose des financements à court terme pour les PME (Petites et Moyennes Entreprises) de façon simple et rapide . Nos clients peuvent passer de la création de leur compte à la réception des fonds sur leur compte bancaire en quelques minutes seulement. Cette rapidité et cette fiabilité sont au cœur de notre produit.
Nous sommes une équipe d'ingénieurs relativement petite par rapport à la complexité métier que nous gérons. Un modèle de données de haute qualité est essentiel pour maintenir notre vélocité de développement, ce qui signifie que nous adaptons fréquemment le schéma de notre base de données.
Si les changements de schéma sont simples sur une base de données avec peu de trafic, ils deviennent un défi dès que l'on gère des opérations complexes et concurrentes. Les cas pathologiques peuvent entraîner des interruptions de service complètes — un impact critique pour une entreprise de la Fintech.
Au fil du temps, nous avons développé un ensemble d'outils et de pratiques qui ont complètement éliminé les pannes liées aux migrations de schéma de BDD. Voici comment nous avons fait.
Notre stack technique pour les migrations de BDD
Notre stack technique pour les migrations de BDD est assez simple :
- une unique base de données PostgreSQL partagée
- Python pour le code back-end
- SQLAlchemy pour la définition du modèle de données et l'exécution des requêtes
- Alembic pour générer, écrire et exécuter les migrations.
Pourquoi les changements de schéma de base de données peuvent être complexes
Deux problèmes principaux font des migrations de base de données une opération à haut risque dans un environnement de production.
1. Verrouillage exclusif
Fondamentalement, de nombreuses opérations de changement de schéma, du moins dans PostgreSQL, acquièrent un verrou exclusif sur les entités qu'elles modifient. Ce verrou empêche les autres opérations de lire ou d'écrire sur ces entités jusqu'à ce que la migration soit terminée. La requête de modification de la base de données attend son tour pour acquérir le verrou, et toutes les requêtes suivantes sur cette table se mettent en file d'attente derrière elle.
Cela ne poserait pas de problème si toutes les requêtes SQL étaient rapides et si toutes les opérations de changement de schéma étaient instantanées. Malheureusement, ce n'est pas la réalité. Une seule requête de longue durée peut empêcher la migration d'acquérir un verrou, la forçant à attendre.
Pendant qu'elle attend, chaque nouvelle requête de l'application sur cette table est mise en attente. Cet effet de cascade peut rapidement saturer toute la capacité de traitement de l'application, entraînant une interruption totale du service.
Lorsque l'on déploie des changements de schéma plusieurs fois par semaine, ce risque devient un problème majeur pour l'entreprise.
2. Versions de code concurrentes
La plupart des workflows de déploiement modernes impliquent une mise à jour progressive (rolling update), où pendant un certain temps, différentes versions de votre code applicatif s'exécutent simultanément sur la même base de données. Cela crée une race condition. Par exemple, l'ancien code pourrait essayer de lire ou d'écrire dans une colonne qu'une nouvelle migration vient de supprimer.
Cela peut entraîner un flot d'erreurs applicatives pendant la fenêtre de déploiement, nuisant à vos contrats de niveau de service (SLA) et à la confiance des utilisateurs.
L'approche de Defacto pour des migrations de BDD sans interruption de service
Pour atténuer les problèmes de verrouillage et de versions concurrentes, notre approche repose sur quatre piliers fondamentaux :
- Adopter le pattern de migration expand -> migrate -> contract.
- Mettre en place un linter de migration de BDD pour éviter les anti-patterns courants.
- Fournir des directives claires aux ingénieurs tout au long du processus de développement et de déploiement.
- Utiliser les fonctionnalités de PostgreSQL pour éviter d'être impacté par les verrous de longue durée.
1. Adopter le pattern Expand -> Migrate -> Contract
Intuitivement, les développeurs ont tendance à regrouper tous les changements nécessaires pour atteindre l'état final souhaité dans une seule migration. Cela semble naturel et fonctionne généralement lorsque le trafic et la charge sont faibles. Par conséquent, les ingénieurs logiciels ont tendance à s'attendre à ce que cela fonctionne bien à chaque fois, pour toujours.
Lorsque le trafic et la charge augmentent, cela peut encore fonctionner pour des migrations simples, comme l'ajout d'une nouvelle colonne à une table. Cependant, cela commence à poser problème pour toute migration complexe. Diviser les migrations en plusieurs étapes plus petites contribue grandement à les rendre à la fois plus robustes et plus simples à gérer.
Nous divisons toutes les migrations potentiellement risquées en trois phases distinctes, déployées séparément.
- 1. Expand : Dans le premier déploiement, on se contente d'ajouter de nouvelles structures. Il peut s'agir d'une nouvelle colonne ou d'une nouvelle table. L'ancien code de l'application ignore complètement ces changements et continue de fonctionner comme avant. Le nouveau code en cours de déploiement n'utilise pas encore les nouvelles structures.
- 2. Migrate: Dans la deuxième phase, on déploie le code applicatif qui utilise les nouvelles structures. Cela implique généralement un déploiement en plusieurs étapes.
- Déployer du code qui écrit à la fois dans les anciennes et les nouvelles structures.
- Exécuter un backfill de données pour remplir les nouvelles structures avec les données dérivées des anciennes.
- Déployer du code qui bascule les lectures vers les nouvelles structures et arrête d'écrire dans les anciennes.
- 3. Contract: Une fois que le nouveau code applicatif est entièrement déployé, stable, et utilise exclusivement le nouveau schéma, on déploie une dernière migration, distincte, pour supprimer les anciennes structures (par exemple, supprimer l'ancienne colonne).
À chaque étape, chaque version du code de l'application en cours d'exécution est compatible avec le schéma actuel de la base de données.
En prenant l'exemple d'un changement qui renomme une colonne, les étapes de migration seraient:
- Ajouter la nouvelle colonne et commencer à écrire les données à la fois dans l’ancienne et la nouvelle colonne.
- Effectuer le backfill de la nouvelle colonne à partir de l’ancienne.
- Lire seulement depuis et écrire seulement dans la nouvelle colonne.
- Supprimer l’ancienne colonne.
Préparer la phase de Contraction avec SQLAlchemy
Un point subtil mais crucial de la phase "contract" est de s'assurer que l'application a cessé d'utiliser l'ancienne colonne avant de la supprimer. Avec un ORM comme SQLAlchemy, vos modèles pourraient toujours essayer de charger une colonne depuis la base de données même si elle n'est pas explicitement utilisée dans votre logique métier. Pour éviter cela, nous configurons temporairement l'ORM pour qu'il cesse de charger la colonne qui est sur le point d'être supprimée.
Par exemple, pour arrêter de charger une old_column_to_drop
avant de la supprimer, nous pouvons la marquer comme différée (deferred) :
from sqlalchemy.orm import deferred
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class MyModel(Base):
__tablename__ = 'my_table'
id = Column(Integer, primary_key=True)
new_column = Column(String)
# On diffère le chargement de la colonne que nous allons supprimer.
# L'ORM n'essaiera plus de la SELECT dans les requêtes standards.
old_column_to_drop = deferred(Column(String))
Afin de faire respecter cette pratique, nous utilisons un hook de pre-commit spécifique qui vérifie les colonnes en cours de suppression par une migration de BDD et génère une erreur si elle n'est pas marquée comme deferred
dans le modèle ORM.
2. Mettre en place un linter de migration de BDD
Pour détecter les changement de schémas dangereux avant même qu'ils n'atteignent la production, nous avons intégré Squawk comme hook de pre-commit. Squawk est un linter pour qui signale automatiquement les opérations à risque dans les fichiers de migration, telles que :
- Ajouter une colonne avec une valeur
DEFAULT
(ce qui peut verrouiller la table). - Ajouter une colonne avec une contrainte
NOT NULL
dès le début. - Ajouter un nouvel index sans le mot-clé
CONCURRENTLY
.
Ce linter sert de première ligne de défense automatisée, en faisant respecter nos normes de sécurité et en informant les ingénieurs sur les pièges potentiels.
3. Fournir des directives claires aux ingénieurs
L'automatisation ne peut pas tout résoudre. La pensée critique reste nécessaire. Pour soutenir nos développeurs, nous avons mis en place quelques processus légers.
- Checklist sur les PR GitHub : Notre modèle de pull request inclut une checklist manuelle pour toute PR contenant une migration de base de données. Elle demande à l'auteur de confirmer qu'il ou elle a suivi les bonnes pratiques, envisagé les scénarios de rollback et utilisé le pattern expand/contract si nécessaire.
- Confirmation Explicite : une vérification automatisée sur GitHub exige qu'un label
db-migration-ok
soit ajouté à toute PR liée à une migration de BDD. Cela agit comme une validation délibérée, forçant le développeur à faire une pause et à revérifier son travail par rapport à la checklist. - Playbook d'Urgence : Nous avons une page Notion centralisée, accessible via un golink facile à retenir, avec des instructions pour gérer une migration bloquée. Elle inclut des requêtes
psql
pour identifier quelle transaction bloque la migration et des procédures sûres pour résoudre le problème.
4. Utiliser les fonctionnalités de PostgreSQL pour atténuer les verrous
Enfin, nous tirons parti des fonctionnalités propres à PostgreSQL pour construire un système plus résilient aux problèmes liés aux migrations de BDD.
Définir un lock_timeout
Au lieu de laisser une migration attendre indéfiniment un verrou (et provoquer une panne), nous configurons un timeout. Une migration qui échoue est toujours préférable à une interruption de service. Nous appliquons ce timeout directement dans nos migrations Alembic.
# Dans votre db/env.py
def run_migrations_online():
# ...
engine = create_engine(
context.config.get_main_option("sqlalchemy.url"),
connect_args={
"options": "-c lock_timeout=2000"
},
)
Éviter les verrous de longue durée
La cause première d'une migration bloquée est souvent une transaction de longue durée provenant de l'application elle-même. Nous travaillons à imposer un idle_in_transaction_session_timeout
sur toute notre application, qui termine automatiquement les sessions qui restent inactives au sein d'une transaction. Cela réduit considérablement la fenêtre d'opportunité pour qu'une transaction égarée bloque un déploiement critique.
Rendre les migrations ré-exécutables
L'objectif des timeouts de verrouillage est de faire échouer les migrations rapidement au lieu de bloquer la BDD pendant une longue période et de provoquer une panne.
Cela signifie que nous devons souvent relancer certaines migrations. Quand une migration échoue, elle est réessayée automatiquement jusqu'à 100 fois. Si elle échoue 100 fois, un développeur doit relancer le processus de CI qui l'exécute, idéalement après avoir enquêté et résolu le problème qui a causé l'échec.
Pour pouvoir relancer les migrations en toute sécurité, elles doivent être atomiques, ou au moins idempotentes. Nous rendons la plupart de nos migrations atomiques en exécutant toutes les étapes d'une migration donnée dans sa propre transaction, et nous tirons parti du DDL transactionnel de PostgreSQL.
Les migrations qui ne peuvent pas être atomiques (par exemple, l'ajout d'index de manière concurrente) sont rendues idempotentes en utilisant des clauses IF NOT EXISTS
.
Notre linter de migration de BDD décrit ci-dessus vérifie que toutes les migrations sont atomiques ou idempotentes, et génère une erreur de pre-commit si ce n'est pas le cas.
Résultats et leçons apprises
Les résultats sont clairs : nous avons complètement éliminé les interruptions de service causées par les migrations de schéma de base de données. Le pire scénario auquel nous sommes confrontés aujourd'hui est une migration qui échoue à cause d'un timeout de verrouillage et doit être relancée.
Cependant, la transition n'a pas été sans défis :
- La complexité reste un facteur :
- Implémenter des migrations suivant le pattern “expand and contract” demande plus de planification.
- Les avertissements de Squawk peuvent être complexes et difficiles à déchiffrer pour les ingénieurs moins familiers avec les rouages internes de PostgreSQL.
- La création d'index concurrents n’est pas simple : Ajouter un index de manière concurrente (
CREATE INDEX CONCURRENTLY
) ne peut pas être exécuté au sein d'une transaction et peut prendre un temps imprévisible. Cela ne s'intègre pas facilement dans un fichier de migration standard. Notre processus actuel — exécuter la commande manuellement puis ajouter l'index avec une clauseIF NOT EXISTS
dans une migration ultérieure — paraît alambiqué (sans mauvais jeu de mots) à beaucoup.
Prochaines étapes
Nous continuons d'améliorer notre processus en nous concentrant sur trois aspects :
- Tirer davantage parti des fonctionnalités de PostgreSQL : Nous voulons corriger les parties restantes de notre application qui nous empêchent d'utiliser un
idle_in_transaction_timeout
agressif au niveau global. Cela devrait réduire encore davantage le besoin d'intervention manuelle sur les migrations qui échouent.
- Améliorer l'expérience développeur : Nous prévoyons de construire plus d'automatisation et d'outillage pour faire en sorte que la méthode de migration la plus sûre soit aussi la plus simple. Cela inclut de meilleurs linters et des outils pour rationaliser la création d'index concurrents et le pattern expand/contract.
- Explorer d'autres paradigmes : Nous prévoyons également d'explorer des paradigmes différents comme pgroll, qui utilise une approche de schéma virtuel pour offrir des migrations natives sans interruption de service. Si vous avez une expérience en production avec cet outil, nous serions ravis d'en discuter !