Lulucat

Warum wir noch keine Datenbank verwenden

Gaoge ZhangGaoge Zhang

Wir haben Turso/libSQL für unsere iPad-App für handschriftliche Notizen evaluiert, alles gemessen und uns für flache Snapshot-Dateien entschieden. Unsere Arbeitslast braucht keine Datenbank — jeder Schritt sollte nur für Probleme bezahlen, die bereits existieren.

Lulucat Notes ist eine App für handschriftliche Notizen auf dem iPad. Bis letzte Woche hatte sie nur eine Leinwand und kein Konzept für eine zweite Notiz. Wir wollten gerade eine Notizbibliothek hinzufügen — mehrere Dokumente mit jeweils mehreren Seiten — und die erste Architekturfrage war die Speicherung.

Eine Datenbank schien die naheliegende Antwort zu sein. Notiz-Apps speichern strukturierte Daten. Strukturierte Daten gehören in Datenbanken. Wir evaluierten Turso und sein Swift SDK, brachten es im iOS-Simulator zum Laufen, benchmarkten echte Strichdaten und entschieden uns dann dagegen.

Stattdessen entschieden wir uns für flache Dateien. Hier ist, was wir herausgefunden haben und warum wir diese Entscheidung getroffen haben.

Ein geöffnetes Notizbuch mit handschriftlichen Notizen und einem Stift auf einem Holztisch.

Foto von Gabriel Cox auf Unsplash. Unsplash-Lizenz.

Was die App tatsächlich mit Daten macht

Eine App für handschriftliche Notizen hat ein enges und vorhersehbares Datenzugriffsmuster. Lesen bedeutet, eine Seite zu öffnen und jedes Element darauf — alle Striche und alle Bilder — auf einmal in den Speicher zu laden. Die Leinwand enthält alles; sie führt nie eine Teilabfrage aus. Schreiben bedeutet, einen Stiftstrich abzuschließen und ein Element an die Seite anzuhängen. In seltenen Fällen löscht der Benutzer einen Teil eines Strichs, verschiebt eine Auswahl oder löscht etwas, aber auch das sind Operationen auf einer einzelnen Seite und einem einzelnen Element.

Es gibt keinen gleichzeitigen Zugriff. Eine Person schreibt jeweils auf einer Seite eines Dokuments. Es gibt auch keine dokumentübergreifende Suche — die Notizbibliothek braucht für jedes Dokument nur einen Titel, einen Zeitstempel, eine Seitenzahl und ein Vorschaubild des Covers; nichts davon erfordert das Lesen von Seiteninhalt.

Abfragen, Indizes und die Koordination gleichzeitiger Zugriffe sind der Grund, warum Datenbanken gebaut werden. Unsere App verwendet keines dieser drei Dinge.

Die Turso-Evaluierung

Wir evaluierten libsql-swift, das offizielle Swift SDK für Tursos libSQL-Engine.

Das SDK funktioniert. Alle neun Testfälle bestehen. Wir integrierten es in eine Kopie der App, bauten sie für den iOS-Simulator, starteten sie und erstellten eine lokale Datenbank in der App-Sandbox. In einer einzigen Transaktion schrieben wir 100 Striche mit jeweils 3,400 Sampling-Punkten — 4,080,000 Byte BLOB-Daten. Auf unserem Entwicklungs-Mac dauerte das etwa 0.019 Sekunden.

Nach dem Ausführen von PRAGMA wal_checkpoint(TRUNCATE) schrumpfte die WAL-Datei auf null, und wir konnten die Hauptdatei .db allein an einen anderen Ort kopieren, öffnen und alle Daten wieder auslesen. Die Engine selbst ist solide.

