Lulucat

Proč zatím nepoužíváme databázi

Gaoge ZhangGaoge Zhang

Pro naši iPadovou aplikaci pro ruční psaní jsme vyhodnotili Turso/libSQL, všechno změřili a zvolili ploché snapshotové soubory. Tato zátěž databázi nepotřebuje — každý další krok by měl platit jen za problémy, které už skutečně existují.

Lulucat Notes je aplikace pro ruční psaní na iPad. Ještě minulý týden měla jednu plochu a neznala pojem druhé poznámky. Chystali jsme se přidat knihovnu poznámek — více dokumentů, každý s více stránkami — a první architektonickou otázkou bylo úložiště.

Databáze působila jako samozřejmá odpověď. Aplikace na poznámky ukládají strukturovaná data. Strukturovaná data patří do databází. Vyhodnotili jsme Turso a jeho Swift SDK, spustili ho v simulátoru iOS, změřili skutečná data tahů a pak se rozhodli ho nepoužít.

Místo toho jsme zvolili ploché soubory. Tady je, co jsme zjistili a proč jsme se tak rozhodli.

Otevřený notýsek s ručně psanými poznámkami a perem na dřevěném stole.

Fotografie od Gabriel Cox na Unsplash. Licence Unsplash.

Co aplikace s daty skutečně dělá

Aplikace pro ruční psaní má úzký a předvídatelný vzorec přístupu k datům. Čtení znamená otevřít stránku a načíst do paměti každý její prvek — všechny tahy i všechny obrázky — najednou. Plátno drží všechno; nikdy nespouští částečný dotaz. Zápis znamená dokončit tah pera a připojit ke stránce jeden prvek. Ve vzácných případech uživatel vymaže část tahu, přesune výběr nebo něco smaže, ale i to jsou operace nad jedinou stránkou a jediným prvkem.

Neexistuje souběžný přístup. Jeden člověk píše v jednu chvíli na jednu stránku jednoho dokumentu. Neexistuje ani vyhledávání napříč dokumenty — knihovna poznámek potřebuje pro každý dokument jen název, časové razítko, počet stránek a náhled titulní stránky; nic z toho nevyžaduje čtení obsahu stránky.

Dotazy, indexy a koordinace souběžnosti jsou důvody, proč databáze vznikly. Naše aplikace nepoužívá ani jedno z toho.

Vyhodnocení Turso

Vyhodnotili jsme libsql-swift, oficiální Swift SDK pro engine libSQL od Turso.

SDK funguje. Všech devět jeho testovacích případů projde. Integrovali jsme ho do kopie aplikace, sestavili ji pro simulátor iOS, spustili ji a v sandboxu aplikace vytvořili lokální databázi. V jediné transakci jsme zapsali 100 tahů, každý s 3,400 vzorkovacími body — 4,080,000 bajtů dat BLOB. Na našem vývojovém Macu to trvalo přibližně 0.019 sekundy.

Po spuštění PRAGMA wal_checkpoint(TRUNCATE) se soubor WAL zmenšil na nulu a mohli jsme zkopírovat samotný hlavní soubor .db na jiné místo, otevřít ho a načíst z něj všechna data. Samotný engine je v pořádku.

SDK má své náklady. CLibsql.xcframework má 161 MB. Po slinkování vzrostla naše Debug sestava pro simulátor z přibližně 1.9 MB na přibližně 8.2 MB. API je synchronní a blokující, bez obalů pro Swift Concurrency. Neexistuje explicitní metoda close(). Transaction.commit() nevyhazuje chybu — podkladové C API vrací void. README repozitáře označuje SDK jako „technical preview“ a nejnovější commit byl v době našeho hodnocení asi rok starý, z července 2025.

Ekosystému Turso něco chybí. Turso nyní pro nové projekty doporučuje svůj nový engine „Turso Database“ a protokol „Turso Sync“. Turso Sync má klientské SDK pro TypeScript, Python, Go a Rust. Pro Swift žádné nemá. Starší režim Embedded Replica existuje v libsql-swift, ale jeho Swift inicializátor nezpřístupňuje parametr offline, který je potřeba pro plně local-first mobilní aplikaci. Přijetím libsql-swift dnes získáme lokální fork SQLite, ale ne synchronizační schopnosti, díky nimž je Turso výjimečné.

