Dlaczego jeszcze nie używamy bazy danych
Oceniliśmy Turso/libSQL dla naszej aplikacji do odręcznego pisania na iPadzie, wszystko zmierzyliśmy i wybraliśmy płaskie pliki snapshotów. To obciążenie nie wymaga bazy danych — a każdy krok powinien płacić tylko za problemy, które już istnieją.
Lulucat Notes to aplikacja do odręcznego pisania na iPada. Do zeszłego tygodnia miała jedno płótno i nie znała pojęcia drugiej notatki. Mieliśmy dodać bibliotekę notatek — wiele dokumentów, każdy z wieloma stronami — a pierwsze pytanie architektoniczne dotyczyło przechowywania danych.
Baza danych wydawała się oczywistą odpowiedzią. Aplikacje do notatek przechowują dane strukturalne. Dane strukturalne trafiają do baz danych. Oceniliśmy Turso i jego Swift SDK, uruchomiliśmy je w symulatorze iOS, zmierzyliśmy rzeczywiste dane pociągnięć, a potem postanowiliśmy z niego nie korzystać.
Zamiast tego wybraliśmy płaskie pliki. Oto, co znaleźliśmy i dlaczego podjęliśmy tę decyzję.

Zdjęcie: Gabriel Cox, opublikowane w serwisie Unsplash. Licencja Unsplash.
Co aplikacja faktycznie robi z danymi
Aplikacja do odręcznego pisania ma wąski i przewidywalny wzorzec dostępu do danych. Odczyt oznacza otwarcie strony i załadowanie do pamięci każdego jej elementu — wszystkich pociągnięć i wszystkich obrazów — naraz. Płótno przechowuje wszystko; nigdy nie wykonuje częściowego zapytania. Zapis oznacza zakończenie pociągnięcia piórem i dopisanie jednego elementu do strony. W rzadkich przypadkach użytkownik wymazuje część pociągnięcia, przesuwa zaznaczenie albo coś usuwa, ale to nadal operacje na jednej stronie i jednym elemencie.
Nie ma dostępu współbieżnego. Jedna osoba pisze naraz na jednej stronie jednego dokumentu. Nie ma też wyszukiwania między dokumentami — bibliotece notatek wystarczą dla każdego dokumentu tytuł, znacznik czasu, liczba stron i miniatura okładki; nie musi odczytywać zawartości stron.
Bazy danych są budowane do obsługi zapytań, indeksów i koordynacji współbieżności. Nasza aplikacja nie używa żadnej z tych trzech rzeczy.
Ocena Turso
Oceniliśmy libsql-swift, oficjalny Swift SDK dla silnika libSQL firmy Turso.
SDK działa. Wszystkie 9 przypadków testowych przechodzi. Zintegrowaliśmy go z kopią aplikacji, zbudowaliśmy aplikację dla symulatora iOS, uruchomiliśmy ją i utworzyliśmy lokalną bazę danych w sandboxie aplikacji. W jednej transakcji zapisaliśmy 100 pociągnięć po 3400 punktów próbkowania każde — 4,080,000 bajtów danych BLOB. Na naszym deweloperskim Macu zajęło to około 0.019s.
Po uruchomieniu PRAGMA wal_checkpoint(TRUNCATE) plik WAL zmniejszył się do zera. Mogliśmy wtedy skopiować sam główny plik .db w inne miejsce, otworzyć go i odczytać z powrotem wszystkie dane. Sam silnik jest solidny.
SDK ma koszty. CLibsql.xcframework waży 161 MB. Po zlinkowaniu nasz build Debug dla symulatora urósł z około 1.9 MB do około 8.2 MB. API jest synchroniczne i blokujące, bez wrapperów dla Swift Concurrency. Nie ma jawnej metody close(). Transaction.commit() nie zgłasza błędów — bazowe C API zwraca void. README repozytorium określa SDK jako „technical preview”, a najnowszy commit pochodził z lipca 2025, około rok przed naszą oceną.
Ekosystem Turso ma lukę. Turso zaleca teraz dla nowych projektów swój nowy silnik „Turso Database” i protokół „Turso Sync”. Turso Sync ma klienckie SDK dla TypeScript, Pythona, Go i Rusta. Nie ma go dla Swifta. Starszy tryb Embedded Replica istnieje w libsql-swift, ale jego inicjalizator Swift nie udostępnia parametru offline, potrzebnego aplikacji mobilnej działającej w pełni local-first. Przyjęcie dziś libsql-swift daje nam lokalny fork SQLite, ale nie możliwości synchronizacji, które wyróżniają Turso.
Ile baza danych kosztowałaby nas teraz
Nawet gdyby SDK było dojrzałe, nadal płacilibyśmy za rzeczy, które nie przynoszą żadnych korzyści przy naszym obciążeniu:
Zarządzanie plikami towarzyszącymi WAL. Działająca baza danych tworzy pliki towarzyszące -wal i -shm. Skopiowanie dokumentu wymaga albo wcześniejszego wykonania checkpointu, albo atomowego skopiowania wszystkich trzech plików. Eksport pakietu .lnote do Files lub AirDrop wymagałby teraz kroku przed eksportem, którego użytkownik nie widzi, a deweloper nie może zapomnieć.
Warstwa adaptera. Pociągnięcia trzeba byłoby serializować do BLOB-ów i deserializować z powrotem. Elementy strony mają naturalną kolejność w tablicy, którą płótno renderuje bezpośrednio; baza danych wprowadziłaby kolejność wierszy i kolumny z-index. Pisalibyśmy warstwę tłumaczącą między dwiema reprezentacjami tych samych danych i utrzymywalibyśmy ją przy każdej zmianie schematu.
Zależność o rozmiarze 161 MB. Dla aplikacji, której build Debug ma mniej niż 2 MB, zależność większa niż 80× od samej aplikacji to koszt wart odnotowania — zwłaszcza gdy jest oznaczona jako „technical preview” i pozostawała nieaktywna przez rok.
Te koszty nie są hipotetyczne. Zaczynają się w chwili zlinkowania zależności. Kupują nam jednak możliwości — zapytania, indeksowanie i zapisy współbieżne — których aplikacja nie używa.
Rozwiązanie, które wdrożyliśmy: pakiety plików snapshotów
Dokument .lnote jest pakietem katalogowym:
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 jest źródłem prawdy dla struktury dokumentu: zawiera jego ID, tytuł, znaczniki czasu oraz uporządkowaną listę stron, w której każda strona ma rozmiar płótna, znaczniki czasu i liczbę elementów. Pliki zawartości stron przechowują tablicę elementów w tym samym skwantyzowanym kodowaniu całkowitoliczbowym, którego aplikacja już używa — współrzędne i promienie z precyzją 0.1 punktu, nacisk w tysięcznych oraz znaczniki czasu w milisekundach względnych.
Biblioteka notatek odczytuje tylko manifest.json i miniatury okładek. Nigdy nie parsuje document.json ani zawartości żadnej strony. Otwarcie strony ładuje jeden plik .content. To jedyny odczyt pliku, który dotyka danych pociągnięć.
Strony rozwiązują problem write amplification
Aplikacja do odręcznego pisania już ma pojęcie strony — to jednostka, o której myślą użytkownicy i między którą przesuwają się gestem. Uczynienie strony jednostką utrwalania danych oznacza, że autozapis przepisuje tylko zmienione strony.
Jedna zapisana odręcznie strona — powiedzmy 1,000–2,000 pociągnięć — zajmuje w naszym skwantyzowanym formacie około 3–5 MB. Jedno nagranie złożone z 21 pociągnięć i 3400 punktów próbkowania po kwantyzacji zajmuje około 55 KB. Zapisanie jednego snapshotu strony w pamięci flash na współczesnym sprzęcie trwa 10–20 ms. Przy debounce ustawionym na 0.5s zapis jest niewidoczny dla użytkownika.
Koszt zapisu rośnie wraz z ilością pisania na bieżącej stronie, a nie z łączną liczbą stron dokumentu. Notes liczący 200 stron zapisuje się dokładnie tak szybko jak notes liczący 2 strony, ponieważ przepisywana jest tylko zmieniona strona.
Każdy zapis korzysta z atomowych operacji na plikach — zapis do pliku tymczasowego, a następnie zmiana nazwy — więc awaria w połowie zapisu nie może utworzyć uciętej strony. Przejście aplikacji do tła natychmiast opróżnia wszystkie zmienione strony, zgodnie z zachowaniem, które aplikacja miała już przy jednym płótnie.
Spójność bez transakcji
Pakiety plików nie mają transakcji, ale mają jasne zasady własności, które pełnią tę samą funkcję:
Zasoby przed odwołaniami. Gdy użytkownik wstawia obraz, plik zasobu jest od razu zapisywany w assets/. Snapshot strony odwołujący się do zasobu po ID jest zapisywany później przez autozapis z debounce. W żadnym momencie strona nie odwołuje się do zasobu, którego nie ma na dysku.
Wygrywa źródło prawdy. document.json i katalog pages/ są źródłem prawdy. manifest.json jest pamięcią podręczną. Jeśli się nie zgadzają, następny zapis uzgadnia cache ze źródłem. Miniatury są pochodne i można je w każdej chwili wygenerować ponownie.
Sieroty zamiast wiszących odwołań. Najgorszym skutkiem awarii jest osierocony zasób — plik w assets/, do którego nie odwołuje się żadna strona. Sieroty są czyszczone przy zamykaniu dokumentu. Odwrotna sytuacja — strona odwołująca się do brakującego pliku — nie może się wydarzyć, ponieważ zasoby są zapisywane przed snapshotem strony, który się do nich odwołuje.
Te zasady są łatwiejsze do rozumowania niż checkpointing WAL i izolacja transakcji, a przy tym dokładnie pasują do wzorca dostępu aplikacji: jeden proces, jedna strona.
Ścieżka rozwoju jest zapisana
Wybór płaskich plików teraz nie oznacza wyboru płaskich plików na zawsze. Struktura pakietu została zaprojektowana tak, aby modernizacja silnika przechowywania zmieniała zawartość pakietu, ale nie sam pakiet.
Poziom 1: snapshot + dziennik dopisywania. Jeśli write amplification stanie się kiedyś odczuwalne — na przykład ciągłe pisanie na stronie z tysiącami pociągnięć spowoduje zauważalne opóźnienie zapisu — każdy plik strony zostanie podzielony na snapshot i dziennik tylko do dopisywania. Nowe elementy będą dopisywane jako ramki [length][CRC][type][payload]. Podczas odtwarzania dziennika każda ramka, której CRC się nie zgadza, zostanie odrzucona, co zapewni bezpieczeństwo po awarii. Gdy dziennik przekroczy próg albo strona zostanie zamknięta, zostanie scalony z powrotem ze snapshotem. To około ~200 LOC bez zewnętrznych zależności.
Ponieważ snapshoty na poziomie stron już eliminują write amplification między stronami, ten poziom może długo nie być potrzebny. Przepisanie strony o rozmiarze 5 MB co 0.5s z dużym zapasem mieści się w budżecie zapisów pamięci flash.
Poziom 2: baza SQLite. Jeśli aplikacja będzie kiedyś potrzebować wyszukiwania pełnotekstowego w notatkach, synchronizacji poszczególnych elementów albo indeksowania między dokumentami, SQLite stanie się właściwym narzędziem. Prawdopodobnym silnikiem będzie wtedy GRDB, dojrzały, kompilowany ze źródeł wrapper Swift o niemal zerowym narzucie rozmiaru binarnego. Do libsql-swift wrócilibyśmy tylko wtedy, gdy ekosystem Turso — konkretnie Turso Sync dla Swifta — stanie się rzeczywistą potrzebą produktu.
Migracja jest mechaniczna: tablica elementów każdej strony zostanie odwzorowana na tabelę strokes / images, z jednym niezmiennym BLOB-em na pociągnięcie (16 bajtów na punkt próbkowania w little-endian binary). Benchmark 0.019s dla 100 pociągnięć potwierdza, że to podejście jest wykonalne. Przed wydaniem migracja sprawdzi, czy liczba elementów i zasobów zgadza się między starym pakietem a nową bazą danych oraz czy przechodzą testy odzyskiwania po awarii, limitów WAL i opróżniania w tle.
Kiedy ponosić koszt
Ta decyzja nie jest oceną baz danych. SQLite obsługuje współbieżnych zapisujących, złożone zapytania i odzyskiwanie po awarii w ramach współdzielonego stanu — nasza aplikacja obecnie nie potrzebuje żadnej z tych rzeczy. Płacenie za możliwości, zanim pojawią się problemy, które te możliwości rozwiązują, jest stratą netto.
Koszty bazy danych — zależność, zarządzanie WAL, warstwa adaptera i rozmiar binarny — zaczynają się w chwili zlinkowania biblioteki. Korzyści zaczynają się wtedy, gdy aplikacja ma zapytania do wykonania, indeksy do utrzymania albo współbieżne zapisy do skoordynowania. Na tym etapie nie ma żadnej z tych trzech rzeczy.
Każdy krok rozwoju naszego przechowywania danych będzie płacił tylko za problemy, które już się pojawiły. Snapshoty na poziomie stron płacą za dzisiejszy problem: zapisywanie dokumentów wielostronicowych bez przepisywania całego pliku. Jeśli write amplification stanie się mierzalne, dziennik dopisywania zapłaci za ten problem. Jeśli wyszukiwanie lub synchronizacja stanie się potrzebą produktu, baza danych zapłaci za ten problem.
Struktura pakietu, manifest i schemat dokumentu nie są związane z żadnym silnikiem przechowywania. Koszty zmiany są niskie, bo granice przebiegają we właściwych miejscach. Gdy nadejdzie dzień, w którym naprawdę będziemy potrzebować bazy danych, przyjmiemy ją dla konkretnego, już zmierzonego problemu — nie dla problemu hipotetycznego.