Lulucat

Hvorfor vi endnu ikke bruger en database

Gaoge ZhangGaoge Zhang

Vi evaluerede Turso/libSQL til vores iPad-app til håndskrift, målte alt og valgte flade snapshot-filer. Arbejdsbelastningen har ikke brug for en database — hvert trin bør kun betale for problemer, der allerede findes.

Lulucat Notes er en håndskrift-app til iPad. Indtil sidste uge havde den ét lærred og intet begreb om en anden note. Vi skulle til at tilføje et note-bibliotek — flere dokumenter, hvert med flere sider — og det første arkitektoniske spørgsmål var lagring.

En database føltes som det oplagte svar. Note-apps gemmer strukturerede data. Strukturerede data hører hjemme i databaser. Vi evaluerede Turso og dets Swift SDK, fik det til at køre i iOS-simulatoren, benchmarkede data fra virkelige streger og valgte derefter ikke at bruge det.

I stedet valgte vi flade filer. Her er, hvad vi fandt, og hvorfor vi traf den beslutning.

En åben notesbog med håndskrevne noter og en pen på et træbord.

Foto af Gabriel CoxUnsplash. Unsplash-licens.

Hvad appen faktisk gør med data

En håndskrift-app har et snævert og forudsigeligt dataadgangsmønster. Læsning betyder at åbne en side og indlæse hvert element på den — alle streger og alle billeder — i hukommelsen på én gang. Lærredet indeholder alt; det kører aldrig en delvis forespørgsel. Skrivning betyder at afslutte en pennestreg og føje ét element til siden. I sjældne tilfælde visker brugeren en del af en streg ud, flytter en markering eller sletter noget, men det er stadig operationer på én side og ét element.

Der er ingen samtidig adgang. Én person skriver på én side i ét dokument ad gangen. Der er heller ingen søgning på tværs af dokumenter — note-biblioteket behøver kun en titel, et tidsstempel, et sidetal og en forside-miniature for hvert dokument, og ingen af delene kræver, at sidens indhold læses.

Forespørgsler, indeks og koordinering af samtidig adgang er det, databaser er bygget til. Vores app bruger ingen af de tre ting.

Evalueringen af Turso

Vi evaluerede libsql-swift, det officielle Swift SDK til Tursos libSQL-motor.

SDK’et virker. Alle ni testsager består. Vi integrerede det i en kopi af appen, byggede til iOS-simulatoren, startede den og oprettede en lokal database i appens sandbox. Vi skrev 100 streger med 3,400 samplingpunkter hver — 4,080,000 bytes BLOB-data — i én transaktion. Det tog cirka 0.019 sekunder på vores udviklings-Mac.

Efter at have kørt PRAGMA wal_checkpoint(TRUNCATE) blev WAL-filen reduceret til nul, og vi kunne kopiere selve hovedfilen .db til en anden placering, åbne den og læse alle data tilbage. Selve motoren er solid.

SDK’et har omkostninger. CLibsql.xcframework fylder 161 MB. Efter linking voksede vores Debug-simulatorbuild fra cirka 1.9 MB til cirka 8.2 MB. API’et er synkront og blokerende, uden wrappers til Swift Concurrency. Der findes ingen eksplicit close()-metode. Transaction.commit() kaster ikke — det underliggende C-API returnerer void. Repositoryets README kalder SDK’et en »technical preview«, og den seneste commit var cirka et år før vores evaluering, i juli 2025.

Turso-økosystemet har et hul. Turso anbefaler nu sin nye »Turso Database«-motor og »Turso Sync«-protokol til nye projekter. Turso Sync har klient-SDK’er til TypeScript, Python, Go og Rust. Det har ikke et til Swift. Den ældre Embedded Replica-tilstand findes i libsql-swift, men dens Swift-initializer eksponerer ikke den offline-parameter, der kræves til en fuldt lokal-først-mobilapp. Hvis vi tager libsql-swift i brug i dag, får vi en lokal SQLite-fork, men ikke de synkroniseringsfunktioner, der gør Turso særligt.