Co by nás databáze právě teď stála

I kdyby bylo SDK vyspělé, platili bychom náklady, které pro naši zátěž nic nepřinášejí:

Správa doprovodných souborů WAL. Běžící databáze vytváří doprovodné soubory -wal a -shm. Zkopírovat dokument znamená nejprve provést checkpoint, nebo atomicky zkopírovat všechny tři soubory. Export balíčku .lnote do Files nebo AirDropu teď vyžaduje krok před exportem, který uživatel nevidí a na který vývojář nemůže zapomenout.

Adaptační vrstva. Tahy by se musely serializovat do BLOB a znovu deserializovat. Prvky stránky mají přirozené pořadí pole, které plátno vykresluje přímo; databáze by zavedla pořadí řádků a sloupce z-index. Psali bychom překladovou vrstvu mezi dvěma reprezentacemi stejných dat a udržovali ji při každé změně schématu.

Závislost o velikosti 161 MB. U aplikace, jejíž Debug sestava má méně než 2 MB, je závislost větší než 80× samotná aplikace náklad, který stojí za povšimnutí — zvlášť když je označená jako „technical preview“ a rok se na ní nic nedělo.

Tyto náklady nejsou hypotetické. Začínají v okamžiku, kdy se závislost slinkuje. A přinášejí nám schopnosti — dotazování, indexování a souběžné zápisy — které naše aplikace nevyužívá.

Řešení, které jsme nasadili: balíčky snapshotových souborů

Dokument .lnote je adresářový balíček:

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 zdrojem pravdy pro strukturu dokumentu: jeho ID, název, časová razítka a uspořádaný seznam stránek, přičemž každá stránka má svou velikost plátna, časová razítka a počet prvků. Soubory s obsahem stránek ukládají pole prvků ve stejném kvantizovaném celočíselném kódování, které aplikace už používá — souřadnice a poloměry s přesností 0.1 bodu, tlak v tisícinách a časová razítka v relativních milisekundách.

Knihovna poznámek čte jen manifest.json a náhledy titulní stránky. Nikdy neparsuje document.json ani obsah žádné stránky. Otevření stránky načte jeden soubor .content. To je jediné čtení souboru, které se dotýká dat tahů.

Stránky řeší write amplification

Aplikace pro ruční psaní už pojem stránky má — je to jednotka, o které uživatel přemýšlí, a uživatel mezi stránkami přejíždí. Když se stránka stane jednotkou persistence, automatické ukládání přepisuje jen stránky, které se změnily.

Jedna stránka ručního psaní — řekněme 1,000 až 2,000 tahů — zabírá v našem kvantizovaném formátu přibližně 3–5 MB. Jeden záznam s 21 tahy a 3,400 vzorkovacími body se po kvantizaci vejde přibližně do 55 KB. Zápis snapshotu jedné stránky do flashového úložiště trvá na moderním hardwaru 10–20 ms. S debounce 0.5 sekundy jsou uložení pro uživatele neviditelná.

Cena ukládání roste s množstvím psaní na aktuální stránce, ne s celkovým počtem stránek v dokumentu. Sešit o 200 stránkách se ukládá přesně stejně rychle jako sešit o 2 stránkách, protože se přepisuje jen změněná stránka.

Každý zápis používá atomické operace se soubory — zápis do dočasného souboru a následné přejmenování — takže pád během ukládání nemůže vytvořit zkrácenou stránku. Přechod na pozadí okamžitě zapíše všechny změněné stránky, stejně jako se aplikace chovala, když měla jedinou plochu.

Konzistence bez transakcí

Souborové balíčky nemají transakce, ale mají jasná pravidla vlastnictví, která plní stejný účel:

Zdroje před odkazy. Když uživatel vloží obrázek, soubor assetu se okamžitě zapíše do assets/. Snapshot stránky, který na asset odkazuje jeho ID, se zapíše později pomocí automatického ukládání s debounce. Stránka nikdy neodkazuje na asset, který na disku neexistuje.

Zdroj pravdy vítězí. document.json a adresář pages/ jsou zdrojem pravdy. manifest.json je cache. Pokud se rozcházejí, další uložení cache srovná se zdrojem. Náhledy jsou odvozené a lze je kdykoli znovu vygenerovat.

