Lulucat

Γιατί δεν χρησιμοποιούμε ακόμη βάση δεδομένων

Gaoge ZhangGaoge Zhang

Αξιολογήσαμε τα Turso/libSQL για την εφαρμογή χειρόγραφων σημειώσεων για iPad, μετρήσαμε τα πάντα και επιλέξαμε επίπεδα αρχεία snapshot. Ο συγκεκριμένος φόρτος εργασίας δεν χρειάζεται βάση δεδομένων — κάθε βήμα πρέπει να πληρώνει μόνο για προβλήματα που ήδη υπάρχουν.

Το Lulucat Notes είναι εφαρμογή χειρόγραφων σημειώσεων για iPad. Μέχρι την περασμένη εβδομάδα είχε έναν μόνο καμβά και καμία έννοια δεύτερης σημείωσης. Ετοιμαζόμασταν να προσθέσουμε βιβλιοθήκη σημειώσεων — πολλά έγγραφα, καθένα με πολλές σελίδες — και το πρώτο αρχιτεκτονικό ερώτημα ήταν η αποθήκευση.

Μια βάση δεδομένων έμοιαζε με την προφανή απάντηση. Οι εφαρμογές σημειώσεων αποθηκεύουν δομημένα δεδομένα. Τα δομημένα δεδομένα μπαίνουν σε βάσεις δεδομένων. Αξιολογήσαμε το Turso και το Swift SDK του, το θέσαμε σε λειτουργία στον προσομοιωτή iOS, μετρήσαμε πραγματικά δεδομένα πινελιών και στη συνέχεια αποφασίσαμε να μην το χρησιμοποιήσουμε.

Αντ’ αυτού επιλέξαμε επίπεδα αρχεία. Δείτε τι βρήκαμε και γιατί πήραμε αυτή την απόφαση.

Ανοιχτό σημειωματάριο με χειρόγραφες σημειώσεις και ένα στυλό πάνω σε ξύλινο γραφείο.

Φωτογραφία του Gabriel Cox στο Unsplash. Άδεια Unsplash.

Τι κάνει πραγματικά η εφαρμογή με τα δεδομένα

Μια εφαρμογή χειρόγραφων σημειώσεων έχει στενό και προβλέψιμο μοτίβο πρόσβασης στα δεδομένα. Ανάγνωση σημαίνει άνοιγμα μιας σελίδας και φόρτωση κάθε στοιχείου της — όλων των πινελιών και όλων των εικόνων — στη μνήμη, ταυτόχρονα. Ο καμβάς περιέχει τα πάντα· δεν εκτελεί ποτέ μερικό ερώτημα. Εγγραφή σημαίνει ολοκλήρωση μιας πινελιάς πένας και προσθήκη ενός στοιχείου στη σελίδα. Σε σπάνιες περιπτώσεις ο χρήστης σβήνει μέρος μιας πινελιάς, μετακινεί μια επιλογή ή διαγράφει κάτι, αλλά και πάλι πρόκειται για λειτουργίες μίας σελίδας και ενός στοιχείου.

Δεν υπάρχει ταυτόχρονη πρόσβαση. Ένα άτομο γράφει κάθε φορά σε μία σελίδα ενός εγγράφου. Δεν υπάρχει ούτε αναζήτηση μεταξύ εγγράφων — η βιβλιοθήκη σημειώσεων χρειάζεται μόνο έναν τίτλο, μια χρονική σήμανση, πλήθος σελίδων και μια μικρογραφία εξωφύλλου για κάθε έγγραφο· τίποτα από αυτά δεν απαιτεί ανάγνωση του περιεχομένου της σελίδας.

Τα ερωτήματα, τα ευρετήρια και ο συντονισμός της ταυτόχρονης πρόσβασης είναι ο λόγος ύπαρξης των βάσεων δεδομένων. Η εφαρμογή μας δεν χρησιμοποιεί κανένα από τα τρία.

Η αξιολόγηση του Turso

Αξιολογήσαμε το libsql-swift, το επίσημο Swift SDK για τη μηχανή libSQL του Turso.

