lars
webmobiledatenbankendevopsarchitektur
hello (at) larskoelpin.de

Append-Only Datenmodellierung

February 02, 2023
datenbanken

Applikationen nutzen zum Speichern von Daten Datenbanken. Datenbanken stellen sicher, dass Daten physisch gespeichert werden. Eine Modellierung, wie diese Daten aussehen und vor allem, welche gespeichert werden, liegen beim Entwickler. Häufig wird der aktuelle Datensatz bestimmter Daten vorgehalten und dieser ggf. verändert. Das schont zwar die Festplatte, kommt aber mit versteckten kosten. Eine Alternative ist es, Daten niemals zu löschen — Append-Only.

Die Append-Only Datenmodellierung (Immutable Datenmodellierung) ist eine Art der Daten-Modellierung, bei der Daten niemals gelöscht werden. Ein Anwendungsfall, wo diese Modellierung die meisten Vortele ausspielt, ist bei einem Anwendungsfall temporaler Datenhaltung.

Fast jede Applikation hat ein Konzept einer bezahlten Mitgliederschaft. Eine Mitgliedschaft ist als eine Entität (Tabelle) modelliert, die, wenn sie für einen bestimmten Zeitraum existiert (temporal), dem Nutzer in der Applikation besondere Rechte einräumt. Es existiert also eine 1 zu 1 Beziehung zwischen Nutzer und einem bestimmten Recht für einen bestimmten Zeitraum.

Häufig wird genau diese Beziehung in relationalen Datenbanken wie eine einfache 1 zu 1 Beziehung abgebildet. Mittels SQL beispielsweise so:

CREATE TABLE subscription (
  user_id BIGINT PRIMARY KEY,
  active_from TIMESTAMP NOT NULL,
  active_to TIMESTAMP NOT NULL,
);

Um zu prüfen, ob ein Nutzer mit der ID=3 jetzt gerade berechtigt ist, reicht dann also SELECT * FROM subscription WHERE user_id=3.

UserId from to
3 01.01.2022 01.03.2022

Ein Problem gibt es dann, wenn die Mitgliedschaft endet — die aktuelle System-Uhr den Wert in active_to überschreitet. Klar — in dem Falle muss ersteinmal die Anfrage das Datum inkludieren, so dass keine Mitgliedschaft besteht.

SELECT * FROM subscription WHERE user_id=3 AND active_to > NOW().

Was aber, wenn die Mitgliedschaft für ein Monat gekündigt wird, und in einem Jahr wieder aufgenommen wird, die Daten also so aussehen?

UserId active_from active_to
1 01.01.2022 01.03.2022
1 01.06.2022 01.12.2022

Spätestens jetzt fällt uns die 1 zu 1 Modellierung auf die Füße: In dem Fall muss die alte Mitgliedschaft gelöscht werden (Der Primary key ist ja die UserId) und eine neue angelegt werden (Es darf durch die 1-zu-1-Abbildung nur genau eine Zeile mit user_id=3 existieren), oder die Daten active_from und active_to angepasst werden. Das heißt, wenn wir die zweite Mitgliedschaft anlegen, verlieren wir in jedem Falle die Daten der Ersten.

Dieses Problem lässt sich mit einer immutable Datenbank-Modellierung lösen — oder besser: es taucht gar nicht erst auf.

Immutable heißt, dass Daten an eine Tabelle nur angehangen, nicht aber aktualisiert oder gelöscht werden. In der Praxis bedeutet das, dass der Nutzer der Datenbank nur das Recht INSERT und SELECT besitzt. Denn sowohl DELETE als auch UPDATE löschen Daten — Sie sind deshalb verboten.

In der Praxis bedeutet das: Jede Aktualisierung einer Entität erstellt eine neue Kopie dessen. Wir passen dazu also die Datenmodellierung an.

CREATE TABLE subscription_versions (
  id BIGINT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  active_from TIMESTAMP NOT NULL,
  active_to TIMESTAMP NOT NULL
);

Ich habe jeder immutable Tabelle ein ID-Attribut spendiert. Diese technische ID sorgt dafür, dass ein Nutzer in der Datenmodellierung mehr als eine subscription besitzen darf. Wir haben also im Datenbank-Sprech aus einer 1 zu 1 Beziehung eine 1 zu N Beziehung gemacht — was ja erstmal grundsätzlich das Problem war: eine 1-1 Beziehung ist eigentlich 1-N mit einem Constraint.

