Khi chạy cơ sở dữ liệu trên máy chủ, hệ thống có toàn quyền tương tác với hệ thống file của hệ điều hành. Nhưng khi đưa SQL vào trong trình duyệt (Client-side), chúng ta phải đối mặt với hai thử thách lớn: làm thế nào để dữ liệu được lưu trữ bền vững (Persistence) qua các phiên tải lại trang, và làm thế nào để tối ưu hóa tốc độ ghi đĩa thông qua cơ chế Write-Ahead Log (WAL) trong môi trường hộp cát (sandbox) bảo mật. Bài viết này sẽ phân tích các cơ chế lưu trữ client-side hiện đại nhất, so sánh các thư viện SQLite WASM và cung cấp bài lab Docker mô phỏng đọc/ghi song song.

1. Cơ Chế Hoạt Động Của Chế Độ WAL (Write-Ahead Log)

Trong chế độ lưu nhật ký giao dịch truyền thống (như DELETE hay TRUNCATE), khi có một phiên ghi (writer) bắt đầu, SQLite sẽ thực hiện khóa độc quyền (Exclusive Lock) trên toàn bộ file cơ sở dữ liệu. Mọi tiến trình đọc (readers) khác đều bị chặn hoàn toàn và báo lỗi database is locked cho tới khi writer hoàn tất và giải phóng khóa.

Chế độ **WAL (Write-Ahead Log)** giải quyết bài toán nghẽn cổ chai này bằng cách thay đổi quy trình ghi dữ liệu:

  1. File cơ sở dữ liệu chính (ví dụ: mydb.sqlite) chỉ chứa dữ liệu đã được cam kết ổn định.
  2. Khi có transaction ghi mới, SQLite ghi trực tiếp thay đổi vào một file log ghi trước riêng biệt có hậu tố `-wal` (ví dụ: mydb.sqlite-wal).
  3. Các tiến trình đọc dữ liệu không cần đợi tiến trình ghi. Chúng đọc trực tiếp dữ liệu cũ từ file chính. Đồng thời, writer ghi tiếp vào file WAL mà không hề xảy ra xung đột khóa.

Khi file WAL tích tụ đủ số lượng trang thay đổi (mặc định trong SQLite là khi đạt $1000$ trang, tức khoảng $4\text{MB}$ dữ liệu), SQLite sẽ thực hiện một thao tác gọi là **Checkpoint**. Checkpoint di chuyển tất cả các trang dữ liệu mới từ file WAL quay trở lại file chính và xóa trắng file WAL để tái sử dụng.

Cú pháp ép buộc thực hiện checkpoint thủ công trong SQL:

-- Thực hiện checkpoint thụ động, chuyển tối đa các trang có thể mà không chặn kết nối khác
PRAGMA wal_checkpoint(PASSIVE);

-- Thực hiện checkpoint triệt để, thu gọn file WAL về kích thước 0
PRAGMA wal_checkpoint(TRUNCATE);

2. Persistence trong Browser: Từ Ghi Đè Nguyên Khối Đến Ghi Theo Trang (OPFS)

Trình duyệt web không cho phép JavaScript ghi trực tiếp vào ổ đĩa vật lý của người dùng vì lý do bảo mật. Do đó, để lưu trữ bền vững dữ liệu SQLite, các nhà phát triển web đã trải qua nhiều thế hệ giải pháp:

Giải pháp lưu trữ Cơ chế hoạt động Ưu điểm Nhược điểm (Hiệu năng)
RAM thô (sql.js) Dữ liệu lưu hoàn toàn trên bộ nhớ RAM ảo của WASM. Dễ tích hợp, không phụ thuộc trình duyệt. Mất hoàn toàn dữ liệu khi F5/reload trang. Phải xuất/nhập thủ công dạng Blob.
Ghi đè IndexedDB Mỗi khi thay đổi, ứng dụng sao chép (serialize) toàn bộ file DB thành mảng byte lớn và ghi đè vào IndexedDB. Dữ liệu được lưu trữ lại qua các phiên tải trang. Cực kỳ chậm khi file DB phình to. File 50MB sẽ tốn ~2-3 giây chỉ để ghi đè dù chỉ thay đổi 1 dòng dữ liệu.
Origin Private File System (OPFS) Hệ thống file ảo tốc độ cao của trình duyệt (File System Access API). Cho phép ghi ngẫu nhiên (random-write) từng page dữ liệu nhỏ ngầm qua Web Worker. Tốc độ đọc ghi tương đương ứng dụng native. Chỉ ghi đúng trang 4KB bị thay đổi. Hỗ trợ đầy đủ WAL. Chỉ hoạt động trên trình duyệt hiện đại. Yêu cầu thiết lập CORS Headers phức tạp (COOP/COEP).

Với OPFS (Origin Private File System), SQLite-WASM có thể mở một kết nối ghi trực tiếp ở tầng thấp. Khi một giao dịch commit, hệ thống chỉ ghi đúng một vài trang dữ liệu 4KB bị thay đổi xuống đĩa ảo thay vì phải copy lại toàn bộ file DB. Điều này giúp hiệu năng ghi tăng vọt lên hàng nghìn lần trên các file cơ sở dữ liệu lớn.

3. Yêu Cầu CORS Để Kích Hoạt OPFS: Thiết Lập COOP và COEP

