Lulucat

Zakaj še ne uporabljamo podatkovne baze

Gaoge ZhangGaoge Zhang

Za našo aplikacijo za ročno pisanje na iPadu smo ocenili Turso/libSQL, vse izmerili in izbrali ploščate datoteke snapshot. Ta delovna obremenitev ne potrebuje podatkovne baze — vsak korak pa naj plača samo za težave, ki že obstajajo.

Lulucat Notes je aplikacija za ročno pisanje na iPadu. Do prejšnjega tedna je imela eno platno in ni poznala pojma drugega zapiska. Dodati smo nameravali knjižnico zapiskov — več dokumentov, vsak z več stranmi — in prvo arhitekturno vprašanje je bilo shranjevanje.

Podatkovna baza se je zdela očiten odgovor. Aplikacije za zapiske shranjujejo strukturirane podatke. Strukturirani podatki sodijo v podatkovne baze. Ocenili smo Turso in njegov Swift SDK, ga zagnali v simulatorju iOS, izmerili resnične podatke potez in se nato odločili, da ga ne bomo uporabili.

Namesto tega smo izbrali ploščate datoteke. Tukaj so naše ugotovitve in razlogi za to odločitev.

Odprt zvezek z ročno napisanimi zapiski in peresom na leseni mizi.

Foto: Gabriel Cox na Unsplash. Licenca Unsplash.

Kaj aplikacija v resnici počne s podatki

Aplikacija za ročno pisanje ima ozek in predvidljiv vzorec dostopa do podatkov. Branje pomeni odpreti stran in hkrati naložiti vsak njen element — vse poteze in vse slike — v pomnilnik. Platno vsebuje vse; nikoli ne izvede delne poizvedbe. Pisanje pomeni končati potezo peresa in strani dodati en element. V redkih primerih uporabnik izbriše del poteze, premakne izbor ali nekaj odstrani, vendar so to še vedno operacije ene strani in enega elementa.

Sočasnega dostopa ni. Ena oseba naenkrat piše na eni strani enega dokumenta. Tudi iskanja po dokumentih ni — knjižnica zapiskov za vsak dokument potrebuje le naslov, časovni žig, število strani in sličico naslovnice; branje vsebine strani ni potrebno.

Podatkovne baze so zgrajene za poizvedbe, indekse in usklajevanje sočasnosti. Naša aplikacija ne uporablja nobene od teh treh stvari.

Ocenjevanje Turso

Ocenili smo libsql-swift, uradni Swift SDK za Tursojev engine libSQL.

SDK deluje. Vseh 9 testnih primerov je uspešnih. Integrirali smo ga v kopijo aplikacije, jo zgradili za simulator iOS, zagnali in v peskovniku aplikacije ustvarili lokalno podatkovno bazo. V eni transakciji smo zapisali 100 potez, vsako s 3400 vzorčnimi točkami — 4,080,000 bajtov podatkov BLOB. Na našem razvijalskem Macu je to trajalo približno 0.019s.

Po zagonu PRAGMA wal_checkpoint(TRUNCATE) se je datoteka WAL skrčila na nič in lahko smo na drugo mesto kopirali samo glavno datoteko .db, jo odprli in prebrali vse podatke. Sam engine je zanesljiv.

SDK ima stroške. CLibsql.xcframework ima 161 MB. Po linkanju se je naš Debug build simulatorja povečal s približno 1.9 MB na približno 8.2 MB. API je sinhron in blokira, brez ovojev za Swift Concurrency. Izrecne metode close() ni. Transaction.commit() ne vrže izjeme — osnovni C API vrne void. README repozitorija SDK označuje kot »technical preview«, najnovejši commit pa je bil približno leto pred našim ocenjevanjem, julija 2025.

Ekosistem Turso ima vrzel. Turso za nove projekte zdaj priporoča svoj novi engine »Turso Database« in protokol »Turso Sync«. Turso Sync ima odjemalske SDK-je za TypeScript, Python, Go in Rust. Za Swift ga nima. Starejši način Embedded Replica obstaja v libsql-swift, vendar njegov Swift initializer ne izpostavi parametra offline, ki ga potrebuje popolnoma local-first mobilna aplikacija. Če danes izberemo libsql-swift, dobimo lokalni fork SQLite, ne pa tudi zmožnosti sinhronizacije, zaradi katerih je Turso poseben.

Koliko bi nas podatkovna baza stala zdaj

