Năm bài đầu đi hết phía đọc. Giờ sang phía ghi, nơi SQLite phải trả lời một câu khó: nếu máy sập ngay giữa một lần ghi — mất điện, kill tiến trình — làm sao file không hỏng dở dang? Cơ chế mặc định trả lời câu này tên là nhật ký hoàn tác (rollback journal), và điều hay là nó để lại dấu vết đo được ngay trên đĩa. Tất cả trong container sqlite 3.45.3.

Trước khi sửa, chép trang gốc ra một tệp riêng

Ý tưởng của rollback journal ngược với trực giác: để ghi an toàn, trước hết hãy lưu lại cái cũ. Khi một giao dịch sắp sửa các trang trong file chính, SQLite chép nội dung GỐC của đúng những trang đó ra một tệp phụ tên <tên-db>-journal, rồi mới sửa file chính. Nếu mọi việc trôi chảy, tới lúc commit nó xóa tệp journal. Nếu máy sập giữa chừng, lần mở sau SQLite thấy tệp journal còn đó và chép các trang gốc từ journal trở lại file chính — hoàn tác, đưa CSDL về đúng trạng thái trước giao dịch.

Bắt tệp này xuất hiện bằng cách mở một giao dịch rồi nhìn thư mục trước khi commit:

journal_mode mặc định:  delete

-- giữa giao dịch (UPDATE một nửa bảng, CHƯA commit):
   2203648  j.db              ← file chính
   2192696  j.db-journal      ← bản sao các trang GỐC, ~2,1 MB

-- sau COMMIT:
   2203648  j.db              ← journal đã BIẾN MẤT

Tệp -journal rộng gần bằng file chính vì lần UPDATE này chạm hầu hết các trang, nên gần như mọi trang gốc bị chép lại. Và ngay khi commit, nó biến mất — đó chính là nghĩa của journal_mode = delete, giá trị mặc định. Tên "hoàn tác" là chính xác: journal chứa cái cũ, đủ để quay ngược.

Ảnh chụp sơ đồ nền tối minh họa cơ chế nhật ký hoàn tác trong container sqlite 3.45.3. Phần trên mô tả một giao dịch ghi theo các bước: bước một chép nội dung gốc của các trang sắp sửa ra tệp db-journal và fsync tệp đó, bước hai sửa các trang trong file chính db, bước ba fsync file chính, bước bốn xóa tệp journal là commit. Phần dưới hai kịch bản sập máy: nếu sập trước khi journal xong thì journal không hợp lệ bị bỏ qua CSDL như chưa đổi; nếu sập sau khi journal fsync thì lần mở sau SQLite chép các trang gốc từ journal trở lại file chính tức hoàn tác. Chú thích journal_mode mặc định delete journal biến mất khi commit, đo được tệp db-journal 2,1 MB giữa giao dịch và mất sau commit

Hình 1: Rollback journal — chép trang gốc ra tệp -journal và fsync, sửa file chính, fsync, rồi xóa journal khi commit. Sập máy giữa chừng thì journal cho phép hoàn tác về trạng thái cũ.

Cái giá thật không phải chép, mà là fsync

Chép vài MB rất nhanh. Cái đắt là fsync — lệnh buộc hệ điều hành đẩy dữ liệu từ bộ đệm xuống đĩa vật lý thật, để sau mất điện dữ liệu vẫn còn. Để an toàn trước sự cố, rollback journal cần fsync hai lần mỗi commit: một lần để chắc tệp journal đã nằm trên đĩa trước khi động vào file chính, một lần để chắc file chính đã xuống đĩa trước khi xóa journal. PRAGMA synchronous điều khiển mức độ cẩn thận này. Đo thật — 5000 lần ghi, mỗi lần một giao dịch tự động:

synchronous=FULL   (mặc định):  5000 giao dịch = 3680 ms  → 0,73 ms/giao dịch
synchronous=NORMAL           :  5000 giao dịch = 3098 ms  → 0,61 ms/giao dịch
synchronous=OFF              :  5000 giao dịch =  109 ms  → 0,02 ms/giao dịch

FULL chậm hơn OFF khoảng 34 lần — toàn bộ khác biệt đó là chi phí fsync. OFF bỏ fsync nên nhanh vọt, nhưng đánh đổi bằng an toàn: mất điện đúng lúc có thể làm hỏng CSDL. NORMAL là mức trung gian. Đây là lý do một vòng lặp chèn từng dòng mỗi dòng một giao dịch lại chậm đến khó tin trên SQLite — không phải SQLite chậm, mà là mỗi dòng đang gánh một cặp fsync thật.

