Lulucat

מדוע אנחנו עדיין לא משתמשים במסד נתונים

Gaoge ZhangGaoge Zhang

הערכנו את Turso/libSQL עבור אפליקציית כתב היד שלנו ל-iPad, מדדנו הכול ובחרנו בקבצים שטוחים. עומס העבודה אינו זקוק למסד נתונים, וכל שלב צריך לשלם רק על בעיות שכבר קיימות.

Lulucat Notes היא אפליקציית כתב יד ל-iPad. עד השבוע שעבר היה בה קנבס אחד בלבד, ולא הייתה בה כלל תפיסה של הערה שנייה. עמדנו להוסיף ספריית הערות — מסמכים מרובים, שבכל אחד מהם דפים מרובים — והשאלה הארכיטקטונית הראשונה הייתה שאלת האחסון.

מסד נתונים נראה כמו התשובה המתבקשת. אפליקציות הערות מאחסנות נתונים מובנים. נתונים מובנים נכנסים למסדי נתונים. הערכנו את Turso ואת ה-SDK שלו ל-Swift, הפעלנו אותו בסימולטור iOS, מדדנו נתוני משיכות אמיתיים, ואז החלטנו לא להשתמש בו.

בחרנו במקום זאת בקבצים שטוחים. הנה מה שמצאנו ולמה קיבלנו את ההחלטה הזו.

מחברת פתוחה עם הערות בכתב יד ועט על שולחן עץ.

צילום: Gabriel Cox ב-Unsplash. רישיון Unsplash.

מה האפליקציה באמת עושה עם הנתונים

לאפליקציית כתב יד יש דפוס גישה צר וצפוי לנתונים. קריאה פירושה לפתוח דף ולטעון לזיכרון את כל הרכיבים שבו בבת אחת — את כל המשיכות ואת כל התמונות. הקנבס מחזיק הכול; הוא אף פעם לא מריץ שאילתה חלקית. כתיבה פירושה לסיים משיכת עט ולהוסיף רכיב אחד לדף. במקרים נדירים המשתמש מוחק חלק ממשיכה, מזיז בחירה או מוחק משהו, אבל גם אלה פעולות על דף יחיד ועל רכיב יחיד.

אין גישה מקבילית. אדם אחד כותב בכל פעם על דף אחד במסמך אחד. אין חיפוש בין מסמכים — ספריית ההערות זקוקה רק לכותרת, לחותמת זמן, למספר הדפים ולתמונה ממוזערת של הכריכה עבור כל מסמך; שום דבר מאלה אינו דורש לקרוא את תוכן הדפים.

מסדי נתונים נבנים עבור שאילתות, אינדקסים ותיאום גישה מקבילית. האפליקציה שלנו אינה משתמשת באף אחד משלושת הדברים האלה.

ההערכה של Turso

הערכנו את libsql-swift, ה-SDK הרשמי של Swift עבור מנוע libSQL של Turso.

ה-SDK עובד. כל תשעת מקרי הבדיקה שלו עוברים. שילבנו אותו בעותק של האפליקציה, בנינו עבור סימולטור iOS, הפעלנו אותו ויצרנו מסד נתונים מקומי ב-sandbox של האפליקציה. כתבנו 100 משיכות, שבכל אחת 3,400 נקודות דגימה — 4,080,000 בתים של נתוני BLOB — בתוך טרנזקציה אחת. זה לקח בערך 0.019 שניות על ה-Mac ששימש אותנו לפיתוח.

לאחר שהרצנו PRAGMA wal_checkpoint(TRUNCATE), קובץ ה-WAL הצטמצם לאפס. יכולנו להעתיק רק את קובץ ה-.db הראשי למיקום אחר, לפתוח אותו ולקרוא שוב את כל הנתונים. המנוע עצמו תקין.

ל-SDK יש עלויות. CLibsql.xcframework שוקל 161 MB. לאחר הקישור, גרסת ה-Debug שלנו לסימולטור גדלה מכ-1.9 MB לכ-8.2 MB. ה-API סינכרוני וחוסם, ואין בו wrappers עבור Swift Concurrency. אין מתודת close() מפורשת. Transaction.commit() לא זורקת שגיאה — ה-C API שמתחתיה מחזיר void. ה-README של המאגר מגדיר את ה-SDK כ-«technical preview», וה-commit האחרון היה בערך שנה לפני ההערכה שלנו, ביולי 2025.

באקוסיסטם של Turso יש פער. Turso ממליצה עכשיו על המנוע החדש שלה, «Turso Database», ועל פרוטוקול «Turso Sync» עבור פרויקטים חדשים. ל-Turso Sync יש SDKs ללקוח עבור TypeScript, Python, Go ו-Rust. עבור Swift אין SDK כזה. מצב Embedded Replica הישן קיים בתוך libsql-swift, אבל ה-initializer שלו ב-Swift אינו חושף את הפרמטר offline, הדרוש לאפליקציה סלולרית שהיא local-first לחלוטין. אימוץ libsql-swift כיום נותן לנו fork מקומי של SQLite, אבל לא את יכולות הסנכרון שהופכות את Turso לייחודית.

