Datenbankmigrationen: Aus Raupen werden Schmetterlinge

Veröffentlicht: 2022-03-11

Den Benutzern ist es egal, was in der von ihnen verwendeten Software enthalten ist; nur dass es reibungslos, sicher und unauffällig funktioniert. Entwickler bemühen sich darum, dies zu erreichen, und eines der Probleme, die sie zu lösen versuchen, besteht darin, sicherzustellen, dass sich der Datenspeicher in einem für die aktuelle Version des Produkts geeigneten Zustand befindet. Software entwickelt sich weiter, und auch ihr Datenmodell kann sich im Laufe der Zeit ändern, z. B. um Designfehler zu beheben. Um das Problem weiter zu verkomplizieren, haben Sie möglicherweise eine Reihe von Testumgebungen oder Kunden, die in unterschiedlichen Geschwindigkeiten auf neuere Versionen des Produkts migrieren. Sie können nicht nur die Struktur des Stores dokumentieren und welche Manipulationen erforderlich sind, um die glänzende neue Version aus einer einzigen Perspektive zu verwenden.

Datenbankmigrationen: Aus Raupen werden Schmetterlinge

Ich habe mich einmal einem Projekt mit einigen Datenbanken angeschlossen, deren Strukturen bei Bedarf direkt von den Entwicklern aktualisiert wurden. Das bedeutete, dass es keinen offensichtlichen Weg gab, herauszufinden, welche Änderungen vorgenommen werden mussten, um die Struktur auf die neueste Version zu migrieren, und es gab überhaupt kein Konzept der Versionierung! Das war in der Prä-DevOps-Ära und würde heutzutage als totales Durcheinander angesehen werden. Wir entschieden uns, ein Tool zu entwickeln, mit dem jede Änderung auf die gegebene Datenbank angewendet werden kann. Es hatte Migrationen und dokumentierte Schemaänderungen. Dies machte uns zuversichtlich, dass es keine versehentlichen Änderungen geben würde und der Schemastatus vorhersehbar wäre.

In diesem Artikel sehen wir uns an, wie man relationale Datenbankschemamigrationen anwendet und wie man damit einhergehende Probleme überwindet.

Zunächst einmal, was sind Datenbankmigrationen? Im Kontext dieses Artikels ist eine Migration eine Reihe von Änderungen, die auf eine Datenbank angewendet werden sollten. Das Erstellen oder Löschen einer Tabelle, Spalte oder eines Indexes sind gängige Beispiele für Migrationen. Die Form Ihres Schemas kann sich im Laufe der Zeit dramatisch ändern, insbesondere wenn mit der Entwicklung begonnen wurde, als die Anforderungen noch vage waren. Im Laufe mehrerer Meilensteine ​​auf dem Weg zu einem Release hat sich Ihr Datenmodell also weiterentwickelt und ist möglicherweise völlig anders geworden als zu Beginn. Migrationen sind nur Schritte zum Zielzustand.

Lassen Sie uns zu Beginn untersuchen, was wir in unserer Toolbox haben, um zu vermeiden, dass wir neu erfinden, was bereits gut gemacht ist.

Werkzeuge

In jeder weit verbreiteten Sprache gibt es Bibliotheken, die Datenbankmigrationen vereinfachen. Im Fall von Java sind beispielsweise Liquibase und Flyway beliebte Optionen. Wir werden Liquibase mehr in Beispielen verwenden, aber die Konzepte gelten für andere Lösungen und sind nicht an Liquibase gebunden.

Warum sich die Mühe machen, eine separate Schemamigrationsbibliothek zu verwenden, wenn einige ORMs bereits eine Option zum automatischen Upgrade eines Schemas bieten und es an die Struktur der zugeordneten Klassen anpassen? In der Praxis führen solche automatischen Migrationen nur einfache Schemaänderungen durch, z. B. das Erstellen von Tabellen und Spalten, und können keine potenziell destruktiven Dinge wie das Löschen oder Umbenennen von Datenbankobjekten tun. Daher sind nicht-automatische (aber immer noch automatisierte) Lösungen normalerweise die bessere Wahl, da Sie gezwungen sind, die Migrationslogik selbst zu beschreiben, und Sie genau wissen, was mit Ihrer Datenbank passieren wird.