Cách chữa: gói nghìn lượt ghi vào một giao dịch

Nếu mỗi giao dịch tốn một cặp fsync, thì ít giao dịch hơn = ít fsync hơn. Gói cả 5000 lượt chèn vào một giao dịch, vẫn để synchronous=FULL an toàn tuyệt đối:

BEGIN;
  INSERT ... (5000 dòng)
COMMIT;
5000 INSERT, mỗi cái 1 giao dịch (FULL):   3680 ms
5000 INSERT, gói trong 1 giao dịch (FULL):    5 ms      ← nhanh hơn ~736 lần

3680 ms xuống 5 ms. Cùng số dòng, cùng mức an toàn FULL, cùng cơ chế journal — khác biệt duy nhất là một cặp fsync thay vì năm nghìn cặp. Đây là mẹo tối ưu ghi quan trọng nhất của SQLite, và nó không hy sinh gì về độ bền: một giao dịch lớn vẫn hoàn tác trọn vẹn nếu sập giữa chừng. Quy tắc: đừng bao giờ chèn/sửa hàng loạt bằng vòng lặp từng dòng tự commit — luôn bọc trong một BEGIN ... COMMIT.

Ảnh chụp sơ đồ nền tối hai phần đo trong container sqlite 3.45.3. Phần trên bảng chi phí fsync với 5000 lượt ghi mỗi lượt một giao dịch: synchronous FULL 3680 mili-giây 0,73 mỗi giao dịch an toàn, NORMAL 3098 mili-giây 0,61 trung gian, OFF 109 mili-giây 0,02 nhanh nhưng mất điện có thể hỏng; FULL chậm hơn OFF 34 lần và toàn bộ là chi phí fsync. Phần dưới so sánh gói giao dịch: 5000 INSERT mỗi cái một giao dịch FULL 3680 mili-giây so với gói cả 5000 trong một giao dịch FULL chỉ 5 mili-giây, nhanh hơn 736 lần vì một cặp fsync thay vì năm nghìn cặp, vẫn an toàn tuyệt đối

Hình 2: Chi phí durability là fsync, không phải chép (trên); và gói nhiều lượt ghi vào một giao dịch cắt từ 3680 ms xuống 5 ms bằng cách giảm số lần fsync — đo bằng container sqlite 3.45.3.

synchronous và journal_mode: chọn theo nhu cầu

Hai núm điều chỉnh đi cùng rollback journal. PRAGMA synchronous chọn mức an toàn: FULL cho dữ liệu không được phép hỏng, OFF cho dữ liệu tạm có thể tạo lại (cache, dữ liệu phát sinh) khi tốc độ quan trọng hơn. PRAGMA journal_mode chọn cách quản tệp journal: mặc định DELETE xóa tệp mỗi commit; TRUNCATE cắt tệp về 0 thay vì xóa (nhanh hơn chút trên vài hệ tệp vì khỏi cập nhật thư mục); PERSIST giữ tệp lại và chỉ ghi đè phần đầu để vô hiệu hóa. Cả ba đều là biến thể của cùng một ý tưởng hoàn tác. Nhưng có một chế độ journal hoàn toàn khác, đảo ngược cả cách tiếp cận — thay vì chép cái cũ ra rồi sửa tại chỗ, nó ghi cái mới ra một tệp phụ và để file chính yên. Đó là WAL, và nó thay đổi hẳn chuyện đọc–ghi đồng thời.

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

Rollback journal là lời giải mặc định cho bài toán an toàn trước sự cố, và nó dạy một bài học đo được: trên SQLite, chi phí ghi bị chi phối bởi số lần fsync, không phải số byte. Ta vừa thấy cùng 5000 lượt chèn chạy từ 3680 ms xuống 5 ms chỉ bằng cách gộp giao dịch — mẹo tối ưu đáng giá nhất mà ai dùng SQLite cũng nên biết. Nhưng rollback journal có một điểm yếu cố hữu: trong lúc một người ghi (và đang giữ journal), không ai đọc được file chính một cách nhất quán, vì file chính đang bị sửa dở. Bài sau mổ xẻ WAL — chế độ lật ngược cơ chế để người đọc và người ghi chạy song song, và đo xem nó đổi các con số đồng thời như thế nào.

Phần sau