Το SDK λειτουργεί. Και οι εννέα δοκιμές του περνούν. Το ενσωματώσαμε σε ένα αντίγραφο της εφαρμογής, κάναμε build για τον προσομοιωτή iOS, το εκκινήσαμε και δημιουργήσαμε μια τοπική βάση δεδομένων στο sandbox της εφαρμογής. Γράψαμε 100 πινελιές με 3,400 σημεία δειγματοληψίας η καθεμία — 4,080,000 bytes δεδομένων BLOB — σε μία συναλλαγή. Χρειάστηκε περίπου 0.019 δευτερόλεπτα στο Mac ανάπτυξής μας.

Αφού εκτελέσαμε το PRAGMA wal_checkpoint(TRUNCATE), το αρχείο WAL συρρικνώθηκε στο μηδέν και μπορέσαμε να αντιγράψουμε μόνο το κύριο αρχείο .db σε άλλη τοποθεσία, να το ανοίξουμε και να διαβάσουμε ξανά όλα τα δεδομένα. Η ίδια η μηχανή είναι αξιόπιστη.

Το SDK έχει κόστος. Το CLibsql.xcframework έχει μέγεθος 161 MB. Μετά τη σύνδεση, το Debug build του προσομοιωτή μας αυξήθηκε από περίπου 1.9 MB σε περίπου 8.2 MB. Το API λειτουργεί συγχρονικά και είναι μπλοκαριστικό, χωρίς wrappers για Swift Concurrency. Δεν υπάρχει ρητή μέθοδος close(). Η Transaction.commit() δεν κάνει throw — το υποκείμενο C API επιστρέφει void. Το README του repository χαρακτηρίζει το SDK «technical preview» και το πιο πρόσφατο commit ήταν περίπου έναν χρόνο πριν από την αξιολόγησή μας, τον Ιούλιο του 2025.

Το οικοσύστημα του Turso έχει ένα κενό. Το Turso προτείνει πλέον τη νέα μηχανή «Turso Database» και το πρωτόκολλο «Turso Sync» για νέα έργα. Το Turso Sync διαθέτει client SDK για TypeScript, Python, Go και Rust. Δεν διαθέτει για Swift. Η παλαιότερη λειτουργία Embedded Replica υπάρχει στο libsql-swift, όμως ο Swift initializer της δεν εκθέτει την παράμετρο offline που απαιτείται για μια πλήρως local-first εφαρμογή κινητού. Η υιοθέτηση του libsql-swift σήμερα μας δίνει ένα τοπικό fork του SQLite, αλλά όχι τις δυνατότητες συγχρονισμού που κάνουν το Turso ξεχωριστό.

Τι θα μας κόστιζε τώρα μια βάση δεδομένων

Ακόμη κι αν το SDK ήταν ώριμο, θα πληρώναμε κόστος που δεν προσφέρει τίποτα στον φόρτο εργασίας μας:

Διαχείριση συνοδευτικών αρχείων WAL. Μια ενεργή βάση δεδομένων δημιουργεί τα συνοδευτικά αρχεία -wal και -shm. Η αντιγραφή ενός εγγράφου σημαίνει είτε checkpoint πρώτα είτε ατομική αντιγραφή και των τριών αρχείων. Η εξαγωγή ενός πακέτου .lnote στα Files ή στο AirDrop απαιτεί πλέον ένα βήμα πριν από την εξαγωγή, το οποίο ο χρήστης δεν βλέπει και ο προγραμματιστής δεν μπορεί να ξεχάσει.

Ένα επίπεδο προσαρμογής. Οι πινελιές θα έπρεπε να σειριοποιούνται σε BLOB και να αποσειριοποιούνται ξανά. Τα στοιχεία μιας σελίδας έχουν φυσική σειρά πίνακα, την οποία ο καμβάς αποδίδει απευθείας· μια βάση δεδομένων θα εισήγαγε σειρά γραμμών και στήλες z-index. Θα γράφαμε ένα επίπεδο μετάφρασης ανάμεσα σε δύο αναπαραστάσεις των ίδιων δεδομένων και θα το συντηρούσαμε σε κάθε αλλαγή σχήματος.