Das SDK verursacht Kosten. CLibsql.xcframework ist 161 MB groß. Nach dem Linken wuchs unser Debug-Simulator-Build von ungefähr 1.9 MB auf ungefähr 8.2 MB. Die API ist synchron und blockierend, ohne Wrapper für Swift Concurrency. Es gibt keine ausdrückliche close()-Methode. Transaction.commit() wirft keinen Fehler — die zugrunde liegende C-API gibt void zurück. Die README des Repositorys bezeichnet das SDK als „technical preview“, und der letzte Commit lag bei unserer Evaluierung etwa ein Jahr zurück, im Juli 2025.

Im Turso-Ökosystem gibt es eine Lücke. Turso empfiehlt für neue Projekte inzwischen seine neue „Turso Database“-Engine und das „Turso Sync“-Protokoll. Turso Sync bietet Client-SDKs für TypeScript, Python, Go und Rust. Für Swift gibt es keines. Der ältere Embedded-Replica-Modus existiert in libsql-swift, aber sein Swift-Initializer stellt den für eine vollständig local-first arbeitende mobile App nötigen offline-Parameter nicht bereit. Wer heute libsql-swift übernimmt, bekommt einen lokalen SQLite-Fork, aber nicht die Synchronisationsfähigkeiten, die Turso besonders machen.

Was uns eine Datenbank jetzt kosten würde

Selbst wenn das SDK ausgereift wäre, würden wir Kosten bezahlen, die für unsere Arbeitslast keinen Nutzen bringen:

Verwaltung der WAL-Begleitdateien. Eine laufende Datenbank erzeugt die Begleitdateien -wal und -shm. Ein Dokument zu kopieren bedeutet, vorher entweder einen Checkpoint auszuführen oder alle drei Dateien atomar zu kopieren. Der Export eines .lnote-Pakets nach Files oder AirDrop erfordert jetzt einen vorgeschalteten Export-Schritt, den der Benutzer nicht sieht und den der Entwickler nicht vergessen kann.

Eine Adapter-Schicht. Striche müssten in BLOBs serialisiert und wieder deserialisiert werden. Seitenelemente haben eine natürliche Array-Reihenfolge, die die Leinwand direkt rendert; eine Datenbank würde eine Reihenfolge der Zeilen und z-index-Spalten einführen. Wir würden eine Übersetzungsschicht zwischen zwei Darstellungen derselben Daten schreiben und sie bei jeder Schemaänderung pflegen.

Eine Abhängigkeit von 161 MB. Für eine App, deren Debug-Build unter 2 MB groß ist, ist eine Abhängigkeit, die mehr als 80× so groß wie die App selbst ist, ein beachtlicher Kostenfaktor — besonders, wenn sie als „technical preview“ gekennzeichnet ist und seit einem Jahr nicht mehr weiterentwickelt wurde.

Diese Kosten sind nicht hypothetisch. Sie beginnen in dem Moment, in dem die Abhängigkeit gelinkt wird. Dafür erhalten wir Fähigkeiten — Abfragen, Indizierung und gleichzeitige Schreibzugriffe —, die unsere App nicht nutzt.

Die ausgelieferte Lösung: Snapshot-Dateipakete

Ein .lnote-Dokument ist ein Verzeichnispaket:

Documents/Notes/<UUID>.lnote/
  manifest.json              # library cache: title, time, page count, cover
  document.json              # source of truth: document metadata + page order
  pages/
    <page-uuid>.content      # one snapshot per page
  assets/                    # document-level shared resources
    <asset-uuid>.jpg
  thumbnails/
    <page-uuid>.jpg          # per-page thumbnail; first page doubles as cover

document.json ist die Quelle der Wahrheit für die Struktur des Dokuments: seine ID, sein Titel, seine Zeitstempel und eine geordnete Liste von Seiten mit der Leinwandgröße, den Zeitstempeln und der Elementanzahl jeder Seite. Die Seiteninhaltsdateien speichern das Element-Array in derselben quantisierten Ganzzahlkodierung, die die App bereits verwendet — Koordinaten und Radien mit 0.1-Punkt-Genauigkeit, Druck in Tausendsteln und Zeitstempel in relativen Millisekunden.

