IT-Modernisierungsprojekte enthalten oft die Herausforderung, über die Jahre umfangreich gewachsene Bestandsdaten in die Zielarchitektur zu migrieren.

In diesem Artikel werfen wir einen kurzen Blick auf die folgenden Themengebiete:

  • Was ist bei einer Bulk Migration von Daten zwischen Datenbanken zu beachten?
  • Was ist die Herausforderung, wenn sich das Zielschema aufgrund von Entwicklungstätigkeiten ändert?
  • Wie kann jOOQ dabei unterstützen, das Risiko für Prozessfehler zu minimieren?

Migration nach PostgreSQL

Sehen wir uns ein Beispiel an: Wir haben eine Anwendung, die in einer Oracle Datenbank betrieben wird und deren Daten in eine PostgreSQL-Datenbank migriert werden sollen. Die Datenbank wurde mittels ora2pg in ein Schema „Staging“ in PostgreSQL migriert. Dieses Schema halten wir dabei in derselben Datenbank wie das spätere Zielschema.

Warum wählen wir diesen Ansatz?

Der Ansatz hilft uns, einfache SQL-Abfragen für die Migration zu nutzen. Alternativ kann es sinnvoll sein, das Staging-Schema in einer eigenen Datenbank, bzw. Datenbankcluster zu halten, um die Datenbanken zu entkoppeln. Dazu können wir Postgres Foreign Data Wrapper nutzen, um genauso einfach auf die Daten zugreifen zu können. Dies kann wichtig sein, etwa um den zusätzlichen Speicher für das Staging Schema wieder freigeben zu können.

Bei vielen Cloud-Anbietern kann der einmal einer DB zugewiesene Speicher nicht wieder freigegeben werden. Liegt das Staging-Schema in einem eigenen Datenbankcluster, kann der gesamte Server einfach gelöscht werden.

Frühzeitig beginnen!

Wir halten es für sinnvoll, möglichst frühzeitig mit der Entwicklung der Migrationsskripte zu beginnen. Häufig stellen wir fest, dass die Altdaten Konstellationen enthalten, die man im zukünftigen Schema nicht mehr zulassen möchte. Dann stellt sich die Frage, wie mit den Altdaten umgegangen werden soll.

Tests mit realistischen Datenmengen

Migration und Re-Implementierung der Anwendung finden gleichzeitig statt. Dieser Umstand erweist sich als Glücksfall, denn die Migration sorgt so als Treiber für einen Datenbank-First-Ansatz. Für die Re-Implementierung ist es hilfreich, dass wir bereits mit den (anonymisierten) Altdaten arbeiten können. Dies liefert eine realistische Datenmenge, denn Abfragen auf leeren Tabellen sind ja immer schnell. Die Nutzung von Produktionsdaten zu Testzwecken ist dagegen ein anderes Thema.

Immer, wenn sich das Zielschema ändert, müssen die Daten-Migrationsskripte angepasst werden.
Was gehört zu einer performanten Migration?

Es gibt verschiedene Arten von Migrationen. In der Regel ist es sinnvoll, bei IT-Systemen eine inkrementelle Migrationsstrategie zu verfolgen. Das kann bedeuten, dass Teile des IT-Systems jeweils in einem Schritt migriert werden (Big Bang). Eine performante Migration der Daten ist dann wichtig.

Beim Bulk-Load von Daten in eine PostgreSQL-Datenbank oder zwischen Tabellen gibt es einiges zu beachten, um eine möglichst gute Performanz zu erzielen. Ähnliches gilt auch für andere Datenbanken.

Angelehnt an die Liste von EDB sind folgende Punkte wichtig für unser Szenario (PostgreSQL/PostgreSQL mittels SQL):

  • Tabellen in die migriert werden sollen, sollten vorher leer sein.
  • Tabellen sollten „unlogged“ sein.
  • Tabellen sollten keine Indizes haben.
  • Tabellen sollten keine Constraints haben.
  • Tabellen sollten keine Triggers haben.
  • Tabellen sollten keine Foreign Keys haben.

Im nächsten Schritt werden die Daten mittels SQL-Skripten geladen. Danach werden die Indizes, Constraints, Triggers und Foreign Keys wiederhergestellt. Zum Abschluss sollte ein analyze ausgeführt werden, um die Statistiken zu aktualisieren.

