Migrations de bases de données : transformer les chenilles en papillons
Publié: 2022-03-11Les utilisateurs ne se soucient pas du contenu du logiciel qu'ils utilisent ; juste que cela fonctionne en douceur, en toute sécurité et discrètement. Les développeurs s'efforcent d'y parvenir, et l'un des problèmes qu'ils tentent de résoudre est de savoir comment s'assurer que le magasin de données est dans un état approprié pour la version actuelle du produit. Le logiciel évolue et son modèle de données peut également changer au fil du temps, par exemple pour corriger des erreurs de conception. Pour compliquer davantage le problème, vous pouvez avoir un certain nombre d'environnements de test ou de clients qui migrent vers des versions plus récentes du produit à des rythmes différents. Vous ne pouvez pas simplement documenter la structure du magasin et les manipulations nécessaires pour utiliser la nouvelle version brillante d'un seul point de vue.
Une fois, j'ai rejoint un projet avec quelques bases de données dont les structures étaient mises à jour à la demande, directement par les développeurs. Cela signifiait qu'il n'y avait aucun moyen évident de savoir quelles modifications devaient être appliquées pour migrer la structure vers la dernière version et qu'il n'y avait aucun concept de gestion des versions ! C'était à l'époque pré-DevOps et serait considéré comme un gâchis total de nos jours. Nous avons décidé de développer un outil qui serait utilisé pour appliquer chaque modification à la base de données donnée. Il avait des migrations et documenterait les changements de schéma. Cela nous a permis de nous assurer qu'il n'y aurait pas de modifications accidentelles et que l'état du schéma serait prévisible.
Dans cet article, nous verrons comment appliquer les migrations de schéma de base de données relationnelle et comment surmonter les problèmes concomitants.
Tout d'abord, que sont les migrations de bases de données ? Dans le contexte de cet article, une migration est un ensemble de modifications qui doivent être appliquées à une base de données. La création ou la suppression d'une table, d'une colonne ou d'un index sont des exemples courants de migrations. La forme de votre schéma peut changer considérablement au fil du temps, surtout si le développement a commencé alors que les exigences étaient encore vagues. Ainsi, au cours de plusieurs étapes sur le chemin d'une version, votre modèle de données aura évolué et peut être devenu complètement différent de ce qu'il était au tout début. Les migrations ne sont que des étapes vers l'état cible.
Pour commencer, explorons ce que nous avons dans notre boîte à outils pour éviter de réinventer ce qui est déjà bien fait.
Outils
Dans chaque langage largement utilisé, il existe des bibliothèques qui facilitent les migrations de bases de données. Par exemple, dans le cas de Java, les options populaires sont Liquibase et Flyway. Nous utiliserons davantage Liquibase dans les exemples, mais les concepts s'appliquent à d'autres solutions et ne sont pas liés à Liquibase.
Pourquoi s'embêter à utiliser une bibliothèque de migration de schéma distincte si certains ORM fournissent déjà une option pour mettre à jour automatiquement un schéma et le faire correspondre à la structure des classes mappées ? En pratique, de telles migrations automatiques n'effectuent que de simples changements de schéma, par exemple, la création de tables et de colonnes, et ne peuvent pas faire des choses potentiellement destructrices comme supprimer ou renommer des objets de base de données. Ainsi, les solutions non automatiques (mais toujours automatisées) sont généralement un meilleur choix car vous êtes obligé de décrire vous-même la logique de migration et vous savez exactement ce qui va arriver à votre base de données.
C'est également une très mauvaise idée de mélanger les modifications de schéma automatisées et manuelles, car vous risquez de produire des schémas uniques et imprévisibles si les modifications manuelles sont appliquées dans le mauvais ordre ou ne sont pas appliquées du tout, même si elles sont nécessaires. Une fois l'outil choisi, utilisez-le pour appliquer toutes les migrations de schéma.
Migrations de base de données typiques
Les migrations typiques incluent la création de séquences, de tables, de colonnes, de clés primaires et étrangères, d'index et d'autres objets de base de données. Pour les types de modifications les plus courants, Liquibase fournit des éléments déclaratifs distincts pour décrire ce qui doit être fait. Il serait trop ennuyeux de lire tous les changements triviaux pris en charge par Liquibase ou d'autres outils similaires. Pour avoir une idée de l'apparence des ensembles de modifications, considérons l'exemple suivant dans lequel nous créons une table (les déclarations d'espace de noms XML sont omises par souci de brièveté) :
<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog> <changeSet author="demo"> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> </changeSet> </databaseChangeLog> Comme vous pouvez le voir, le journal des modifications est un ensemble d'ensembles de modifications, et les ensembles de modifications sont constitués de modifications. Des changements simples comme createTable peuvent être combinés pour implémenter des migrations plus complexes ; par exemple, supposons que vous deviez mettre à jour le code produit pour tous les produits. Il peut facilement être réalisé avec le changement suivant :
<sql>UPDATE product SET code = 'new_' || code</sql>Les performances en souffriront si vous avez des millions de produits. Pour accélérer la migration, nous pouvons la réécrire dans les étapes suivantes :
- Créez une nouvelle table pour les produits avec
createTable, comme nous l'avons vu précédemment. A ce stade, il est préférable de créer le moins de contraintes possible. Nommons la nouvelle tablePRODUCT_TMP. - Remplissez
PRODUCT_TMPavec SQL sous la forme deINSERT INTO ... SELECT ...en utilisantsqlchange. - Créez toutes les contraintes (
addNotNullConstraint,addUniqueConstraint,addForeignKeyConstraint) et les index (createIndex) dont vous avez besoin. - Renommez la table
PRODUCTen quelque chose commePRODUCT_BAK. Liquibase peut le faire avecrenameTable. - Renommez
PRODUCT_TMPenPRODUCT(encore une fois, en utilisantrenameTable). - Éventuellement, supprimez
PRODUCT_BAKavecdropTable.
Bien sûr, il vaut mieux éviter de telles migrations, mais il est bon de savoir comment les mettre en œuvre au cas où vous vous heurteriez à l'un de ces rares cas où vous en auriez besoin.
Si vous considérez que XML, JSON ou YAML sont trop bizarres pour la tâche de description des modifications, utilisez simplement SQL simple et utilisez toutes les fonctionnalités spécifiques au fournisseur de base de données. En outre, vous pouvez implémenter n'importe quelle logique personnalisée en Java ordinaire.
La façon dont Liquibase vous dispense d'écrire du SQL spécifique à la base de données peut conduire à un excès de confiance, mais vous ne devez pas oublier les bizarreries de votre base de données cible ; par exemple, lorsque vous créez une clé étrangère, un index peut ou non être créé, selon le système de gestion de base de données spécifique utilisé. En conséquence, vous pourriez vous retrouver dans une situation délicate. Liquibase vous permet de spécifier qu'un ensemble de modifications doit être exécuté uniquement pour un type particulier de base de données, par exemple, PostgreSQL, Oracle ou MySQL. Cela rend cela possible en utilisant les mêmes ensembles de modifications indépendants du fournisseur pour différentes bases de données et pour d'autres ensembles de modifications, en utilisant une syntaxe et des fonctionnalités spécifiques au fournisseur. L'ensemble de modifications suivant sera exécuté uniquement si vous utilisez une base de données Oracle :
<changeSet dbms="oracle" author="..."> ... </changeSet>Outre Oracle, Liquibase prend en charge quelques autres bases de données prêtes à l'emploi.
Nommer les objets de la base de données
Chaque objet de base de données que vous créez doit être nommé. Vous n'êtes pas obligé de fournir explicitement un nom pour certains types d'objets, par exemple, pour les contraintes et les index. Mais cela ne signifie pas que ces objets n'auront pas de noms ; leurs noms seront de toute façon générés par la base de données. Le problème survient lorsque vous devez référencer cet objet pour le supprimer ou le modifier. Il est donc préférable de leur donner des noms explicites. Mais y a-t-il des règles sur les noms à donner ? La réponse est courte : Soyez cohérent ; par exemple, si vous avez décidé de nommer les index comme ceci : IDX_<table>_<columns> , alors un index pour la colonne CODE susmentionnée devrait être nommé IDX_PRODUCT_CODE .
Les conventions de nommage sont incroyablement controversées, nous ne prétendons donc pas donner ici des instructions complètes. Soyez cohérent, respectez les conventions de votre équipe ou de votre projet, ou inventez-les simplement s'il n'y en a pas.
Organisation des ensembles de modifications
La première chose à décider est de savoir où stocker les changesets. Il existe essentiellement deux approches :
- Conservez les ensembles de modifications avec le code de l'application. Il est pratique de le faire car vous pouvez valider et réviser ensemble les ensembles de modifications et le code de l'application.
- Conservez les ensembles de modifications et le code d'application séparés , par exemple, dans des référentiels VCS distincts. Cette approche convient lorsque le modèle de données est partagé entre plusieurs applications et qu'il est plus pratique de stocker tous les ensembles de modifications dans un référentiel dédié et de ne pas les disperser dans plusieurs référentiels où réside le code de l'application.
Où que vous stockiez les changesets, il est généralement raisonnable de les répartir dans les catégories suivantes :
- Migrations indépendantes qui n'affectent pas le système en cours d'exécution. Il est généralement sûr de créer de nouvelles tables, séquences, etc., si l'application actuellement déployée n'en a pas encore connaissance.
- Modifications de schéma qui altèrent la structure du magasin , par exemple, ajout ou suppression de colonnes et d'index. Ces modifications ne doivent pas être appliquées lorsqu'une ancienne version de l'application est encore utilisée, car cela peut entraîner des verrous ou un comportement étrange en raison de modifications du schéma.
- Des migrations rapides qui insèrent ou mettent à jour de minuscules quantités de données. Si plusieurs applications sont déployées, les ensembles de modifications de cette catégorie peuvent être exécutés simultanément sans dégrader les performances de la base de données.
- Migrations potentiellement lentes qui insèrent ou mettent à jour un grand nombre de données. Il est préférable d'appliquer ces modifications lorsqu'aucune autre migration similaire n'est en cours d'exécution.
Ces ensembles de migrations doivent être exécutés consécutivement avant de déployer une version plus récente d'une application. Cette approche devient encore plus pratique si un système est composé de plusieurs applications distinctes et que certaines d'entre elles utilisent la même base de données. Sinon, il vaut la peine de ne séparer que les ensembles de modifications qui pourraient être appliqués sans affecter les applications en cours d'exécution, et les ensembles de modifications restants peuvent être appliqués ensemble.