Hvad en database ville koste os lige nu

Selv hvis SDK’et var modent, ville vi betale omkostninger, der ikke giver noget til vores arbejdsbelastning:

Håndtering af WAL-sidefiler. En kørende database opretter ledsagefilerne -wal og -shm. At kopiere et dokument betyder enten først at lave checkpoint eller at kopiere alle tre filer atomisk. Eksport af en .lnote-pakke til Files eller AirDrop kræver nu et for-eksporttrin, som brugeren ikke kan se, og som udvikleren ikke kan glemme.

Et adapterlag. Streger skulle serialiseres til BLOB’er og deserialiseres igen. Sideelementer har en naturlig array-rækkefølge, som lærredet renderer direkte; en database ville indføre rækkefølge på rækker og z-index-kolonner. Vi ville skrive et oversættelseslag mellem to repræsentationer af de samme data og vedligeholde det ved hver skemaændring.

En afhængighed på 161 MB. For en app, hvis Debug-build er under 2 MB, er en afhængighed, der er større end 80× selve appen, en omkostning, der er værd at bemærke — især når den er mærket »technical preview« og har været inaktiv i et år.

Disse omkostninger er ikke hypotetiske. De begynder i det øjeblik, afhængigheden linkes. Og de køber os funktioner — forespørgsler, indeksering og samtidige skrivninger — som vores app ikke bruger.

Løsningen, vi leverede: snapshot-filpakker

Et .lnote-dokument er en mappepakke:

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 er kilden til sandheden for dokumentets struktur: dets ID, titel, tidsstempler og en ordnet liste over sider med hver sides lærredsstørrelse, tidsstempler og elementantal. Sideindholdsfilerne gemmer element-arrayet i den samme kvantiserede heltalskodning, som appen allerede bruger — koordinater og radier med 0.1 punkts præcision, tryk i tusindedele og tidsstempler i relative millisekunder.

Note-biblioteket læser kun manifest.json og forside-miniaturer. Det parser aldrig document.json eller indholdet af en side. Når en side åbnes, indlæses én .content-fil. Det er den eneste fillæsning, der rører ved stregdata.

Sider løser write amplification

En håndskrift-app har allerede begrebet en side — det er den enhed, brugeren tænker i, og det, brugeren swiper mellem. Når siden gøres til persistensenheden, betyder det, at auto-save kun omskriver de sider, der er ændret.

En enkelt side med håndskrift — lad os sige 1,000 til 2,000 streger — fylder cirka 3–5 MB i vores kvantiserede format. En optagelse med 21 streger og 3,400 samplingpunkter kvantiseres til cirka 55 KB. Det tager 10–20 ms at skrive ét sidesnapshot til flashlager på moderne hardware. Med en debounce på 0.5 sekunder er gemninger usynlige for brugeren.

Gemmeomkostningen skalerer med mængden af skrivning på den aktuelle side, ikke med det samlede antal sider i dokumentet. En notesbog på 200 sider gemmes præcis lige så hurtigt som en notesbog på 2 sider, fordi kun den ændrede side omskrives.

Alle skrivninger bruger atomiske filoperationer — skriv til en midlertidig fil, og omdøb den derefter — så et crash midt i en gemning ikke kan skabe en afkortet side. Når appen går i baggrunden, skrives alle ændrede sider straks, så adfærden matcher den, appen allerede havde med ét lærred.

Konsistens uden transaktioner

Filpakker har ingen transaktioner, men de har klare ejerskabsregler, der tjener samme formål:

Ressourcer før referencer. Når brugeren indsætter et billede, skrives asset-filen straks til assets/. Sidesnapshotten, der refererer til assetet via dets ID, skrives senere af det debouncede auto-save. På intet tidspunkt refererer en side til et asset, der ikke findes på disken.

Kilden til sandheden vinder. document.json og mappen pages/ er kilden til sandheden. manifest.json er en cache. Hvis de er uenige, afstemmer den næste gemning cachen, så den matcher kilden. Miniaturer er afledte og kan genskabes når som helst.

