Bài trước thấy rollback journal chép trang cũ ra một tệp phụ rồi sửa file chính tại chỗ — an toàn, nhưng có một hệ quả: trong lúc người ghi đang sửa file chính, người đọc không thể thấy một trạng thái nhất quán. WAL (Write-Ahead Log) lật ngược hẳn ý tưởng đó, và đổi lại là một tính chất mà rollback journal không có: người đọc và người ghi chạy song song. Bài này đo cơ chế WAL qua chính các tệp nó tạo, và đo khác biệt đồng thời cùng hiệu năng trong container sqlite 3.45.3.

Lật ngược: ghi trang MỚI ra tệp riêng, để file chính yên

Ý tưởng WAL ngược với rollback journal. Thay vì chép trang cũ ra rồi sửa file chính, WAL ghi các trang MỚI vào một tệp phụ tên -wal và không đụng gì tới file chính cho tới khi checkpoint. Người đọc muốn một trang thì nhìn vào -wal trước (phiên bản mới nhất ở đó); nếu không có thì lấy từ file chính. Một tệp thứ hai, -shm (shared memory), là chỉ mục trỏ nhanh tới vị trí mỗi trang trong -wal.

Đo thật: bật WAL, tắt checkpoint tự động, chèn 50.000 dòng rồi commit — và giữ kết nối mở để xem các tệp trước khi checkpoint:

sau COMMIT 50 000 dòng, TRƯỚC checkpoint (kết nối còn mở):
   w.db       :       4096 B   ← file chính gần như KHÔNG đổi (1 trang)
   w.db-wal   :  1 104 192 B   ← toàn bộ 50 000 dòng MỚI nằm ở đây (~1,1 MB)
   w.db-shm   :     32 768 B   ← chỉ mục bộ nhớ chia sẻ

File chính đứng yên ở 4096 byte trong khi tệp -wal phình lên 1,1 MB ôm trọn dữ liệu mới. Người ghi chỉ nối thêm vào cuối -wal — một thao tác ghi tuần tự, nhanh — chứ không rải rác sửa khắp file chính. Đây là khác biệt cốt lõi với rollback journal.

Checkpoint: dồn WAL trở lại file chính

Dữ liệu không thể nằm mãi trong -wal. Định kỳ, một thao tác checkpoint sao các trang từ -wal trở vào file chính, rồi cắt -wal đi:

sau PRAGMA wal_checkpoint(TRUNCATE):
   w.db       :  1 089 536 B   ← GIỜ mới lớn lên (data đã dồn vào)
   w.db-wal   :          0 B   ← đã cắt về 0

Giờ file chính mới mang dữ liệu, và -wal rỗng, sẵn sàng cho vòng ghi sau. Mặc định SQLite tự checkpoint khi -wal đạt khoảng 1000 trang (wal_autocheckpoint), nên bình thường bạn không phải gọi tay. Nhưng nếu luôn có người đọc giữ kết nối, checkpoint bị hoãn và -wal có thể phình to — một điểm cần canh trong hệ nhiều người đọc.

Ảnh chụp sơ đồ nền tối so sánh hai cơ chế ghi của SQLite trong container sqlite 3.45.3. Bên trái rollback journal chép trang gốc ra tệp -journal rồi sửa file chính tại chỗ, người đọc không thấy trạng thái nhất quán khi đang sửa. Bên phải WAL ghi các trang mới vào tệp -wal và để file chính yên, một tệp -shm làm chỉ mục bộ nhớ chia sẻ, người đọc nhìn -wal trước rồi file chính. Phần dưới là số đo thật sau khi commit 50000 dòng ở WAL trước checkpoint: file chính w.db chỉ 4096 byte còn w.db-wal 1104192 byte ôm trọn trang mới và w.db-shm 32768 byte; sau checkpoint file chính lên 1089536 byte và w.db-wal cắt về 0

Hình 1: Rollback journal sửa file chính tại chỗ (trái); WAL ghi trang mới ra tệp -wal và để file chính yên tới checkpoint (phải). Số đo: sau 50.000 dòng, file chính 4096 B còn -wal 1,1 MB.

Đo điều WAL làm được mà rollback không: đọc–ghi song song

Vì người ghi nối vào -wal chứ không sửa file chính, người đọc vẫn thấy file chính (cộng phần -wal tới thời điểm họ bắt đầu) một cách nhất quán — không bị người ghi chặn, và không chặn người ghi. So trực tiếp hai chế độ bằng cách mở một người đọc giữ giao dịch, rồi cho một người ghi thử chèn (đặt busy_timeout=0 để thấy ngay có bị chặn không):