Μια εξάρτηση 161 MB. Για μια εφαρμογή της οποίας το Debug build είναι μικρότερο από 2 MB, μια εξάρτηση μεγαλύτερη από 80× την ίδια την εφαρμογή είναι κόστος που αξίζει να προσέξουμε — ειδικά όταν χαρακτηρίζεται «technical preview» και παραμένει ανενεργή εδώ και έναν χρόνο.

Αυτά τα κόστη δεν είναι υποθετικά. Ξεκινούν τη στιγμή που συνδέεται η εξάρτηση. Και μας προσφέρουν δυνατότητες — ερωτήματα, ευρετηρίαση, ταυτόχρονες εγγραφές — που η εφαρμογή μας δεν χρησιμοποιεί.

Η λύση που διαθέσαμε: πακέτα αρχείων snapshot

Ένα έγγραφο .lnote είναι πακέτο καταλόγου:

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 είναι η πηγή αλήθειας για τη δομή του εγγράφου: το ID, τον τίτλο, τις χρονικές σημάνσεις του και μια ταξινομημένη λίστα σελίδων, όπου κάθε σελίδα έχει το μέγεθος καμβά, τις χρονικές σημάνσεις και το πλήθος στοιχείων της. Τα αρχεία περιεχομένου σελίδας αποθηκεύουν τον πίνακα στοιχείων με την ίδια κβαντισμένη ακέραια κωδικοποίηση που χρησιμοποιεί ήδη η εφαρμογή — συντεταγμένες και ακτίνες με ακρίβεια 0.1 point, πίεση σε χιλιοστά και χρονικές σημάνσεις σε σχετικά χιλιοστά του δευτερολέπτου.

Η βιβλιοθήκη σημειώσεων διαβάζει μόνο το manifest.json και τις μικρογραφίες εξωφύλλου. Δεν αναλύει ποτέ το document.json ή το περιεχόμενο κάποιας σελίδας. Το άνοιγμα μιας σελίδας φορτώνει ένα αρχείο .content. Αυτή είναι η μόνη ανάγνωση αρχείου που αγγίζει δεδομένα πινελιών.

Οι σελίδες λύνουν το write amplification

Μια εφαρμογή χειρόγραφων σημειώσεων έχει ήδη την έννοια της σελίδας — είναι η μονάδα με την οποία σκέφτεται ο χρήστης και ο χρήστης μετακινείται με swipe από τη μία σελίδα στην άλλη. Αν κάνουμε τη σελίδα μονάδα persistence, το auto-save ξαναγράφει μόνο τις σελίδες που άλλαξαν.

Μια σελίδα χειρόγραφου — ας πούμε 1,000 έως 2,000 πινελιές — καταλαμβάνει περίπου 3–5 MB στην κβαντισμένη μορφή μας. Μια καταγραφή 21 πινελιών με 3,400 σημεία δειγματοληψίας κβαντίζεται σε περίπου 55 KB. Η εγγραφή ενός snapshot σελίδας στη μνήμη flash διαρκεί 10–20 ms σε σύγχρονο hardware. Με debounce 0.5 δευτερολέπτου, οι αποθηκεύσεις είναι αόρατες στον χρήστη.

Το κόστος αποθήκευσης αυξάνεται ανάλογα με την ποσότητα γραφής στην τρέχουσα σελίδα, όχι με τον συνολικό αριθμό σελίδων του εγγράφου. Ένα σημειωματάριο 200 σελίδων αποθηκεύεται ακριβώς τόσο γρήγορα όσο ένα σημειωματάριο 2 σελίδων, επειδή ξαναγράφεται μόνο η σελίδα που έχει αλλάξει.