Để OPFS hoạt động với SQLite-WASM một cách hiệu quả và an toàn, trình duyệt yêu cầu môi trường chạy phải được thiết lập cô lập bộ nhớ nâng cao (Cross-Origin Isolation). Điều này đòi hỏi máy chủ web (Web Server) của bạn phải cấu hình trả về hai HTTP Response Headers bắt buộc sau:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Nếu thiếu hai tiêu đề này, trình duyệt sẽ khóa tính năng SharedArrayBuffer (bộ nhớ chia sẻ giữa Main Thread và Web Worker). Thiếu SharedArrayBuffer, SQLite-WASM sẽ không thể truy cập vào AccessHandle đồng thì của hệ thống file OPFS và sẽ tự động fallback về chế độ lưu trữ tạm thời trên RAM.

4. So Sánh Các Thư Viện SQLite WASM Hiện Nay

Khi tích hợp SQLite vào dự án Web Frontend, bạn cần cân nhắc lựa chọn giữa các thư viện phổ biến:

  • sql.js: Thư viện lâu đời, gọn nhẹ. Hoạt động 100% trên bộ nhớ RAM. Thích hợp cho các công cụ nhỏ, xử lý dữ liệu một lần (như chính trang web js-tools.org này).
  • wa-sqlite: Thư viện tối ưu hóa cao của Roy Hashimoto. Nó tự viết một lớp ảo VFS kết nối SQLite-WASM với IndexedDB thông qua cơ chế ghi theo trang (Page-level Virtual File System), mang lại sự cân bằng hoàn hảo giữa tính bền vững và tính tương thích cao (không bắt buộc CORS headers).
  • SQLite Official WASM: Bản phát hành chính thức từ đội ngũ SQLite Core (kể từ bản 3.40.0). Tích hợp sẵn OPFS VFS tối ưu tuyệt đối về mặt tốc độ ghi nhưng bắt buộc phải có cấu hình COOP/COEP headers từ phía máy chủ web.

5. Mô Phỏng Checkpoint Trên Trình Duyệt

Sân chơi tương tác dưới đây mô phỏng hoạt động của chế độ WAL và checkpoint. Bạn có thể chèn dữ liệu ghi nhận và gọi lệnh checkpoint để theo dõi trạng thái.

Đang tải SQLite-WASM...

📋 Sơ đồ bảng hiện có (Schema)

Kết quả truy vấn sẽ hiển thị ở đây...

6. Thực Hành Nâng Cao Với Docker: Đọc Ghi Song Song Trên WAL Thật

Do WASM chạy trong môi trường đơn luồng của sandbox trình duyệt nên chúng ta khó lòng tự tay kiểm chứng việc đọc ghi song song (đồng thời). Bạn hãy thực hiện lab Docker tại nhà bằng SQLite CLI để cảm nhận sự khác biệt.

🐳 Lab tại nhà: Kiểm chứng đọc ghi song song giữa journal thô và WAL
Hãy chuẩn bị một file database SQLite trống test.db trong thư mục local và mở hai Terminal cùng trỏ vào thư mục đó:
  1. Mô phỏng với chế độ DELETE mặc định:
    • Terminal 1: Khởi tạo bảng và chạy một giao dịch ghi dài:
      sqlite3 test.db
      PRAGMA journal_mode = DELETE;
      CREATE TABLE logs (val TEXT);
      BEGIN;
      INSERT INTO logs VALUES ('log_data');
      -- Giữ nguyên Terminal 1 chưa gọi COMMIT!
    • Terminal 2: Thử thực hiện đọc dữ liệu:
      sqlite3 test.db
      SELECT * FROM logs;
      -- → Kết quả: Bị chặn hoàn toàn và báo lỗi: Error: database is locked
  2. Mô phỏng với chế độ WAL:
    • Quay lại Terminal 1COMMIT; để giải phóng khóa.
    • Thiết lập chế độ WAL:
      PRAGMA journal_mode = WAL;
      BEGIN;
      INSERT INTO logs VALUES ('new_log_data');
      -- Vẫn giữ nguyên chưa COMMIT!
    • Sang Terminal 2 chạy lại câu lệnh đọc:
      SELECT * FROM logs;
      -- → Kết quả: ĐỌC ĐƯỢC NGAY LẬP TỨC dữ liệu cũ ('log_data') mà không hề bị chặn! Ghi và Đọc đã diễn ra song song hoàn hảo.

Kiểm tra kiến thức ôn tập

Trắc nghiệm ôn tập

Câu 1: Điểm cải tiến cốt lõi nhất của chế độ Write-Ahead Log (WAL) so với các chế độ lưu nhật ký truyền thống trong SQLite là gì?

Trắc nghiệm ôn tập

Câu 2: Tại sao giải pháp lưu trữ Origin Private File System (OPFS) lại có hiệu năng vượt trội hơn phương pháp lưu đè nguyên khối IndexedDB khi dữ liệu phình to?

Trắc nghiệm ôn tập

Câu 3: Tại sao trình duyệt lại yêu cầu cấu hình các HTTP response headers COOP và COEP để sử dụng OPFS AccessHandle?

Trắc nghiệm ôn tập

Câu 4: Thao tác Checkpoint trong chế độ WAL của SQLite thực chất làm công việc gì?

Trắc nghiệm ôn tập

Câu 5: Trong thư viện wa-sqlite, làm thế nào để nó giải quyết bài toán lưu trữ bền vững mà không bắt buộc máy chủ phải cấu hình CORS Headers COOP/COEP phức tạp?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 17: Dự Án Mini Analytics Dashboard Bài 15: Performance Engineering 🐳 Quay lại Lộ trình Series SQL