Es ist auch eine sehr schlechte Idee, automatisierte und manuelle Schemaänderungen zu mischen, da Sie möglicherweise einzigartige und unvorhersehbare Schemata erstellen, wenn manuelle Änderungen in der falschen Reihenfolge oder gar nicht angewendet werden, selbst wenn sie erforderlich sind. Sobald das Tool ausgewählt ist, verwenden Sie es, um alle Schemamigrationen anzuwenden.

Typische Datenbankmigrationen

Typische Migrationen umfassen das Erstellen von Sequenzen, Tabellen, Spalten, Primär- und Fremdschlüsseln, Indizes und anderen Datenbankobjekten. Für die gängigsten Arten von Änderungen stellt Liquibase eindeutige deklarative Elemente bereit, um zu beschreiben, was zu tun ist. Es wäre zu langweilig, über jede triviale Änderung zu lesen, die von Liquibase oder anderen ähnlichen Tools unterstützt wird. Um eine Vorstellung davon zu bekommen, wie die Änderungssätze aussehen, betrachten Sie das folgende Beispiel, in dem wir eine Tabelle erstellen (XML-Namespace-Deklarationen werden der Kürze halber weggelassen):

 <?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>

Wie Sie sehen, besteht das Änderungsprotokoll aus einer Reihe von Änderungssätzen, und Änderungssätze bestehen aus Änderungen. Einfache Änderungen wie createTable können kombiniert werden, um komplexere Migrationen zu implementieren; Angenommen, Sie müssen den Produktcode für alle Produkte aktualisieren. Dies kann leicht mit der folgenden Änderung erreicht werden:

 <sql>UPDATE product SET code = 'new_' || code</sql>

Die Leistung leidet, wenn Sie Millionen von Produkten haben. Um die Migration zu beschleunigen, können wir sie in die folgenden Schritte umschreiben:

  1. Erstellen Sie eine neue Tabelle für Produkte mit createTable , genau wie wir es zuvor gesehen haben. In diesem Stadium ist es besser, so wenige Einschränkungen wie möglich zu erstellen. Nennen wir die neue Tabelle PRODUCT_TMP .
  2. PRODUCT_TMP mit SQL in Form von INSERT INTO ... SELECT ... unter Verwendung von sql change.
  3. Erstellen Sie alle Einschränkungen ( addNotNullConstraint , addUniqueConstraint , addForeignKeyConstraint ) und Indizes ( createIndex ), die Sie benötigen.
  4. Benennen Sie die Tabelle PRODUCT in etwas wie PRODUCT_BAK . Liquibase kann dies mit renameTable tun.
  5. PRODUCT_TMP in PRODUCT um (erneut mit renameTable ).
  6. Entfernen Sie optional PRODUCT_BAK mit dropTable .

Natürlich ist es besser, solche Migrationen zu vermeiden, aber es ist gut zu wissen, wie man sie implementiert, falls Sie auf einen dieser seltenen Fälle stoßen, in denen Sie es brauchen.

Wenn Sie XML, JSON oder YAML für zu bizarr halten, um Änderungen zu beschreiben, verwenden Sie einfach einfaches SQL und nutzen Sie alle datenbankanbieterspezifischen Funktionen. Außerdem können Sie jede benutzerdefinierte Logik in einfachem Java implementieren.

Die Art und Weise, wie Liquibase Sie davon befreit, tatsächlich datenbankspezifisches SQL zu schreiben, kann zu Selbstüberschätzung führen, aber Sie sollten die Macken Ihrer Zieldatenbank nicht vergessen; Wenn Sie beispielsweise einen Fremdschlüssel erstellen, kann ein Index erstellt werden oder nicht, je nach verwendetem Datenbankverwaltungssystem. Infolgedessen könnten Sie sich in einer unangenehmen Situation befinden. Mit Liquibase können Sie angeben, dass ein Änderungssatz nur für einen bestimmten Datenbanktyp ausgeführt werden soll, z. B. PostgreSQL, Oracle oder MySQL. Dies ermöglicht die Verwendung derselben herstellerunabhängigen Änderungssätze für verschiedene Datenbanken und für andere Änderungssätze unter Verwendung herstellerspezifischer Syntax und Funktionen. Das folgende Changeset wird nur ausgeführt, wenn eine Oracle-Datenbank verwendet wird:

 <changeSet dbms="oracle" author="..."> ... </changeSet>

