SQLite đo thật: từ định dạng tệp tới WAL
Mổ xẻ SQLite bằng đo đạc: định dạng tệp một file, cây B-tree, index, bộ lập kế hoạch truy vấn, nhật ký hoàn tác, WAL, giao dịch và khóa, kiểu động, VACUUM, cache. Mỗi phần đo thật bằng container sqlite3.
8/12 phần đã đăng
Cơ sở dữ liệu
1
SQLite đo thật — kiến trúc và định dạng tệp một file: cả cơ sở dữ liệu nằm gọn trong một file, header 100 byte mở đầu bằng chuỗi SQLite format 3, và kích thước tệp luôn đúng bằng page_size nhân page_count
Bài mở đầu sê-ri SQLite đo thật: SQLite không phải tiến trình máy chủ mà là một thư viện nhúng, cả CSDL là một file duy nhất. Đo trong container sqlite 3.45.3: file rỗng 0 byte cho tới lần ghi đầu, sau một CREATE TABLE là đúng 8192 byte (2 trang 4096), 16 byte đầu là chuỗi ma thuật SQLite format 3, page_size nằm ở offset 16 (0x1000 = 4096), bật WAL đổi hai byte phiên bản từ 01/01 sang 02/02, và bảng lược đồ sqlite_master chính là một cây B-tree nằm trên trang 1.
15/11/2026
· 8 phút đọc
2
SQLite đo thật — cây B-tree: vì sao một triệu dòng chỉ sâu ba tầng nên tìm theo khóa chính tốn tối đa ba lần đọc trang, và index là một cây B-tree riêng nhỏ hơn nằm cạnh cây của bảng
Phần 2 sê-ri SQLite đo thật: mọi bảng và index trong SQLite đều là một cây B-tree. Đo trong container sqlite 3.45.3 bằng bảng ảo dbstat: bảng một triệu dòng dùng 7125 trang nhưng cây chỉ sâu 3 tầng vì mỗi trang trong (internal) trỏ tới trung bình 373 trang con — hệ số phân nhánh khổng lồ khiến chiều cao cây tăng theo log cơ số lớn. Trang lá chứa trọn dòng dữ liệu (trung bình 140 dòng/trang), trang trong chỉ chứa khóa và con trỏ để điều hướng. Tạo index trên một cột sinh ra một cây B-tree thứ hai riêng biệt, nhỏ hơn vì chỉ lưu cột đó cộng rowid.
15/11/2026
· 7 phút đọc
3
SQLite đo thật — rowid, INTEGER PRIMARY KEY và WITHOUT ROWID: vì sao mỗi bảng thường có một rowid ẩn, một khóa chính không phải số nguyên sinh thêm cả một cây B-tree, và WITHOUT ROWID cắt một nửa dung lượng
Phần 3 sê-ri SQLite đo thật: mỗi bảng thường (rowid table) có một khóa 64-bit ẩn tên rowid, và khai INTEGER PRIMARY KEY chỉ là đặt bí danh cho rowid chứ không lưu thêm cột. Đo trong container sqlite 3.45.3: một khóa chính TEXT trên bảng rowid tạo thêm một cây B-tree index tự động (sqlite_autoindex) gần như nhân đôi dung lượng — 500 nghìn dòng chiếm 24 MB. Khai cùng bảng đó WITHOUT ROWID khiến chính khóa chính thành khóa của cây bảng, bỏ hẳn index phụ, còn 12 MB — đúng một nửa. Đổi lại WITHOUT ROWID có cái giá của nó với index thứ cấp.
15/11/2026
· 6 phút đọc
4
SQLite đo thật — index và EXPLAIN QUERY PLAN: đọc một chữ SCAN hay SEARCH là biết truy vấn quét hai triệu dòng hay đi thẳng qua cây B-tree, và chênh lệch đo được là ba nghìn lần
Phần 4 sê-ri SQLite đo thật: EXPLAIN QUERY PLAN cho biết SQLite sẽ quét toàn bảng (SCAN) hay tìm theo index (SEARCH) trước khi chạy một dòng nào. Đo trên bảng hai triệu dòng trong container sqlite 3.45.3: tìm theo cột chưa có index là SCAN mất 43 mili-giây mỗi truy vấn; thêm index rồi thì kế hoạch đổi thành SEARCH USING INDEX chỉ còn 0,014 mili-giây — nhanh hơn khoảng ba nghìn lần; tìm theo khóa chính INTEGER PRIMARY KEY còn nhanh hơn ở 0,003 mili-giây; và khi mọi cột cần lấy đều nằm trong index, kế hoạch thành COVERING INDEX, khỏi phải tra về cây bảng.
15/11/2026
· 5 phút đọc
5
SQLite đo thật — bộ lập kế hoạch và ANALYZE: khi một bảng có nhiều index, thiếu thống kê thì SQLite đoán và chọn nhầm index chậm năm nghìn lần, chạy ANALYZE một lần là chọn đúng
Phần 5 sê-ri SQLite đo thật: khi một truy vấn có thể dùng nhiều index, bộ lập kế hoạch phải chọn một, và nó chọn theo ước lượng chi phí. Đo trên bảng hai triệu dòng trong container sqlite 3.45.3: với hai điều kiện WHERE trên tp (ít chọn lọc, 400 nghìn dòng mỗi giá trị) và khach (rất chọn lọc, 40 dòng mỗi giá trị), khi chưa có thống kê SQLite chọn nhầm index idx_tp mất 53 mili-giây; chạy ANALYZE điền bảng sqlite_stat1 với số dòng trung bình mỗi giá trị, rồi cùng truy vấn đó SQLite chọn idx_khach chỉ còn 0,010 mili-giây — nhanh hơn khoảng năm nghìn lần, không đổi một dòng SQL nào.
15/11/2026
· 6 phút đọc
6
SQLite đo thật — nhật ký hoàn tác: trước khi sửa, SQLite chép trang gốc ra tệp -journal rồi xóa khi commit, và cái giá thật không phải chép mà là fsync, gói nghìn lượt ghi vào một giao dịch nhanh hơn bảy trăm lần
Phần 6 sê-ri SQLite đo thật: cơ chế an toàn mặc định là nhật ký hoàn tác. Đo trong container sqlite 3.45.3: giữa giao dịch, một tệp -journal xuất hiện chứa bản sao các trang GỐC trước khi sửa, và biến mất khi commit (journal_mode mặc định delete); sập máy giữa chừng thì lần mở sau SQLite chép trang gốc trở lại. Cái giá đắt là fsync chứ không phải chép: 5000 giao dịch đơn lẻ ở synchronous FULL mất 3680 mili-giây so với OFF 109; nhưng gói cả 5000 lượt ghi vào MỘT giao dịch chỉ còn 5 mili-giây — nhanh hơn bảy trăm lần mà vẫn an toàn.
15/11/2026
· 6 phút đọc
7
SQLite đo thật — chế độ WAL: thay vì chép trang cũ ra rồi sửa tại chỗ, nó ghi trang mới vào tệp -wal và để file chính yên, nhờ đó người đọc và người ghi chạy song song không chặn nhau và ghi nhanh gần bốn lần
Phần 7 sê-ri SQLite đo thật: WAL (Write-Ahead Log) lật ngược cơ chế của rollback journal. Đo trong container sqlite 3.45.3: sau khi commit 50000 dòng ở chế độ WAL, file chính vẫn chỉ 4096 byte còn các trang MỚI nằm hết trong tệp -wal (1,1 MB) cùng tệp -shm chỉ mục; checkpoint mới dồn chúng vào file chính (lên 1,08 MB) và cắt -wal về 0. Nhờ ghi ra tệp riêng, đọc và ghi chạy song song: ở DELETE một người đọc đang mở làm người ghi bị chặn database is locked, còn ở WAL người ghi vẫn thành công. Ghi cũng nhanh hơn: 3000 giao dịch nhỏ mất 2170 mili-giây ở DELETE so với 581 ở WAL.
15/11/2026
· 6 phút đọc
8
SQLite đo thật — giao dịch và khóa: vì sao BEGIN mặc định (DEFERRED) khiến hai giao dịch cùng đọc rồi cùng ghi rơi vào deadlock database is locked không cứu được, còn BEGIN IMMEDIATE cộng busy_timeout thì xếp hàng sạch sẽ
Phần 8 sê-ri SQLite đo thật: SQLite có ba kiểu giao dịch — DEFERRED (mặc định, lấy khóa lười), IMMEDIATE (lấy khóa ghi ngay ở BEGIN), EXCLUSIVE. Đo trong container sqlite 3.45.3: với BEGIN DEFERRED, hai giao dịch cùng đọc trước rồi cùng thử ghi thì người sau nhận database is locked (5) ở GIỮA giao dịch — một deadlock mà busy_timeout không cứu được vì không ai chịu nhả. Dùng BEGIN IMMEDIATE lấy khóa ghi ngay từ đầu: người ghi thứ hai với busy_timeout 3000 chỉ việc CHỜ 1228 mili-giây rồi chạy tiếp, xếp hàng sạch sẽ không deadlock. Quy tắc: giao dịch sẽ ghi thì mở bằng BEGIN IMMEDIATE.
15/11/2026
· 5 phút đọc
Còn 4 phần nữa sẽ lần lượt được đăng.