SQL, NoSQL, und warum ich Kanta DB gestartet habe

SQL ist immer noch der Standard aus einem bestimmten Grund…

Wenn ich heute einen konventionellen Python-Dienst bauen würde, würde ich wahrscheinlich mit PostgreSQL, SQLAlchemy und Alembic beginnen. PostgreSQL bietet mir echte UUIDs, JSONB, Arrays, Transaktionen, Einschränkungen und eine ausgezeichnete Indizierung. SQLAlchemy mappet das meiste davon sauber in Python. Alembic hält Schemaänderungen explizit und versioniert.

Das kommt ziemlich weit, bevor irgendetwas anfängt, mich zu nerven.

… aber das Modell hat Kanten

Ein Datenbankschema und eine Python-Datenstruktur sind nicht ganz dasselbe.

Mit SQLAlchemy kann ich typisierte Modelle definieren und verwenden uuid.UUID Direkt gegen PostgreSQL UUID-Spalten, JSONB in Python-Container mappen und die meisten routinemäßigen Konvertierungen aus meinem eigenen Code heraushalten. Das ist wesentlich besser, als jeden Datenbankwert als String zu behandeln oder SQL manuell zusammenzustellen.

Trotzdem wird das ORM-Modell zu einer besonderen Art von Objekt. Es enthält Spalten, Beziehungen, Sitzungsverhalten und Persistenzregeln. Wenn der Rest des Programms einfache Anwendungsstrukturen wünscht, lasse ich entweder Datenbankanliegen nach außen übergreifen oder füge eine weitere Konvertierungsschicht hinzu.

Das Problem wird deutlicher, wenn sich das Schema ändert.

Das Hinzufügen eines Feldes zu einer Python-Struktur erscheint trivial. Das Hinzufügen einer Spalte zu persistenten Daten bedeutet, das Modell zu ändern und eine Migration zu erstellen. Komplexere Änderungen erfordern Datenumwandlungen und Kompatibilitätsentscheidungen. Alembic handhabt dies vernünftig, aber ich muss trotzdem eine Historie der strukturellen Änderungen pflegen, damit alte Zeilen zu neuen Zeilen werden können.

Das ist kein Fehler in PostgreSQL. Die Datenbank hat sich zu einem Schema verpflichtet, also hat dessen Änderung Konsequenzen.

Die Geschichte schafft eine weitere Ebene. Wenn ich wissen möchte, wer einen Wert geändert hat, wann er ihn geändert hat oder wie der Datensatz am letzten Dienstag aussah, muss ich das modellieren. Ich kann Audit-Tabellen, Trigger, Zeitstempelspalten oder eine Event-Sourcing-Ebene hinzufügen. PostgreSQL kann all dies recht gut unterstützen.

Aber die normale Reihe repräsentiert immer noch das, was jetzt existiert. Die Geschichte bleibt etwas, das ich darum herum aufbaue.

Alternativen zu SQL

Wie wäre es mit einer schönen Tasse NoSQL

MongoDB beseitigt einige Hindernisse

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 und neuere Datensätze nebeneinander existieren lassen, während die Anwendung beide versteht. Das macht die Schema-Evolution oft weniger zeremoniell. Anstatt zuerst die gesamte Datenbank zu migrieren, kann ich manchmal alte Dokumente aktualisieren, wenn ich sie lese oder ändere.

Das Schema existiert immer noch. Mein Code erwartet immer noch bestimmte Felder mit bestimmten Bedeutungen. MongoDB gibt mir einfach mehr Freiheit darüber, wann ich diese Vereinbarung durchsetze.

Redis gibt mir ausgezeichnete Stücke

Redis ist wunderbar praktisch.

Wenn ich einen Cache, eine Warteschlange, einen Zähler, eine sortierte Menge, eine verteilte Sperre oder einen flüchtigen gemeinsamen Zustand benötige, hat Redis normalerweise eine kompakte Antwort.

