Chuẩn SQL định nghĩa bốn mức cô lập bằng cách liệt kê hiện tượng nào được phép xảy ra ở mức nào. Phần này dựng lại từng hiện tượng bằng hai phiên chạy song song và đo xem PostgreSQL thực sự làm gì.

Bốn mức, hiện tượng lệch ghi, và cái giá của mức cao

Bốn mức, ba hành vi

PostgreSQL chấp nhận cả bốn tên:

begin isolation level read uncommitted;
show transaction_isolation;   -->  read uncommitted

báo đúng tên bạn khai. Tôi từng nghĩ nó sẽ tự nâng lên read committed và báo lại như vậy, nhưng không — chỉ hành vi mới giống nhau.

Kiểm chứng bằng phép thử đọc bản chưa commit. Phiên A cập nhật số dư từ 1000 xuống 1 rồi chưa commit; phiên B đọc:

Mức của phiên B Giá trị đọc được
read uncommitted 1000
read committed 1000

Cả hai đều thấy 1000 — giá trị đã commit. Sau đó A rollback và giá trị thật vẫn là 1000, tức là nếu B đọc được số 1 thì nó đã làm việc với dữ liệu chưa từng tồn tại.

Kiến trúc MVCC (phần 21) khiến chuyện đọc bản chưa commit không thể xảy ra: mỗi phiên bản dòng mang xmin là số hiệu giao dịch tạo ra nó, và quy tắc hiển thị luôn kiểm tra giao dịch đó đã commit chưa. Không có đường nào để lọt.

Vậy PostgreSQL thực chất có ba mức khác nhau về hành vi.

Đọc lại không giống lần đầu

Phiên A mở giao dịch, đọc số dư, chờ, đọc lại. Ở giữa, phiên B cộng 500 và commit:

Mức Lần đọc 1 Lần đọc 2
read committed 1000 1500
repeatable read 1000 1000

read committed, mỗi câu lệnh lấy một ảnh chụp mới. Hai câu SELECT trong cùng một giao dịch có thể thấy hai trạng thái khác nhau của cơ sở dữ liệu.

Đây là mặc định của PostgreSQL, và nó là nguồn của một lớp lỗi khó tìm: bạn đọc số dư, kiểm tra điều kiện, rồi ghi — nhưng giữa lúc đọc và lúc ghi, dữ liệu đã đổi.

repeatable read, ảnh chụp được lấy một lần ở câu lệnh đầu tiên và giữ nguyên tới hết giao dịch.

Dòng mới xuất hiện

Cùng bố trí, nhưng phiên B chèn một dòng thay vì cập nhật:

Mức Đếm lần 1 Đếm lần 2
read committed 2 3
repeatable read 2 2

Chuẩn SQL cho phép repeatable read vẫn xảy ra hiện tượng này — đó là lý do chuẩn định nghĩa mức serializable riêng. Nhưng PostgreSQL dùng ảnh chụp cho toàn bộ giao dịch nên nó chặn luôn, chặt hơn chuẩn đòi hỏi.

Điều này có nghĩa: nếu bạn đọc tài liệu chuẩn SQL rồi suy ra hành vi của PostgreSQL, bạn sẽ suy sai ở ô này. Và cũng có nghĩa là mã viết cho một CSDL khác dựa trên "repeatable read vẫn có phantom" sẽ chạy khác trên PostgreSQL.

Hiện tượng mà repeatable read không chặn được

Đây là phần quan trọng nhất của bài, vì nó là chỗ trực giác dễ sai nhất.

Quy tắc nghiệp vụ: bệnh viện luôn phải có ít nhất một người trực. An và Bình đều đang trực. Cả hai cùng lúc muốn xin nghỉ, và mỗi người tự kiểm tra trước:

begin isolation level repeatable read;
select count(*) from truc where dang_truc;   -- thấy 2, yên tâm
update truc set dang_truc = false where id = <chính mình>;
commit;

Kết quả đo được:

phiên 1 thấy số người đang trực: 2  ->  commit thành công
phiên 2 thấy số người đang trực: 2  ->  commit thành công
=> số người còn trực: 0

Quy tắc nghiệp vụ bị phá, và không có lỗi nào hiện ra.

repeatable read không phát hiện được vì hai giao dịch không hề ghi đè lên nhau: phiên 1 sửa dòng của An, phiên 2 sửa dòng của Bình. Không có xung đột ghi. Cái xung đột nằm ở chỗ mỗi phiên đọc dữ liệu mà phiên kia sắp ghi — và ảnh chụp che mất chuyện đó.

Hiện tượng này gọi là lệch ghi (write skew). Nó là lý do tồn tại của mức thứ tư.

Cùng phép thử ở serializable:

phiên 1 thấy số người đang trực: 2  ->  commit thành công
phiên 2 thấy số người đang trực: 2  ->
ERROR:  could not serialize access due to read/write dependencies among transactions
HINT:  The transaction might succeed if retried.
=> số người còn trực: 1

serializable theo dõi cả quan hệ đọc–ghi giữa các giao dịch, phát hiện rằng không tồn tại thứ tự tuần tự nào cho ra kết quả này, và huỷ một trong hai.

Chú ý câu HINT: PostgreSQL không nói giao dịch của bạn sai, nó nói hãy thử lại. Đó là hợp đồng của mức serializable — bạn được bảo đảm đúng đắn, đổi lại bạn phải viết vòng thử lại.

Cái giá, đo bằng tải thật

Sáu tiến trình cùng cập nhật một nhóm nhỏ 5 dòng, mỗi giao dịch sửa hai dòng. Không có vòng thử lại — hệt như mã ứng dụng thông thường:

