SQL, NoSQL und warum ich Kanta DB angefangen habe
SQL ist aus gutem Grund immer noch der Standard âŠ
Wenn ich heute einen konventionellen Python-Dienst bauen wĂŒrde, wĂŒrde ich wahrscheinlich mit PostgreSQL, SQLAlchemy und Alembic anfangen. PostgreSQL gibt mir echte UUIDs, JSONB, Arrays, Transaktionen, Constraints und hervorragendes Indexing. SQLAlchemy bildet das meiste davon sauber auf Python ab. Alembic hĂ€lt SchemaĂ€nderungen explizit und versioniert.
Damit kommt man ziemlich weit, bevor irgendetwas anfÀngt zu nerven.
⊠aber das Modell hat seine Ecken
Ein Datenbankschema und eine Python-Datenstruktur sind nicht ganz dasselbe.
Mit SQLAlchemy kann ich typisierte Modelle definieren, uuid.UUID direkt gegen PostgreSQL-UUID-Spalten verwenden, JSONB auf Python-Container abbilden und die meisten Routinekonvertierungen aus meinem eigenen Code heraushalten. Das ist erheblich besser, als jeden Datenbankwert als String zu behandeln oder SQL manuell zusammenzubauen.
Trotzdem wird das ORM-Modell zu einer besonderen Art von Objekt. Es trĂ€gt Spalten, Beziehungen, Session-Verhalten und Persistenzregeln in sich. Wenn der Rest des Programms schlichte Anwendungsstrukturen will, lasse ich entweder Datenbankbelange nach auĂen durchsickern oder fĂŒge eine weitere Konvertierungsschicht hinzu.
Das Problem wird sichtbarer, wenn sich das Schema Àndert.
Ein Feld zu einer Python-Struktur hinzuzufĂŒgen fĂŒhlt sich trivial an. Eine Spalte zu persistierten Daten hinzuzufĂŒgen bedeutet, das Modell zu Ă€ndern und eine Migration zu erstellen. AufwĂ€ndigere Ănderungen erfordern Datentransformationen und KompatibilitĂ€tsentscheidungen. Alembic handhabt das vernĂŒnftig, aber ich muss trotzdem eine Historie struktureller Ănderungen pflegen, nur damit alte Zeilen zu neuen Zeilen werden können.
Das ist kein Fehler von PostgreSQL. Die Datenbank hat sich auf ein Schema festgelegt, also hat dessen Ănderung Konsequenzen.
Historie erzeugt eine weitere Schicht. Wenn ich wissen will, wer einen Wert geĂ€ndert hat, wann er ihn geĂ€ndert hat oder wie der Datensatz letzten Dienstag aussah, muss ich das modellieren. Ich kann Audit-Tabellen, Trigger, Zeitstempelspalten oder eine Event-Sourcing-Schicht hinzufĂŒgen. PostgreSQL kann all das ziemlich gut unterstĂŒtzen.
Aber die normale Zeile reprÀsentiert weiterhin was jetzt existiert. Die Historie bleibt etwas, das ich darum herum baue.
Alternativen zu SQL
MongoDB beseitigt etwas Reibung
MongoDB bringt die Form der Daten viel nÀher an die Form, die ich im Programm verwende.
Ich kann verschachtelte Dokumente direkt speichern, Felder hinzufĂŒgen, ohne eine Tabelle zu Ă€ndern, und Ă€ltere sowie neuere DatensĂ€tze koexistieren lassen, wĂ€hrend die Anwendung beide versteht. Das macht Schemaevolution oft weniger zeremoniell. Statt zuerst die ganze Datenbank zu migrieren, kann ich manchmal alte Dokumente aktualisieren, wenn ich sie lese oder verĂ€ndere.
Das Schema existiert trotzdem. Mein Code erwartet weiterhin bestimmte Felder mit bestimmten Bedeutungen. MongoDB gibt mir lediglich mehr Freiheit, wann ich diese Vereinbarung durchsetze.
Redis gibt mir hervorragende Bausteine
Redis ist wunderbar praktisch.
Wenn ich einen Cache, eine Queue, einen ZĂ€hler, ein Sorted Set, einen Distributed Lock oder einen flĂŒchtigen gemeinsamen Zustand brauche, hat Redis meist eine kompakte Antwort.
Wenn meine Daten natĂŒrlich als JSON oder in einem anderen strukturierten Format ankommen, liegt es auch vollstĂ€ndig an mir, sie auf diese Redis-Primitiven herunterzubrechen oder sie einfach als Ganzes zu dumpen und all die feinen Werkzeuge zu ignorieren.
Pushen und Pullen
Lass es mich wissen, wenn jemand meine Daten anfasst
Firebase beginnt bei der Synchronisation
Firebase geht einen direkteren Weg. Seine Datenbanken behandeln Live-Client-Updates als Teil des Produkts.
Ich kann einen Listener an Daten hĂ€ngen und der Client empfĂ€ngt Ănderungen, sobald sie passieren. Offline-Verhalten und Wiederverbindung gehören ebenfalls zum selben System, statt spĂ€ter als WebSocket-Projekt aufzutauchen.
Das ist attraktiv.
Wir haben alle Ănderungen
MongoDB hat hierauf die starke eingebaute Antwort. Change Streams lassen mich eine Collection, eine Datenbank oder ein ganzes Deployment beobachten und Inserts, Updates, Deletes usw. empfangen. Updates enthalten normalerweise die geÀnderten Felder, und jedes Event trÀgt ein Resume Token, sodass ich mich neu verbinden und dort weitermachen kann, wo ich aufgehört habe, solange das Oplog diesen Punkt noch enthÀlt.
PostgreSQL kann tatsĂ€chliche DatenbankĂ€nderungen ebenfalls ĂŒber Logical Decoding und Replikation bereitstellen. Redis Change Streams bieten eine Ă€hnliche Lösung in diesem Bereich.
Change-Data-Capture-Systeme bauen darauf sehr leistungsfĂ€hige Pipelines, aber dann mĂŒssen wir SQL-Anweisungen oder Redis-Befehle parsen und den Zustand selbst verfolgen.
Publish und Subscribe
PostgreSQL und Redis bieten auĂerdem altmodische Message-Channels.
Ich könnte LISTEN/NOTIFY oder PUB/SUB verwenden und eine Benachrichtigung aus derselben Transaktion senden, die die Daten verÀndert. Das vermeidet einen Teil der HÀsslichkeit eines unabhÀngigen Message Bus.
Aber ich muss trotzdem Benachrichtigungen erstellen und empfangen, um zu entscheiden, was gelesen und was gesendet werden soll. Und es kommt mit Race Conditions zwischen der tatsĂ€chlichen Ănderung und der Benachrichtigung.
FĂŒr mein Problem fĂŒhlte es sich an, als wĂŒrde man ziemlich weit unterhalb der Abstraktion anfangen, die ich eigentlich wollte.
Ich wollte nicht nur wissen, dass sich der Zustand geÀndert hatte.
Ich wollte die Ănderung selbst, und den Zustand davor und danach.
Vielleicht sollte ich meine eigene bauen?
Nachdem ich all das verwendet hatte und immer wieder feststellte, dass das ORM-Kriechen ĂŒber meine Codebasis unertrĂ€glich wurde, begann ich schlieĂlich an einer Idee zu arbeiten, die ich ĂŒber Jahre und Jahre still entwickelt hatte.
Meine eigene Datenbank zu bauen. Du weiĂt schon, so etwas, von dem sie dir immer sagen, dass du es nicht einmal versuchen sollst.
Lass das Log die Datenbank sein
Das wurde der Ausgangspunkt fĂŒr Kanta.
Statt den neuesten Zustand als primĂ€ren Datensatz zu speichern und Historie darum herum anzufĂŒgen, wollte ich Ănderungen speichern.
Ein Objekt beginnt mit leerem Zustand. Jede spĂ€tere Operation zeichnet nur auf, was sich geĂ€ndert hat, zusammen mit einem Zeitstempel und allen anderen Metadaten, die zu dieser Ănderung gehören.
Der aktuelle Zustand entsteht durch Anwenden des Logs. Jetzt braucht die Historie kein eigenes Schema mehr. Abfragen mĂŒssen nicht mehr den jĂŒngsten Zeitstempel finden, weil sie auf dem Zustandsobjekt zu jedem beliebigen Zeitpunkt arbeiten.
Eine Struktur durchgehend
Msgspec gibt mir typisierte, kompakte Datenstrukturen mit sehr schneller Serialisierung. Ăhnlich wie Dataclasses oder Pydantic verarbeitet es verschachtelte Strukturen und gĂ€ngige native Typen wie UUIDs, Enums und Datetimes, ohne die Objekte in ORM-Entities zu verwandeln. Die Definition deiner Datenstrukturen wird so einfach:
class Data(msgspec.Struct):
servername: str
users: dict[UUID, User]
Das bedeutet, dass ich dieselbe Art von Objekt im gesamten Programm verwenden kann. Ich kann es serialisieren. Ich kann es ĂŒber das Netzwerk senden. Ich kann seine Ănderungen persistieren. Ich kann es auf der anderen Seite rekonstruieren.
Ich brauche keine Klasse fĂŒr meine Anwendung, eine weitere fĂŒr SQLAlchemy, ein weiteres Schema fĂŒr die Serialisierung und kleine Konvertierungsfunktionen, die zwischen all diesen vermitteln.
Es lebt
Ich habe einen einfachen JSONL-Logger zusammengehackt, eine Ănderung pro Zeile, mit einem jsondiff-Ănderungsdatensatz darauf. Es war kein BinĂ€rformat, wie ich es vielleicht bevorzugt hĂ€tte, aber es ist etwas, das sich zum Editieren und Debuggen geradezu trivial verhĂ€lt.
Reads sind einfach Reads aus Python-Variablen, viel schneller als Redis oder jede externe Datenbank!
Writes erforderten zusĂ€tzliches Nachdenken. Statt der Datenbank zu sagen, was geĂ€ndert werden soll, bearbeiten wir den Zustand, und die Datenbank persistiert einen Ănderungsdatensatz.
with kanta.transaction(action="new_user"):
data.users[uuid7()] = User(...)
Beachte, dass es dort kein async with oder await gibt, obwohl wir mit async Python arbeiten. Die Transaktion ist sofort und synchron. Du brauchst keine Locks oder Synchronisation darum. Die ZustĂ€nde davor und danach werden gespeichert und verglichen, um das Change-Diff zu erzeugen. Im Fehlerfall wird der vorherige Zustand wiederhergestellt, wobei alles bereits in dieser Transaktion Getane zurĂŒckgerollt wird.
Die Ănderungen werden von einem Hintergrund-Thread auf die Festplatte persistiert, in eine Append-only-Datei, die sich im Falle von Korruption durch Stromausfall oder AbstĂŒrze leicht reparieren lĂ€sst.
Das war ungefÀhr der Punkt, an dem Kanta aufhörte, wie ein Datenbankexperiment auszusehen, und anfing, wie ein kohÀrentes Modell auszusehen, auf dem man aufbauen kann.
Warum nicht in Produktion ausprobieren?
Nachdem ich es mit meinen eigenen Projekten getestet hatte, verdrahtete ich es schnell mit einer ernsthafteren Anwendung, die schwerere Daten und viele Benutzer hatte. Das beantwortete meine BefĂŒrchtungen ĂŒber mögliche Performance-Probleme, denn immerhin verwendeten wir tatsĂ€chlich JSON fĂŒr eine Datenbank, und zwar in einer Logdb-Struktur, die andere meines Wissens seit den frĂŒhen Tagen der Computertechnik nicht mehr verwendet hatten.
Wie erwartet wurde das erneute Abspielen groĂer Changesets beim Anwendungsstart mit der Zeit langsam, also fĂŒgte ich vollstĂ€ndige Snapshot-Zeilen hinzu, um die langen Replays ab der initialen Revision zu vermeiden. Danach hat die Performance alle meine Anforderungen ĂŒbertroffen.
Aber der entscheidende Punkt ist nicht die Performance, sondern die Einfachheit. Indem wir annehmen, dass unsere App in einem einzigen Worker-Prozess leben und den vollstÀndigen Zustand in ihrem Speicher halten kann, beseitigen wir die meisten Probleme, die mit der typischen Datenbank einhergehen.
Dank der Log-Struktur kommt das ZurĂŒckspulen der Historie gratis. Ich habe sogar eine Reihe von Transaktionen mitten in der Historie rĂŒckgĂ€ngig gemacht â um einen Benutzer zu retten, der einen Teil des Projekts gelöscht und danach weitere Ănderungen vorgenommen hatte.
Diese Website ist ebenfalls auf Kanta gebaut (Pagerite CMS).
Migrationen
Tooling
Kanta also has a small CLI for inspecting databases directly. It can replay a database, inspect selected ranges of its history, load the applicationâs actual data types and run migrations when needed, and dump the resulting state as JSON. A good tool also beats treating it as a text file.
Das Single-Process-Modell beseitigt eine Menge Maschinerie, aber es beseitigt nicht die Tatsache, dass sich Software Àndert.
Niemand mochte jemals Migrationen. Sie sind die BĂŒrde, WartungsĂ€nderungen an Daten vorzunehmen. Noch eine SQL-Migration oder noch ein Mongo-Legacy-Fallback mit Update-Zweig hinzuzufĂŒgen ist einfach zu viel Aufwand, also vermeidet man diese Ănderungen lieber.
Auch hier ĂŒbernimmt msgspec einen GroĂteil der schweren Arbeit. Wenn wir ein Feld hinzufĂŒgen oder entfernen wollen, setzen wir das neue Feld einfach mit einem Standardwert auf die Datenstrukturen oder entfernen ein beliebiges altes Feld. Es migriert stillschweigend in das neue Format. Wenn das zu ladende Format nicht zu den Strukturen passt, bekommen wir einen Fehler, der sagt, was und wo etwas falsch ist.
Cool und einfach, aber nicht ausreichend fĂŒr eine Datenbank.
Hin und wieder wollen wir ein Feld umbenennen, Datentypen Ă€ndern oder die gesamten Daten glatt umstrukturieren, möglicherweise wĂ€hrenddessen neue externe Daten abrufen (habe ich gemacht). Das erfordert eine echte Migrationsfunktion, die weiĂ, was sie tut.
def migrate_v1(d: dict) -> None:
"""Rename counter to total"""
d["total"] = d["counter"]
Das Format ist einfach: Die Datenbank-Revisionsnummer kommt direkt aus dem Funktionsnamen. Die Funktion manipuliert das schlichte Dict-Format, sodass wir keine msgspec.Structs Ă€lterer Versionen pflegen mĂŒssen. Der Docstring liefert die geloggte Beschreibung dessen, was getan wurde.
Um zu vermeiden, den Rest unseres Programms damit zu vermĂŒllen, können die Migrationen in ihr eigenes Python-Modul gelegt werden, auf das wir beim Definieren der Datenbank einfach mit dem Python-Modulpfad verweisen:
kanta = Kanta("foo.kantadb", migrations="foo.migrations")
Die Changeset-Metadaten und Snapshots enthalten die Versionsnummer, die sie reprĂ€sentieren, und wir wenden alle gefundenen Migrate-Funktionen aufwĂ€rts ab dieser Version an und fĂŒhren zum Abschluss der Migration in jedem Fall die Msgspec-Konvertierung durch. Falls sich daraus Ănderungen ergaben, loggen und speichern wir die durchgefĂŒhrten Migrationen und erstellen danach einen Snapshot.
Die Ă€ltesten Migrationsfunktionen können ebenfalls entfernt werden, wenn solche Versionen nicht mehr unterstĂŒtzt werden mĂŒssen, wodurch dieses gesamte Migrationssystem handhabbar bleibt.
Format auf der Festplatte
Das Standardformat bleibt bewusst anspruchslos: JSON-DatensÀtze, einer pro Zeile. Msgspec kodiert Bytes und andere Typen hinein.
Vor der Veröffentlichung wollte ich eine Kritik adressieren, die ich selbst sicherlich am fehlenden BinĂ€rsupport ĂŒben wĂŒrde, indem ich diesen als Option implementierte.
WĂ€hrend wir bei JSONL vom Dateiende aus nach Newlines suchen können, um einen Snapshot zu finden, könnten die BinĂ€rdaten den Parser verwirren. Wir können vom Dateianfang an lesen fĂŒr einen vollstĂ€ndig deterministischen Ansatz, aber wenn die Datenbank groĂ wĂ€re, wĂŒrden wir es vorziehen, nahe dem Ende zu suchen. Ich wollte auĂerdem, dass es Append-only bleibt, also konnten wir den Snapshot-Offset nicht einfach an den Anfang schreiben.
Die Lösung ist zweiteilig:
- Unvorhersehbarer zufÀlliger Nonce als Syncword
- Blake3-Hash zur IntegritĂ€tsprĂŒfung
Die Change-Payloads selbst sind derzeit mit MessagePack kodiert, das in msgspec ohnehin unterstĂŒtzt wird.
Ich wĂŒrde das gerne durch etwas ersetzen, das nĂ€her an Protocol Buffers liegt, ohne Feldnamen und -typen ĂŒberhaupt zu speichern, und dabei ausnutzen, dass msgspec die Struktur und Typen meiner Structs bereits kennt und daher in der Lage sein sollte, aus denselben Informationen eine kompakte BinĂ€rreprĂ€sentation abzuleiten.
Das wĂŒrde die Eigenschaft bewahren, die mir am wichtigsten ist: eine einzige Definition der Datenstruktur, statt eines Python-Modells plus eines ORM-Modells plus eines Serialisierungsschemas.
Die Datenbank ist nur die HĂ€lfte des Zustands
Den maĂgeblichen Zustand im Speicher zu halten, macht etwas anderes schwer zu ignorieren: Die meisten interaktiven Anwendungen halten bereits eine weitere Kopie woanders.
Der Browser hat ebenfalls eine.
Sobald die Datenbank selbst einen geordneten Strom von Ănderungen aufzeichnet, wird die Verwendung desselben Stroms zur Synchronisation ein offensichtlicher nĂ€chster Schritt.
Stores ĂŒber WebSocket synchronisieren
Die FastAPI-App sendet Ănderungen ĂŒber einen WebSocket und wendet eingehende Ănderungen zurĂŒck auf die Datenbank an.
Auf jedem Client pflegen wir eine Schattenkopie, also den zuletzt gesehenen Serverzustand, und den aktuellen Arbeitszustand des Clients in seinem nativen Vue Pinia-Store. Oder Svelte- oder React-Ăquivalenten.
Der Store ist das verbindende Glied, das ReaktivitĂ€t durchgehend von der Datenbank bis zur BenutzeroberflĂ€che und zurĂŒck bietet.
Der Client kann offline oder gleichzeitig mit anderen Clients arbeiten, und die Ănderungen werden zusammengefĂŒhrt, wenn sie auf dem Server ankommen, unter Verwendung eines Three-way-Merge mit automatischer Auflösung. Kombiniert mit Ăberschreiben und Validierung/Ablehnung â wir wollen definitiv nicht, dass Merge-Konflikte eine manuelle Lösung im Git-Stil erfordern.
Diese Maschinerie ist derzeit eher in Anwendungen eingebettet, als dass sie als Teil von Kanta paketiert wĂ€re. Der nĂ€chste Schritt ist, sie in ein separates Kanta-kompatibles Synchronisationsmodul zu extrahieren, mit Adaptern auch fĂŒr die Javascript-Frontends.
Das ist wahrscheinlich ein weiterer Artikel.
Probier es aus
All diese Designentscheidungen zÀhlen nur, wenn sie gewöhnlichen Anwendungscode einfacher machen.
Mit Kanta ist die Datenbank nicht etwas, das ich ĂŒber ein anderes Modell abfrage. Ich definiere den Zustand, den ich will, öffne ihn und arbeite mit normalen Python-Objekten. Reads sind einfach Reads:
user = data.users[user_id]
Wenn ich etwas Ă€ndern will, mache ich diese Ănderung innerhalb einer Transaktion:
with kanta.transaction(action="update"):
data.users[user_id].name = "Alice"
Es gibt keine Abfragesprache dazwischen, kein ORM-Objekt, das zurĂŒck in mein Anwendungsmodell ĂŒbersetzt werden muss, und keinen separaten Audit-Code, an den man denken muss. Die Transaktion, Historie, Rollback und Persistenz kommen alle aus derselben Operation.
uv add kanta
FĂŒge es deinem Projekt mit uv hinzu oder lies mehr auf git.zi.fi.
uv run --with kanta demo.py
demo.py
import asyncio, msgspec
from kanta import Kanta, configure_logging
# For demonstration purposes, we use "original v0" and "modified v1" in this same script
# Normally your app would only have the latest supported data model
class Data(msgspec.Struct): # type: ignore - intentionally redefined later
users: dict[str, dict] = {}
counter: int = 0
kanta_v0 = Kanta("demo.kantadb", Data())
@kanta_v0.bootstrap
def bootstrap(data: Data) -> None:
"""Create the initial admin user."""
data.users["userid001"] = {"name": "Alice", "role": "admin"}
# Redefinition to simulate new version
class Data(msgspec.Struct):
users: dict[str, dict] = {}
total: int = 0 # Replaces old counter field
lang: str = "en" # New field
def migrate_v1(d: dict) -> None:
"""Rename counter to total"""
d["total"] = d["counter"]
kanta_v1 = Kanta("demo.kantadb", Data(), migrations="demo")
@kanta_v1.logfmt
def resolve_user(value: str, path: str, state: dict) -> str | None:
"""Resolve user ids to names from the database state itself."""
if path != "$user" and not path.startswith("users."):
return None
return state.get("users", {}).get(value, {}).get("name")
async def main() -> None:
print("Database creation with v0 schema and basic transactions:\n")
# Open and close automatically; you can also `await kanta.open()` instead
async with kanta_v0 as kanta:
with kanta.transaction(action="create", user="userid001") as data:
data.users["userid002"] = {"name": "Bob", "role": "user"}
with kanta.transaction(action="update", user="userid001") as data:
data.users["userid002"]["role"] = "editor"
data.counter = 1
# Display-only extra string, appended after the action.
with kanta.transaction(action="export", user="userid002", extra="(we are still v0)") as data:
data.counter = 2
print("\nA new data model, migrations and logfmt pretty names:\n")
async with kanta_v1 as kanta:
with kanta.transaction(action="update", user="userid002", extra="(new version)") as data:
data.total += 1
try:
with kanta.transaction(action="reset", user="userid001") as data:
data.total = 99
raise ValueError("simulated failure")
except ValueError:
print(f"\nReset rolled back: {data.total=} (we can always read data without tx)\n")
with kanta.transaction(action="delete", user="userid002") as data:
data.users.pop("userid001", None) # del if exists
if __name__ == "__main__":
configure_logging(debug=True)
asyncio.run(main())