Natürlich, wenn meine Daten in JSON oder einem anderen strukturierten Format vorliegen, liegt es auch ganz bei mir, sie in diese Redis-Primitiven aufzuschlüsseln oder sie einfach als dumpsGanzes einzufügen, ohne all die feinen Tools zu beachten.

Schieben und Ziehen

Lass mich wissen, wenn jemand meine Daten berührt

Firebase beginnt mit der Synchronisierung

Firebase nimmt einen direkteren Weg. Seine Datenbanken behandeln Live-Client-Updates als Teil des Produkts.

Ich kann einen Listener an Daten anhängen und den Client Änderungen empfangen lassen, sobald sie auftreten. Offline-Verhalten und Wiederverbindung gehören ebenfalls zum selben System, anstatt später als WebSocket-Projekt zu erscheinen.

Das ist attraktiv.

Wir haben alle Änderungen

MongoDB hat hierfür eine starke integrierte Lösung. Change Streams ermöglichen es mir, eine Sammlung, Datenbank oder eine ganze Bereitstellung zu überwachen und Einfügungen, Aktualisierungen, Löschungen usw. zu empfangen. Aktualisierungen beinhalten normalerweise die geänderten Felder, und jedes Ereignis enthält ein Resume-Token, sodass ich mich wieder verbinden und dort fortsetzen kann, wo ich aufgehört habe, solange der Oplog diesen Punkt noch enthält.

PostgreSQL kann auch tatsächliche Datenbankänderungen durch logische Dekodierung und Replikation offenlegen. Redis Change Streams bieten eine ähnliche Lösung in diesem Bereich.

Änderungsdatenerfassungssysteme bauen darüber hinaus sehr leistungsfähige Pipelines auf, aber dann müssen wir SQL-Anweisungen oder Redis-Befehle analysieren und den Status selbst im Auge behalten.

Veröffentlichen und abonnieren

Auf PostgreSQL und Redis bieten auch Oldskool-Nachrichtenkanäle an.

Ich könnte LISTEN/NOTIFY oder PUB/SUB verwenden und eine Benachrichtigung aus derselben Transaktion senden, die die Daten ändert. Das vermeidet einige der Unannehmlichkeiten eines nicht verwandten Nachrichtenbusses.

Aber ich muss immer noch Benachrichtigungsnachrichten erstellen und empfangen, um zu entscheiden, was ich lesen und was ich senden soll. Und das führt zu Rennenbedingungen zwischen der tatsächlichen Änderung und der Benachrichtigung.

Für mein Problem fühlte es sich an, als würde ich ziemlich weit unter der Abstraktion beginnen, die ich eigentlich wollte.

Ich wollte nicht nur wissen, dass sich dieser Zustand geändert hatte.
Ich wollte die Veränderung selbst, und den Zustand davor und danach.

Vielleicht sollte ich mein eigenes machen?

Nachdem ich all das oben genannte verwendet hatte und immer feststellte, dass das ORM in meiner Codebasis unerträglich wurde, begann ich schließlich, an einer Idee zu arbeiten, die ich jahrelang heimlich entwickelt hatte.

Um meine eigene Datenbank zu erstellen. Weißt du, etwas, von dem sie dir immer sagen, du solltest es nicht einmal versuchen.

Lass das Protokoll die Datenbank sein

Das wurde zum Ausgangspunkt für Kanta.

Anstatt den neuesten Zustand als primären Datensatz zu speichern und die Historie darum herum hinzuzufügen, wollte ich Änderungen speichern.

Ein Objekt beginnt mit einem leeren Zustand. Jede spätere Operation zeichnet nur das, was sich geändert hat, zusammen mit einem Zeitstempel und allen anderen Metadaten, die zu dieser Änderung gehören, auf.

Der aktuelle Zustand resultiert aus der Anwendung des Logs. Jetzt benötigt die Historie kein eigenes Schema mehr. Abfragen müssen nicht mehr den neuesten Zeitstempel finden, da sie zu jedem gegebenen Zeitpunkt mit dem Zustandsobjekt arbeiten.