Die Notizbibliothek liest nur manifest.json und die Cover-Miniaturen. Sie parst niemals document.json oder den Inhalt einer Seite. Beim Öffnen einer Seite wird genau eine .content-Datei geladen. Das ist der einzige Dateizugriff, der Strichdaten berührt.

Seiten lösen die Schreibverstärkung

Eine App für handschriftliche Notizen kennt das Konzept einer Seite bereits — sie ist die Einheit, in der Benutzer denken, und die Seiten, zwischen denen sie wischen. Wenn die Seite zur Persistenzeinheit wird, schreibt das automatische Speichern nur die Seiten neu, die sich geändert haben.

Eine einzelne Seite mit Handschrift — sagen wir 1,000 bis 2,000 Striche — belegt in unserem quantisierten Format ungefähr 3–5 MB. Eine Aufzeichnung mit 21 Strichen und 3,400 Sampling-Punkten wird auf etwa 55 KB quantisiert. Das Schreiben eines Seiten-Snapshots in den Flash-Speicher dauert auf moderner Hardware 10–20 ms. Mit einem 0.5-Sekunden-Debounce bleiben die Speichervorgänge für den Benutzer unsichtbar.

Die Kosten des Speicherns skalieren mit dem Schreibumfang auf der aktuellen Seite, nicht mit der Gesamtzahl der Seiten im Dokument. Ein Notizbuch mit 200 Seiten wird genauso schnell gespeichert wie eines mit 2 Seiten, weil nur die geänderte Seite neu geschrieben wird.

Jeder Schreibvorgang verwendet atomare Dateioperationen — in eine temporäre Datei schreiben, dann umbenennen —, sodass ein Absturz während des Speicherns keine abgeschnittene Seite erzeugen kann. Beim Wechsel in den Hintergrund werden alle geänderten Seiten sofort geschrieben, genau wie zuvor bei der einzigen Leinwand.

Konsistenz ohne Transaktionen

Dateipakete haben keine Transaktionen, aber klare Besitzregeln, die demselben Zweck dienen:

Ressourcen vor Referenzen. Wenn der Benutzer ein Bild einfügt, wird die Asset-Datei sofort in assets/ geschrieben. Der Seiten-Snapshot, der über die ID auf das Asset verweist, wird später durch das verzögerte automatische Speichern geschrieben. Zu keinem Zeitpunkt verweist eine Seite auf ein Asset, das nicht auf der Festplatte existiert.

Die Quelle der Wahrheit gewinnt. document.json und das Verzeichnis pages/ sind die Quelle der Wahrheit. manifest.json ist ein Cache. Wenn sie voneinander abweichen, gleicht das nächste Speichern den Cache an die Quelle an. Miniaturen sind abgeleitet und können jederzeit neu erzeugt werden.

Verwaiste Dateien sind besser als ins Leere zeigende Referenzen. Das schlimmste Ergebnis eines Absturzes ist ein verwaistes Asset — eine Datei in assets/, auf die keine Seite verweist. Verwaiste Assets werden beim Schließen des Dokuments bereinigt. Der umgekehrte Fall — eine Seite verweist auf eine fehlende Datei — kann nicht eintreten, weil Assets vor dem referenzierenden Seiten-Snapshot geschrieben werden.

Diese Regeln sind leichter zu durchdenken als WAL-Checkpointing und Transaktionsisolation und entsprechen genau dem Zugriffsmuster der App mit einem Prozess und einer Seite.

Der Upgrade-Pfad ist aufgeschrieben

Jetzt flache Dateien zu wählen bedeutet nicht, sie für immer zu wählen. Die Paketstruktur ist so gestaltet, dass ein Upgrade der Speicher-Engine den Inhalt des Pakets ändert, ohne das Paket selbst zu ändern.