Was ist die Herausforderung bei gleichzeitigen Entwicklungstätigkeiten?

Wird die Anwendung neu entwickelt, so ändert sich das Zielschema. Dabei sind insbesondere die Änderungen an den Zieltabellen interessant in die hinein migriert wird. Typische Änderungen die Auswirkung auf die Migrationsskripte haben sind:

  • Neue Indizes
  • Neue Constraints (inklusive Foreign Keys)
  • Neue Trigger
  • Umbenennungen von Spalten
  • Neue Not-Null Spalten

Es ist wichtig, dass diese Änderungen entweder automatisch erkannt und berücksichtigt werden oder vor Ausführung der Skripte auffallen. Da die Laufzeiten für die Migrationen sehr lang sein können (Stunden bis Tage), müssen Fehler möglichst früh erkannt werden. Sonst kann die (unbeobachtete) Probemigration erst nach vielen Stunden fehlschlagen und wird nicht abgeschlossen. Damit werden viele Stunden wertvoller Projektzeit verschwendet.

Wie kann jOOQ helfen?

jOOQ generiert aus einem Datenbankschema ein Klassenmodell. Dieses beinhaltet Informationen über die gängigen Datenbankobjekte wie Tabellen, Spalten, Indizes, Constraints, Triggers und Foreign Keys. Mittels dieses Klassenmodells kann man dann einfach alle Indizes oder Constraints einer Tabelle entfernen oder neu erzeugen.

Das Beispielprojekt nutzt jOOQ, um die SQL-Migrationsskripte zu erzeugen, ohne die eigentliche Migration durchzuführen.

Wenn wir dabei alle Tabellen mittels eines topologischen Sortieralgorithmus sortieren, dann können wir die Tabellen in der richtigen Reihenfolge behandeln. Dies ist wichtig, damit wir Constraints in der richtigen Reihenfolge löschen und wiederherstellen. Dies gilt im Kontext dieses Blog-Post zumindest solange, wie der „Tabellengraph“ keine Zyklen enthält.

Wir nutzen die generierten SQL-Skripte für die Behandlung der Indexe und Constraints. Dadurch vergessen wir keine Objekte zu löschen oder zu erzeugen, die im Rahmen der Weiterentwicklung noch entstehen oder gelöscht werden.

Der Hintergrund für den Umweg über die SQL-Skripte ist, dass wir die Skripte auch in anderen Umgebungen ausführen können. Generell sollten die Aktionen nicht vom Notebook eines Entwicklers oder einer Entwicklerin ausgeführt werden, sondern von einem dedizierten Migrationssystem (virtuelle Maschine). Wenn ihr die Migration manuell startet, solltet ihr darauf achten, dass dies unter einem Terminal-Multiplexer wie tmux oder screen geschieht. Damit die Ausführung nicht abbricht, wenn der ausführende Benutzer die Verbindung beendet. Die Dauer der Migration hängt stark von der Größe der Datenbank ab. Selbst für einige kleine Datenbanken mit < 250 Mio Einträgen und mehreren Tabellen kann die Migration mehrere Stunden dauern. Bei größeren Migrationen ist sicherlich noch eine gesonderte Betrachtung notwendig, um Laufzeiten zu optimieren.

Ein weiterer Vorteil kann die Nachbearbeitung der Skripte sein. Etwa wenn ihr beim Umgang mit den Constraints leichte Anpassungen vornehmen müsst, weil die Datenbank doch Zyklen enthält.

Beispielprojekt auf Github

Das Beispielprojekt ist auf GitHub verfügbar. Eine etwas ausführlichere Dokumentation findet sich auf den zugehörigen Github Pages.

Datenbank aufsetzen

Die PostgreSQL Datenbank kann mittels Docker Compose aufgesetzt werden.

docker compose up -d

Verbinden könnt ihr euch mittels des Nutzers jooq_demo_admin und dem Passwort jooq_demo_admin. Dieser Nutzer hat die notwendigen Rechte auf der Datenbank und den Schemas.

Es gibt die folgenden Schemas:

jooq_demo
Das Zielschema, das später von der Anwendung genutzt werden soll.
staging
Das Staging-Schema, welches aus der Oracle Datenbank migriert wurde.
extensions
Das Schema, in dem die Erweiterungen für die Datenbank liegen (z. B. uuid-ossp), die nicht zu viele eigene Funktionen und Objekte bereitstellen.

