Vì sao chúng tôi chưa dùng cơ sở dữ liệu
Chúng tôi đã đánh giá Turso/libSQL cho ứng dụng viết tay trên iPad, đo mọi thứ rồi chọn các tệp snapshot phẳng. Workload này không cần cơ sở dữ liệu; mỗi bước chỉ nên trả giá cho những vấn đề đã xuất hiện.
Lulucat Notes là một ứng dụng viết tay cho iPad. Cho đến tuần trước, ứng dụng chỉ có một canvas và chưa có khái niệm về ghi chú thứ hai. Chúng tôi chuẩn bị thêm thư viện ghi chú gồm nhiều tài liệu, mỗi tài liệu có nhiều trang; câu hỏi kiến trúc đầu tiên là lưu trữ.
Cơ sở dữ liệu có vẻ là câu trả lời hiển nhiên. Ứng dụng ghi chú lưu dữ liệu có cấu trúc. Dữ liệu có cấu trúc được lưu trong cơ sở dữ liệu. Chúng tôi đã đánh giá Turso và Swift SDK của nó, chạy thử trên trình mô phỏng iOS, benchmark bằng dữ liệu stroke thực tế, rồi quyết định không dùng nó.
Thay vào đó, chúng tôi chọn các tệp phẳng. Đây là kết quả và lý do của quyết định đó.