Κάθε εγγραφή χρησιμοποιεί ατομικές λειτουργίες αρχείων — εγγραφή σε προσωρινό αρχείο και έπειτα μετονομασία — έτσι ώστε ένα crash στη μέση της αποθήκευσης να μην μπορεί να δημιουργήσει κομμένη σελίδα. Η είσοδος στο παρασκήνιο γράφει αμέσως όλες τις σελίδες που έχουν αλλάξει, όπως ακριβώς συνέβαινε ήδη στην εφαρμογή με έναν μόνο καμβά.

Συνέπεια χωρίς συναλλαγές

Τα πακέτα αρχείων δεν έχουν συναλλαγές, αλλά έχουν σαφείς κανόνες ιδιοκτησίας που εξυπηρετούν τον ίδιο σκοπό:

Πόροι πριν από αναφορές. Όταν ο χρήστης εισάγει μια εικόνα, το αρχείο του asset γράφεται αμέσως στο assets/. Το snapshot της σελίδας, που αναφέρεται στο asset μέσω του ID του, γράφεται αργότερα από το debounced auto-save. Σε κανένα σημείο μια σελίδα δεν αναφέρεται σε asset που δεν υπάρχει στον δίσκο.

Η πηγή αλήθειας κερδίζει. Το document.json και ο κατάλογος pages/ είναι η πηγή αλήθειας. Το manifest.json είναι cache. Αν διαφωνούν, η επόμενη αποθήκευση συγχρονίζει την cache με την πηγή. Οι μικρογραφίες είναι παράγωγες και μπορούν να αναδημιουργηθούν ανά πάσα στιγμή.

Τα ορφανά είναι καλύτερα από τις μετέωρες αναφορές. Το χειρότερο αποτέλεσμα ενός crash είναι ένα ορφανό asset — ένα αρχείο στο assets/ που δεν αναφέρεται από καμία σελίδα. Τα ορφανά καθαρίζονται όταν κλείνει το έγγραφο. Το αντίστροφο — μια σελίδα να αναφέρεται σε αρχείο που λείπει — δεν μπορεί να συμβεί, επειδή τα assets γράφονται πριν από το snapshot της σελίδας που τα αναφέρει.

Αυτοί οι κανόνες είναι ευκολότερο να γίνουν κατανοητοί από το WAL checkpointing και την απομόνωση συναλλαγών, και ταιριάζουν ακριβώς στο μοτίβο πρόσβασης μίας διεργασίας και μίας σελίδας της εφαρμογής.

Η διαδρομή αναβάθμισης είναι καταγεγραμμένη

Η επιλογή επίπεδων αρχείων τώρα δεν σημαίνει επιλογή επίπεδων αρχείων για πάντα. Η δομή του πακέτου έχει σχεδιαστεί έτσι ώστε η αναβάθμιση της μηχανής αποθήκευσης να αλλάζει ό,τι βρίσκεται μέσα στο πακέτο, χωρίς να αλλάζει το ίδιο το πακέτο.

Επίπεδο 1: snapshot + append journal. Αν το write amplification γίνει ποτέ αισθητό — ας πούμε ότι η συνεχής γραφή σε μια σελίδα με χιλιάδες πινελιές προκαλεί αξιοσημείωτη καθυστέρηση αποθήκευσης — κάθε αρχείο σελίδας χωρίζεται σε snapshot και append-only journal. Τα νέα στοιχεία προστίθενται ως frames [length][CRC][type][payload]. Κατά την επανάληψη του journal απορρίπτεται κάθε frame του οποίου το CRC δεν ταιριάζει, παρέχοντας ασφάλεια σε περίπτωση crash. Όταν το journal ξεπεράσει ένα όριο ή κλείσει η σελίδα, συγχωνεύεται ξανά στο snapshot. Αυτό είναι περίπου 200 LOC χωρίς εξωτερικές εξαρτήσεις.

Επειδή τα snapshot σε επίπεδο σελίδας εξαλείφουν ήδη το write amplification μεταξύ σελίδων, μπορεί να μη χρειαστούμε αυτό το επίπεδο για πολύ καιρό. Η επανεγγραφή μιας σελίδας 5 MB κάθε 0.5 δευτερόλεπτα βρίσκεται άνετα μέσα στα όρια εγγραφής της flash storage.