כמה מסד נתונים היה עולה לנו עכשיו

גם אילו ה-SDK היה בשל, עדיין היינו משלמים עלויות שאינן נותנות דבר לעומס העבודה שלנו:

ניהול קובצי WAL נלווים. מסד נתונים פעיל יוצר את קובצי הליווי -wal ו--shm. העתקת מסמך דורשת לבצע checkpoint קודם, או להעתיק את כל שלושת הקבצים באופן אטומי. ייצוא חבילת .lnote ל-Files או ל-AirDrop ידרוש עכשיו שלב ייצוא מקדים שהמשתמש אינו רואה ושהמפתח אינו יכול לשכוח.

שכבת מתאם. יהיה צורך לבצע סריאליזציה של המשיכות ל-BLOB ולבצע להן דה-סריאליזציה בחזרה. לרכיבי הדף יש סדר טבעי של array שהקנבס מרנדר ישירות; מסד נתונים יוסיף עמודות לסדר השורות ול-z-index. נכתוב שכבת תרגום בין שני ייצוגים של אותם נתונים, ונצטרך לתחזק אותה בכל שינוי סכימה.

תלות של 161 MB. עבור אפליקציה שגרסת ה-Debug שלה קטנה מ-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 הוא מקור האמת למבנה המסמך: המזהה, הכותרת, חותמות הזמן שלו ורשימה מסודרת של דפים הכוללת עבור כל דף את גודל הקנבס, חותמות הזמן ומספר הרכיבים. קובצי תוכן הדפים שומרים את מערך הרכיבים באותו קידוד מספרים שלמים מכומת שהאפליקציה כבר משתמשת בו — קואורדינטות ורדיוסים בדיוק של 0.1 נקודה, לחץ באלפיות וחותמות זמן באלפיות שנייה יחסיות.

ספריית ההערות קוראת רק את manifest.json ואת התמונות הממוזערות של הכריכות. היא אף פעם לא מנתחת את document.json או תוכן של דף כלשהו. פתיחת דף טוענת קובץ .content אחד. זו קריאת הקובץ היחידה שנוגעת בנתוני המשיכות.

הדפים פותרים את הגברת הכתיבה

לאפליקציית כתב יד כבר יש מושג של דף — זו היחידה שהמשתמשים חושבים בה, והם מחליקים מדף לדף. הפיכת הדף ליחידת ההתמדה פירושה ששמירה אוטומטית כותבת מחדש רק את הדפים שהשתנו.

דף כתב יד יחיד — נניח 1,000 עד 2,000 משיכות — תופס בערך 3–5 MB בפורמט המכומת שלנו. הקלטה אחת של 21 משיכות עם 3,400 נקודות דגימה נכמתת לכ-55 KB. כתיבת snapshot של דף לאחסון flash אורכת 10–20 ms בחומרה מודרנית. עם debounce של 0.5 שנייה, השמירות אינן מורגשות למשתמש.

עלות השמירה גדלה לפי כמות הכתיבה בדף הנוכחי, לא לפי המספר הכולל של הדפים במסמך. מחברת בת 200 דפים נשמרת בדיוק באותה מהירות כמו מחברת בת 2 דפים, משום שרק הדף המלוכלך נכתב מחדש.

כל כתיבה משתמשת בפעולות קובץ אטומיות — כתיבה לקובץ זמני ולאחר מכן שינוי שמו — ולכן קריסה באמצע שמירה אינה יכולה ליצור דף קטוע. המעבר לרקע מרוקן מיד את כל הדפים המלוכלכים, בהתאם להתנהגות שכבר הייתה לאפליקציה עם קנבס יחיד.

עקביות בלי טרנזקציות

לחבילות קבצים אין טרנזקציות, אבל יש להן כללי בעלות ברורים שממלאים את אותו תפקיד:

משאבים לפני הפניות. כשהמשתמש מוסיף תמונה, קובץ המשאב נכתב מיד אל assets/. ה-snapshot של הדף, שמפנה למשאב לפי המזהה שלו, נכתב מאוחר יותר על ידי השמירה האוטומטית עם debounce. בשום שלב דף אינו מפנה למשאב שאינו קיים בדיסק.

מקור האמת קובע. document.json והתיקייה pages/ הם מקור האמת. manifest.json הוא cache. אם הם אינם תואמים, השמירה הבאה מסנכרנת את ה-cache כך שיתאים למקור. התמונות הממוזערות נגזרות, וניתן ליצור אותן מחדש בכל עת.

