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. Nghĩa là trong cùng một giao dịch, hai câu
SELECTcá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). Đọ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.
Đ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.
Một lần tôi đo hớ: "đọ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, đổi tính nhất quán lấy độ song song.
Cái tôi đo hớ 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. Bài học đo lường: 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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).
Hệ quả 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ụ.
Hệ quả thứ ba là bài học đo lường. Con số mang theo: 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; và khi cùng một câu truy vấn cho hai kết quả trong một giao dịch, mức cô lập là biến ẩn cần kiểm trước khi nghi bất cứ điều gì khác.
Thử ba mươi giây
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.