Lulucat

เหตุใดเราจึงยังไม่ใช้ฐานข้อมูล

Gaoge ZhangGaoge Zhang

เราประเมิน Turso/libSQL สำหรับแอปเขียนด้วยลายมือบน iPad วัดทุกอย่าง แล้วเลือกใช้ไฟล์ snapshot แบบ flat งานของเราไม่ต้องใช้ฐานข้อมูล และแต่ละขั้นควรจ่ายเฉพาะปัญหาที่เกิดขึ้นแล้ว

Lulucat Notes เป็นแอปเขียนด้วยลายมือสำหรับ iPad จนถึงสัปดาห์ที่แล้ว แอปมีแคนวาสเพียงหนึ่งผืน และยังไม่มีแนวคิดเรื่องโน้ตที่สอง เรากำลังจะเพิ่มคลังโน้ต ซึ่งประกอบด้วยเอกสารหลายฉบับ แต่ละฉบับมีหลายหน้า และคำถามด้านสถาปัตยกรรมข้อแรกคือเรื่องการจัดเก็บข้อมูล

ฐานข้อมูลดูเหมือนเป็นคำตอบที่ชัดเจน แอปโน้ตเก็บข้อมูลที่มีโครงสร้าง และข้อมูลที่มีโครงสร้างก็ควรอยู่ในฐานข้อมูล เราประเมิน Turso และ Swift SDK ของมัน ทำให้ทำงานบน iOS Simulator ได้ ทดสอบประสิทธิภาพด้วยข้อมูลเส้นจริง แล้วตัดสินใจไม่ใช้มัน

เราเลือกใช้ไฟล์แบบ flat แทน ผลลัพธ์และเหตุผลมีดังนี้

สมุดโน้ตที่เปิดอยู่พร้อมข้อความลายมือและปากกาบนโต๊ะไม้

ภาพถ่ายโดย Gabriel Cox จาก Unsplash Unsplash License

แอปทำอะไรกับข้อมูลจริง ๆ

แอปเขียนด้วยลายมือมีรูปแบบการเข้าถึงข้อมูลที่แคบและคาดเดาได้ การอ่านหมายถึงการเปิดหน้าหนึ่งหน้าแล้วโหลดทุกองค์ประกอบในหน้านั้น ทั้งเส้นทั้งหมดและรูปภาพทั้งหมด เข้าไปในหน่วยความจำพร้อมกัน แคนวาสเก็บทุกอย่างไว้ และไม่เคยทำ partial query การเขียนหมายถึงการเขียนเส้นปากกาหนึ่งเส้นให้เสร็จ แล้วเพิ่มองค์ประกอบหนึ่งรายการลงในหน้า ในบางกรณีที่เกิดขึ้นไม่บ่อย ผู้ใช้อาจลบเส้นบางส่วน ย้าย selection หรือลบองค์ประกอบบางอย่าง แต่ทั้งหมดก็ยังเป็นการทำงานกับหน้าเดียวและองค์ประกอบเดียว

ไม่มีการเข้าถึงพร้อมกันทีละหลายฝ่าย คนหนึ่งคนเขียนบนหน้าหนึ่งของเอกสารหนึ่งฉบับในแต่ละครั้ง ไม่มีการค้นหาข้ามเอกสาร คลังโน้ตต้องอ่านเพียงชื่อเรื่อง timestamp จำนวนหน้า และ thumbnail หน้าปกของแต่ละเอกสาร ไม่จำเป็นต้องอ่านเนื้อหาในหน้า

ฐานข้อมูลถูกสร้างมาเพื่อ query, index และการประสานงานระหว่างการทำงานพร้อมกัน แอปของเราไม่ใช้ทั้งสามอย่าง

การประเมิน Turso

เราได้ประเมิน libsql-swift ซึ่งเป็น Swift SDK อย่างเป็นทางการสำหรับเอนจิน libSQL ของ Turso

SDK ใช้งานได้ test case ทั้ง 9 รายการผ่านทั้งหมด เรานำมันไปผนวกเข้ากับสำเนาของแอป สร้างสำหรับ iOS Simulator เปิดใช้งาน และสร้างฐานข้อมูลภายในเครื่องใน sandbox ของแอป เราเขียนเส้น 100 เส้น โดยแต่ละเส้นมีจุดตัวอย่าง 3,400 จุด รวมเป็นข้อมูล BLOB ขนาด 4,080,000 bytes ใน transaction เดียว บน Mac ที่ใช้พัฒนาใช้เวลาประมาณ 0.019s