Wenn die Datenbank läuft, dann können die Tabellen mittels

./gradlew :db:update

erzeugt werden. Die Ausführung von liquibaseUpdate triggert auch die Erzeugung der jOOQ Klassen.

Es wird eine minimale Web-Oberfläche mittels Adminer bereitgestellt, die unter http://localhost:8080 erreichbar ist. Wer sich darüber anmelden möchte, der kann die folgenden Daten verwenden:

Attribut

Wert

Datenbank System

PostgreSQL

Server

postgres

Benutzer

jooq_demo_admin

Passwort

jooq_demo_admin

Datenbank

jooq_demo

Spring Shell bauen

Bei der Anwendung, welche die Testdaten generiert, Migrationsskripte erstellt und ausführt, handelt es sich um eine Spring Shell Anwendung.

Ein Gradle Build erzeugt die Anwendung.

./gradlew build

Testdaten erzeugen

Jetzt benötigen wir Testdaten in der Staging Datenbank. Die Daten werden mittels DataFaker erzeugt und mittels jOOQ in die Datenbank geschrieben.

java -jar library-migration/build/libs/library-migration-0.0.1-SNAPSHOT.jar generateData

Migrationsskripte erstellen

Jetzt geht es darum, die eigentlichen Migrationsskripte erstellen zu lassen.

java -jar library-migration/build/libs/library-migration-0.0.1-SNAPSHOT.jar generateScripts

Die Migrationsskripte werden im scripts Verzeichnis des Sub-Projekts library-migration abgelegt. Es wird ein Skript 0000_run_all.sql erzeugt, welches mittels psql ausgeführt werden kann.

Dieser Schritt enthält die eigentliche Magie.

Wenn wir jetzt einmal die Skripte anschauen, die erzeugt werden, so sehen wir folgende Liste:

0000_run_all.sql
0010_disable_indexe.sql
0020_drop_constraints.sql
0030_unlog_tables.sql
1010_create_mapping_tables.sql
1020_members.sql
1030_books.sql
1040_checkout.sql
2040_log_tables.sql
2050_add_constraints.sql
2060_enable_indexe.sql
2070_analyze_tables.sql

Die Skripte spiegeln die oben genannten Schritte für einen Bulk-Load wieder.

Einige Dateien sind aktuell leer, wie disable_indexe und enable_indexe, da keine expliziten Indexe definiert sind. Wir erkennen aber alle Schritte wieder, die wir im Rahmen eines Builds durchführen würden.

Wir brauchen aber nur die Skripte schreiben, welche die wirkliche Migration der Daten durchführen.

Book Mapping using a lookup table
   var sql = dsl.insertInto(
                            target,
                            target.ID,
                            target.INSTANCE_ID,
                            target.MEMBER_ID,
                            target.CHECKOUT_DATE,
                            target.RETURN_DATE,
                            target.ACTUAL_RETURN_DATE)
                    .select(dsl.select(
                                    Routines.uuidGenerateV7(),
                                    instance.ID,
                                    mappingMembers.UUID,
                                    source.CHECKOUT_DATE,
                                    source.RETURN_DATE,
                                    source.ACTUAL_RETURN_DATE)
                            .from(source)
                            .join(mappingBooks)
                            .on(source.ISBN13.eq(mappingBooks.ISBN13))
                            .join(mappingMembers)
                            .on(source.MEMBER_ID.eq(mappingMembers.MEMBER_ID))
                            .join(instance)
                            .on(instance.BOOK_ID.eq(mappingBooks.UUID)))
                    .onConflictDoNothing()
                    .getSQL();

Dieses Beispiel befindet sich in der Klasse LibraryMigration, welche die Methode migrateTables unserer FullMigrationSupport Oberklasse überschreibt.

Überschriebene Methode migrateTables
  @Override
    protected void migrateTables() throws Exception {
        createBookMappingTables(migrationScriptsCollector.newScript("1010_create_mapping_tables.sql"));
        mapMembers(migrationScriptsCollector.newScript("1020_members.sql"));
        mapBooks(migrationScriptsCollector.newScript("1030_books.sql"));
        mapCheckouts(migrationScriptsCollector.newScript("1040_checkout.sql"));
    }