Tudi če bi bil SDK zrel, bi še vedno plačevali stroške, ki naši delovni obremenitvi ne prinesejo ničesar:

Upravljanje spremljevalnih datotek WAL. Delujoča podatkovna baza ustvari spremljevalni datoteki -wal in -shm. Kopiranje dokumenta pomeni, da je treba najprej izvesti checkpoint ali atomsko kopirati vse tri datoteke. Izvoz paketa .lnote v Files ali AirDrop bi zdaj zahteval korak pred izvozom, ki ga uporabnik ne vidi in ga razvijalec ne sme pozabiti.

Adapterska plast. Poteze bi bilo treba serializirati v BLOB-e in jih nato deserializirati. Elementi strani imajo naraven vrstni red v polju, ki ga platno izriše neposredno; podatkovna baza bi dodala vrstni red vrstic in stolpce z-index. Napisali bi prevajalno plast med dvema predstavitvama istih podatkov in jo vzdrževali ob vsaki spremembi sheme.

Odvisnost velikosti 161 MB. Za aplikacijo, katere Debug build je manjši od 2 MB, je odvisnost, ki je več kot 80× večja od same aplikacije, strošek, vreden pozornosti — še posebej, če je označena kot »technical preview« in je eno leto nedejavna.

Ti stroški niso hipotetični. Začnejo se v trenutku, ko je odvisnost linkana. Kupijo pa nam zmožnosti — poizvedovanje, indeksiranje in sočasno pisanje — ki jih naša aplikacija ne uporablja.

Rešitev, ki smo jo izdali: paketi datotek snapshot

Dokument .lnote je paket imenika:

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 je vir resnice za strukturo dokumenta: njegov ID, naslov, časovne žige in urejen seznam strani, pri čemer ima vsaka stran velikost platna, časovne žige in število elementov. Datoteke z vsebino strani shranjujejo polje elementov v istem kvantiziranem celoštevilskem kodiranju, ki ga aplikacija že uporablja — koordinate in polmere z natančnostjo 0.1 točke, pritisk v tisočinkah in časovne žige v relativnih milisekundah.

Knjižnica zapiskov bere samo manifest.json in sličice naslovnic. Nikoli ne razčleni document.json ali vsebine katere koli strani. Odpiranje strani naloži eno datoteko .content. To je edino branje datoteke, ki se dotakne podatkov potez.

Strani rešijo write amplification

Aplikacija za ročno pisanje že ima pojem strani — to je enota, o kateri uporabniki razmišljajo in med katero podrsavajo. Če stran postane enota obstojnega shranjevanja, samodejno shranjevanje prepiše samo strani, ki so se spremenile.

Ena stran rokopisa — recimo 1,000–2,000 potez — v našem kvantiziranem formatu zavzame približno 3–5 MB. Posnetek z 21 potezami in 3400 vzorčnimi točkami se po kvantizaciji zmanjša na približno 55 KB. Zapis enega snapshot-a strani v flashovnem pomnilniku na sodobni strojni opremi traja 10–20 ms. Z debounceom 0.5s so shranjevanja za uporabnika nevidna.

Cena shranjevanja se povečuje s količino pisanja na trenutni strani, ne s skupnim številom strani v dokumentu. 200-stranski zvezek se shrani natanko tako hitro kot 2-stranski, ker se prepiše samo dirty stran.

Vsak zapis uporablja atomske operacije z datotekami — zapis v začasno datoteko in nato preimenovanje —, zato sesutje sredi shranjevanja ne more ustvariti okrnjene strani. Ob prehodu aplikacije v ozadje se vse dirty strani takoj flushajo, kar ustreza vedenju, ki ga je imela aplikacija že z enim platnom.

Doslednost brez transakcij

Paketi datotek nimajo transakcij, imajo pa jasna pravila lastništva, ki služijo istemu namenu:

Viri pred sklici. Ko uporabnik vstavi sliko, se datoteka asset takoj zapiše v assets/. Snapshot strani, ki se na asset sklicuje po ID-ju, se pozneje zapiše z zakasnjenim samodejnim shranjevanjem. Stran se v nobenem trenutku ne sklicuje na asset, ki ne obstaja na disku.

Vir resnice zmaga. document.json in imenik pages/ sta vir resnice. manifest.json je predpomnilnik. Če se ne ujemata, naslednje shranjevanje predpomnilnik uskladi z virom. Sličice so izpeljane in jih je mogoče kadar koli znova ustvariti.