Neben Oracle unterstützt Liquibase standardmäßig einige andere Datenbanken.

Benennen von Datenbankobjekten

Jedes von Ihnen erstellte Datenbankobjekt muss benannt werden. Für einige Objekttypen, z. B. für Constraints und Indizes, müssen Sie nicht explizit einen Namen angeben. Aber das bedeutet nicht, dass diese Objekte keine Namen haben; ihre Namen werden trotzdem von der Datenbank generiert. Das Problem tritt auf, wenn Sie auf dieses Objekt verweisen müssen, um es zu löschen oder zu ändern. Daher ist es besser, ihnen eindeutige Namen zu geben. Aber gibt es Regeln, welche Namen zu vergeben sind? Die Antwort ist kurz: Seien Sie konsequent; Wenn Sie sich beispielsweise entschieden haben, Indizes wie folgt zu benennen: IDX_<table>_<columns> , dann sollte ein Index für die oben erwähnte CODE -Spalte IDX_PRODUCT_CODE .

Namenskonventionen sind unglaublich umstritten, daher maßen wir uns nicht an, hier umfassende Anweisungen zu geben. Seien Sie konsequent, respektieren Sie Ihre Team- oder Projektkonventionen oder erfinden Sie sie einfach, wenn es keine gibt.

Organisation von Changesets

Als erstes muss entschieden werden, wo die Änderungssätze gespeichert werden sollen. Grundsätzlich gibt es zwei Ansätze:

  1. Behalten Sie Änderungssätze mit dem Anwendungscode bei. Dies ist praktisch, da Sie Änderungssätze und Anwendungscode gemeinsam festschreiben und überprüfen können.
  2. Bewahren Sie Änderungssätze und Anwendungscode getrennt auf, z. B. in separaten VCS-Repositories. Dieser Ansatz ist geeignet, wenn das Datenmodell von mehreren Anwendungen gemeinsam genutzt wird und es praktischer ist, alle Änderungssätze in einem dedizierten Repository zu speichern und sie nicht über mehrere Repositorys zu verteilen, in denen sich der Anwendungscode befindet.

Unabhängig davon, wo Sie die Änderungssätze speichern, ist es im Allgemeinen sinnvoll, sie in die folgenden Kategorien zu unterteilen:

  1. Unabhängige Migrationen, die das laufende System nicht beeinträchtigen. Es ist normalerweise sicher, neue Tabellen, Sequenzen usw. zu erstellen, wenn die aktuell bereitgestellte Anwendung diese noch nicht kennt.
  2. Schemaänderungen, die die Struktur des Speichers ändern , z. B. Hinzufügen oder Löschen von Spalten und Indizes. Diese Änderungen sollten nicht angewendet werden, während eine ältere Version der Anwendung noch verwendet wird, da dies aufgrund von Änderungen im Schema zu Sperren oder seltsamem Verhalten führen kann.
  3. Schnelle Migrationen, die winzige Datenmengen einfügen oder aktualisieren. Wenn mehrere Anwendungen bereitgestellt werden, können Änderungssätze dieser Kategorie gleichzeitig ausgeführt werden, ohne dass die Datenbankleistung beeinträchtigt wird.
  4. Potenziell langsame Migrationen, die viele Daten einfügen oder aktualisieren. Diese Änderungen sollten besser angewendet werden, wenn keine anderen ähnlichen Migrationen ausgeführt werden.

grafische Darstellung der vier Kategorien

