Wie man Änderungen verfolgt, agil bleibt und dabei den Verstand behält

Die Einleitung
Willkommen! Wenn ihr Softwareentwickler seid, habt ihr vielleicht schon ähnliche Herausforderungen erlebt wie die, die ich gleich beschreiben werde. Vielleicht habt ihr euch gefragt, ob ihr wirklich die beste Lösung gefunden habt. Code (SQL, Kotlin) gibt es nur im Kapitel „Der Kampf”. Falls ihr Computer nicht aus Leidenschaft mit Befehlen füttert – ihr werdet wissen, was zu tun ist.
Die Philosophie
Man sagt, Veränderung sei die einzige Konstante im Leben. Mit „man” meine ich, dass dies Heraklit, einem griechischen Philosophen, zugeschrieben wird. Es wird oft in abgewandelter Form verwendet, um in einem klassischen Wortspiel zu zeigen, wie unrealistisch unser Wunsch ist, Regeln zu definieren, die monatelang oder jahrelang gültig sein sollen. In der folgenden Unternehmung sind mein mutiges Team und ich nicht nur auf sich über die Zeit verändernde Anforderungen gestoßen, sondern das Verfolgen von Änderungen war Teil des Features selbst – ja – ein bisschen meta, ich weiß.
Die Aufgabe
Ohne weitere Umschweife: Was hatten wir vor uns?
„Wir werden viele Daten haben, die in vielen verschiedenen Formen hinzugefügt werden” – ziemlich standard.
„Von einem kleinen Formular bis zu Hunderten von Formularen und Tabellen” – also größere Datenbankschemata.
„Daten, die mehrere Sprachen und verschiedene Konfigurationen unterstützen” – okay, wir könnten in Datenbankmigrationen investieren.
„Vieles davon wird optional sein, einiges wird sich in den nächsten Monaten und Jahren ändern” – schauen wir mal…
„All diese Daten werden einen Status erhalten, und sobald sie eingereicht wurden, sollen sie versioniert und abrufbar sein” – oh je…
So viel zu einer relationalen Datenbank – oder doch?
Nachdem ich ähnliche Anforderungen in verschiedenen Projekten gesehen hatte, wusste ich, dass es Zeit war, mich auf einen längeren Lauf vorzubereiten. Sei es das Verwalten historischer Daten für Versicherungsverträge, bei denen es nicht nur aktuelle und historische, sondern auch ausstehende Versionen gibt. Große Universitätsdatenbanken, in denen alte Lehrplandaten lange unterstützt werden müssen. Oder Projekte, bei denen es einfach darum geht, Änderungen an Stammdaten zu verfolgen, die von mehreren Benutzern in einem System geändert werden können. Es war Zeit, ein Schachspieler zu werden, der mehrere Schritte und Änderungen vorausdenkt, um nicht nur die aktuell beste Lösung zu wählen, sondern diejenige, die angepasst werden kann, damit das zukünftige Ich weniger Sorgen hat.
Die Helden
Normalerweise melden sich einige der folgenden Lösungen zu Wort:
- Das zusätzliche relationale Datenbankschema (der Alte, unterscheidet immer perfekt zwischen den Herausforderungen)
- Die relationale Datenbank mit zusätzlichen historischen Tabellen (der alte Held, oft besungen für seine stabilen Ergebnisse)
- Die nicht-relationale Datenbank (der Neue, gefeiert für seine Agilität bei Änderungen)
- Der zusätzliche kombinierte Schlüssel mit Zeitstempel, um alles an einem Ort zu haben (der Alleskönner, vermeidet Konflikte durch Langsamkeit)
- Die datenbankspezifische Versionierung (der Außenseiter, meist nur für begrenzten Einsatz)
- Die kombinierte Struktur – früher XML, heute oft JSON – in eine relationale Datenbank gepresst (die Harley Quinn, meistens chaotisch, kann unerwartete Ergebnisse produzieren)
Ja, sie alle haben ihre eigenen Nachteile:
- Probleme beim Vergessen, Änderungen in historischen Tabellen anzupassen
- Das Synchronhalten verschiedener Tabellen in verschiedenen Schemata oder dann duplizierte Logik
- Der Umgang mit komplexen Schlüsseln und Abfragen wegen zu vieler Daten
- Inkompatibilitäten entstehen durch zu viele Änderungen in nicht-relationalen Strukturen
- Die Abhängigkeit von einer bestimmten Datenbank ohne jeden Ausweg oder…
- die schiere, suizidale Menge an Problemen, die Harley Quinn auf den Tisch bringen könnte (Wortspiel beabsichtigt)
Der Außenseiter
Vor diesem Hintergrund entschied sich unser Team für den eher unbesungenen Helden: datenbankspezifische Versionierung. Wir entschieden uns für die Verwendung von System-versionierten Tabellen, die von MariaDB in Version 10.3.4 im Jahr 2018 eingeführt wurden [1]. Das war nur möglich, weil wir uns für MariaDB entschieden hatten, als wir unsere relationale Datenbank wählten – aber es gibt ähnliche Lösungen für andere Datenbanken. Die Auswahl dieser Lösung ist möglicherweise nicht selbstverständlich. Lassen mich erläutern, warum:
System-versionierte Tabellen sind eine datenbankspezifische Lösung, was bedeutet, dass native Abfragen geschrieben werden müssen. Aus einer generalistischen Sicht ist das umstritten, da manche lieber auf jeder unteren Schicht datenbankabhängig bleiben möchten.
Dann können system-versionierte Datenzeilen nicht auf einfache Weise gesichert werden, was Kopien und Backups etwas weniger offensichtlich macht und Untersuchungen von Problemen zusätzliche Schritte erfordern.
Diese Zeilen können auch von Natur aus nicht angepasst werden, was bedeutet, dass Änderungen nur an der letzten, aktuellen Zeile vorgenommen werden können. Das ist eines der wichtigsten Merkmale unseres Helden, über das man nachdenken sollte.
Obwohl diese Datenzeilen nicht geändert werden können, könnten sich Daten und Typ ändern, wenn die Tabellen- oder Spaltenkonfiguration geändert wird. Das muss in jeder Migration berücksichtigt werden.
Jede Änderung an einem Wert in einer System-versionierten Tabelle führt zu einer weiteren hinzugefügten Zeile, was normalerweise bedeutet, dass man auf SEHR VIELE Daten vorbereitet sein muss.
Warum würden wir diese Lösung trotz ihrer vielen Einschränkungen wählen? Um das zu beantworten, möchte ich nochmals hervorheben, was ich am Anfang über Schach gesagt habe. Wir brauchen eine Lösung, die uns nicht ausbremst und uns ermöglicht, später viele Änderungen vorzunehmen. Auch wenn wir berücksichtigen, dass wir native Abfragen benötigen und Probleme entstehen, wenn wir vergessen, diese zu ändern, haben wir den Vorteil eines einzigen Schemas und einer einzigen Menge von Entitäten, die unterstützt werden müssen. Es wird Compilerfehler geben, wenn es Probleme bei Mappings oder inkompatible Änderungen in der Zukunft gibt. Das alles ist eine enorme Verbesserung gegenüber der Alternative mit zusätzlichen historischen Tabellen oder dem Einschieben von allem in nicht-relationale Datenbankstrukturen. Ein weiterer Nachteil ist, dass wir, da wir nur bestimmte eingereichte Versionen benötigen, potenziell viele Daten generieren, die wir gar nicht brauchen. Hier wussten wir, dass wir das später behandeln können, indem wir nicht verwendete Zeilen bereinigen, und so den Luxus genossen, uns nicht extra darum kümmern zu müssen, wenn wir neue versionierte Tabellen für unsere Zwecke erstellen.
Der Kampf
Das Aktivieren der System-Versionierung ist der einfache Teil. Alles, was man braucht (obwohl dies MariaDB-spezifisch ist, aber in anderen Datenbanken ähnlich ist), ist das Hinzufügen der System-Versionierung:
ALTER TABLE project ADD SYSTEM VERSIONING;
Einige potenzielle Probleme wurden im obigen Kapitel antizipiert, aber schauen wir uns die tatsächlichen Herausforderungen an, denen wir begegnet sind. Und natürlich, wie wir verhindert haben, früh K.o. zu gehen.
Runde 1
Am Anfang ging es vor allem darum herauszufinden, dass wir nicht um das Schreiben nativer Abfragen herumkommen. Das war auch ein interner Kampf mit uns selbst. Es bedeutete, Frieden damit zu schließen, die Regel aufzugeben, so allgemein und unabhängig wie möglich von einer bestimmten Datenbank zu bleiben. Trotzdem gingen wir mit einem Duplikationsansatz vor: Bereitstellung verschiedener Logik zur Unterstützung eines aktuellen Versions-Abrufs ohne spezifische Abfragen. Das würde in der Zukunft helfen, potenzielle Performance-Probleme zu finden und auf dieser Seite kompatibel und neutral zu bleiben. So war es einfacher, den historischen Abfrageteil schrittweise zu implementieren.
@Transactional(readOnly = true)
override fun getPartnerFinancing(
partnerId: Long,
version: String?
): PartnerFinancing {
return projectVersionUtils.fetch(version,
projectId = projectVersionUtils.fetchProjectId(version, partnerId),
currentVersionFetcher = {
PartnerFinancing(
finances = partnerFinancingRepository
.findPartnerFinancingById(partnerId)
.toFinancingModel()
)},
previousVersionFetcher = { timestamp ->
PartnerFinancing(
finances = partnerFinancingRepository
.findPartnerFinancingByIdAsOfTimestamp(partnerId, timestamp)
.toPartnerFinancingHistoricalData()
)}
) ?: PartnerFinancing(
finances = emptyList()
)
}
Die Erkenntnis: Es ist völlig in Ordnung, sich auf ein Datenbanksystem festzulegen (Wortspiel beabsichtigt), das man in der heutigen Software verwendet.
Runde 2
Als Nächstes kam die Erkenntnis, was es bedeutet, wenn Schemaänderungen notwendig sind. MariaDB verhindert standardmäßig versehentliche Änderungen, sodass jede Änderung an system-versionierten Tabellen beim Ausführen dieser Migrationsskripte zu Fehlern führt. Es gibt eine Eigenschaft, die gesetzt werden kann, um das zu erlauben. Aber natürlich muss man versuchen, mit der Tatsache zu leben, dass das technisch gesehen die Vergangenheit verändert.
SET @@system_versioning_alter_history = 1;
Jede Änderung an versionierten Tabellen sollte immer eine bewusste Entscheidung sein, die nicht leichtfertig getroffen wird.
Runde 3
Der erste kleine Herzinfarkt, als wir spontan gebeten wurden zu zeigen, wie man Daten sichert, bevor wir dazu lokal bereit waren. Obwohl das offensichtlich klingt, da die Dokumentation uns vorab darüber informiert hatte, war es dennoch ein Moment der Unsicherheit. Wissend, dass es möglich sein sollte, weil Daten abgefragt werden können, aber auch wissend, dass historische Daten wirklich nicht manuell eingefügt werden können.
mysqldump liest keine historischen Zeilen aus versionierten Tabellen, sodass historische Daten nicht gesichert werden. Außerdem wäre eine Wiederherstellung der Zeitstempel nicht möglich, da sie nicht durch ein Insert/einen Benutzer definiert werden können. [1]
Wie sich hier auch herausstellt: eine containerisierte Umgebung ist definitiv ein Gewinn. Ein paar Befehle und alles ist erledigt, ohne sich um datenbankspezifische Abfragen kümmern zu müssen. Siehe Docker ‘Use Volumes’ [2].
Kennt eure Umgebung! Verwendet Container – und mappt eure Volumes!
Runde 4
Als Nächstes war es Zeit für den ersten Fast-K.o. beim Debuggen historischer Werte. Wenn nur bestimmte Versionen benötigt werden, wie in unserem Fall, besteht die Notwendigkeit einer eigenen Versions-Tabelle, um system-versionierte Zeilen den eigenen Versionen zuzuordnen. Das macht den row_end-Wert zu einem der wichtigsten Werte. Daher ist es gut zu wissen, dass diese Spalten abgefragt werden können, auch wenn sie möglicherweise nicht in der Datenbankansicht eurer Wahl erscheinen.
SELECT id, name, row_start, row_end FROM project;
Kennt alle Features, die ihr verwendet, und lest zuerst über ihre Einschränkungen.
Runde 5
Die letzte große Herausforderung, die ich beschreiben möchte, war der Versuch, gegen den neuen Gegner zu gewinnen: die Anforderung, alte Versionen „wiederzuöffnen”. Wie oben erwähnt, können historische Zeilen nicht geändert werden, was bedeutete, alte historische Daten erneut zu speichern, um diese Werte als aktuelle zurückzubekommen. Um diesen Angriff auf die aufgebauten Regeln abzuwehren, war es notwendig, einen Trick anzuwenden. Andernfalls hätte es bedeutet, etwas Spezifisches für praktisch alle verschiedenen Tabellen zu implementieren, die wir bis dahin hatten, da es nicht einfach gewesen wäre, alle Daten zu kopieren und bestimmte bereits vorhandene IDs zu entfernen. Dieser Plan ersparte uns viele Stunden potenziell schmerzhafter Entwicklung und umfasste folgende Teile:
- das Wissen, dass wir alle versions-spezifischen Tabellen über ihre Beziehungen auf einen einzigen bestimmten Bezeichner zurückführen können
- die Möglichkeit, nach Tabellennamen aufgrund ihrer Constraints abzufragen, die Fähigkeit, nur Abfragen für Tabellen zu generieren, die geänderte, für die Wiederherstellung relevante Daten hatten
- das Wissen, was wir tun, und den Mut zu haben, die Fremdschlüssel-Constraints für die eigentliche Ausführung des erneuten Speicherns zu deaktivieren (hier könnte ein Emoji stehen – entweder schreiend oder staunend)
Manchmal können gefährliche Aktionen (wie das Deaktivieren von Constraints) den Versuch wert sein.
Nun läuft alles, und ich bin sicher, dass noch mehr Runden kommen werden.
Der Abschluss
Was haben wir gelernt? Es mag keine natürliche Selektion sein, aber es ist die passendste Methode, nach der wir suchen. Es ist die Implementierung, die wir wählen sollten – nicht weil sie die beste oder schnellste ist, sondern diejenige, die wir idealerweise früh implementieren können, leicht anpassen können und die insgesamt die besten Kompromisse bietet.
Also da habt ihr es: ein technischer Artikel, der mit einem griechischen Philosophen beginnt und mit Darwin endet!
Ende.
[0] https://pixabay.com/photos/caterpillars-cocoons-hatch-5486651/ Quellbild für den Header
[1] https://mariadb.com/kb/en/system-versioned-tables MariaDB versionierte Tabellen
[2] https://docs.docker.com/storage/volumes/ Docker-Dokumentation: Volumes verwenden
Agent Hub
Gehen Sie über vereinzelte AI-Piloten hinaus und schaffen Sie eine sichere, skalierbare Grundlage für Agentic AI in Ihrer gesamten Organisation.
Zur Demo




