Bạn đang xem một trận bóng qua ba kiểu màn hình. Kiểu thứ nhất: TV trực tiếp — mỗi lần liếc lên, tỉ số là mới nhất, có thể vừa đổi từ 1-0 thành 1-1 giữa hai lần bạn nhìn. Kiểu thứ hai: một tấm ảnh chụp lúc bạn ngồi xuống — liếc bao nhiêu lần tỉ số vẫn y như lúc đầu, dù ngoài kia đã đá xong. Kiểu thứ ba: một cuốn biên bản chính thức, đảm bảo mọi người đọc đều thấy một câu chuyện nhất quán, nhưng nếu hai biên bản mâu thuẫn thì một cái bị xé đi làm lại. Ba kiểu màn hình đó chính là ba mức cô lập của cơ sở dữ liệu, và bài này là lần tôi tưởng mình đang xem ảnh chụp trong khi thật ra đang xem TV trực tiếp.

Bài giao dịch lo chữ A (Atomicity). Giờ tới chữ I — Isolation (cô lập): khi nhiều giao dịch chạy đồng thời, mỗi giao dịch thấy gì từ các giao dịch khác? Câu trả lời không cố định — nó phụ thuộc mức cô lập (isolation level) bạn chọn, và mặc định không phải mức chặt nhất. Bài này chạy hai phiên song song để đo trực tiếp, và vấp một giả định tưởng chừng hiển nhiên: "đọc lại cùng một hàng thì phải ra cùng giá trị".

Ba mức, ba cách thấy dữ liệu

Khi hai giao dịch chạy cùng lúc, chúng có thể "giẫm" lên nhau — một bên đọc trong khi bên kia ghi. Mức cô lập quyết định một giao dịch được thấy bao nhiêu thay đổi của bên khác. PostgreSQL có ba mức thực dụng:

  • READ COMMITTED (mặc định): mỗi câu lệnh thấy dữ liệu đã được COMMIT tính tới thời điểm câu lệnh đó chạy — đúng cái TV trực tiếp. Nghĩa là trong cùng một giao dịch, hai câu SELECT cách nhau có thể thấy dữ liệu khác nhau nếu một phiên khác vừa commit ở giữa.
  • REPEATABLE READ: cả giao dịch làm việc trên một ảnh chụp (snapshot) chụp tại lúc BEGIN (đúng cơ chế MVCC ở bài phần 10) — tấm ảnh chụp lúc ngồi xuống. Đọc lại luôn thấy cùng dữ liệu, bất kể phiên khác commit gì.
  • SERIALIZABLE: mạnh nhất — kết quả như thể các giao dịch chạy tuần tự, lần lượt. Nếu phát hiện một xung đột không thể sắp tuần tự, nó hủy một giao dịch (lỗi serialization) và bên đó phải thử lại — cuốn biên bản bị xé làm lại.

Đo: cùng câu hỏi, ba câu trả lời

Tôi dựng hai phiên psql song song. Phiên A mở giao dịch, đọc x (đang là 100), chờ, rồi đọc x lần nữa. Trong lúc A chờ, phiên B chạy UPDATE x = 200 và commit. Tôi lặp thí nghiệm ở từng mức cô lập:

Mức cô lập A đọc lần 1 A đọc lần 2
READ COMMITTED (mặc định) 100 200
REPEATABLE READ 100 100

Ở READ COMMITTED, A đọc lần 2 ra 200 — nó thấy commit của B xảy ra giữa hai lần đọc. Cùng một câu SELECT, cùng một giao dịch của A, mà hai kết quả khác nhau: đây là hiện tượng non-repeatable read (đọc không lặp lại được). Ở REPEATABLE READ, A đọc lần 2 vẫn ra 100 — nó dùng ảnh chụp từ lúc BEGIN và không thấy thay đổi của B, dù B đã commit xong.

Rồi tôi thử SERIALIZABLE với một tình huống khác: cả A và B cùng đọc x rồi ghi một giá trị dựa trên nó (kiểu đọc-rồi-tăng). B commit trước (x thành 120); khi A cố commit, PostgreSQL trả ERROR: could not serialize access due to concurrent update — giao dịch của A bị hủy, phải chạy lại từ đầu. SERIALIZABLE không âm thầm cho kết quả sai; nó thà hủy một bên còn hơn để hai giao dịch tạo ra một trạng thái không thể xảy ra nếu chúng chạy lần lượt. Kết quả cuối cùng chỉ phản ánh giao dịch đã commit thành công (x=120), còn A phải chạy lại nếu muốn.

"Đọc lại thì y nguyên chứ"

Cái bẫy nằm ở giả định nền tảng nhất. Tôi đọc cùng một hàng hai lần trong một giao dịch, và đinh ninh chắc chắn hai lần phải ra cùng giá trị — "mình đang trong một giao dịch, đọc lại thì y nguyên chứ, có ai sửa được đâu". Nghe hiển nhiên tới mức không cần kiểm.