Pour les applications plus simples, l'ensemble complet des migrations nécessaires peut être appliqué au démarrage de l'application. Dans ce cas, tous les changesets appartiennent à une seule catégorie et sont exécutés chaque fois que l'application est initialisée.
Quelle que soit l'étape choisie pour appliquer les migrations, il convient de mentionner que l'utilisation de la même base de données pour plusieurs applications peut entraîner des blocages lors de l'application des migrations. Liquibase (comme beaucoup d'autres solutions similaires) utilise deux tables spéciales pour enregistrer ses métadonnées : DATABASECHANGELOG et DATABASECHANGELOGLOCK . Le premier est utilisé pour stocker des informations sur les ensembles de modifications appliqués, et le second pour empêcher les migrations simultanées au sein du même schéma de base de données. Ainsi, si plusieurs applications doivent utiliser le même schéma de base de données pour une raison quelconque, il est préférable d'utiliser des noms autres que ceux par défaut pour les tables de métadonnées afin d'éviter les verrous.
Maintenant que la structure de haut niveau est claire, vous devez décider comment organiser les changesets dans chaque catégorie.
Cela dépend grandement des exigences spécifiques de l'application, mais les points suivants sont généralement raisonnables :
- Gardez les journaux des modifications regroupés par versions de votre produit. Créez un nouveau répertoire pour chaque version et placez-y les fichiers journaux des modifications correspondants. Ayez un journal des modifications racine et incluez des journaux des modifications correspondant aux versions. Dans les journaux des modifications de la version, incluez d'autres journaux des modifications comprenant cette version.
- Ayez une convention de nommage pour les fichiers journaux des modifications et les identifiants des ensembles de modifications, et respectez-la, bien sûr.
- Évitez les changesets avec beaucoup de changements. Préférez plusieurs changesets à un seul long changeset.
- Si vous utilisez des procédures stockées et devez les mettre à jour, envisagez d'utiliser l'
runOnChange="true"de l'ensemble de modifications dans lequel cette procédure stockée est ajoutée. Sinon, à chaque mise à jour, vous devrez créer un nouvel ensemble de modifications avec une nouvelle version de la procédure stockée. Les exigences varient, mais il est souvent acceptable de ne pas suivre cet historique. - Envisagez d'écraser les modifications redondantes avant de fusionner les branches de fonctionnalités. Parfois, il arrive que dans une branche de fonctionnalité (en particulier dans une branche de longue durée), les ensembles de modifications ultérieurs affinent les modifications apportées aux ensembles de modifications précédents. Par exemple, vous pouvez créer un tableau, puis décider d'y ajouter d'autres colonnes. Il vaut la peine d'ajouter ces colonnes à la modification initiale de
createTablesi cette branche de fonctionnalité n'a pas encore été fusionnée avec la branche principale. - Utilisez les mêmes journaux des modifications pour créer une base de données de test. Si vous essayez de le faire, vous découvrirez peut-être bientôt que tous les ensembles de modifications ne sont pas applicables à l'environnement de test ou que des ensembles de modifications supplémentaires sont nécessaires pour cet environnement de test spécifique. Avec Liquibase, ce problème est facilement résolu en utilisant des contextes . Ajoutez simplement l'attribut
context="test"aux changesets qui doivent être exécutés uniquement avec des tests, puis initialisez Liquibase avec le contexte detestactivé.
Reculer
Comme d'autres solutions similaires, Liquibase prend en charge la migration de schéma « vers le haut » et « vers le bas ». Mais attention : annuler les migrations n'est peut-être pas facile et cela n'en vaut pas toujours la peine. Si vous avez décidé de prendre en charge l'annulation des migrations pour votre application, soyez cohérent et faites-le pour chaque ensemble de modifications qui devrait être annulé. Avec Liquibase, l'annulation d'un ensemble de modifications s'effectue en ajoutant une balise de rollback contenant les modifications requises pour effectuer une restauration. Considérez l'exemple suivant :
<changeSet author="..."> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> <rollback> <dropTable tableName="PRODUCT"/> </rollback> </changeSet> La restauration explicite est redondante ici car Liquibase effectuerait les mêmes actions de restauration. Liquibase est capable d'annuler automatiquement la plupart de ses types de modifications pris en charge, par exemple, createTable , addColumn ou createIndex .
Réparer le passé
Personne n'est parfait et nous faisons tous des erreurs. Certains d'entre eux peuvent être découverts trop tard lorsque des modifications gâchées ont déjà été appliquées. Explorons ce qui pourrait être fait pour sauver la situation.
Mettre à jour manuellement la base de données
Cela implique de jouer avec DATABASECHANGELOG et votre base de données de la manière suivante :
- Si vous souhaitez corriger les mauvais ensembles de modifications et les exécuter à nouveau :
- Supprimez les lignes de
DATABASECHANGELOGqui correspondent aux ensembles de modifications. - Supprimez tous les effets secondaires introduits par les ensembles de modifications ; par exemple, restaurer une table si elle a été supprimée.
- Corrigez les mauvais ensembles de modifications.
- Exécutez à nouveau les migrations.
- Supprimez les lignes de
- Si vous souhaitez corriger les mauvais ensembles de modifications mais ne pas les appliquer à nouveau :
- Mettez à jour
DATABASECHANGELOGen définissant la valeur du champMD5SUMsurNULLpour les lignes qui correspondent aux ensembles de modifications incorrects. - Corrigez manuellement ce qui s'est mal passé dans la base de données. Par exemple, si une colonne a été ajoutée avec le mauvais type, lancez une requête pour modifier son type.
- Corrigez les mauvais ensembles de modifications.
- Exécutez à nouveau les migrations. Liquibase calculera la nouvelle somme de contrôle et l'enregistrera dans
MD5SUM. Les changesets corrigés ne seront plus exécutés.
- Mettez à jour
Évidemment, il est facile de faire ces astuces pendant le développement, mais cela devient beaucoup plus difficile si les modifications sont appliquées à plusieurs bases de données.
Écrire des ensembles de modifications correctives
En pratique, cette approche est généralement plus appropriée. Vous vous demandez peut-être pourquoi ne pas simplement modifier le jeu de modifications d'origine ? La vérité est que cela dépend de ce qui doit être changé. Liquibase calcule une somme de contrôle pour chaque ensemble de modifications et refuse d'appliquer de nouvelles modifications si la somme de contrôle est nouvelle pour au moins un des ensembles de modifications précédemment appliqués. Ce comportement peut être personnalisé pour chaque ensemble de modifications en spécifiant l' runOnChange="true" . La somme de contrôle n'est pas affectée si vous modifiez les conditions préalables ou les attributs facultatifs de l'ensemble de modifications ( context , runOnChange , etc.).
Maintenant, vous vous demandez peut-être comment corriger éventuellement les modifications avec des erreurs ?
- Si vous souhaitez que ces modifications soient toujours appliquées aux nouveaux schémas, ajoutez simplement des ensembles de modifications correctives. Par exemple, si une colonne a été ajoutée avec le mauvais type, modifiez son type dans le nouveau jeu de modifications.
- Si vous souhaitez prétendre que ces mauvais ensembles de modifications n'ont jamais existé, procédez comme suit :
- Supprimez les changesets ou ajoutez l'attribut
contextavec une valeur garantissant que vous n'essaierez plus jamais d'appliquer des migrations avec un tel contexte, par exemple,context="graveyard-changesets-never-run". - Ajoutez de nouveaux ensembles de modifications qui annuleront ce qui a été mal fait ou le corrigeront. Ces modifications ne doivent être appliquées que si de mauvaises modifications ont été appliquées. Cela peut être réalisé avec des conditions préalables, comme avec
changeSetExecuted. N'oubliez pas d'ajouter un commentaire expliquant pourquoi vous le faites. - Ajoutez de nouveaux ensembles de modifications qui modifient le schéma de la bonne manière.
- Supprimez les changesets ou ajoutez l'attribut
Comme vous le voyez, réparer le passé est possible, même si ce n'est pas toujours simple.
Atténuer les douleurs de croissance
Au fur et à mesure que votre application vieillit, son journal des modifications grandit également, accumulant chaque changement de schéma le long du chemin. C'est par conception, et il n'y a rien de mal à cela. Les journaux de modifications longs peuvent être raccourcis en écrasant régulièrement les migrations, par exemple après la publication de chaque version du produit. Dans certains cas, cela accélérerait l'initialisation d'un nouveau schéma.
L'écrasement n'est pas toujours anodin et peut provoquer des régressions sans apporter beaucoup de bénéfices. Une autre excellente option consiste à utiliser une base de données de départ pour éviter d'exécuter tous les ensembles de modifications. Il est bien adapté aux environnements de test si vous avez besoin d'avoir une base de données prête le plus rapidement possible, peut-être même avec des données de test. Vous pouvez le considérer comme une forme d'écrasement des ensembles de modifications : à un moment donné (par exemple, après avoir publié une autre version), vous effectuez un vidage du schéma. Après avoir restauré le vidage, vous appliquez les migrations comme d'habitude. Seuls les nouveaux changements seront appliqués car les plus anciens étaient déjà appliqués avant de faire le vidage ; par conséquent, ils ont été restaurés à partir de la décharge.
Conclusion
Nous avons intentionnellement évité d'approfondir les fonctionnalités de Liquibase pour livrer un article court et direct, axé sur l'évolution des schémas en général. Espérons que les avantages et les problèmes posés par l'application automatisée des migrations de schémas de base de données et dans quelle mesure tout cela s'intègre dans la culture DevOps sont clairs. Il est important de ne pas transformer même de bonnes idées en dogme. Les exigences varient et, en tant qu'ingénieurs de bases de données, nos décisions doivent favoriser l'avancement d'un produit et non pas simplement adhérer aux recommandations de quelqu'un sur Internet.