Stufe 1: Snapshot + Append-Journal. Wenn die Schreibverstärkung jemals spürbar wird — etwa wenn kontinuierliches Schreiben auf einer Seite mit Tausenden von Strichen eine merkliche Verzögerung beim Speichern verursacht —, wird jede Seitendatei in einen Snapshot und ein Append-only-Journal geteilt. Neue Elemente werden als [length][CRC][type][payload]-Frames angehängt. Beim Abspielen des Journals wird jeder Frame verworfen, dessen CRC nicht übereinstimmt; das sorgt für Absturzsicherheit. Wenn das Journal einen Schwellenwert überschreitet oder die Seite geschlossen wird, wird es wieder in den Snapshot zusammengeführt. Das sind ungefähr 200 LOC ohne externe Abhängigkeiten.

Da Snapshots auf Seitenebene die Schreibverstärkung zwischen Seiten bereits beseitigen, brauchen wir diese Stufe möglicherweise lange nicht. Ein Überschreiben einer 5-MB-Seite alle 0.5 Sekunden liegt problemlos innerhalb des Schreibbudgets von Flash-Speicher.

Stufe 2: SQLite-Datenbank. Wenn die App irgendwann eine Volltextsuche über Notizen, eine Synchronisation pro Element oder eine dokumentübergreifende Indizierung benötigt, wird SQLite das richtige Werkzeug. Die wahrscheinliche Engine wäre dann GRDB, ein ausgereifter, aus dem Quellcode kompilierter Swift-Wrapper mit nahezu keinem zusätzlichen Binärgrößen-Overhead. libsql-swift würden wir nur dann erneut in Betracht ziehen, wenn das Turso-Ökosystem — insbesondere Turso Sync für Swift — zu einem echten Produktbedarf wird.

Der Migrationspfad ist mechanisch: Das Element-Array jeder Seite wird auf eine strokes- / images-Tabelle abgebildet, mit einem unveränderlichen BLOB pro Strich (16 Byte pro Sampling-Punkt in little-endian-Binärform). Der Benchmark von 0.019 Sekunden für 100 Striche bestätigt, dass der Ansatz praktikabel ist. Vor der Auslieferung würde die Migration prüfen, ob Element- und Asset-Anzahlen zwischen dem alten Paket und der neuen Datenbank übereinstimmen und ob Absturzwiederherstellung, WAL-Grenzen und Hintergrund-Flush alle Tests bestehen.

Wann man bezahlen sollte

Diese Entscheidung ist kein Urteil über Datenbanken. SQLite bewältigt gleichzeitige Schreibzugriffe, komplexe Abfragen und die Wiederherstellung gemeinsam genutzter Zustände nach einem Absturz — nichts davon braucht unsere App derzeit. Für diese Fähigkeiten zu bezahlen, bevor die App die von ihnen gelösten Probleme hat, ist ein Nettoverlust.

Die Kosten einer Datenbank — die Abhängigkeit, die WAL-Verwaltung, die Adapter-Schicht und die Binärgröße — beginnen in dem Moment, in dem die Bibliothek gelinkt wird. Die Vorteile beginnen, wenn die App Abfragen auszuführen, Indizes zu pflegen oder gleichzeitige Schreiber zu koordinieren hat. In diesem Stadium hat sie keines der drei Dinge.

Jeder Schritt in der Entwicklung unseres Speichers wird nur für Probleme bezahlen, die bereits aufgetreten sind. Snapshots auf Seitenebene lösen das Problem, das wir heute haben: mehrseitige Dokumente zu speichern, ohne die gesamte Datei neu zu schreiben. Wenn die Schreibverstärkung messbar wird, löst ein Append-Journal dieses Problem. Wenn Suche oder Synchronisation zu einem Produktbedarf werden, löst eine Datenbank dieses Problem.

Die Paketstruktur, das Manifest und das Dokumentschema sind an keine Speicher-Engine gebunden. Die Wechselkosten sind niedrig, weil die Grenzen an der richtigen Stelle liegen. Wenn der Tag kommt, an dem wir wirklich eine Datenbank brauchen, werden wir sie für ein konkretes, bereits gemessenes Problem einsetzen — nicht für ein hypothetisches.