Osiroteli viri namesto visečih sklicev. Najslabši izid sesutja je osiroteli asset — datoteka v assets/, na katero se ne sklicuje nobena stran. Osiroteli viri se počistijo ob zaprtju dokumenta. Obratno — stran, ki se sklicuje na manjkajočo datoteko — se ne more zgoditi, ker se asseti zapišejo pred snapshotom strani, ki se nanje sklicuje.

Ta pravila je lažje razumeti kot WAL checkpointing in izolacijo transakcij, hkrati pa se natančno ujemajo z vzorcem dostopa aplikacije: en proces, ena stran.

Pot nadgradnje je zapisana

Današnja izbira ploščatih datotek ne pomeni, da smo jih izbrali za vedno. Struktura paketa je zasnovana tako, da nadgradnja shranjevalnega engina spremeni vsebino paketa, ne pa paketa samega.

Raven 1: snapshot + append journal. Če write amplification kdaj postane opazen — recimo, če neprekinjeno pisanje na strani s tisoči potez povzroči zaznavno zamudo pri shranjevanju — se vsaka datoteka strani razdeli na snapshot in journal, v katerega se samo dodaja. Novi elementi se dodajo kot okvirji [length][CRC][type][payload]. Pri predvajanju journala se zavrže vsak okvir, katerega CRC se ne ujema, kar zagotovi varnost ob sesutju. Ko journal preseže prag ali je stran zaprta, se združi nazaj v snapshot. To je približno ~200 LOC brez zunanjih odvisnosti.

Ker snapshot-i na ravni strani že odpravijo write amplification med stranmi, ta raven morda še dolgo ne bo potrebna. Prepis strani velikosti 5 MB vsakih 0.5s zlahka ostane znotraj proračuna zapisov flashovnega pomnilnika.

Raven 2: podatkovna baza SQLite. Če bo aplikacija kdaj potrebovala full-text iskanje po zapiskih, sinhronizacijo posameznih elementov ali indeksiranje med dokumenti, bo SQLite pravo orodje. Verjetni engine bo takrat GRDB, zrel Swiftov ovoj, preveden iz izvorne kode, s skoraj ničelnim dodatnim vplivom na velikost binarne datoteke. K libsql-swift bi se vrnili samo, če ekosistem Turso — posebej Turso Sync za Swift — postane resnična potreba izdelka.

Migracija je mehanska: polje elementov vsake strani se preslika v tabelo strokes / images, z enim nespremenljivim BLOB-om na potezo (16 bajtov na vzorčno točko v binarnem zapisu little-endian). Benchmark 0.019s za 100 potez potrjuje, da je pristop izvedljiv. Pred izdajo bo migracija preverila, ali se število elementov in assetov ujema med starim paketom in novo podatkovno bazo ter ali prestanejo preverjanja obnovitve po sesutju, omejitev WAL in flush-a v ozadju.

Kdaj plačati

Ta odločitev ni sodba o podatkovnih bazah. SQLite obravnava sočasne zapisovalce, zapletene poizvedbe in obnovitev po sesutju nad deljenim stanjem — naša aplikacija trenutno ne potrebuje ničesar od tega. Plačevanje za zmožnosti, preden ima aplikacija težave, ki jih te zmožnosti rešujejo, je čista izguba.

Stroški podatkovne baze — odvisnost, upravljanje WAL, adapterska plast in velikost binarne datoteke — se začnejo v trenutku, ko je knjižnica linkana. Koristi se začnejo, ko ima aplikacija poizvedbe za izvajanje, indekse za vzdrževanje ali sočasne zapisovalce za usklajevanje. Na tej stopnji nima nobene od teh treh stvari.

Vsak korak razvoja našega shranjevanja bo plačal samo za težave, ki so se že pojavile. Snapshot-i na ravni strani plačajo za današnjo težavo: shranjevanje večstranskih dokumentov brez prepisovanja celotne datoteke. Če postane write amplification merljiv, bo append journal plačal za to težavo. Če iskanje ali sinhronizacija postaneta potreba izdelka, bo podatkovna baza plačala za to težavo.

Struktura paketa, manifest in shema dokumenta niso vezani na noben shranjevalni engine. Stroški preklopa so nizki, ker so meje na pravih mestih. Ko bo prišel dan, ko bomo podatkovno bazo res potrebovali, jo bomo uvedli za konkreten, že izmerjen problem — ne za hipotetičnega.