Nhưng ở mức mặc định READ COMMITTED, lần đọc thứ nhất ra 100 và lần thứ hai ra 200. Cùng một câu hỏi, trong cùng một giao dịch, hai đáp án khác nhau. Theo kỷ luật đo lường, hai con số mâu thuẫn cho cùng một phép đo nghĩa là có biến ẩn — và biến ẩn ở đây là mức cô lập. Giả định của tôi ("đọc lặp lại được") chỉ đúng từ mức REPEATABLE READ trở lên; mức mặc định không đảm bảo điều đó. Mặc định READ COMMITTED cố ý cho phép thấy commit mới của phiên khác giữa chừng — nó là TV trực tiếp, không phải ảnh chụp — đổi tính nhất quán lấy độ song song.

Cái tôi trật có hai lớp. Thứ nhất: cứ tưởng mặc định là mức chặt nhất — thực ra mặc định của PostgreSQL (và hầu hết cơ sở dữ liệu) là READ COMMITTED, mức thấp trong ba mức, để tối đa song song. Thứ hai, một bất ngờ ở mức cao: SERIALIZABLE không chỉ "chậm hơn" như tôi tưởng — nó có thể hủy giao dịch của bạn bằng lỗi serialization, buộc ứng dụng phải bắt lỗi đó và thử lại. Khi cùng một phép đo (đọc một hàng) cho hai đáp án khác nhau, đừng nghi cơ sở dữ liệu tính sai — hỏi bạn đang chạy ở mức cô lập nào, vì đó là biến ẩn quyết định bạn thấy gì.

Về đến code hằng ngày

Điều đầu tiên: đừng cho rằng dữ liệu bạn đọc đầu giao dịch còn nguyên ở cuối — trừ khi bạn đã đặt mức cô lập đủ cao. Ở mặc định READ COMMITTED, một mẫu code phổ biến bị lỗi: đọc số dư, kiểm tra "đủ tiền không", rồi trừ — giữa bước đọc và bước trừ, một giao dịch khác có thể đã đổi số dư. Muốn "đọc rồi quyết định rồi ghi" an toàn, hoặc nâng mức cô lập, hoặc khóa hàng khi đọc (SELECT ... FOR UPDATE, bài sau), hoặc dùng cập nhật nguyên tử (UPDATE ... SET x = x - 1 WHERE x >= 1).

Điều thứ hai: chọn mức cô lập là chọn đánh đổi giữa an toàn và song song/chi phí. READ COMMITTED nhanh và đủ cho phần lớn truy vấn, nhưng để non-repeatable read và một số bất thường lọt qua. REPEATABLE READ cho ảnh chụp nhất quán, hợp với báo cáo đọc nhiều hàng cần thấy một trạng thái đồng nhất. SERIALIZABLE an toàn tuyệt đối về mặt logic nhưng bắt bạn xử lý lỗi serialization và thử lại — nếu không có vòng retry, ứng dụng sẽ thỉnh thoảng ném lỗi lạ dưới tải cao. Biết ba mức và cái giá của chúng là biết chọn đúng cho từng nghiệp vụ.

Và điều đáng nhớ nhất: cái bẫy "tưởng mặc định là mức an toàn nhất" không riêng gì SQL — nó là một sai lầm về mặc định mà lập trình viên mắc ở khắp nơi. Nhiều người tưởng synchronized/khóa lo hết mọi vấn đề đồng thời (không — nó không chống được mọi race trên dữ liệu chia sẻ ngoài vùng khóa). Tưởng volatile của Java hay biến thường là đủ cho đa luồng (đã đo ở loạt bài trước: không). Tưởng một API mặc định trả về dữ liệu nhất quán khi thực ra nó đọc từ replica có độ trễ (eventual consistency). Mẫu số chung đáng mang theo: với mọi hệ đồng thời, "mặc định" gần như luôn là mức lỏng nhất để tối đa hiệu năng, không phải mức an toàn nhất — nên trước khi dựa vào một đảm bảo (đọc lặp lại, thấy ghi mới nhất, không mất cập nhật), hãy kiểm nó có thật sự được bảo đảm ở cấu hình mặc định không, đừng cho là hiển nhiên. Gói lại bằng số: mức cô lập quyết định một giao dịch thấy gì — đọc lại một hàng ở READ COMMITTED (mặc định) có thể đổi (100 rồi 200), ở REPEATABLE READ thì giữ (100), và SERIALIZABLE có thể hủy giao dịch (phải retry); mặc định không phải mức chặt nhất.

Muốn tự thấy thì mở hai cửa sổ psql cạnh nhau vào cùng cơ sở dữ liệu. Ở cửa sổ 1: BEGIN; SELECT x FROM kv WHERE k='x'; (ghi lại giá trị). Ở cửa sổ 2: UPDATE kv SET x = x + 1 WHERE k='x'; (tự commit). Quay lại cửa sổ 1, chạy lại SELECT x ... — bạn sẽ thấy giá trị đã đổi, dù bạn vẫn trong cùng giao dịch chưa commit: đó là READ COMMITTED. Giờ làm lại nhưng mở cửa sổ 1 bằng BEGIN ISOLATION LEVEL REPEATABLE READ; — lần này đọc lại thấy y nguyên, bất kể cửa sổ 2 làm gì. Kiểm mức mặc định của bạn bằng SHOW default_transaction_isolation;. Đó là biến ẩn quyết định hai phiên của bạn thấy nhau ra sao.