Mức Thành công Thất bại Tổng cộng đúng phải là 480
read committed 240 0 (0%) 480
repeatable read 103 137 (57%) 206
serializable 108 132 (55%) 216

Hơn một nửa số giao dịch thất bại, và tổng cộng chỉ còn 206 thay vì 480. Nếu ứng dụng không bắt lỗi và thử lại, 137 lần ghi biến mất.

Đây là cái bẫy thực tế: người ta chuyển sang repeatable read hoặc serializable vì nghe nói nó "an toàn hơn", rồi mất hơn nửa số ghi mà không biết. Mức cô lập cao không làm giao dịch của bạn đúng hơn — nó từ chối những giao dịch không đúng, và phần còn lại là việc của bạn.

Chú ý cả repeatable read cũng thất bại 57%. Thông báo lỗi ở đây khác với lệch ghi:

could not serialize access due to concurrent update

Đây là xung đột ghi–ghi: hai giao dịch cùng sửa một dòng, và ở mức repeatable read trở lên, giao dịch thứ hai bị huỷ thay vì chờ. Ở read committed nó chờ rồi làm tiếp trên giá trị mới — đó là lý do cột "thất bại" của nó bằng 0.

Có vòng thử lại thì sao

Thêm vòng thử lại, đếm tổng số lần chạy thật để hoàn thành 180 giao dịch, lặp ba lần:

Mức Lần 1 Lần 2 Lần 3 Hệ số
read committed 180 180 180 1,0
repeatable read 282 282 334 1,6 – 1,9
serializable 323 283 280 1,6 – 1,8

Cả ba mức đều cho kết quả đúng 480 khi có thử lại. read committed không bao giờ phải chạy lại lần nào.

Và kết quả đáng chú ý nhất: serializable tốn ngang repeatable read. Lần đo đầu tiên tôi thấy serializable cần ít lần thử lại hơn (415 so với 516) và suýt viết rằng nó rẻ hơn — nhưng lặp ba lần cho thấy hai dải chồng lên nhau hoàn toàn. Đó là nhiễu.

Kết luận thực dụng rút ra được: trên tải nặng ghi kiểu này, phần lớn lần thử lại đến từ xung đột ghi–ghi mà cả hai mức đều gặp, nên phần bảo đảm thêm của serializable gần như miễn phí. Nếu bạn đã chấp nhận viết vòng thử lại, hãy lấy luôn mức chặt hơn.

Vòng thử lại viết thế nào

Chỉ thử lại đúng hai mã lỗi:

SQLSTATE Ý nghĩa
40001 serialization_failure — cả hai loại lỗi ở trên
40P01 deadlock_detected — phần sau bàn kỹ
for (int lan = 0; lan < 5; lan++) {
    try (Connection c = ds.getConnection()) {
        c.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
        c.setAutoCommit(false);
        // ... công việc ...
        c.commit();
        break;
    } catch (SQLException e) {
        if (!"40001".equals(e.getSQLState()) && !"40P01".equals(e.getSQLState())) throw e;
        Thread.sleep((1L << lan) * 10);   // lùi dần
    }
}

Ba điều bắt buộc:

Chờ lùi dần. Thử lại ngay lập tức làm hai giao dịch va nhau lại đúng như cũ. Không có bước chờ, tỷ lệ thất bại không giảm.

Có trần số lần. Vòng lặp vô hạn dưới tải cao là cách biến một lỗi tạm thời thành sự cố toàn hệ thống.

Toàn bộ giao dịch phải chạy lại từ đầu, kể cả phần đọc. Giao dịch bị huỷ nghĩa là ảnh chụp của nó đã sai; giữ lại kết quả đọc cũ rồi chỉ chạy lại phần ghi là tái tạo đúng cái lỗi mà mức cô lập vừa chặn.

Chọn mức nào

Tình huống Mức
Phần lớn ứng dụng web, đọc–ghi từng dòng độc lập read committed (mặc định)
Báo cáo cần mọi câu lệnh nhìn cùng một trạng thái repeatable read
Quy tắc nghiệp vụ trải trên nhiều dòng, và có vòng thử lại serializable

repeatable read nằm ở vị trí kỳ lạ: nó chịu gần hết cái giá của serializable mà không có phần bảo đảm quan trọng nhất. Trường hợp dùng đúng của nó khá hẹp — chủ yếu là báo cáo nhiều câu lệnh cần nhất quán, nơi bạn đọc chứ không ghi, nên không bao giờ gặp lỗi 40001.

Một lựa chọn thứ tư mà bài này không đo: giữ read committed và dùng khoá tường minh (select ... for update). Đó là nội dung phần sau.

Thử ba mươi giây

Xem mức mặc định của máy chủ và của phiên hiện tại:

show default_transaction_isolation;
show transaction_isolation;

Và đếm số lần thất bại tuần tự hoá kể từ khi khởi động:

select datname, xact_commit, xact_rollback,
       round(100.0 * xact_rollback / nullif(xact_commit + xact_rollback, 0), 2) as phan_tram_rollback
from pg_stat_database where datname = current_database();

Tỷ lệ rollback cao bất thường trên hệ thống dùng repeatable read hoặc serializable thường có nghĩa là lỗi 40001 đang xảy ra và không ai thử lại — tức là dữ liệu đang mất im lặng.

Phần sau đo khoá hàng và deadlock: cách dựng lại một deadlock có chủ đích, cách PostgreSQL phát hiện nó, và cách gỡ.