Versionner le schéma de base de données d'une application métier — pratiques de migration pour éviter que « chaque client ait une base différente »
· Go Komura · Base de données, SQLite, SQL Server, Migration, Gestion des schémas, C#, .NET, Maintenance, Tableau de décision, Développement Windows
« La base de données installée chez le client A a cette colonne, mais celle du client B ne l’a pas. Et on ne sait même plus dans quelle version elle a été ajoutée » — reprendre la maintenance d’une application métier installée séparément chez chaque client, c’est tomber, dans une bonne partie des cas, sur exactement cette situation.
Le manuel de mise à jour indique : « Exécutez ce SQL sur la base de données. » Mais seule la personne ayant effectué l’intervention sur place sait si cela a réellement été fait, et avec le temps se mélangent des clients chez qui l’étape a été oubliée, d’autres où une erreur est survenue en cours de route et où l’on a laissé les choses en l’état, et d’autres encore qui ont sauté une version lors d’une mise à jour et se retrouvent avec un ALTER TABLE intermédiaire manquant. Des années plus tard, des efforts d’ingénierie considérables finissent engloutis dans l’investigation d’une « erreur qui ne se produit que chez ce client-là ».
Sur ce blog, nous avons déjà présenté une forme minimale de schéma numéroté par version dans « Choisir l’emplacement de stockage des données d’une application Windows », et détaillé la conception opérationnelle de SQLite dans « Utiliser SQLite dans les applications métier avec C# ». Cet article prolonge ces deux précédents et approfondit comment versionner les changements de schéma d’une base de données et les appliquer en toute sécurité aux nombreuses bases dispersées chez les clients. SQLite sert de fil conducteur, mais la conception se généralise tout aussi bien à SQL Server (Express).
1. L’essentiel d’abord
- Les changements de schéma doivent être livrés sous forme de code — des migrations numérotées intégrées à l’application elle-même — plutôt que sous forme de manuel de procédures SQL, et être appliqués automatiquement au démarrage. Tout fonctionnement reposant sur l’exécution manuelle d’un manuel de procédures s’effondre dès que la base de données se disperse chez les clients.
- La base de données elle-même doit enregistrer sa version de schéma actuelle. Sous SQLite,
PRAGMA user_versionest un emplacement réservé exactement à cet usage.1 Sous SQL Server, conservez l’historique des applications dans une table dédiée. - Les migrations n’avancent que dans un sens et ne s’ajoutent qu’en fin de liste. Ne réécrivez jamais le SQL d’un numéro déjà livré ; corrigez-le plutôt sous un nouveau numéro. Ainsi, même une mise à jour qui saute des versions, de v1.2 directement à v1.5, se réduit simplement à « appliquer dans l’ordre tout ce qui n’a pas encore été appliqué ».
- Traitez les changements destructeurs — suppression ou renommage d’une colonne — comme une mise en production en deux temps, expand-contract. Livrez d’abord une version qui ne fait qu’ajouter, et ne livrez la version qui supprime l’ancienne forme qu’une fois que plus rien n’y fait référence.
- Protégez-vous contre l’accident d’une ancienne version de l’application ouvrant une base plus récente grâce à une vérification de version minimale. Le principe : ne jamais laisser l’application écrire dans un schéma futur qu’elle ne comprend pas.
- Prenez une sauvegarde automatique avant toute application. Sous SQLite, une simple instruction
VACUUM INTOproduit une copie cohérente,2 ce qui transforme la récupération après un échec en un simple remplacement de fichier. - Une migration égale une transaction, et la mise à jour du numéro de version doit se trouver dans cette même transaction. SQLite peut aussi annuler du DDL au sein d’une transaction.3 SQL Server a des instructions DDL qui font exception à cette règle, il faut donc isoler ces opérations dans des migrations à part.4
2. Pourquoi « chaque client a une base de données différente » se produit
Décomposez les causes, et toutes remontent à un fonctionnement qui suppose qu’un humain s’en charge.
- Un ALTER manuel oublié. Rien dans la base de données elle-même n’enregistre si le SQL du manuel a réellement été exécuté, et dès que le seul moyen de vérifier est de « regarder la définition des tables à l’œil », des oublis sont garantis.
- Un échec en cours d’exécution laissé tel quel. Si la troisième des cinq instructions SQL du manuel échoue, la personne qui l’exécute ne peut juger s’il faut continuer ou annuler, et cela se termine par « l’application fonctionne quand même, on laisse comme ça ». Cette base de données possède désormais un schéma unique en son genre, qui ne correspond à aucune version.
- Les mises à jour qui sautent des versions. Un client passant directement de v1.2 à v1.5 doit correctement parcourir à la fois les changements de schéma de v1.3 et de v1.4, ce qui est difficile à garantir dans un fonctionnement basé sur des manuels de procédures.
- Un correctif d’urgence sur place. Il arrive qu’« on ajoute cette colonne en avance, juste pour ce client », et la mise à jour officielle ultérieure échoue alors avec une erreur de double application.
Un système web avec un seul serveur n’a qu’une seule base de données, dont l’état est toujours connu. Ce qui rend les applications métier de bureau fondamentalement plus difficiles, c’est que la base de données de la même application est dispersée sur des dizaines, voire des centaines de PC chez les clients et sur les différents sites, et toutes ne sont pas nécessairement à la même version. Un fonctionnement où une personne traite les machines une par une s’effondre proportionnellement au nombre de machines ; il n’y a donc qu’une seule conclusion possible : donner à l’application elle-même la capacité d’examiner sa propre base de données et de la faire monter jusqu’au schéma le plus récent.
3. Le schéma de base : version de schéma + migrations avant uniquement
Le squelette du mécanisme ne comporte que trois éléments.
- La base de données elle-même possède un numéro de version de schéma (un entier réservé au schéma, distinct de la version produit de l’application).
- Les changements de schéma s’ajoutent au code de l’application sous forme d’une séquence numérotée de migrations.
- Au démarrage (immédiatement après la connexion à la base de données), l’application applique, dans l’ordre et dans des transactions, toutes les migrations dont le numéro est supérieur à la version actuelle.
Pour SQLite, PRAGMA user_version sert d’emplacement pour stocker le numéro de version. C’est un entier stocké dans l’en-tête de la base de données (à l’offset 60), et la documentation officielle indique explicitement que « l’application est libre de l’utiliser comme bon lui semble, et SQLite lui-même ne touche jamais cette valeur ».1 Sans créer aucune table dédiée, un simple fichier de base de données peut déclarer lui-même sa propre version.
Une implémentation maison en C# devient utilisable en quelques dizaines de lignes.
using Microsoft.Data.Sqlite;
public static class SchemaMigrator
{
// Liste en ajout uniquement. Ne jamais réécrire le SQL d'un numéro déjà livré
private static readonly (int Version, string Sql)[] Migrations =
{
(1, "CREATE TABLE customer (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"),
(2, "ALTER TABLE customer ADD COLUMN phone TEXT"),
(3, """
CREATE TABLE invoice (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customer(id),
issued_at TEXT NOT NULL, -- stocké en UTC, avec un format fixe
amount INTEGER NOT NULL -- montants en entiers, dans la plus petite unité monétaire
)
"""),
};
public static void Migrate(SqliteConnection conn)
{
// Une erreur d'ajout (numéro dupliqué ou hors ordre) se traduit par une double
// application ou un oubli silencieux : on la détecte et on s'arrête avant d'appliquer quoi que ce soit
for (int i = 1; i < Migrations.Length; i++)
if (Migrations[i].Version <= Migrations[i - 1].Version)
throw new InvalidOperationException(
"Les numéros de migration doivent être strictement croissants et uniques.");
int current = GetUserVersion(conn);
int latest = Migrations[^1].Version;
if (current > latest)
// Cas où une base créée par une nouvelle application est ouverte par une ancienne (voir 5.2).
// Il est plus sûr de s'arrêter ici plutôt que de toucher un schéma inconnu
throw new InvalidOperationException(
$"Cette base de données (schéma v{current}) a été créée par une version plus " +
"récente de l'application. Veuillez mettre à jour l'application.");
foreach (var (version, sql) in Migrations)
{
if (version <= current) continue;
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
// Valider la mise à jour de version dans la même transaction.
// Cela élimine l'état où « le changement est appliqué mais le numéro est resté ancien »
cmd.CommandText = $"PRAGMA user_version = {version}";
cmd.ExecuteNonQuery();
tx.Commit();
}
}
private static int GetUserVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
Cela résout structurellement les problèmes du chapitre 2. Rien n’est oublié (une vérification a lieu à chaque démarrage), un échec en cours de route est annulé (chapitre 6), et sauter des versions ne pose aucun problème (si une base v1.2 est au schéma v2, une application v1.5 applique simplement 3, 4 et 5 dans l’ordre). Même « dans quel état se trouve la base de données de ce client » peut se répondre par une simple lecture de PRAGMA user_version.
Il n’y a que deux règles opérationnelles à respecter sans exception.
- Ne jamais réécrire un numéro déjà livré. Même si le SQL de v3 contient un bug, corrigez-le en v4. Le réécrire crée une nouvelle source de divergence : certaines bases ont « l’ancien v3 appliqué », d’autres ont « le nouveau v3 appliqué ».
- Inclure aussi la transformation des données dans la migration. Au-delà de l’ajout d’une colonne, la migration des données existantes (un UPDATE) doit se trouver sous le même numéro. Unifier dès le départ les colonnes de date/heure sur UTC et un format fixe — comme le décrit « Date, heure et fuseaux horaires dans les applications métier » — simplifie les migrations ultérieures.
Il reste une tâche supplémentaire, nécessaire uniquement lorsqu’on greffe ce mécanisme sur un système existant. Une base de données exploitée jusque-là par des correctifs manuels peut se retrouver dans un état où « user_version est encore à 0, mais le schéma réel a partiellement avancé » (le correctif d’urgence du chapitre 2 en est exactement un exemple). La brancher telle quelle sur cette chaîne fait échouer un ALTER TABLE pour un changement déjà appliqué, avec une erreur « la colonne existe déjà ». Pour la première version qui introduit ce système, examinez le schéma réel comme une étape de référence (baseline) ponctuelle (sous SQLite, vérifiez la présence d’une colonne avec PRAGMA table_info), inscrivez le numéro de version correspondant pour toute base connue comme ayant reçu un correctif manuel, puis confiez tout ce qui suit aux migrations avant uniquement. Sauter cette étape n’est possible que si ce mécanisme a été intégré dès la toute première version.
SQL Server n’a pas d’équivalent à user_version ; on insère donc une ligne par migration dans une table dédiée (par exemple schema_version) enregistrant le numéro de version, la date d’application et la version de l’application qui l’a appliquée. Comme l’historique persiste sous forme de lignes, il devient plus facile d’investiguer par la suite.
4. Utiliser un outil ou tout faire soi-même — un tableau de décision
Trois familles d’outils permettent d’atteindre le même résultat : EF Core Migrations, une bibliothèque de migration (comme DbUp), et l’implémentation maison du chapitre précédent.
| Axe de comparaison | EF Core Migrations | Bibliothèque de migration (DbUp, etc.) | Implémentation maison |
|---|---|---|---|
| Description d’un changement | Généré automatiquement à partir d’un changement de modèle C# | Scripts SQL conservés tels quels comme des actifs | Chaînes SQL ou code C# |
| Coût d’apprentissage | Élevé (nécessite de comprendre le modèle, l’outillage et les contraintes) | Faible à moyen | Minimal (comprendre quelques dizaines de lignes) |
| Compatibilité avec SQLite | △ Les changements/suppressions de colonnes deviennent une reconstruction de table. Impossible de générer des scripts idempotents5 | ○ Centré sur SQL Server, mais prend aussi en charge SQLite, etc.6 | ◎ Écriture directe en pleine connaissance des contraintes |
| Réutilisation du SQL brut existant | Difficile à réutiliser (doit être remplacé par des définitions de modèle) | ◎ Le SQL du manuel de procédures peut être repris presque tel quel | ◎ Idem |
| Suivi de ce qui est appliqué | Table d’historique (automatique) | Table journal (automatique)6 | user_version / table maison |
| Compatibilité avec la distribution | Intégré à l’application, Migrate() au démarrage (précautions ci-dessous) |
Intégré à l’application, exécuté au démarrage | Intégré à l’application, exécuté au démarrage |
Voici les recommandations selon la situation.
| Situation | Recommandation | Raison |
|---|---|---|
| Accès aux données déjà via EF Core | EF Core Migrations | Évite de gérer le modèle et le schéma en double ; aucune raison d’ajouter un autre outil |
| Principalement du SQL brut (ADO.NET / Dapper) + SQLite | Implémentation maison | Aucune dépendance suffit ; de toute façon, il faudra tenir compte des limites d’ALTER TABLE de SQLite |
| Principalement du SQL brut + SQL Server, avec un gros stock de SQL de manuels de procédures | Une bibliothèque telle que DbUp | Le SQL existant peut devenir des scripts-actifs, sans avoir à développer soi-même le suivi des applications |
| Beaucoup de procédures stockées / vues | Une bibliothèque telle que DbUp | Pour les objets qu’un modèle ne peut pas générer, les gérer comme des scripts SQL est la voie la plus directe |
| Base de données petite et changements peu fréquents | Implémentation maison | Minimise le coût d’entretien du mécanisme |
DbUp est « une bibliothèque .NET qui aide à déployer des changements sur une base de données SQL Server » ; elle enregistre les scripts exécutés dans une table journal et n’exécute que ceux qui ne l’ont pas encore été. Elle prend aussi en charge SQLite, PostgreSQL, MySQL, entre autres.6 En tant que chemin de migration pour « transformer le SQL d’un manuel de procédures en une application automatique et tracée », c’est ce qui se fait de plus court.
4.1 Précautions pour utiliser EF Core Migrations dans une application distribuée
En développement, les migrations EF Core s’appliquent avec dotnet ef database update, mais le PC d’un client ne dispose ni du SDK ni des sources. La méthode d’application réaliste est context.Database.Migrate() au démarrage de l’application.
Il faut savoir ici que la documentation de Microsoft déconseille explicitement d’appliquer les migrations au démarrage comme méthode de gestion d’une base de données de production. Les cinq raisons avancées sont : (1) des échecs ou des corruptions dus à l’application simultanée par plusieurs instances (avant EF Core 9), (2) d’autres applications accédant à la base pendant l’application des migrations peuvent provoquer des problèmes sérieux, (3) l’application a besoin de droits élevés pour modifier le schéma, (4) il n’existe guère de mécanisme de retour arrière, et (5) il est impossible de vérifier ou corriger à l’avance le SQL qui va s’exécuter — la recommandation étant plutôt de générer des scripts SQL et de les appliquer dans le cadre du processus de déploiement.7
Mais cette recommandation suppose un système serveur avec « une seule base de données et un véritable processus de déploiement ». Pour une application de bureau avec une base de données locale sur chaque PC client, transporter physiquement des scripts sur chaque site pour les appliquer, c’est exactement le problème du chapitre 2 — de sorte que Migrate() au démarrage devient de facto la solution standard. Il reste à traiter les préoccupations restantes.
- Concurrence : à partir d’EF Core 9,
Migrate()acquiert automatiquement un verrou pour empêcher plusieurs processus d’exécuter des migrations en même temps.7 Sur les versions antérieures, sérialisez vous-même comme décrit au chapitre 6. Notez que ce verrou ne sérialise que les exécutions de migrations entre elles — il n’empêche pas une ancienne version de l’application de lire et écrire normalement pendant qu’une migration est en cours. Pour une base de données partagée, prévoyez de combiner cela avec la vérification de version minimale (section 5.2) ou une fenêtre de maintenance. - Vérification préalable du SQL : révisez toujours la migration générée, et répétez-la sur une base de données comparable à la production avant la mise en production (section 6.3).
- Ne pas mélanger avec
EnsureCreated(): cela construit le schéma sans historique de migration, etMigrate()échoue ensuite. Uniformisez surMigrate()dès le départ.7
Avec le fournisseur SQLite, une migration qui change le type d’une colonne ou en supprime une s’exécute comme une reconstruction complète de table — créer une nouvelle table, copier les données, supprimer l’ancienne table, renommer — et il est également impossible de générer des scripts idempotents.5 La question de savoir s’il faut utiliser EF Core est traitée au chapitre 8 de « Utiliser SQLite dans les applications métier avec C# ».
5. Écrire des migrations qui ne cassent rien
Le principe directeur de toute migration individuelle est : ne jamais introduire un changement incompatible avec l’existant dans une seule mise en production.
5.1 Traiter les changements destructeurs avec expand-contract (une mise en production en deux temps)
Ajouter une colonne est sûr, mais en supprimer une, la renommer, ou changer son type casse « tout ce qui supposait l’ancienne forme ». Même avec une base SQLite locale en relation un-à-un avec l’application, on se retrouve généralement avec au moins l’un des cas suivants : (a) la possibilité de revenir à une ancienne version de l’application si quelque chose casse, (b) un autre outil qui lit directement la base de données (un outil d’édition, un exportateur CSV, une intégration Access), ou (c) une configuration SQL Server où d’anciens et de nouveaux clients se connectent en même temps. Les changements destructeurs se découpent donc en deux temps — expand, puis contract.
| Changement | Ce qui se passe si on le fait d’un coup | Une approche sûre en deux temps |
|---|---|---|
| Renommer une colonne | Les anciennes applications/anciens rapports référençant l’ancien nom meurent instantanément | expand : ajouter la nouvelle colonne et copier les valeurs depuis l’ancienne. La nouvelle application écrit dans les deux, et la lecture continue de considérer l’ancienne colonne comme faisant foi (car, sur une base partagée où anciennes et nouvelles applications tournent en même temps, l’ancienne application n’écrit que dans l’ancienne colonne — un déclencheur côté base peut aussi servir à les garder synchronisées) → contract : une fois les anciennes applications exclues, faire une copie finale des dernières valeurs de l’ancienne colonne vers la nouvelle, basculer la lecture sur la nouvelle colonne, puis supprimer l’ancienne (basculer avant d’exclure les anciennes applications ferait perdre toute mise à jour que l’ancienne application n’aurait écrite que dans l’ancienne colonne) |
| Supprimer une colonne | Les INSERT/SELECT de l’ancienne application échouent | expand : l’application cesse simplement d’y faire référence (la colonne reste) → contract : la supprimer plusieurs versions plus tard |
| Changement de type/de sens (ex. heure locale → UTC) | Anciennes et nouvelles valeurs coexistent dans une seule colonne, et tout casse silencieusement | expand : ajouter une nouvelle colonne et la peupler avec des valeurs converties. Traiter la période de coexistence comme pour un renommage (la nouvelle application écrit dans les deux, et la lecture considère l’ancienne colonne comme faisant foi) → contract : une fois les anciennes applications exclues, faire une conversion finale depuis l’ancienne colonne, basculer la lecture, puis supprimer l’ancienne colonne |
| Ajout d’une contrainte NOT NULL | L’application échoue sur les lignes NULL existantes ; une écriture NULL d’une ancienne application meurt aussi instantanément sur la contrainte | expand : fournir une valeur par défaut et mettre à jour tous les clients vers une version qui écrit des valeurs non NULL → contract : une fois les anciennes applications exclues, combler par UPDATE les NULL restants, puis ajouter la contrainte |
Il est plus sûr de ne livrer le volet contract (suppression) de la mise en production qu’une fois la vérification de version minimale (section suivante) en place et capable d’exclure réellement les anciennes applications.
SQLite a ses propres limites ici : ALTER TABLE ne prend en charge que le renommage de la table, le renommage d’une colonne, l’ajout d’une colonne et la suppression d’une colonne, et même la suppression d’une colonne s’accompagne d’une longue liste de restrictions — on ne peut pas supprimer une colonne faisant partie d’une clé PRIMARY KEY ou d’une contrainte UNIQUE, ni une colonne référencée par un index, une contrainte CHECK, une clé étrangère ou une vue. Tout autre changement passe par la procédure décrite dans la documentation officielle : créer une nouvelle table dans une transaction, déplacer les données avec INSERT INTO new_X SELECT ... FROM X, puis supprimer l’ancienne table et la renommer.3 Sur une grosse table, cela devient une copie intégrale ; prévoyez donc à la fois le temps d’application et l’espace disque libre nécessaire.
5.2 Se prémunir contre les rétrogradations — vérification de version minimale
Dans une conception à migrations avant uniquement, on n’écrit pas de scripts de migration arrière (ils ne sont jamais exercés chez un client, et du code non testé n’est qu’un risque). Ce qu’il faut à la place, c’est un mécanisme qui arrête tout si une ancienne version de l’application ouvre un jour une base plus récente. C’est exactement ce que fait le début du code du chapitre 3 : si user_version est supérieur au plus grand numéro connu de l’application, elle lève une exception et interrompt le démarrage.
Si l’on s’engage dès le départ à « aucun changement destructeur dans une version susceptible de nécessiter un retour en arrière — expand uniquement », il devient alors sûr pour une ancienne application de lire une base plus récente, et l’on peut assouplir la vérification en quelque chose comme « avertir et démarrer en lecture seule ». Le choix dépend de la tolérance de l’activité aux interruptions.
5.3 Sauvegardes automatiques avant application
Une migration est une opération chirurgicale sur des « données de production se trouvant sur le PC de quelqu’un d’autre ». Automatisez la pratique consistant à prendre une sauvegarde avant de l’exécuter. Sous SQLite, VACUUM INTO est l’outil idéal — une seule instruction produit un instantané cohérent dans un fichier séparé, même à partir d’une base de données en cours d’utilisation.2
// Prendre une sauvegarde d'une génération, juste avant, uniquement si une migration est réellement nécessaire
if (GetUserVersion(conn) < latest)
{
Directory.CreateDirectory(backupDir);
var backupPath = Path.Combine(backupDir,
$"app_schema_v{GetUserVersion(conn)}_{DateTime.Now:yyyyMMdd_HHmmss}.db");
// Créer sous un nom temporaire et ne renommer qu'après succès, afin qu'un fichier incomplet
// laissé par une coupure de courant ou l'arrêt forcé du processus en cours d'écriture ne
// ressemble jamais à une « sauvegarde terminée »
var tempPath = backupPath + ".tmp";
using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", tempPath);
cmd.ExecuteNonQuery(); // Exécuter VACUUM en dehors d'une transaction
File.Move(tempPath, backupPath);
// Si un fichier *.tmp subsiste au démarrage, c'est la trace d'un échec précédent : le supprimer
}
Inclure la version du schéma dans le nom de fichier permet de voir d’un coup d’œil « jusqu’où » on revient lors d’une restauration. Pour en savoir plus sur les sauvegardes — y compris pourquoi une simple copie de fichier d’une base en cours d’utilisation est un terreau de corruption — voir le chapitre 7 de « Utiliser SQLite dans les applications métier avec C# ». Pour SQL Server, l’équivalent consiste à exécuter BACKUP DATABASE avant l’application ; l’idée sous-jacente reste la même.
6. Pièges opérationnels
6.1 Échecs en cours de route et transactions — connaître les différences entre moteurs de bases de données
Le code du chapitre 3 enveloppe une migration dans une transaction et inclut la mise à jour de user_version dans cette même transaction. Cela fonctionne parce que SQLite peut exécuter du DDL (CREATE TABLE, ALTER TABLE, etc.) dans une transaction et l’annuler en cas d’échec. La procédure officielle de reconstruction de table est elle-même structurée comme « démarrer une transaction, effectuer les CREATE/INSERT/DROP/RENAME, puis valider ».3 Même si l’alimentation coupe en cours de route, la base de données au démarrage suivant se trouve dans l’état cohérent de « juste avant cette migration ».
SQL Server peut lui aussi exécuter une grande partie du DDL dans une transaction, mais avec des exceptions. ALTER DATABASE, par exemple, ne peut pas être utilisé dans une transaction explicite, et CREATE FULLTEXT INDEX ne peut pas non plus être placé dans une transaction utilisateur.4 EF Core enveloppe lui aussi automatiquement chaque migration dans une transaction lorsque c’est possible, tout en précisant explicitement que « certaines opérations ne peuvent pas s’exécuter dans une transaction selon la base de données ».8 En pratique, la règle se résume à ceci : ne jamais mélanger, dans une même migration, une opération incapable de participer à une transaction avec un changement de schéma ordinaire. En changeant de moteur de base de données, vérifiez toujours si le DDL participe aux transactions.
Un accident classique est « la mise à jour de version se retrouve dans une transaction séparée ». Si le changement lui-même réussit mais que le processus meurt avant la mise à jour du numéro, la même migration se réexécute au démarrage suivant et le démarrage échoue indéfiniment avec une erreur « la table existe déjà ». En gardant la mise à jour de version dans la même transaction, cela ne peut se produire, par principe.
6.2 Démarrages simultanés de plusieurs processus — sérialiser l’application
Une application métier est un logiciel que « tout le monde démarre en même temps le matin ». Plusieurs clients pointant vers une base partagée (SQL Server), ou plusieurs instances lancées sur le même PC, peuvent finir par exécuter des migrations en même temps.
- À partir d’EF Core 9,
Migrate()acquiert automatiquement un verrou portant sur l’ensemble de la base de données pour empêcher les applications concurrentes (les versions antérieures n’offrent aucune protection de ce type). Notez que le verrou du fournisseur SQLite est implémenté via une table dédiée, et la documentation officielle signale que cette table peut subsister si le processus appliquant une migration se termine anormalement.7 Si le démarrage reste bloqué en attente de ce verrou, vérifiez qu’aucun autre processus n’exécute réellement une migration, puis récupérez en supprimant la table de verrouillage restante (__EFMigrationsLock). - Avec une implémentation maison, pour une base de données locale, la sérialisation via un Mutex nommé est la solution la plus simple.
// Préfixer avec Global\ pour que la sérialisation s'applique à l'ensemble du PC même en cas de
// lancement depuis plusieurs sessions de connexion via RDP ou changement d'utilisateur (Local\
// ne concerne qu'une seule session)
using var mutex = new Mutex(false, @"Global\MyApp.SchemaMigration");
try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
// Le cas où le processus propriétaire précédent s'est terminé anormalement sans jamais
// appeler Release. Même si une exception est levée, la possession elle-même a bien été
// acquise ; il est donc possible de continuer. La possibilité que l'application précédente
// se soit arrêtée à mi-chemin est couverte par la nouvelle vérification de version juste
// après, ainsi que par les transactions par migration
}
try
{
SchemaMigrator.Migrate(conn);
}
finally
{
mutex.ReleaseMutex();
}
Le processus qui attendait revérifie la version dès qu’il obtient le verrou (le code du chapitre 3 revérifie version <= current à chaque fois, avant d’appliquer quoi que ce soit), donc cela ne se transforme jamais en double application. Une mise en garde : un objet nommé Global\ porte par défaut une ACL dérivée de l’utilisateur qui l’a créé, si bien qu’ouvrir le même Mutex depuis la session d’un compte Windows différent peut lever UnauthorizedAccessException. Si une utilisation multi-comptes est prévue, créez-le soit avec MutexAcl de System.Threading.AccessControl en accordant les droits de synchronisation/modification aux utilisateurs concernés, soit en vous appuyant plutôt sur le verrouillage côté base de données décrit ci-après. Comme un Mutex ne peut pas franchir les limites d’une machine, pour une base de données partagée, privilégiez la sérialisation au niveau de la base — « terminer l’application côté serveur avant de distribuer la mise à jour », ou « prendre un verrou côté base au démarrage de l’application (BEGIN IMMEDIATE pour SQLite, un verrou d’application pour SQL Server) ».
6.3 Répétition générale — tester une application d’un coup depuis « la base la plus ancienne »
Les bugs de migration ne se révèlent presque jamais sur la machine d’un développeur, car la base de données d’une machine de développement est toujours au schéma le plus récent et ses données sont propres. Ce qui casse réellement, c’est une base de données client ancienne, volumineuse et remplie de données auxquelles personne n’avait pensé. Il y a au minimum trois choses à faire avant une mise en production.
- Conserver un fichier de base de données pour chaque version de schéma comme fixture de test, et automatiser un test qui applique d’un coup, depuis chacune d’elles, jusqu’à la dernière. Des motifs de saut comme « de v1 à v5 » ou « de v3 à v5 » sont exactement la réalité chez les clients. Avec SQLite, comme il suffit de placer le fichier de base de données dans le dépôt, ce type de test est plutôt facile à écrire.
- Tester avec un volume et une qualité de données comparables à la production. Des colonnes remplies de NULL, des doublons inattendus, et le temps de reconstruction sur une table volumineuse (section 5.1) ne se révèlent que si les données se rapprochent des données réelles. Dans la mesure du possible, répétez sur une base de données client anonymisée.
- Tester le scénario d’échec. Tuez le processus en cours d’application, et vérifiez que le démarrage suivant récupère correctement — qu’il réapplique à partir de la version à laquelle il est revenu.
7. Résumé
- « Chaque client a une base de données différente » n’est pas un manque de rigueur de la part des intervenants, c’est la conséquence structurelle d’un fonctionnement où une personne exécute un manuel de procédures SQL. Pour une application métier de bureau dont les bases de données sont dispersées sur les sites, la seule véritable option est de faire en sorte que l’application mette elle-même sa propre base à jour.
- Le squelette est constitué d’un numéro de version de schéma détenu par la base de données elle-même (
PRAGMA user_versionpour SQLite1) et de l’application, au démarrage, de migrations numérotées et avant uniquement. En C#, quelques dizaines de lignes de code maison suffisent à le faire fonctionner. - Les trois familles d’outils sont EF Core Migrations, une bibliothèque comme DbUp, et une implémentation maison. Choisissez selon que vous utilisez déjà EF Core, et selon la quantité de SQL brut déjà en votre possession (le tableau de décision du chapitre 4). Le
Migrate()d’EF Core au démarrage s’accompagne de précautions officiellement documentées,7 utilisez-le donc en l’associant à la gestion de la concurrence et à des répétitions générales. - Traitez les changements destructeurs comme une mise en production en deux temps, expand-contract, et arrêtez l’accident d’une ancienne application ouvrant une nouvelle base grâce à une vérification de version minimale. Suivez la documentation officielle pour les limites d’ALTER TABLE de SQLite et sa procédure de reconstruction.3
- Le principe est une migration égale une transaction, avec la mise à jour de version dans cette même transaction. SQL Server possède du DDL incapable de participer à une transaction,4 il faut donc isoler ces opérations. Ce n’est qu’une fois ajoutée une sauvegarde
VACUUM INTOavant application2, et répétée une application d’un coup depuis la version la plus ancienne, qu’une migration devient réellement quelque chose que l’on peut livrer à un client.
Si les ALTER pilotés par manuel de procédures vous parlent, essayez d’introduire, dès la prochaine version, ne serait-ce que « l’enregistrement d’un numéro de version » et « l’application au démarrage ». Une fois cette base en place, les mises en production en deux temps et les sauvegardes peuvent s’ajouter petit à petit par la suite.
Articles connexes
- Utiliser SQLite dans les applications métier avec C# — mode WAL, contrôle des accès concurrents, prévention de la corruption et quand privilégier EF Core
- Choisir l’emplacement de stockage des données d’une application Windows — tableau de décision SQLite / JSON / Registre / Access
- Au-delà de appsettings.json — guide pratique de la gestion de configuration dans les applications métier Windows
- Date, heure et fuseaux horaires dans les applications métier — des pièges de DateTime au principe de stockage en UTC et à la conception des tests
Domaines de conseil associés
KomuraSoft LLC (合同会社小村ソフト) prend en charge la conception de bases de données et la mise en place d’une infrastructure de migration pour les applications métier installées chez chaque client, l’investigation et la normalisation des schémas devenus disparates sous un fonctionnement par manuels de procédures, ainsi que la conception de la distribution des mises à jour, aussi bien pour des configurations EF Core que SQL brut.
- Développement d’applications Windows
- Modernisation et maintenance de logiciels Windows existants
- Conseil technique et revue de conception
- Contact
Références
-
SQLite, Pragma statements supported by SQLite - user_version. Sur le fait que user_version est un entier stocké dans l’en-tête de la base de données (à l’offset 60), réservé à l’usage libre de l’application, SQLite lui-même ne touchant jamais cette valeur. ↩ ↩2 ↩3
-
SQLite, VACUUM. Sur le fait que VACUUM INTO laisse la base de données d’origine inchangée tout en créant un instantané cohérent d’une base de données en cours d’utilisation dans un fichier séparé, utilisable comme alternative à l’API de sauvegarde. ↩ ↩2 ↩3
-
SQLite, ALTER TABLE. Sur le fait que l’ALTER TABLE de SQLite se limite au renommage de table, au renommage de colonne, à l’ajout de colonne et à la suppression de colonne ; sur les nombreuses restrictions entourant la suppression d’une colonne ; et sur le fait que les autres changements de schéma s’effectuent via la procédure officielle consistant à créer une nouvelle table dans une transaction, copier les données, supprimer l’ancienne table, puis renommer. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, ALTER DATABASE (Transact-SQL) et CREATE FULLTEXT INDEX (Transact-SQL). Sur le fait qu’ALTER DATABASE doit s’exécuter en mode de validation automatique et n’est pas autorisé dans une transaction explicite ou implicite, et sur le fait que CREATE FULLTEXT INDEX ne peut pas être placé dans une transaction utilisateur. ↩ ↩2 ↩3
-
Microsoft Learn, SQLite EF Core Database Provider Limitations. Sur le fait que de nombreuses opérations de migration s’exécutent comme une reconstruction de table sous le fournisseur SQLite, et sur l’impossibilité de générer des scripts idempotents. ↩ ↩2
-
DbUp, DbUp Documentation et Supported Databases. Sur le fait qu’il s’agit d’une bibliothèque .NET aidant à déployer des changements sur une base de données SQL Server, enregistrant les scripts SQL exécutés et n’exécutant que ceux qui ne l’ont pas encore été, et prenant en charge SQLite, PostgreSQL, MySQL, entre autres. ↩ ↩2 ↩3
-
Microsoft Learn, Applying Migrations (EF Core). Sur les cinq raisons pour lesquelles l’application des migrations à l’exécution (au démarrage) est jugée inadaptée à la gestion d’une base de données de production, sur le fait que la génération de scripts SQL est l’approche recommandée à la place, sur l’interdiction de combiner EnsureCreated() avec Migrate(), sur le fait que Migrate() acquiert automatiquement un verrou portant sur l’ensemble de la base de données à partir d’EF Core 9, et sur le fait que le verrou du fournisseur SQLite est implémenté sous forme de table pouvant subsister après une terminaison anormale. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Managing Migrations (EF Core). Sur le fait qu’EF Core enveloppe automatiquement chaque migration dans une transaction lorsque c’est possible au moment de l’application, et sur le fait que certaines opérations ne peuvent pas s’exécuter dans une transaction selon la base de données. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Jusqu'à quand les applications VB6 continueront-elles de fonctionner ? — état du support du runtime et démarche concrète vers une migration .NET
Jusqu'à quand les applications VB6 continueront-elles de fonctionner ? Cet article clarifie l'asymétrie entre la politique de support du ...
CI/CD pratique pour les applications WinForms / WPF — Automatiser du build à la signature et à la distribution avec GitHub Actions
Guide pratique pour mettre en place le CI/CD des applications WinForms / WPF avec GitHub Actions. Couvre un YAML minimal de build+tests s...
Modifier en toute sécurité une application métier legacy sans tests — Tests de caractérisation et refactorisation en pratique
Pour modifier en toute sécurité une application métier sans tests, cet article explique, avec des exemples en C#, la procédure du test de...
Comment choisir où une application Windows stocke ses données locales — tableau de décision pour SQLite / JSON / Registre / Access
Où et sous quelle forme une application de bureau Windows doit-elle stocker ses données ? Cet article organise le choix entre AppData et ...
La gestion d'incident ne s'arrête pas au rétablissement — Un modèle de postmortem (prévention de la récurrence) pour les petites équipes de développement
Traiter un incident comme terminé une fois corrigé et les excuses présentées garantit que vous le reproduirez. Cet article traduit le pos...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
Maintenance et modernisation de logiciels Windows
Ajouts de fonctions, maintenance et modernisation progressive de logiciels Windows existants.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Comment gérer les changements de schéma de la base de données d'une application métier ?
- Plutôt qu'un fonctionnement où une personne exécute un manuel de procédures SQL, il faut intégrer des migrations numérotées (du code de changement de schéma) directement dans l'application elle-même et les appliquer automatiquement au démarrage. La base de données doit elle-même enregistrer sa version de schéma actuelle (PRAGMA user_version pour SQLite, une table dédiée pour SQL Server), et l'application applique, dans l'ordre et dans des transactions, uniquement les numéros non encore appliqués. Avec cette approche, même une mise à jour qui saute des versions, de v1.2 à v1.5, applique tous les changements de schéma intermédiaires, et l'état où « chaque client a une base de données de forme différente » cesse structurellement de se produire.
- Peut-on appeler Migrate() d'EF Core au démarrage de l'application ?
- C'est un choix réaliste, sous conditions. La documentation de Microsoft met en garde contre l'application des migrations au démarrage en production — en raison de l'application simultanée par plusieurs instances, du fait de donner à l'application des droits de modification du schéma, de l'impossibilité de vérifier le SQL à l'avance, entre autres — et recommande, pour les applications serveur, une application via des scripts SQL générés au préalable. En revanche, pour une application métier de bureau où chaque PC client possède sa propre base de données locale, un fonctionnement où l'on exécute des scripts sur place n'est pas viable, si bien que Migrate() au démarrage devient de facto la solution standard. Dans ce cas aussi, il faut impérativement le combiner avec une protection contre les démarrages simultanés (verrouillage automatique d'EF Core 9 et versions ultérieures, ou un Mutex maison) et une sauvegarde prise avant application.
- Que devient la base de données si une migration échoue en cours de route ?
- Si une migration est enveloppée dans une seule transaction et que la mise à jour du numéro de version est incluse dans cette même transaction, un échec entraîne un retour en arrière à l'état précédant le début de cette migration, et aucun schéma à moitié appliqué ne subsiste. SQLite peut exécuter du DDL (CREATE TABLE, ALTER TABLE, etc.) dans une transaction, et la procédure officielle de reconstruction de table est elle-même écrite en partant du principe qu'elle s'exécute dans une transaction. SQL Server peut lui aussi exécuter la plupart des DDL dans une transaction, mais avec des exceptions — ALTER DATABASE et les opérations liées aux index de recherche en texte intégral, entre autres — il faut donc isoler ces opérations exceptionnelles dans des migrations à part. Avec en plus une sauvegarde automatique prise avant application, même le pire des cas peut être récupéré par un simple remplacement de fichier.
- Si le schéma de base de données est déjà disparate d'un client à l'autre, comment le normaliser ?
- Commencez par arrêter une seule « version de référence » du schéma attendu, puis examinez la base de données de chaque client pour relever son écart avec l'état actuel. Ensuite, pour les bases de données sans numéro de version, écrivez une migration de référence (baseline) qui détecte chacun des cas réellement rencontrés sur le terrain et les ramène à la forme normalisée, puis inscrivez le numéro de version une fois cette migration terminée. Avec SQLite, on peut détecter mécaniquement la présence d'une colonne via sqlite_master ou PRAGMA table_info, et absorber les écarts avec du SQL défensif du type « ajouter la colonne si elle n'existe pas déjà ». Une fois que tous les changements suivants reposent sur des migrations numérotées, les écarts ne réapparaîtront plus.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.
Liens publics