Dabei haben wir aber die Komplexität reingeholt, dass wir jetzt immer die aktive subscription herausfinden müssen, so wie es vorher war, da die Tabelle ja nichtmehr nur den aktuellen Stand vorhält. Und da hilft ein altes Datenbank-Konzept: Eine simple View.

CREATE VIEW active_subscription AS SELECT * FROM subscription WHERE active_from >= NOW() AND active_to < NOW()

Durch diese virtuelle Tabelle haben wir genau diesen Fakt der Zeit in eine fertige Abfrage codiert — das was in der alten Tabelle implizit passiert ist (durch Anwenden von UPDATE-statements in der Applikation selbst). Und schon erhalten wir das Verhalten unserer alten Tabelle. Man spricht bei diesem Anwendungsfall auch von temporalen Tabellen und wird von vielen Datenbanken nativ unterstützt (ohne explizite Modellierung) — von SQLite meines Wissens nach leider nicht.

Der Anwendungsfall der temporalen Tabellen lässt sich allerdings auch auf beliebige Datensätze ohne Datumgrenze übertragen.

Beispielsweise kann ein Nutzer wie folgt immutable abgebildet werden.

CREATE TABLE user_versions (
  id TEXT PRIMRY KEY,
  user_id BIGINT NOT NULL,
  name VARCHAR(255) NOT NULL,
  version: BIGINT,
);

CREATE VIEW users AS SELECT *, MAX(version) FROM user_versions GROUP BY id WHERE version IS NOT NULL

Die Tabelle user_versions bildet zu jeder Zeile eine technische ID, eine Identifikation des Datensatzes einen Name und eine Technische Version des Datensatzes ab.

Sollen jetzt die bekannten Operationen — Create, Read, Update und Delete — ausgeführt werden, passiert dies durch verschiedene INSERTs und SELECTs.

INSERT INTO user_versions VALUES(UUID(), 1,  'lars', 1675096097819); //CREATE
SELECT * FROM users WHERE user_id=1; // READ
SELECT * FROM user_versions WHERE user_id=1 AND VERSION IS NOT NULL ORDER BY VERSION DESC LIMIT 1; // READ
INSERT INTO user_versions VALUES(UUID(), 1,  'LarsRenamed', 1675096097400); // UPDATE
INSERT INTO user_versions VALUES(UUID(), 1,  'LarsRenamed', NULL); // DELETE

Dadurch, dass der konzeptionelle User nur in der View existiert, kann diese bestimmen ob dieser überhaupt existiert, oder welche Version dessen die richtige ist. Der physische Datensatz nach jeder dieser Operationen lässt sich aus der Datenbank jederzeit auslesen.

id user_id name version
1 1 lars 1
2 1 larsRenamed 2
3 1 lars NULL

Fazit

Streng genommen sind immutable Datenhaltungen aus Entwickler und Ops-Brille strikt besser, als ständig die Daten zu verändern — Daten zu verlieren ist objektiv schlecht. Aber natürlich heißt eine solche Art der Modellierung auch, dass mehr Speicherplatz verbraucht, geladen und durchsucht werden muss.

Bei der Immutable Modellierung werden alle Schreibe-Operationen durch ein “append-only” abgebildet — Updates und Deletes sind einfach nur Kopien oder Markierungen an bestehenden Daten.

Immutable Modellierung erlaubt Zeitreisen und bringt Sicherheit. Wenn wir Entwickler eine fehlerhafte Migration ausliefern, können wir sicher sein, dass zumindest keine Daten verloren gehen. Durch die immutable Modellierung ist es möglich Daten zu jedem Zeitpunkt wiederherzustellen.

Das ist besonders gut für Applikationen, wo das Darstellen und Vorhalten von historische Daten ein essentieller Bestandteil ist (Workout-Apps, Habit Tracker-Apps, Reminde-Apps, Vertragliche Apps …)

Auch für lokale Apps, wo die Datenmengen überschaubar bleiben, ist eine immutable Modellierung häufig sinnvoll.

Konzeptionell lässt sich das Ganze übrigens 1 zu 1 auf State-Management bei Single-Page-Applications übertragen — doch das ist eine andere Geschichte…