Bài trước thấy WAL cho nhiều người đọc chạy cùng một người ghi — nhưng vẫn chỉ một người ghi tại một thời điểm. Vậy khi hai nơi cùng muốn ghi thì sao? Câu trả lời nằm ở cách SQLite lấy khóa, và ở một bẫy tinh vi: kiểu giao dịch mặc định (BEGIN DEFERRED) có thể đẩy hai bên vào một deadlock mà busy_timeout không gỡ được. Bài này đo đúng bẫy đó và cách tránh, trong container sqlite 3.45.3.
Ba kiểu giao dịch: lấy khóa lúc nào?
BEGIN có ba biến thể, khác nhau ở thời điểm lấy khóa ghi:
BEGIN DEFERRED(mặc định): không lấy khóa gì khi mở. Lần đọc đầu tiên lấy khóa đọc; lần ghi đầu tiên mới thử nâng cấp lên khóa ghi.BEGIN IMMEDIATE: lấy ngay khóa ghi tạiBEGIN. Chỉ một giao dịchIMMEDIATEtồn tại một lúc; người khác chờ ngay từ đầu.BEGIN EXCLUSIVE: nhưIMMEDIATEnhưng chặn cả người đọc (ít dùng với WAL).
Khác biệt nghe nhỏ — "lấy khóa sớm hay muộn" — nhưng hệ quả về tranh chấp thì rất lớn, như phép đo dưới đây cho thấy.
Bẫy: DEFERRED và deadlock nâng cấp khóa
Dựng kịch bản thường gặp: hai giao dịch cùng đọc một giá trị, rồi cùng muốn ghi dựa trên giá trị đó (ví dụ "đọc số dư rồi trừ tiền"). Cả hai mở BEGIN DEFERRED, cùng SELECT (giờ cả hai giữ khóa đọc), rồi cùng UPDATE:
A: BEGIN DEFERRED; SELECT ... (khóa đọc)
B: BEGIN DEFERRED; SELECT ... (khóa đọc)
A: UPDATE ... → nâng cấp lên khóa ghi: OK
B: UPDATE ... → nâng cấp lên khóa ghi: database is locked (5)
Kết quả đo: A ghi được, B nhận database is locked (5). B đang giữ khóa đọc và muốn nâng lên khóa ghi, nhưng A đã cầm khóa ghi — và A sẽ không nhả cho tới khi commit, trong khi commit của A không bị cản bởi B. Đây là một deadlock một chiều: busy_timeout cũng vô dụng, vì chờ lâu bao nhiêu thì A vẫn giữ khóa cho tới khi xong, còn B thì đã lỡ vào giữa giao dịch. B buộc phải ROLLBACK và làm lại từ đầu — nhưng nếu nó đã làm việc dở dang, đây là lỗi khó chịu và khó tái hiện.

Hình 1: Bẫy DEFERRED — hai giao dịch cùng đọc rồi cùng nâng cấp lên ghi; người sau (B) nhận database is locked (5) ở giữa giao dịch, và busy_timeout không gỡ được vì A chỉ nhả khi commit.
Cách tránh: BEGIN IMMEDIATE cho giao dịch sẽ ghi
Nếu bạn biết một giao dịch rồi sẽ ghi, hãy lấy khóa ghi ngay từ đầu bằng BEGIN IMMEDIATE. Khi đó xung đột ghi–ghi được phát hiện tại BEGIN — trước khi làm bất cứ việc gì — và busy_timeout làm đúng việc của nó: cho người ghi thứ hai chờ sạch sẽ rồi chạy tiếp. Đo hai người ghi IMMEDIATE, người thứ hai đặt busy_timeout=3000:
A: BEGIN IMMEDIATE; UPDATE ... (giữ khóa ghi ~1,2 giây)
B: PRAGMA busy_timeout=3000;
BEGIN IMMEDIATE; UPDATE; COMMIT; → CHỜ 1228 ms rồi THÀNH CÔNG
B không lỗi, không deadlock — nó chỉ đợi 1228 mili-giây cho A commit xong rồi tiến hành bình thường. So với busy_timeout=0 thì B nhận database is locked ngay lập tức; đặt timeout đủ dài, các người ghi tự xếp hàng. Khác biệt mấu chốt: với IMMEDIATE, tranh chấp xảy ra ở BEGIN nơi chờ đợi là an toàn, chứ không phải ở giữa giao dịch nơi đã lỡ làm việc.

Hình 2: BEGIN IMMEDIATE cộng busy_timeout cho người ghi thứ hai chờ 1228 ms rồi thành công thay vì deadlock (trên), và bảng so ba kiểu giao dịch (dưới) — đo trong container sqlite 3.45.3.
busy_timeout: luôn đặt, nhưng hiểu giới hạn
PRAGMA busy_timeout = <ms> bảo SQLite: khi gặp khóa, thử lại trong tối đa ngần ấy mili-giây trước khi trả SQLITE_BUSY. Gần như mọi ứng dụng nên đặt nó (ví dụ 5000) thay vì để mặc định 0 — vì mặc định, bất kỳ tranh chấp thoáng qua nào cũng thành lỗi ngay. Nhưng busy_timeout chỉ cứu được tranh chấp sẽ tự hết khi bên kia commit; nó không cứu được deadlock nâng cấp khóa của DEFERRED, vì ở đó bên giữ khóa không nhả còn bên chờ đã kẹt. Đó là lý do hai công cụ phải đi cùng nhau: busy_timeout để xếp hàng những người ghi tuần tự, và BEGIN IMMEDIATE để tranh chấp xảy ra ở chỗ busy_timeout xử lý được.
Vì sao điều này quan trọng
Khóa là nơi lý thuyết giao dịch gặp thực tế đồng thời, và SQLite có một mặc định gài bẫy: BEGIN trơn là DEFERRED, êm đẹp cho tới ngày hai giao dịch cùng đọc–rồi–ghi va nhau và một bên nhận database is locked không hiểu vì sao. Ta vừa đo cả bẫy lẫn cách thoát: mở giao dịch-sẽ-ghi bằng BEGIN IMMEDIATE, và luôn đặt busy_timeout. Tới đây ta đã hiểu SQLite tổ chức, tra cứu và ghi dữ liệu an toàn ra sao. Phần sau chuyển sang một nét rất riêng của SQLite mà các CSDL khác hầu như không có: kiểu dữ liệu động. Cột khai INTEGER vẫn có thể chứa chuỗi, và quy tắc "type affinity" quyết định một giá trị được lưu thế nào — đo bằng chính cách SQLite cất mỗi giá trị trên đĩa.