Migrationsskripte ausführen

Jetzt müssen die Daten mittels der Skripte migriert werden.

java -jar library-migration/build/libs/library-migration-0.0.1-SNAPSHOT.jar applyScripts

Das führt einfach die Skripte aus (mittels Spring ScriptUtils Klasse). Häufig werden die Skripte aber auch mittels psql ausgeführt, ggf. sogar für die Produktivmigration von entsprechenden DBAs.

Simulation von Entwicklungstätigkeiten im Zielschema

Mit den folgenden Aktionen simulieren wir Entwicklungstätigkeiten im Zielschema und schauen, wie sich das in den Migrationsskripten widerspiegelt.

Zusätzlichen Index erstellen

Wir wollen einen zusätzlichen Index auf der Tabelle book erstellen. Dieser sollte dann auch automatisch in den Migrationsskripten gedroppt und neu erstellt werden.

./gradlew :db:update -PliquibaseExtraArgs="contexts=demo-1"
./gradlew --no-build-cache clean build
java -jar library-migration/build/libs/library-migration-0.0.1-SNAPSHOT.jar generateScripts

Das Skript 0010_disable_index.sql enthält jetzt den Befehl zum Löschen des Indexes und entsprechend 2060_enable_indexe.sql enthält den Befehl zum Erstellen des Indexes.

Es gibt also keine Indexe, die man vergessen kann zu löschen und zu erstellen.

Das ist schon mal sehr hilfreich. Was hier passiert ist, dass an ein Liquibase Update (mittels ./gradlew :db:update) den Task jooqCodegen auslöst. Damit wird das jOOQ Klassenmodell aktualisiert.

Wir schauen uns jetzt einmal an, was passiert, wenn wir eine Spalte umbenennen.

Spalte umbenennen

Wir nennen in der Tabelle checkout die Spalte return_date in borrowed_until_date um.

./gradlew :db:update -PliquibaseExtraArgs="contexts=demo-2"
./gradlew --no-build-cache clean build

Der Build schlägt fehl, da es die Spalte mit dem alten Namen nicht mehr gibt.

Fehlermeldung
/Users/rat/devel/playground/jooq-migration/library-migration/src/main/java/com/opitzconsulting/cattlecrew/jooqmigration/migration/LibraryMigration.java:90: Fehler: Symbol nicht gefunden
                            target.RETURN_DATE,

Da es nur ein Demo ist, können wir die Spalte wieder umbenennen und den Build wiederholen.

./gradlew :db:rollbackCount -PliquibaseCommandValue=1 -PliquibaseExtraArgs="contexts=demo-2"

Wer noch mehr ausprobieren möchte, der kann das natürlich auch einfach mittels SQL auf der Datenbank machen.

Was nicht funktioniert

Wenn ihr eine neue Not-Null-Spalte hinzufügt, wird nicht automatisch erkannt, dass in den Abfragen kein Wert für die Spalte bereitgestellt wird. Wer sich die Datenbankdefinition genau angesehen hat, wird feststellen, dass ein PostgreSQL-spezifisches Exclusion Constraint auf der Checkout-Tabelle definiert ist. Dieses wurde vom Meta-Modell nicht erkannt und auch nicht in den Skripten berücksichtigt.

Daher ist es immer noch wichtig, sich die generierten Skripte anzuschauen und ggf. zu prüfen und zu ergänzen. Gleiches gilt auch, wenn zyklische Abhängigkeiten zwischen Tabellen bestehen.

Fazit

Auch wenn Änderungen nicht erkannt werden, ist die Nutzung von jOOQ für die Erstellung von Migrationsskripten sehr hilfreich und kann einige Fehlerquellen adressieren, die bei gleichzeitiger Entwicklungstätigkeit auftreten können, wenn das Zielschema nicht eingefroren ist. Notwendige Ergänzungen können im Code oder in den generierten Skripten vorgenommen werden.

Alle Beiträge von Richard Attermeyer

Richard Attermeyer arbeitet als Chief Solution Architect bei OPITZ CONSULTING. Er ist seit vielen Jahren als Entwickler, Architekt und Coach für die Themen Modernisierung, Architektur und agile Projekte tätig und hilft Unternehmen, mit motivierten Teams erfolgreiche Projekte zu realisieren.

Schreibe einen Kommentar