Diese Migrationssätze sollten nacheinander ausgeführt werden, bevor eine neuere Version einer Anwendung bereitgestellt wird. Dieser Ansatz wird noch praktischer, wenn ein System aus mehreren separaten Anwendungen besteht und einige von ihnen dieselbe Datenbank verwenden. Andernfalls lohnt es sich, nur die Änderungssätze zu trennen, die angewendet werden könnten, ohne die laufenden Anwendungen zu beeinträchtigen, und die verbleibenden Änderungssätze können zusammen angewendet werden.

Für einfachere Anwendungen kann der vollständige Satz erforderlicher Migrationen beim Anwendungsstart angewendet werden. In diesem Fall fallen alle Änderungssätze in eine einzige Kategorie und werden immer dann ausgeführt, wenn die Anwendung initialisiert wird.

Unabhängig davon, in welcher Phase Migrationen angewendet werden, sollte erwähnt werden, dass die Verwendung derselben Datenbank für mehrere Anwendungen zu Sperren führen kann, wenn Migrationen angewendet werden. Liquibase verwendet (wie viele andere ähnliche Lösungen) zwei spezielle Tabellen, um seine Metadaten aufzuzeichnen: DATABASECHANGELOG und DATABASECHANGELOGLOCK . Ersteres wird zum Speichern von Informationen über angewendete Änderungssätze verwendet, und letzteres, um gleichzeitige Migrationen innerhalb desselben Datenbankschemas zu verhindern. Wenn also aus irgendeinem Grund mehrere Anwendungen dasselbe Datenbankschema verwenden müssen, ist es besser, nicht standardmäßige Namen für Metadatentabellen zu verwenden, um Sperren zu vermeiden.

Nachdem die allgemeine Struktur nun klar ist, müssen Sie entscheiden, wie Sie die Änderungssätze innerhalb jeder Kategorie organisieren.

Beispiel einer Changeset-Organisation

Es hängt stark von den spezifischen Anwendungsanforderungen ab, aber die folgenden Punkte sind normalerweise sinnvoll:

  1. Bewahren Sie Änderungsprotokolle gruppiert nach Releases Ihres Produkts auf. Erstellen Sie für jede Version ein neues Verzeichnis und legen Sie die entsprechenden Changelog-Dateien darin ab. Führen Sie ein Root-Änderungsprotokoll und fügen Sie Änderungsprotokolle ein, die Releases entsprechen. Fügen Sie in Versionsänderungsprotokollen andere Änderungsprotokolle hinzu, die diese Version umfassen.
  2. Haben Sie eine Namenskonvention für Changelog-Dateien und Changeset-IDs – und befolgen Sie diese natürlich.
  3. Vermeiden Sie Changesets mit vielen Änderungen. Bevorzugen Sie mehrere Änderungssätze einem einzelnen langen Änderungssatz.
  4. Wenn Sie gespeicherte Prozeduren verwenden und diese aktualisieren müssen, sollten Sie das runOnChange="true" -Attribut des Änderungssatzes verwenden, in dem diese gespeicherte Prozedur hinzugefügt wird. Andernfalls müssen Sie bei jeder Aktualisierung ein neues Änderungsset mit einer neuen Version der gespeicherten Prozedur erstellen. Die Anforderungen variieren, aber es ist oft akzeptabel, einen solchen Verlauf nicht zu verfolgen.
  5. Erwägen Sie, redundante Änderungen zu komprimieren, bevor Sie Feature-Zweige zusammenführen. Manchmal kommt es vor, dass in einem Feature-Branch (insbesondere in einem langlebigen) spätere Changesets Änderungen verfeinern, die in früheren Changesets vorgenommen wurden. Sie können beispielsweise eine Tabelle erstellen und ihr dann weitere Spalten hinzufügen. Es lohnt sich, diese Spalten zur anfänglichen createTable Änderung hinzuzufügen, wenn dieser Feature-Zweig noch nicht mit dem Hauptzweig zusammengeführt wurde.
  6. Verwenden Sie dieselben Änderungsprotokolle, um eine Testdatenbank zu erstellen. Wenn Sie dies versuchen, werden Sie möglicherweise bald feststellen, dass nicht alle Änderungssätze auf die Testumgebung anwendbar sind oder dass zusätzliche Änderungssätze für diese bestimmte Testumgebung benötigt werden. Mit Liquibase lässt sich dieses Problem einfach mithilfe von Kontexten lösen. Fügen Sie einfach das Attribut context="test" zu den Changesets hinzu, die nur mit Tests ausgeführt werden müssen, und initialisieren Sie dann Liquibase mit aktiviertem test .