หลังจากรัน PRAGMA wal_checkpoint(TRUNCATE) ไฟล์ WAL หดเหลือศูนย์ เราจึงคัดลอกเฉพาะไฟล์ .db หลักไปยังตำแหน่งอื่น เปิดไฟล์นั้น และอ่านข้อมูลทั้งหมดกลับมาได้ เอนจินเองทำงานได้ดี

SDK มีต้นทุน CLibsql.xcframework มีขนาด 161 MB หลังจากลิงก์แล้ว build สำหรับ Debug Simulator ของเราเพิ่มจากประมาณ 1.9 MB เป็นประมาณ 8.2 MB API เป็นแบบ synchronous และ blocking ไม่มี wrapper สำหรับ 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 มาใช้วันนี้ทำให้เราได้ SQLite fork ที่ทำงานในเครื่อง แต่ไม่ได้ความสามารถด้านการซิงก์ที่ทำให้ Turso แตกต่าง

ฐานข้อมูลจะมีต้นทุนอะไรกับเราตอนนี้

ต่อให้ SDK โตพอใช้งาน เราก็ยังต้องจ่ายต้นทุนที่ไม่ได้ให้อะไรกับ workload ของเรา:

การจัดการไฟล์ sidecar ของ WAL ฐานข้อมูลที่กำลังทำงานจะสร้างไฟล์ประกอบ -wal และ -shm การคัดลอกเอกสารจึงต้อง checkpoint ก่อน หรือคัดลอกทั้งสามไฟล์แบบ atomic การ export แพ็กเกจ .lnote ไปยัง Files หรือ AirDrop จะต้องมีขั้นตอนก่อน export ที่ผู้ใช้มองไม่เห็นและนักพัฒนาไม่อาจลืมได้

ชั้น adapter เราต้อง serialize เส้นเป็น BLOB แล้ว deserialize กลับมา องค์ประกอบในหน้ามีลำดับ array ตามธรรมชาติ ซึ่งแคนวาสนำไปเรนเดอร์ได้โดยตรง แต่ฐานข้อมูลจะเพิ่มลำดับแถวและคอลัมน์ z-index เราจะต้องเขียนชั้นแปลงระหว่างตัวแทนข้อมูลสองแบบที่จริง ๆ คือข้อมูลเดียวกัน และดูแลชั้นนี้ทุกครั้งที่ schema เปลี่ยน

dependency ขนาด 161 MB สำหรับแอปที่ build แบบ Debug มีขนาดต่ำกว่า 2 MB dependency ที่ใหญ่กว่าตัวแอปเอง 80× เป็นต้นทุนที่ต้องสังเกต โดยเฉพาะเมื่อ dependency นั้นถูกระบุว่าเป็น technical preview และไม่มีการเคลื่อนไหวมาหนึ่งปี

ต้นทุนเหล่านี้ไม่ใช่เรื่องสมมติ มันเริ่มทันทีที่ลิงก์ dependency และสิ่งที่ได้กลับมาคือความสามารถด้าน query, index และการเขียนพร้อมกันที่แอปของเราไม่ได้ใช้

โซลูชันที่เราเผยแพร่: แพ็กเกจไฟล์ 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 คือ source of truth ของโครงสร้างเอกสาร ซึ่งประกอบด้วย ID ชื่อเรื่อง timestamp และรายการหน้าเรียงตามลำดับ แต่ละหน้ายังมีขนาดแคนวาส timestamp และจำนวนองค์ประกอบ บันทึกเนื้อหาของหน้าจะเก็บ array ขององค์ประกอบด้วย quantized integer encoding แบบเดียวกับที่แอปใช้อยู่แล้ว ได้แก่ พิกัดและรัศมีความละเอียด 0.1 จุด แรงกดเป็นหน่วยหนึ่งในพัน และ timestamp เป็นมิลลิวินาทีแบบสัมพัทธ์

คลังโน้ตอ่านเฉพาะ manifest.json และ thumbnail หน้าปก ไม่เคย parse document.json หรือเนื้อหาของหน้าใดเลย การเปิดหน้าหนึ่งหน้าจะโหลดไฟล์ .content เพียงไฟล์เดียว นี่คือการอ่านไฟล์เพียงอย่างเดียวที่แตะข้อมูลเส้น