[DELETE]  người đọc đang mở → người ghi:  BỊ CHẶN → "database is locked" (5)
[WAL]     người đọc đang mở → người ghi:  THÀNH CÔNG (không bị chặn)

Ở chế độ DELETE (rollback), người ghi cần khóa độc quyền file chính, mà người đọc đang giữ khóa chia sẻ — nên người ghi nhận lỗi database is locked và phải chờ. Ở chế độ WAL, người ghi nối vào -wal nên không xung đột với người đọc: nó thành công ngay. Đây là lý do WAL gần như bắt buộc cho ứng dụng có nhiều luồng/tiến trình vừa đọc vừa ghi — ví dụ một server web nhúng SQLite. (Lưu ý: WAL cho nhiều người đọc đồng thời với một người ghi; vẫn chỉ một người ghi tại một thời điểm.)

Và ghi cũng nhanh hơn

Ngoài đồng thời, WAL thường ghi nhanh hơn vì mỗi commit chỉ cần fsync tệp -wal (một lần, ghi tuần tự nối đuôi) thay vì cặp fsync journal-rồi-db của rollback. Đo 3000 giao dịch nhỏ riêng lẻ:

DELETE (rollback):  3000 giao dịch nhỏ = 2170 ms
WAL              :  3000 giao dịch nhỏ =  581 ms      ← nhanh hơn ~3,7 lần

Cùng dữ liệu, cùng mức an toàn, khác biệt đến từ cơ chế ghi tuần tự một-fsync của WAL. Với tải nhiều giao dịch nhỏ — đúng kiểu của ứng dụng tương tác — WAL thắng cả về thông lượng lẫn độ trễ.

Ảnh chụp sơ đồ nền tối hai phần đo trong container sqlite 3.45.3. Phần trên là thí nghiệm đồng thời: một người đọc giữ giao dịch mở, ở chế độ DELETE người ghi bị chặn với lỗi database is locked số 5, ở chế độ WAL người ghi thành công không bị chặn vì nối vào tệp -wal không xung đột với người đọc; chú thích WAL cho nhiều người đọc với một người ghi cùng lúc, vẫn chỉ một người ghi tại một thời điểm. Phần dưới là hiệu năng ghi 3000 giao dịch nhỏ: DELETE 2170 mili-giây so với WAL 581 mili-giây nhanh hơn ba phẩy bảy lần vì mỗi commit chỉ fsync tệp wal một lần thay vì cặp fsync journal rồi db

Hình 2: WAL cho người đọc và người ghi chạy song song trong khi DELETE chặn (trên), và ghi nhanh hơn ~3,7 lần nhờ một-fsync tuần tự (dưới) — đo trong container sqlite 3.45.3.

Cái giá của WAL

WAL không miễn phí. Nó dùng bộ nhớ chia sẻ (-shm) nên không chạy được trên hệ tệp mạng (NFS) nơi nhiều máy mở cùng file — một giới hạn thật nếu đặt file SQLite trên ổ chia sẻ. Tệp -wal có thể phình to nếu checkpoint bị hoãn vì luôn có người đọc. Và CSDL giờ là ba tệp (.db, -wal, -shm) thay vì một, nên sao lưu bằng cách copy mỗi file .db lúc đang mở WAL là sai — phải checkpoint trước hoặc dùng công cụ sao lưu đúng cách. Dù vậy, với phần lớn ứng dụng một máy có đọc–ghi đồng thời, WAL là lựa chọn nên bật, và chỉ cần một lệnh: PRAGMA journal_mode=WAL.

Vì sao điều này quan trọng

WAL là nâng cấp đồng thời quan trọng nhất của SQLite, và ta vừa đo cả hai mặt của nó: cơ chế (trang mới vào -wal, file chính yên, checkpoint dồn lại) và lợi ích (đọc–ghi song song, ghi nhanh hơn ~3,7 lần). Với một server nhúng SQLite phục vụ nhiều yêu cầu, bật WAL thường là khác biệt giữa tắc nghẽn database is locked và chạy mượt. Nhưng WAL chỉ nới lỏng xung đột đọc–ghi, không xóa bỏ chuyện một người ghi tại một thời điểm. Khi hai người cùng muốn ghi, hoặc một giao dịch cần giữ khóa lâu, cơ chế khóa và các loại giao dịch của SQLite quyết định ai chờ ai và khi nào nhận SQLITE_BUSY. Bài sau đo đúng các tình huống tranh chấp đó.

Phần sau