Zurück rollen

Wie andere ähnliche Lösungen unterstützt Liquibase die Migration von Schemas „nach oben“ und „nach unten“. Aber seien Sie gewarnt: Das Rückgängigmachen von Migrationen ist möglicherweise nicht einfach und lohnt sich nicht immer. Wenn Sie sich entschieden haben, das Rückgängigmachen von Migrationen für Ihre Anwendung zu unterstützen, dann seien Sie konsequent und tun Sie dies für jeden Änderungssatz, der rückgängig gemacht werden müsste. Mit Liquibase wird ein Änderungssatz rückgängig gemacht, indem ein rollback -Tag hinzugefügt wird, das Änderungen enthält, die zum Ausführen eines Rollbacks erforderlich sind. Betrachten Sie das folgende Beispiel:

 <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>

Ein explizites Rollback ist hier überflüssig, da Liquibase die gleichen Rollback-Aktionen ausführen würde. Liquibase ist in der Lage, die meisten seiner unterstützten Arten von Änderungen automatisch rückgängig zu machen, z. B. createTable , addColumn oder createIndex .

Vergangenheit reparieren

Niemand ist perfekt und wir alle machen Fehler. Einige von ihnen werden möglicherweise zu spät entdeckt, wenn verdorbene Änderungen bereits angewendet wurden. Lassen Sie uns untersuchen, was getan werden könnte, um den Tag zu retten.

Aktualisieren Sie die Datenbank manuell

Es geht darum, mit DATABASECHANGELOG und Ihrer Datenbank auf folgende Weise herumzuspielen:

  1. Wenn Sie fehlerhafte Änderungssätze korrigieren und erneut ausführen möchten:
    • Entfernen Sie Zeilen aus DATABASECHANGELOG , die den Changesets entsprechen.
    • Entfernen Sie alle Nebeneffekte, die durch die Änderungssätze eingeführt wurden; B. eine Tabelle wiederherstellen, wenn sie gelöscht wurde.
    • Korrigieren Sie die fehlerhaften Änderungssätze.
    • Führen Sie die Migrationen erneut aus.
  2. Wenn Sie fehlerhafte Änderungssätze korrigieren, aber ihre erneute Anwendung überspringen möchten:
    • Aktualisieren Sie DATABASECHANGELOG , indem Sie den MD5SUM -Feldwert für die Zeilen, die den fehlerhaften Changesets entsprechen, auf NULL setzen.
    • Korrigieren Sie manuell, was in der Datenbank falsch gemacht wurde. Wenn beispielsweise eine Spalte mit dem falschen Typ hinzugefügt wurde, geben Sie eine Abfrage aus, um ihren Typ zu ändern.
    • Korrigieren Sie die fehlerhaften Änderungssätze.
    • Führen Sie die Migrationen erneut aus. Liquibase berechnet die neue Prüfsumme und speichert sie in MD5SUM . Korrigierte Änderungssätze werden nicht erneut ausgeführt.

Natürlich ist es einfach, diese Tricks während der Entwicklung durchzuführen, aber es wird viel schwieriger, wenn die Änderungen auf mehrere Datenbanken angewendet werden.

Schreiben Sie korrigierende Änderungssätze

In der Praxis ist diese Vorgehensweise meist angemessener. Sie fragen sich vielleicht, warum nicht einfach den ursprünglichen Änderungssatz bearbeiten? Die Wahrheit ist, dass es darauf ankommt, was geändert werden muss. Liquibase berechnet eine Prüfsumme für jeden Änderungssatz und weigert sich, neue Änderungen anzuwenden, wenn die Prüfsumme für mindestens einen der zuvor angewendeten Änderungssätze neu ist. Dieses Verhalten kann pro Änderungssatz angepasst werden, indem das runOnChange="true" angegeben wird. Die Prüfsumme wird nicht beeinflusst, wenn Sie Vorbedingungen oder optionale Changeset-Attribute ( context , runOnChange usw.) ändern.