Επίπεδο 2: βάση δεδομένων SQLite. Αν η εφαρμογή χρειαστεί κάποτε full-text search σε όλες τις σημειώσεις, συγχρονισμό ανά στοιχείο ή ευρετηρίαση μεταξύ εγγράφων, το SQLite θα γίνει το σωστό εργαλείο. Η πιθανότερη μηχανή τότε θα είναι το GRDB, ένα ώριμο Swift wrapper που μεταγλωττίζεται από τον πηγαίο κώδικα και έχει σχεδόν μηδενικό overhead στο δυαδικό μέγεθος. Θα ξανασκεφτούμε το libsql-swift μόνο αν το οικοσύστημα του Turso — συγκεκριμένα το Turso Sync για Swift — γίνει πραγματική ανάγκη του προϊόντος.

Η διαδρομή μετεγκατάστασης είναι μηχανική: ο πίνακας στοιχείων κάθε σελίδας αντιστοιχίζεται σε έναν πίνακα strokes / images με ένα αμετάβλητο BLOB ανά πινελιά (16 bytes ανά σημείο δειγματοληψίας σε little-endian binary). Το benchmark των 0.019 δευτερολέπτων για 100 πινελιές επιβεβαιώνει ότι η προσέγγιση είναι βιώσιμη. Πριν από την κυκλοφορία, η μετεγκατάσταση θα επαληθεύσει ότι τα πλήθη στοιχείων και assets ταιριάζουν ανάμεσα στο παλιό πακέτο και τη νέα βάση δεδομένων και ότι η ανάκτηση από crash, τα όρια WAL και το background flush περνούν όλα.

Πότε πληρώνουμε

Αυτή η απόφαση δεν είναι κρίση για τις βάσεις δεδομένων. Το SQLite χειρίζεται ταυτόχρονους writers, σύνθετα ερωτήματα και ανάκτηση από crash σε κοινόχρηστη κατάσταση — τίποτα από αυτά δεν χρειάζεται σήμερα η εφαρμογή μας. Το να πληρώνουμε για αυτές τις δυνατότητες πριν εμφανιστούν τα προβλήματα που λύνουν είναι καθαρή ζημία.

Το κόστος μιας βάσης δεδομένων — η εξάρτηση, η διαχείριση WAL, το επίπεδο προσαρμογής, το δυαδικό μέγεθος — ξεκινά τη στιγμή που συνδέεται η βιβλιοθήκη. Τα οφέλη ξεκινούν όταν η εφαρμογή έχει ερωτήματα για εκτέλεση, ευρετήρια για συντήρηση ή ταυτόχρονους writers για συντονισμό. Σε αυτό το στάδιο δεν έχει κανένα από τα τρία.

Κάθε βήμα στην εξέλιξη της αποθήκευσής μας θα πληρώνει μόνο για προβλήματα που έχουν ήδη εμφανιστεί. Τα snapshot σε επίπεδο σελίδας λύνουν το σημερινό πρόβλημα: αποθήκευση εγγράφων πολλών σελίδων χωρίς επανεγγραφή ολόκληρου του αρχείου. Αν το write amplification γίνει μετρήσιμο, ένα append journal θα λύσει αυτό το πρόβλημα. Αν η αναζήτηση ή ο συγχρονισμός γίνει ανάγκη του προϊόντος, μια βάση δεδομένων θα λύσει αυτό το πρόβλημα.

Η δομή του πακέτου, το manifest και το σχήμα του εγγράφου δεν εξαρτώνται από καμία μηχανή αποθήκευσης. Το κόστος αλλαγής είναι χαμηλό επειδή τα όρια βρίσκονται στο σωστό σημείο. Όταν έρθει η μέρα που θα χρειαστούμε πραγματικά βάση δεδομένων, θα την υιοθετήσουμε για ένα συγκεκριμένο, ήδη μετρημένο πρόβλημα — όχι για ένα υποθετικό.