Forældreløse filer er bedre end hængende referencer. Det værste resultat af et crash er et forældreløst asset — en fil i assets/, som ingen side refererer til. Forældreløse assets ryddes op, når dokumentet lukkes. Det omvendte — en side, der refererer til en manglende fil — kan ikke ske, fordi assets skrives før den sidesnapshot, der refererer til dem.

Disse regler er nemmere at gennemskue end WAL-checkpointing og transaktionsisolering, og de passer præcist til appens adgangsmønster med én proces og én side.

Opgraderingsvejen er skrevet ned

At vælge flade filer nu betyder ikke, at vi vælger flade filer for altid. Pakkestrukturen er designet, så en opgradering af lagringsmotoren ændrer det, der ligger inde i pakken, uden at ændre selve pakken.

Niveau 1: snapshot + append-journal. Hvis write amplification nogensinde bliver mærkbar — lad os sige, at kontinuerlig skrivning på en side med tusindvis af streger giver en tydelig gemmeforsinkelse — deles hver sidefil i et snapshot og en append-only journal. Nye elementer føjes til som [length][CRC][type][payload]-rammer. Ved afspilning af journalen kasseres enhver ramme, hvis CRC ikke passer, hvilket giver crash-sikkerhed. Når journalen overskrider en tærskel, eller siden lukkes, flettes den tilbage i snapshotten. Det er omtrent 200 LOC uden eksterne afhængigheder.

Fordi snapshots på sideniveau allerede fjerner write amplification på tværs af sider, har vi måske ikke brug for dette niveau i lang tid. En omskrivning af en side på 5 MB hvert 0.5 sekund ligger klart inden for flashlagerets skrivebudgetter.

Niveau 2: SQLite-database. Hvis appen nogensinde får brug for fuldtekstsøgning på tværs af noter, synkronisering pr. element eller indeksering på tværs af dokumenter, bliver SQLite det rigtige værktøj. Den sandsynlige motor på det tidspunkt er GRDB, en moden, kildekompileret Swift-wrapper med næsten intet ekstra binærstørrelses-overhead. libsql-swift vil kun blive taget op til genovervejelse, hvis Turso-økosystemet — specifikt Turso Sync til Swift — bliver et reelt produktbehov.

Migrationsvejen er mekanisk: hvert sides element-array mappes til en strokes / images-tabel med én uforanderlig BLOB pr. streg (16 bytes pr. samplingpunkt i little-endian-binær). Benchmarken på 0.019 sekunder for 100 streger bekræfter, at tilgangen er levedygtig. Før udgivelse vil migrationen kontrollere, at antal elementer og assets stemmer mellem den gamle pakke og den nye database, og at crash recovery, WAL-grænser og baggrundsflush alle består.

Hvornår skal man betale

Denne beslutning er ikke en dom over databaser. SQLite håndterer samtidige skrivere, komplekse forespørgsler og crash recovery på tværs af delt tilstand — intet af det har vores app brug for lige nu. At betale for disse funktioner, før appen har de problemer, de løser, er et nettotab.

Omkostningerne ved en database — afhængigheden, WAL-håndteringen, adapterlaget og den binære størrelse — begynder, så snart biblioteket linkes. Fordelene begynder, når appen har forespørgsler at køre, indeks at vedligeholde eller samtidige skrivere at koordinere. På dette tidspunkt har den ingen af de tre.

Hvert trin i vores udvikling af lagringen skal kun betale for problemer, der allerede er dukket op. Snapshots på sideniveau betaler for det problem, vi har i dag: at gemme dokumenter med flere sider uden at omskrive hele filen. Hvis write amplification bliver målbar, betaler en append-journal for det problem. Hvis søgning eller synkronisering bliver et produktbehov, betaler en database for det problem.

Pakkestrukturen, manifestet og dokumentskemaet er ikke bundet til nogen lagringsmotor. Skifteomkostningen er lav, fordi grænserne ligger det rigtige sted. Når dagen kommer, hvor vi faktisk har brug for en database, tager vi den i brug til et konkret, allerede målt problem — ikke et hypotetisk.