หน้าแก้ปัญหา write amplification

แอปเขียนด้วยลายมือมีแนวคิดเรื่องหน้าอยู่แล้ว หน้าเป็นหน่วยที่ผู้ใช้คิดถึงและเป็นสิ่งที่ผู้ใช้ปัดสลับไปมา การทำให้หน้าเป็นหน่วย persistence ทำให้ auto-save เขียนซ้ำเฉพาะหน้าที่เปลี่ยนแปลง

หน้าที่เขียนด้วยลายมือหนึ่งหน้า เช่น 1,000 ถึง 2,000 เส้น ใช้พื้นที่ประมาณ 3–5 MB ในฟอร์แมตแบบ quantized ของเรา การบันทึกเส้น 21 เส้นที่มีจุดตัวอย่าง 3,400 จุด quantize แล้วมีขนาดประมาณ 55 KB การเขียน snapshot ของหน้าหนึ่งลง flash storage ใช้เวลา 10–20 ms บนฮาร์ดแวร์รุ่นใหม่ เมื่อใช้ debounce 0.5s ผู้ใช้จะไม่รู้สึกถึงการบันทึก

ต้นทุนการบันทึกขึ้นกับปริมาณการเขียนในหน้าปัจจุบัน ไม่ใช่จำนวนหน้าทั้งหมดในเอกสาร โน้ตบุ๊ก 200 หน้าจะบันทึกได้เร็วเท่ากับโน้ตบุ๊ก 2 หน้า เพราะเขียนซ้ำเฉพาะหน้าที่ dirty

ทุกการเขียนใช้ atomic file operations คือเขียนลงไฟล์ชั่วคราวแล้ว rename ดังนั้น crash ระหว่างการบันทึกจะไม่สร้างไฟล์หน้าที่ถูกตัดขาด เมื่อแอปเข้า background จะ flush หน้าที่ dirty ทั้งหมดทันที ซึ่งตรงกับพฤติกรรมเดิมของแอปตอนมีแคนวาสเดียว

ความสอดคล้องโดยไม่มี transaction

แพ็กเกจไฟล์ไม่มี transaction แต่มี ownership rules ที่ชัดเจนและทำหน้าที่เดียวกัน:

เขียน resource ก่อน reference เมื่อผู้ใช้แทรกรูปภาพ ไฟล์ asset จะถูกเขียนลง assets/ ทันที ส่วน snapshot ของหน้าที่อ้างถึง asset ด้วย ID จะถูกเขียนภายหลังโดย debounced auto-save จึงไม่มีช่วงที่หน้าจะอ้างถึง asset ที่ยังไม่มีอยู่บนดิสก์

Source of truth เป็นหลัก document.json และไดเรกทอรี pages/ คือ source of truth ส่วน manifest.json เป็น cache หากข้อมูลไม่ตรงกัน การบันทึกครั้งถัดไปจะปรับ cache ให้ตรงกับ source รูปภาพขนาดย่อเป็นข้อมูลที่สร้างจากข้อมูลอื่น และสร้างใหม่ได้ทุกเมื่อ

ยอมให้มี orphan ดีกว่ามี dangling reference ผลลัพธ์ที่แย่ที่สุดจาก crash คือ orphan asset ซึ่งเป็นไฟล์ใน assets/ ที่ไม่มีหน้าใดอ้างถึง เมื่อปิดเอกสารจะเก็บ orphan เหล่านี้ออก ส่วนกรณีที่หน้าอ้างถึงไฟล์ที่หายไปจะไม่เกิดขึ้น เพราะเขียน asset ก่อนเขียน snapshot ของหน้าที่อ้างถึงมัน

กฎเหล่านี้ทำความเข้าใจได้ง่ายกว่า WAL checkpointing และ transaction isolation และตรงกับรูปแบบการเข้าถึงแบบ single-process, single-page ของแอปพอดี

เส้นทางการอัปเกรด

การเลือกใช้ flat files ตอนนี้ไม่ใช่การตัดสินใจถาวร โครงสร้างแพ็กเกจถูกออกแบบให้เปลี่ยนสิ่งที่อยู่ภายในเพื่ออัปเกรด storage engine ได้ โดยไม่ต้องเปลี่ยนตัวแพ็กเกจ