Eine Struktur auf der ganzen Strecke durch

Msgspec gibt mir typisierte, kompakte Datenstrukturen mit sehr schneller Serialisierung. Ähnlich wie bei Dataklassen oder Pydantic handhabt es verschachtelte Strukturen und gängige native Typen wie UUIDs, Enumerationen und Datumsangaben, ohne die Objekte in ORM-Entitäten umzuwandeln. Die Definition Ihrer 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 beibehalten. Ich kann es auf der anderen Seite rekonstruieren.

Ich brauche keine Klasse für meine Anwendung, eine andere für SQLAlchemy, ein weiteres Schema für die Serialisierung und kleine Konvertierungsfunktionen, die zwischen all diesen hin und her vermitteln.

Es ist lebendig

Ich habe einen einfachen JSONL-Logger zusammengestellt, eine Änderung pro Zeile, mit einem jsondiff-Änderungsprotokoll darauf. Es war kein binäres Format, wie ich es vielleicht bevorzugt hätte, aber es ist etwas sehr einfaches zum Bearbeiten und Debuggen.

Lesevorgänge sind einfach Lesevorgänge aus Python-Variablen, viel schneller als Redis oder jede externe Datenbank!

Schreiben erforderte zusätzliches Nachdenken. Anstatt der Datenbank zu sagen, was geändert werden soll, würden wir den Zustand bearbeiten, und die Datenbank würde einen Änderungsdatensatz speichern.

with kanta.transaction(action="new_user"):
    data.users[uuid7()] = User(...)

Beachten Sie, dass es kein async with Oder await Darin, obwohl wir an asynchronem Python arbeiten. Die Transaktion ist unmittelbar und synchron. Sie benötigen keine Sperren oder Synchronisation darum herum. Die Zustände vor und nachher werden gespeichert und verglichen, um den Änderungsunterschied zu erzeugen. Im Falle eines Fehlers wird der vorherige Zustand wiederhergestellt, wobei alles, was bereits in dieser Transaktion getan wurde, zurückgesetzt wird.

Die Änderungen werden von einem Hintergrund-Thread auf einer Append-Only-Datei auf der Festplatte gespeichert, die im Falle einer Beschädigung aufgrund von Stromausfällen oder Abstürzen leicht repariert werden kann.

Das war ungefähr der Punkt, an dem Kanta aufhörte, wie ein Datenbank-Experiment auszusehen, und begann, wie ein kohärentes Modell auszusehen, auf dem man aufbauen kann.

Warum nicht in der Produktion ausprobieren?

Nachdem ich mit meinen eigenen Projekten experimentiert hatte, verband ich es schnell mit einer ernsthafteren Anwendung, die größere Datenmengen und viele Benutzer hatte. Dies zerstreute meine Bedenken hinsichtlich möglicher Leistungsprobleme, denn schließlich verwendeten wir tatsächlich JSON für eine Datenbank und in einer Logdb-Struktur, die meines Wissens nach seit den frühen Zeiten des Computing von niemandem sonst verwendet wurde.

Tatsächlich wurde das erneute Abspielen großer Changesets beim Start der Anwendung mit der Zeit langsamer, also fügte ich vollständige Snapshot-Zeilen hinzu, um die langen Wiederholungen der ersten Revision zu vermeiden. Danach hat die Leistung alle meine Anforderungen übertroffen.

Aber der Hauptpunkt ist nicht die Leistung, sondern die Einfachheit. Indem wir davon ausgehen, dass unsere App in einem einzigen Worker-Prozess existieren und den vollständigen Zustand in ihrem Speicher aufrechterhalten kann, beseitigen wir die meisten Probleme, die mit der typischen Datenbank einhergehen.

Aufgrund der Log-Struktur ist das Zurückspulen der Geschichte kostenlos. Ich habe sogar eine Reihe von Transaktionen in der Mitte der Geschichte rückgängig gemacht - um einen Benutzer zu retten, der einen Teil des Projekts gelöscht und dann weitere Änderungen vorgenommen hatte.