Ảnh do Gabriel Cox chụp trên Unsplash. Unsplash License.
Ứng dụng thực sự làm gì với dữ liệu
Ứng dụng viết tay có một mô hình truy cập dữ liệu hẹp và dễ dự đoán. Đọc nghĩa là mở một trang rồi tải mọi phần tử trên trang đó — tất cả stroke và tất cả hình ảnh — vào bộ nhớ cùng lúc. Canvas giữ toàn bộ nội dung; nó không bao giờ chạy truy vấn một phần. Ghi nghĩa là hoàn thành một stroke bút rồi thêm một phần tử vào trang. Trong những trường hợp hiếm, người dùng xóa một phần stroke, di chuyển vùng chọn hoặc xóa một thứ gì đó, nhưng đó vẫn là các thao tác trên một trang và một phần tử.
Không có truy cập đồng thời. Mỗi lần chỉ có một người viết trên một trang của một tài liệu. Cũng không có tìm kiếm giữa các tài liệu; thư viện ghi chú chỉ cần tiêu đề, timestamp, số trang và thumbnail bìa của từng tài liệu, không cần đọc nội dung trang.
Cơ sở dữ liệu được xây dựng cho truy vấn, index và điều phối truy cập đồng thời. Ứng dụng của chúng tôi không dùng cả ba.
Đánh giá Turso
Chúng tôi đã đánh giá libsql-swift, Swift SDK chính thức cho engine libSQL của Turso.
SDK hoạt động. Cả 9 test case của nó đều pass. Chúng tôi tích hợp SDK vào một bản sao của ứng dụng, build cho trình mô phỏng iOS, khởi chạy và tạo một cơ sở dữ liệu cục bộ trong sandbox của ứng dụng. Trong một transaction duy nhất, chúng tôi ghi 100 stroke, mỗi stroke có 3,400 điểm lấy mẫu — tổng cộng 4,080,000 bytes dữ liệu BLOB. Trên Mac dùng để phát triển, thao tác này mất khoảng 0.019s.
Sau khi chạy PRAGMA wal_checkpoint(TRUNCATE), tệp WAL thu nhỏ về 0. Chúng tôi có thể chỉ sao chép tệp .db chính sang vị trí khác, mở nó và đọc lại toàn bộ dữ liệu. Bản thân engine hoạt động ổn định.
SDK có chi phí. CLibsql.xcframework chiếm 161 MB. Sau khi link, bản build Debug cho trình mô phỏng tăng từ khoảng 1.9 MB lên khoảng 8.2 MB. API là synchronous và blocking, không có wrapper cho Swift Concurrency. Không có phương thức close() rõ ràng. Transaction.commit() không throw vì C API bên dưới trả về void. README của repository gắn nhãn SDK là technical preview, còn commit gần nhất được thực hiện khoảng một năm trước thời điểm đánh giá, vào tháng 7 năm 2025.
Hệ sinh thái Turso còn một khoảng trống. Turso hiện khuyến nghị engine Turso Database mới và protocol Turso Sync cho các dự án mới. Turso Sync có client SDK cho TypeScript, Python, Go và Rust, nhưng không có SDK cho Swift. Chế độ Embedded Replica cũ vẫn tồn tại trong libsql-swift, nhưng Swift initializer của nó không cung cấp tham số offline cần cho một ứng dụng di động local-first hoàn toàn. Dùng libsql-swift hôm nay cho chúng ta một nhánh SQLite cục bộ, nhưng không có khả năng đồng bộ làm nên sự khác biệt của Turso.
Chi phí của cơ sở dữ liệu lúc này
Ngay cả khi SDK đã trưởng thành, chúng tôi vẫn phải trả những chi phí không mang lại lợi ích nào cho workload của mình:
Quản lý tệp sidecar của WAL. Một cơ sở dữ liệu đang chạy tạo các tệp đi kèm -wal và -shm. Muốn sao chép một tài liệu, hoặc phải checkpoint trước, hoặc phải sao chép nguyên tử cả ba tệp. Xuất một gói .lnote sang Files hoặc AirDrop giờ sẽ cần một bước trước khi xuất mà người dùng không nhìn thấy còn lập trình viên thì không thể quên.
Một lớp adapter. Stroke phải được serialize thành BLOB rồi deserialize trở lại. Các phần tử trang có thứ tự mảng tự nhiên mà canvas có thể render trực tiếp; cơ sở dữ liệu sẽ thêm thứ tự hàng và các cột z-index. Chúng tôi sẽ phải viết một lớp chuyển đổi giữa hai biểu diễn của cùng một dữ liệu và duy trì nó sau mỗi thay đổi schema.
Một dependency 161 MB. Với một ứng dụng có bản build Debug dưới 2 MB, dependency lớn hơn chính ứng dụng 80× là một chi phí đáng chú ý — đặc biệt khi dependency đó được gắn nhãn technical preview và không có hoạt động nào trong một năm.
Các chi phí này không phải giả định. Chúng bắt đầu ngay khi dependency được link. Đổi lại, chúng tôi nhận được các khả năng truy vấn, lập index và ghi đồng thời mà ứng dụng không dùng.
Giải pháp chúng tôi đã triển khai: các gói tệp snapshot
Một tài liệu .lnote là một gói thư mục:
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 là source of truth cho cấu trúc tài liệu: ID, tiêu đề, timestamp và danh sách trang theo thứ tự của tài liệu. Nó cũng lưu kích thước canvas, timestamp và số lượng phần tử của từng trang. Các tệp nội dung trang lưu mảng phần tử bằng cùng cách mã hóa số nguyên đã lượng tử hóa mà ứng dụng đang dùng — tọa độ và bán kính có độ chính xác 0.1 point, áp lực tính theo phần nghìn, timestamp tính theo mili giây tương đối.
Thư viện ghi chú chỉ đọc manifest.json và thumbnail bìa. Nó không bao giờ parse document.json hay bất kỳ nội dung trang nào. Mở một trang chỉ tải một tệp .content. Đây là lần đọc tệp duy nhất chạm vào dữ liệu stroke.
Các trang giải quyết write amplification
Ứng dụng viết tay vốn đã có khái niệm trang: đó là đơn vị người dùng nghĩ tới và là thứ họ vuốt qua lại. Đặt trang làm đơn vị persistence giúp auto-save chỉ ghi lại những trang đã thay đổi.
Một trang viết tay — chẳng hạn 1,000 đến 2,000 stroke — chiếm khoảng 3–5 MB trong định dạng đã lượng tử hóa của chúng tôi. Một bản ghi 21 stroke với 3,400 điểm lấy mẫu, sau khi lượng tử hóa, có kích thước khoảng 55 KB. Ghi một snapshot của trang vào flash storage mất 10–20 ms trên phần cứng hiện đại. Với debounce 0.5s, người dùng không nhận thấy việc lưu.
Chi phí lưu tăng theo lượng chữ viết trên trang hiện tại, không theo tổng số trang trong tài liệu. Một cuốn sổ 200 trang lưu nhanh đúng bằng một cuốn 2 trang, vì chỉ trang dirty được ghi lại.
Mỗi lần ghi dùng các thao tác tệp nguyên tử — ghi vào tệp tạm rồi đổi tên — nên crash giữa lúc lưu không thể tạo ra một tệp trang bị cắt cụt. Khi ứng dụng vào background, nó lập tức flush mọi trang dirty, giống với hành vi vốn có khi ứng dụng chỉ có một canvas.
Tính nhất quán không cần transaction
Các gói tệp không có transaction, nhưng có những quy tắc ownership rõ ràng phục vụ cùng mục đích:
Resource trước reference. Khi người dùng chèn một hình ảnh, tệp asset được ghi ngay vào assets/. Snapshot của trang tham chiếu asset bằng ID được debounced auto-save ghi sau. Vì vậy, một trang không thể tham chiếu đến asset chưa tồn tại trên đĩa.
Source of truth được ưu tiên. document.json và thư mục pages/ là source of truth. manifest.json là cache. Nếu chúng không khớp, lần lưu tiếp theo sẽ điều chỉnh cache để khớp với source. Thumbnail là dữ liệu phái sinh và có thể được tạo lại bất cứ lúc nào.
Chấp nhận orphan thay vì tham chiếu treo. Kết quả tệ nhất sau một crash là một asset mồ côi — một tệp trong assets/ không được trang nào tham chiếu. Các asset mồ côi được dọn khi đóng tài liệu. Trường hợp ngược lại — một trang tham chiếu đến tệp bị thiếu — không thể xảy ra, vì asset được ghi trước snapshot của trang tham chiếu đến nó.
Các quy tắc này dễ suy luận hơn WAL checkpointing và transaction isolation, đồng thời khớp chính xác với mô hình truy cập single-process, single-page của ứng dụng.
Lộ trình nâng cấp
Chọn tệp phẳng hôm nay không phải là quyết định vĩnh viễn. Cấu trúc gói được thiết kế để nâng cấp storage engine mà không thay đổi bản thân gói; chỉ nội dung bên trong gói thay đổi.
Cấp 1: snapshot + append journal. Nếu write amplification trở nên dễ nhận thấy — chẳng hạn viết liên tục trên một trang có hàng nghìn stroke khiến việc lưu chậm thấy rõ — mỗi tệp trang sẽ tách thành một snapshot và một journal chỉ cho phép append. Các phần tử mới được append thành các frame [length][CRC][type][payload]. Khi replay journal, mọi frame có CRC không khớp sẽ bị loại bỏ, đảm bảo an toàn khi crash. Khi journal vượt quá một ngưỡng hoặc trang được đóng, nó sẽ được gộp trở lại vào snapshot. Phần này chỉ khoảng ~200 LOC và không có dependency bên ngoài.
Vì snapshot cấp trang đã loại bỏ write amplification giữa các trang, cấp này có thể chưa cần đến trong thời gian dài. Ghi lại một trang 5 MB mỗi 0.5s vẫn nằm trong ngân sách ghi của flash.
Cấp 2: cơ sở dữ liệu SQLite. Nếu ứng dụng cần tìm kiếm toàn văn giữa các ghi chú, đồng bộ hóa theo từng phần tử hoặc lập index giữa các tài liệu, SQLite sẽ là công cụ phù hợp. Engine có khả năng được dùng khi đó là GRDB, một Swift wrapper trưởng thành được biên dịch từ source với gần như không có overhead về kích thước binary. Chúng tôi chỉ xem xét lại libsql-swift nếu hệ sinh thái Turso — cụ thể là Turso Sync for Swift — trở thành nhu cầu sản phẩm thực sự.
Lộ trình migration khá trực tiếp: mảng phần tử của mỗi trang ánh xạ vào bảng strokes / images, mỗi stroke có một BLOB bất biến, và mỗi sampling point dùng 16 bytes trong little-endian binary. Benchmark 0.019s trên 100 stroke xác nhận cách tiếp cận này khả thi. Trước khi phát hành, migration sẽ kiểm tra số lượng phần tử và asset trong package cũ và database mới có khớp không, đồng thời kiểm tra crash recovery, giới hạn WAL và background flush đều pass.
Khi nào nên chấp nhận chi phí
Lựa chọn này dựa trên nhu cầu hiện tại của ứng dụng. SQLite xử lý writer đồng thời, truy vấn phức tạp và khôi phục sau crash trên trạng thái dùng chung; ứng dụng của chúng tôi hiện chưa cần điều nào trong số đó. Trả tiền cho các khả năng này trước khi ứng dụng gặp vấn đề tương ứng chỉ làm tăng chi phí mà chưa đem lại lợi ích.
Chi phí của cơ sở dữ liệu — dependency, quản lý WAL, lớp adapter và kích thước binary — bắt đầu ngay khi thư viện được link. Lợi ích chỉ bắt đầu khi ứng dụng có truy vấn cần chạy, index cần duy trì hoặc writer đồng thời cần điều phối. Hiện tại ứng dụng không có điều nào trong ba việc đó.
Mỗi bước trong quá trình phát triển storage của chúng tôi chỉ trả giá cho những vấn đề đã xuất hiện. Snapshot cấp trang giải quyết vấn đề hiện tại: lưu tài liệu nhiều trang mà không phải ghi lại toàn bộ tệp. Nếu write amplification trở nên đo được, append journal sẽ giải quyết vấn đề đó. Nếu search hoặc sync trở thành nhu cầu sản phẩm, cơ sở dữ liệu sẽ giải quyết vấn đề đó.
Cấu trúc gói, manifest và schema tài liệu không bị ràng buộc với storage engine nào. Chi phí chuyển đổi thấp vì các ranh giới nằm đúng chỗ. Khi thực sự cần cơ sở dữ liệu, chúng tôi sẽ dùng nó cho một vấn đề cụ thể đã được đo lường, không phải cho một vấn đề giả định.