Level 1: snapshot + append journal หาก write amplification สังเกตเห็นได้เมื่อใด เช่น การเขียนต่อเนื่องบนหน้าที่มีเส้นหลายพันเส้นทำให้การบันทึกช้าลงอย่างเห็นได้ชัด ไฟล์ของแต่ละหน้าจะแยกเป็น snapshot และ append-only journal องค์ประกอบใหม่จะถูก append เป็นเฟรม [length][CRC][type][payload] การ replay journal จะทิ้งเฟรมที่ CRC ไม่ตรง เพื่อให้เกิด crash safety เมื่อ journal เกิน threshold หรือปิดหน้าแล้ว มันจะ merge กลับเข้า snapshot นี่เป็นโค้ดประมาณ ~200 LOC โดยไม่มี dependency ภายนอก

เนื่องจาก snapshot ระดับหน้ากำจัด write amplification ข้ามหน้าไปแล้ว level นี้อาจไม่จำเป็นไปอีกนาน การเขียนหน้า 5 MB ซ้ำทุก 0.5s ยังอยู่ในงบการเขียนของ flash

Level 2: SQLite database หากแอปต้องการ full-text search ข้ามโน้ต การซิงก์ระดับองค์ประกอบ หรือการทำดัชนีข้ามเอกสาร SQLite จะเป็นเครื่องมือที่เหมาะสม ตอนนั้น engine ที่น่าจะใช้คือ GRDB ซึ่งเป็น Swift wrapper ที่ mature และ compile จาก source โดยมี binary size overhead แทบเป็นศูนย์ เราจะพิจารณา libsql-swift อีกครั้งก็ต่อเมื่อระบบนิเวศของ Turso โดยเฉพาะ Turso Sync for Swift กลายเป็นความต้องการของผลิตภัณฑ์จริง

เส้นทาง migration เป็นงานเชิงกลไก array ขององค์ประกอบในแต่ละหน้าจะ map ไปยังตาราง strokes / images โดยมี BLOB แบบ immutable หนึ่งก้อนต่อ stroke และใช้ 16 bytes ต่อ sampling point ใน little-endian binary เบนช์มาร์ก 0.019s สำหรับ 100 strokes ยืนยันว่าแนวทางนี้ใช้ได้ ก่อนเผยแพร่ migration จะตรวจว่า element counts และ asset counts ในแพ็กเกจเดิมกับ database ใหม่ตรงกัน และตรวจว่า crash recovery, WAL bounds และ background flush ผ่านทั้งหมด

เมื่อไรจึงควรจ่าย

การเลือกนี้ขึ้นอยู่กับความต้องการของแอปในปัจจุบัน SQLite รองรับ concurrent writers, complex queries และ crash recovery บน shared state ได้ แต่แอปของเรายังไม่ต้องการสิ่งเหล่านี้ การจ่ายสำหรับความสามารถเหล่านี้ก่อนที่แอปจะมีปัญหาซึ่งต้องใช้ความสามารถเหล่านี้ จะเพิ่มต้นทุนโดยยังไม่ให้ประโยชน์ที่สอดคล้องกัน

ต้นทุนของฐานข้อมูล ทั้ง dependency, WAL management, adapter layer และ binary size เริ่มตั้งแต่วินาทีที่ลิงก์ library ประโยชน์จะเริ่มเมื่อแอปมี query ให้รัน มี index ให้ดูแล หรือมี concurrent writers ให้ประสานงาน ตอนนี้ยังไม่มีทั้งสามอย่าง

ทุกขั้นของวิวัฒนาการด้าน storage ของเราจะจ่ายเฉพาะปัญหาที่เกิดขึ้นแล้ว snapshot ระดับหน้าจ่ายให้กับปัญหาที่มีอยู่วันนี้ คือการบันทึกเอกสารหลายหน้าโดยไม่ต้องเขียนไฟล์ทั้งหมดใหม่ หาก write amplification วัดได้ชัดเจน append journal จะจ่ายให้กับปัญหานั้น หาก search หรือ sync กลายเป็นความต้องการของผลิตภัณฑ์ database จะจ่ายให้กับปัญหานั้น

โครงสร้างแพ็กเกจ manifest และ document schema ไม่ผูกกับ storage engine ใด ต้นทุนการเปลี่ยนจึงต่ำเพราะขอบเขตอยู่ถูกที่ เมื่อถึงวันที่เราต้องใช้ database เราจะเลือกใช้มันเพื่อปัญหาเฉพาะที่วัดได้แล้ว ไม่ใช่ปัญหาที่มีเพียงสมมติฐาน