Diese Website ist auch auf Kanta (Pagerite CMS) aufgebaut.

Migrationen

Bild
Überprüfen Sie jeden Ausschnitt durch Ihre Datenbank und suchen Sie nach den Daten, die Sie benötigen

Werkzeugausrüstung

Kanta hat auch eine kleine CLI zum direkten Überprüfen von Datenbanken. Sie kann eine Datenbank wiedergeben, ausgewählte Bereiche ihrer Historie überprüfen, die tatsächlichen Datentypen der Anwendung laden und bei Bedarf Migrationen ausführen sowie den resultierenden Zustand als JSON exportieren. Ein gutes Tool ist auch besser, als es als Textdatei zu behandeln.

Das Single-Process-Modell entfernt einen Großteil der Maschinen, aber es entfernt nicht die Tatsache, dass sich die Software ändert.

Niemand hat Migrationen jemals gemocht. Sie sind die Last, alle Wartungsänderungen an den Daten vorzunehmen. Das Hinzufügen einer weiteren SQL-Migration oder eines weiteren Mongo-Legacy-Fallbacks und eines Update-Branches ist einfach zu viel Ärger, also vermeidet man diese Änderungen lieber.

Hier übernimmt msgspec wieder einen Großteil der schweren Arbeit. Wenn wir ein Feld hinzufügen oder entfernen wollen, fügen wir einfach das neue Feld mit einem Standardwert zu den Datenstrukturen hinzu oder entfernen ein altes Feld. Es wird stillschweigend in das neue Format migriert. Wenn das geladene Format nicht mit den Strukturen übereinstimmt, erhalten wir einen Fehler, der angibt, was und wo falsch ist.

Cool und einfach, aber nicht ausreichend für eine Datenbank.

Von Zeit zu Zeit möchten wir ein Feld umbenennen, Datentypen ändern oder die gesamten Daten komplett neu strukturieren und dabei möglicherweise neue externe Daten abrufen (das habe ich getan). Dies 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 Revisionsnummer der Datenbank kommt direkt aus dem Funktionsnamen. Die Funktion manipuliert das einfache Dict-Format, so dass wir keine msgspec.Structs von älteren Versionen pflegen müssen. Der Docstring gibt die protokollierte Beschreibung dessen, was getan wurde.

Um zu vermeiden, den Rest unseres Programms mit diesen zu überschwemmen, können die Migrationen in ein eigenes Python-Modul gelegt werden, auf das wir bei der Definition der Datenbank einfach mit dem Pfad des Python-Moduls verweisen:

kanta = Kanta("foo.kantadb", migrations="foo.migrations")

Die Metadaten und Snapshots des Changesets enthalten die Versionsnummer, die sie repräsentieren, und wir wenden alle gefundenen Migrationsfunktionen von dieser Version aufwärts an und führen zur Vervollständigung der Migration in jedem Fall die MSGSPEC-Konvertierung durch. Wenn eine dieser Änderungen zur Folge hat, protokollieren und speichern wir die durchgeführten Migrationen und erstellen anschließend einen Snapshot.

Älteste Migrationsfunktionen können ebenfalls entfernt werden, wenn solche Versionen nicht mehr unterstützt werden müssen, wodurch das gesamte Migrationssystem überschaubar bleibt.

Über das Diskettenformat

Das Standardformat bleibt absichtlich unkompliziert: JSON-Einträge, einer pro Zeile. Msgspec codiert Bytes und andere Typen darin.

Bevor ich es veröffentliche, möchte ich auf eine Kritik eingehen, die ich selbst sicherlich über den Mangel an Binärunterstützung äußern würde, indem ich dies als Option implementiere.

Während wir mit JSONL nach Zeilenumbrüchen vom Ende der Datei suchen können, um einen Snapshot zu finden, könnten die binären Daten den Parser verwirren. Wir können vom Anfang der Datei lesen, um einen vollständig deterministischen Ansatz zu erreichen, aber wenn die Datenbank groß wäre, würden wir es vorziehen, in der Nähe des Endes zu suchen. Ich wollte es auch nur anhängend halten, also konnten wir nicht einfach den Snapshot-Offset am Anfang schreiben.