Jetzt fragen Sie sich vielleicht, wie Sie Änderungssätze mit Fehlern letztendlich korrigieren können.

  1. Wenn Sie möchten, dass diese Änderungen weiterhin auf neue Schemas angewendet werden, fügen Sie einfach korrigierende Änderungssätze hinzu. Wenn beispielsweise eine Spalte mit dem falschen Typ hinzugefügt wurde, ändern Sie ihren Typ im neuen Änderungssatz.
  2. Wenn Sie so tun möchten, als ob diese fehlerhaften Änderungssätze nie existiert haben, gehen Sie wie folgt vor:
    • Entfernen Sie die Änderungssätze oder fügen Sie das context mit einem Wert hinzu, der garantiert, dass Sie nie wieder versuchen würden, Migrationen mit einem solchen Kontext anzuwenden, z. B. context="graveyard-changesets-never-run" .
    • Fügen Sie neue Änderungssätze hinzu, die entweder das rückgängig machen, was falsch gemacht wurde, oder es beheben. Diese Änderungen sollten nur angewendet werden, wenn fehlerhafte Änderungen angewendet wurden. Dies kann mit Vorbedingungen erreicht werden, z. B. mit changeSetExecuted . Vergessen Sie nicht, einen Kommentar hinzuzufügen, der erklärt, warum Sie dies tun.
    • Fügen Sie neue Änderungssätze hinzu, die das Schema auf die richtige Weise ändern.

Wie Sie sehen, ist es möglich, die Vergangenheit zu reparieren, auch wenn es nicht immer einfach ist.

Linderung von Wachstumsschmerzen

Wenn Ihre Anwendung älter wird, wächst auch ihr Änderungsprotokoll und sammelt jede Schemaänderung entlang des Pfads. Es ist beabsichtigt, und daran ist nichts falsch. Lange Änderungsprotokolle können verkürzt werden, indem Migrationen regelmäßig gequetscht werden, z. B. nach der Veröffentlichung jeder Version des Produkts. In einigen Fällen würde es die Initialisierung eines neuen Schemas beschleunigen.

Illustration von gequetschten Änderungsprotokollen

Squashing ist nicht immer trivial und kann Regressionen verursachen, ohne viele Vorteile zu bringen. Eine weitere großartige Option ist die Verwendung einer Seed-Datenbank, um zu vermeiden, dass alle Änderungssätze ausgeführt werden. Es eignet sich gut für Testumgebungen, wenn Sie eine Datenbank so schnell wie möglich bereit haben müssen, vielleicht sogar mit einigen Testdaten. Sie können es sich als eine Form des Squashing für Changesets vorstellen: An einem bestimmten Punkt (z. B. nach der Veröffentlichung einer anderen Version) erstellen Sie einen Dump des Schemas. Nach dem Wiederherstellen des Dumps wenden Sie die Migrationen wie gewohnt an. Es werden nur neue Änderungen angewendet, da bereits ältere Änderungen angewendet wurden, bevor der Speicherauszug erstellt wurde. daher wurden sie von der Müllhalde wiederhergestellt.

Abbildung einer Seed-Datenbank

Fazit

Wir haben absichtlich vermieden, tiefer in die Funktionen von Liquibase einzutauchen, um einen kurzen und auf den Punkt gebrachten Artikel zu liefern, der sich auf sich entwickelnde Schemas im Allgemeinen konzentriert. Hoffentlich ist klar, welche Vorteile und Probleme die automatisierte Anwendung von Datenbankschemamigrationen mit sich bringt und wie gut das alles in die DevOps-Kultur passt. Es ist wichtig, auch gute Ideen nicht zu Dogmen zu machen. Die Anforderungen variieren, und als Datenbankingenieure sollten unsere Entscheidungen dazu beitragen, ein Produkt voranzubringen und nicht nur Empfehlungen von jemandem aus dem Internet zu befolgen.