Sirotci jsou lepší než visící odkazy. Nejhorším výsledkem pádu je osamocený asset — soubor v assets/, na který neodkazuje žádná stránka. Sirotčí assety se uklidí při zavření dokumentu. Opačný případ — stránka odkazující na chybějící soubor — nemůže nastat, protože assety se zapisují před snapshotem stránky, který na ně odkazuje.

Tato pravidla se vysvětlují snadněji než checkpointing WAL a izolace transakcí a přesně odpovídají přístupu aplikace s jediným procesem a jedinou stránkou.

Cesta upgradu je sepsaná

Volba plochých souborů teď neznamená volbu plochých souborů navždy. Struktura balíčku je navržená tak, aby upgrade úložiště změnil obsah balíčku, ale ne balíček samotný.

Úroveň 1: snapshot + append journal. Pokud by write amplification někdy začala být znatelná — řekněme, že nepřetržité psaní na stránce s tisíci tahů způsobí citelné zpoždění ukládání — rozdělí se každý soubor stránky na snapshot a append-only journal. Nové prvky se přidávají jako rámce [length][CRC][type][payload]. Při přehrávání journalu se zahodí každý rámec, jehož CRC nesouhlasí, což poskytuje bezpečnost při pádu. Když journal překročí práh nebo se stránka zavře, sloučí se zpět do snapshotu. Je to zhruba 200 LOC s nulovými externími závislostmi.

Protože snapshoty na úrovni stránek už odstraňují write amplification mezi stránkami, možná tuto úroveň nebudeme dlouho potřebovat. Přepis 5 MB stránky každých 0.5 sekundy je bezpečně v mezích rozpočtu zápisů flashového úložiště.

Úroveň 2: databáze SQLite. Pokud aplikace někdy potřebuje fulltextové vyhledávání napříč poznámkami, synchronizaci po prvcích nebo indexování napříč dokumenty, SQLite se stane správným nástrojem. Pravděpodobným enginem v takové chvíli bude GRDB, vyspělý Swift wrapper kompilovaný ze zdrojů s téměř nulovou binární režií. K libsql-swift bychom se vrátili jen tehdy, kdyby se ekosystém Turso — konkrétně Turso Sync pro Swift — stal skutečnou potřebou produktu.

Migrační cesta je mechanická: pole prvků každé stránky se namapuje na tabulku strokes / images s jedním neměnným BLOBem pro každý tah (16 bajtů na vzorkovací bod v binárním formátu little-endian). Benchmark 0.019 sekundy pro 100 tahů potvrzuje, že je tento přístup proveditelný. Před vydáním migrace ověří, že počty prvků a assetů souhlasí mezi starým balíčkem a novou databází a že kontrolou projdou obnova po pádu, limity WAL i flush na pozadí.

Kdy platit

Toto rozhodnutí není soudem nad databázemi. SQLite zvládá souběžné zapisovatele, složité dotazy a obnovu sdíleného stavu po pádu — nic z toho naše aplikace v současnosti nepotřebuje. Platit za tyto schopnosti dřív, než aplikace má problémy, které řeší, je čistá ztráta.

Náklady databáze — závislost, správa WAL, adaptační vrstva a velikost binárního souboru — začínají v okamžiku, kdy se knihovna slinkuje. Přínosy začínají ve chvíli, kdy má aplikace dotazy ke spuštění, indexy k údržbě nebo souběžné zapisovatele ke koordinaci. V této fázi nemá ani jedno z toho.

Každý krok ve vývoji našeho úložiště zaplatí jen za problémy, které se už objevily. Snapshoty na úrovni stránek řeší dnešní problém: ukládání vícestránkových dokumentů bez přepisování celého souboru. Pokud bude write amplification měřitelná, append journal tento problém vyřeší. Pokud se vyhledávání nebo synchronizace stanou potřebou produktu, databáze tento problém vyřeší.

Struktura balíčku, manifest a schéma dokumentu nejsou svázané s žádným enginem úložiště. Náklady na přechod jsou nízké, protože hranice jsou na správném místě. Až přijde den, kdy databázi skutečně potřebujeme, nasadíme ji kvůli konkrétnímu, už změřenému problému — ne kvůli hypotetickému.