Die Lösung ist zweifach:

  • Unvorhersehbare zufällige Nonce als Synchronisationswort
  • Blake3-Hash zur Integritätsüberprüfung

Die Change Payloads selbst sind derzeit mit MessagePack codiert, was in msgspec problemlos unterstützt wird.

Ich möchte das durch etwas ersetzen, das näher an Protocol Buffers ist, ohne überhaupt Feldnamen und -typen zu speichern, und dabei davon profitieren, dass msgspec bereits die Struktur und Typen meiner Strukturen kennt, und daher in der Lage sein sollte, aus denselben Informationen eine kompakte binäre Darstellung abzuleiten.

Das würde die Eigenschaft, die mir am meisten am Herzen liegt, beibehalten: eine Definition der Datenstruktur, anstatt eines Python-Modells plus eines ORM-Modells plus eines Serialisierungsschemas.

Die Datenbank ist nur die Hälfte des Staates

Die Beibehaltung des autoritativen Zustands im Gedächtnis macht es schwierig, etwas anderes zu ignorieren: Die meisten interaktiven Anwendungen behalten bereits eine weitere Kopie an einem anderen Ort.

Der Browser hat auch einen.

Sobald die Datenbank selbst einen geordneten Strom von Änderungen aufzeichnet, wird die Verwendung desselben Stroms zur Synchronisation zu einem offensichtlichen nächsten Schritt.

Synchronisierung von Geschäften über WebSocket

Die FastAPI-App sendet Änderungen über einen WebSocket und wendet eingehende Änderungen wieder auf die Datenbank an.

Auf jedem Client pflegen wir eine Schattenkopie, d.h. den zuletzt gesehenen Serverzustand, und den aktuellen Arbeitszustand des Clients in seinem nativen Vue Pinia Store. Oder Svelte- oder React-Äquivalente.

Der Store ist das Verbindungsglied, das Reaktivität 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 landen, unter Verwendung eines Drei-Wege-Merges mit automatischer Auflösung. In Kombination mit Überschreiben und Validierung/Ablehnung - wir wollen definitiv nicht, dass Merge-Konflikte eine manuelle Lösung im Git-Stil erfordern.

Ich habe diese Maschinerie derzeit in Anwendungen eingebettet, anstatt sie als Teil von Kanta zu paketieren. Der nächste Schritt besteht darin, sie in ein separates Kanta-kompatibles Synchronisationsmodul zu extrahieren, mit Adaptern auch für die Javascript-Frontends.

Das ist wahrscheinlich ein anderer Artikel.

Probieren Sie es aus

All diese Designentscheidungen sind nur dann wichtig, wenn sie den normalen Anwendungscode einfacher machen.

Mit Kanta ist die Datenbank nicht etwas, das ich über ein anderes Modell abfrage. Ich definiere den gewünschten Zustand, öffne ihn und arbeite an normalen Python-Objekten. Lesevorgänge sind einfach Lesevorgänge:

user = data.users[user_id]

Wenn ich etwas ändern möchte, führe ich diese Änderung innerhalb einer Transaktion durch:

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 sich erinnern muss. Die Transaktion, die Historie, der Rollback und die Persistenz kommen alle aus derselben Operation.

uv add kanta

Fügen Sie es Ihrem Projekt mit UV hinzu oder lesen Sie mehr auf git.zi.fi.

Bild
Die Demo geht absichtlich ein wenig über Hello World hinaus. Sie erstellt eine Datenbank mit einer Version des Datenmodells, modifiziert sie durch mehrere Transaktionen, öffnet dann dieselbe Datenbank mit einem neueren Modell, führt eine Migration durch, demonstriert Rollback und arbeitet normal weiter damit. Das ist ungefähr die Art von Lebenszyklus, den ich langweilig machen wollte.
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())