עדיף יתומים מהפניות תלויות. התוצאה הגרועה ביותר של קריסה היא משאב יתום — קובץ ב-assets/ שאף דף אינו מפנה אליו. יתומים מנוקים כשהמסמך נסגר. המצב ההפוך — דף שמפנה לקובץ חסר — אינו יכול לקרות, מפני שהמשאבים נכתבים לפני ה-snapshot של הדף שמפנה אליהם.

קל יותר לחשוב על הכללים האלה מאשר על WAL checkpointing ועל בידוד טרנזקציות, והם תואמים בדיוק לדפוס הגישה של האפליקציה: תהליך יחיד ודף יחיד.

מסלול השדרוג כתוב

בחירה בקבצים שטוחים עכשיו אינה אומרת שנבחר בהם לנצח. מבנה החבילה תוכנן כך ששדרוג מנוע האחסון ישנה את מה שנמצא בתוך החבילה בלי לשנות את החבילה עצמה.

רמה 1: snapshot + יומן append-only. אם הגברת הכתיבה תהפוך אי פעם למורגשת — למשל, אם כתיבה רציפה בדף עם אלפי משיכות תגרום לעיכוב שמירה מורגש — כל קובץ דף יתפצל ל-snapshot וליומן append-only. רכיבים חדשים יתווספו כמסגרות [length][CRC][type][payload]. בעת שחזור היומן מושלכת כל מסגרת שה-CRC שלה אינו תואם, וכך מתקבלת בטיחות מפני קריסות. כשהיומן עובר סף מסוים או כשהדף נסגר, הוא מתמזג בחזרה לתוך ה-snapshot. מדובר ב-~200 LOC ובאפס תלויות חיצוניות.

מכיוון ש-snapshots ברמת הדף כבר מבטלים את הגברת הכתיבה בין דפים, ייתכן שלא נצטרך את הרמה הזו במשך זמן רב. כתיבה מחדש של דף בגודל 5 MB כל 0.5 שנייה נמצאת היטב בתוך תקציב כתיבות ה-flash.

רמה 2: מסד נתונים SQLite. אם האפליקציה תזדקק אי פעם לחיפוש טקסט מלא בין הערות, לסנכרון ברמת הרכיב או לאינדוקס בין מסמכים, SQLite יהיה הכלי הנכון. המנוע הסביר בשלב הזה יהיה GRDB, עטיפת Swift בשלה שמקומפלת מקוד המקור ובעלת תקורה בינארית כמעט אפסית. נשקול מחדש את libsql-swift רק אם האקוסיסטם של Turso — ובמיוחד Turso Sync עבור Swift — יהפוך לצורך מוצר אמיתי.

ההגירה מכנית: מערך הרכיבים של כל דף ממופה לטבלת strokes / images עם BLOB בלתי משתנה אחד לכל משיכה (16 בתים לכל נקודת דגימה בבינארי little-endian). המדידה של 0.019 שנייה עבור 100 משיכות מאשרת שהגישה אפשרית. לפני האספקה, ההגירה תוודא שמספרי הרכיבים והמשאבים תואמים בין החבילה הישנה למסד הנתונים החדש, וששחזור מקריסה, גבולות WAL וריקון ברקע עוברים כולם.

מתי לשלם

ההחלטה הזו אינה ביקורת על מסדי נתונים. SQLite מטפל בכותבים מקביליים, בשאילתות מורכבות ובשחזור מקריסה של מצב משותף — האפליקציה שלנו אינה זקוקה כרגע לאף אחד מאלה. תשלום על היכולות האלה לפני שלאפליקציה יש את הבעיות שהן פותרות הוא הפסד נטו.

העלויות של מסד נתונים — התלות, ניהול ה-WAL, שכבת המתאם וגודל הקובץ הבינארי — מתחילות ברגע שהספרייה מקושרת. התועלות מתחילות כשהאפליקציה כוללת שאילתות להרצה, אינדקסים לתחזוקה או כותבים מקביליים לתיאום. בשלב הזה אין לה אף אחד משלושת הדברים.

כל שלב בהתפתחות האחסון שלנו ישלם רק על בעיות שכבר הופיעו. snapshots ברמת הדף משלמים על הבעיה שיש לנו היום: שמירת מסמכים מרובי-דפים בלי לכתוב מחדש את הקובץ כולו. אם הגברת הכתיבה תהפוך למדידה, יומן append-only ישלם על הבעיה הזו. אם חיפוש או סנכרון יהפכו לצורך מוצר, מסד נתונים ישלם על הבעיה הזו.

מבנה החבילה, ה-manifest וסכימת המסמך אינם קשורים למנוע אחסון כלשהו. עלויות המעבר נמוכות, כי הגבולות נמצאים במקום הנכון. כשיגיע היום שבו באמת נצטרך מסד נתונים, נאמץ אותו עבור בעיה מסוימת שכבר נמדדה — לא עבור בעיה היפותטית.