Bài trước cho thấy MVCC khiến mỗi giao dịch thấy một "ảnh chụp" (snapshot) dữ liệu. Nhưng chụp lúc nào và chụp lại ra sao thì tùy mức cô lập giao dịch (transaction isolation level). Đây là núm chỉnh quan trọng nhất của lập trình đồng thời với database — chọn thấp thì nhanh nhưng dễ dính bug dữ liệu, chọn cao thì an toàn nhưng tốn hơn và có thể phải thử lại.

Chuẩn SQL định nghĩa bốn mức qua ba bất thường (anomaly) mà chúng ngăn hay cho phép. Bài này (phần 2 loạt PostgreSQL) demo thật từng anomaly trên pg-lab, và chỉ ra PostgreSQL khác chuẩn ở hai điểm quan trọng mà nhiều người không biết.

Ba bất thường và bốn mức cô lập

Khi hai giao dịch chạy song song, ba loại "nhìn thấy sai" có thể xảy ra:

  • Dirty read (đọc bẩn): đọc thấy dữ liệu giao dịch khác chưa commit — nguy hiểm nhất, vì dữ liệu đó có thể bị rollback.
  • Non-repeatable read (đọc lặp sai): đọc cùng một hàng hai lần, ra hai giá trị khác nhau (vì có UPDATE + commit xen giữa).
  • Phantom read: chạy cùng một truy vấn hai lần, số hàng khác nhau (vì có INSERT + commit xen giữa).

Bốn mức cô lập giao dịch PostgreSQL và ba bất thường: dirty read đọc bẩn là đọc thấy dữ liệu giao dịch khác chưa commit, non-repeatable read đọc lặp sai là đọc cùng một hàng hai lần ra hai giá trị vì có UPDATE commit giữa chừng, phantom read là chạy cùng một truy vấn hai lần số hàng khác nhau vì có INSERT commit giữa chừng; bảng chuẩn SQL mức nào ngăn anomaly nào Read Uncommitted cho phép cả ba, Read Committed ngăn dirty read cho phép non-repeatable và phantom, Repeatable Read ngăn dirty read và non-repeatable cho phép phantom, Serializable ngăn cả ba; PostgreSQL khác chuẩn không có dirty read Read Uncommitted bằng Read Committed và Repeatable Read ngăn luôn phantom mạnh hơn chuẩn; đặt mức bằng BEGIN ISOLATION LEVEL READ COMMITTED mặc định REPEATABLE READ snapshot đóng băng từ đầu tx SERIALIZABLE như chạy tuần tự

Hình 1: Ba anomaly và bảng bốn mức cô lập chuẩn SQL. PostgreSQL khác chuẩn: không có dirty read (Read Uncommitted = Read Committed), và Repeatable Read ngăn luôn phantom (mạnh hơn chuẩn). Đặt mức bằng BEGIN ISOLATION LEVEL ....

BEGIN ISOLATION LEVEL READ COMMITTED;   -- mac dinh cua PostgreSQL
BEGIN ISOLATION LEVEL REPEATABLE READ;  -- snapshot dong bang tu dau tx
BEGIN ISOLATION LEVEL SERIALIZABLE;     -- nhu chay tuan tu (bai 4)

Đo thật: non-repeatable read ở RC vs RR

Mình mở phiên A đọc cùng một hàng hai lần quanh một khoảng chờ; ở giữa, phiên B UPDATE giá trị rồi commit. Chạy với hai mức cô lập khác nhau. Kết quả thật từ pg-lab:

Bảng kết quả đo thật các anomaly trên pg-lab PostgreSQL 16.15 với 2 phiên psql đồng thời B UPDATE INSERT commit giữa 2 lần đọc của A: non-repeatable read đọc cùng hàng 2 lần, READ COMMITTED read1 100 B UPDATE v 200 commit read2 200 đọc lặp sai xảy ra, REPEATABLE READ read1 100 B UPDATE v 200 commit read2 100 an toàn snapshot đóng băng; phantom read đếm số hàng 2 lần B chèn thêm 4 5, READ COMMITTED count1 3 B INSERT 2 hàng commit count2 5 phantom xảy ra, REPEATABLE READ count1 3 B INSERT 2 hàng commit count2 3 an toàn chuẩn SQL cho phép phantom ở Repeatable Read nhưng PostgreSQL ngăn luôn mạnh hơn chuẩn; dirty read PostgreSQL không có kể cả READ UNCOMMITTED B đang UPDATE v 777 chưa commit A đặt READ UNCOMMITTED rồi đọc v 100 bản cũ không thấy 777. Badge output thật màu xanh

Hình 2: Kết quả thật. Non-repeatable read xảy ra ở Read Committed (100→200), biến mất ở Repeatable Read (100→100). Phantom xảy ra ở Read Committed (3→5), biến mất ở Repeatable Read (3→3). Dirty read không xảy ra kể cả ở Read Uncommitted (A đọc v=100, không thấy 777 chưa commit).

  • Read Committed: read1=100, sau khi B commit v=200, read2=200. Hai lần đọc cùng hàng ra hai kết quả — đọc lặp sai xảy ra. Đây là mặc định của PostgreSQL: mỗi câu lệnh lấy snapshot mới nhất đã commit.
  • Repeatable Read: read1=100, dù B commit v=200, read2=100 — an toàn. Repeatable Read đóng băng snapshot từ đầu giao dịch, nên mọi lần đọc trong giao dịch thấy cùng dữ liệu.

Đo thật: phantom read và dirty read

Phantom read (đếm số hàng hai lần, B chèn thêm 2 hàng giữa chừng):

  • Read Committed: count1=3, sau khi B chèn, count2=5 — phantom xảy ra.
  • Repeatable Read: count1=3, dù B chèn, count2=3 — an toàn. Đây là điểm PostgreSQL mạnh hơn chuẩn SQL: chuẩn cho phép phantom ở Repeatable Read, nhưng PostgreSQL (dùng snapshot isolation) ngăn luôn.

Dirty read (B đang UPDATE v=777 chưa commit, A đọc ở Read Uncommitted):

  • A đọc được v=100 (bản cũ), không thấy 777. PostgreSQL không bao giờ đọc dữ liệu chưa commit, kể cả khi bạn cố đặt READ UNCOMMITTED — nó ánh xạ mức này thành READ COMMITTED. Đây là hệ quả trực tiếp của MVCC (bài 1).

Hệ quả: PostgreSQL thực tế chỉ có 3 mức khác biệt

Gộp lại, PostgreSQL chỉ có ba mức hành xử khác nhau (dù khai được bốn):

  • Read Committed (mặc định): chống dirty read; vẫn có non-repeatable + phantom.
  • Repeatable Read: chống dirty read + non-repeatable + phantom (snapshot đóng băng từ đầu tx).
  • Serializable: chống mọi anomaly, như thể các giao dịch chạy tuần tự (bài 4).

(READ UNCOMMITTED chỉ là bí danh của READ COMMITTED.)

Đánh đổi cần cân nhắc

Mức cao hơn an toàn hơn nhưng tốn hơn và có thể phải thử lại. Read Committed rẻ nhất và đủ cho đa số ứng dụng CRUD — nhưng bạn phải ý thức rằng hai câu SELECT liên tiếp trong cùng giao dịch có thể thấy dữ liệu khác nhau. Repeatable Read cho nhất quán trong suốt giao dịch (hợp cho báo cáo đọc nhiều bảng cần ảnh chụp đồng nhất), nhưng giữ snapshot lâu hơn nên cản trở vacuum (bài 8) và có thể gặp lỗi could not serialize access khi ghi xung đột — phải bắt lỗi và thử lại. Serializable an toàn tuyệt đối nhưng tốn nhất và tỉ lệ thử lại cao (bài 4). Chọn mức thấp nhất đủ dùng cho logic nghiệp vụ.

Read Committed có một cạm bẫy tinh vi với đọc-rồi-ghi. Mẫu SELECT balance rồi UPDATE balance = balance - 100 dựa trên giá trị vừa đọc, ở Read Committed, có thể dính lost update: giữa SELECT và UPDATE, một giao dịch khác đã đổi balance. Mức cô lập cao hơn (Repeatable Read) sẽ phát hiện và báo lỗi thay vì ghi đè âm thầm. Nhưng cách đúng thường là dùng khóa tường minh (SELECT ... FOR UPDATE, bài 5) hoặc câu UPDATE nguyên tử — không chỉ dựa vào mức cô lập.

"Repeatable Read ngăn phantom" là đặc thù PostgreSQL — đừng mang giả định này sang database khác. Ở MySQL/InnoDB, Repeatable Read cũng ngăn nhiều phantom nhưng theo cơ chế khác (gap lock) với hành vi biên khác; ở các database theo đúng chuẩn SQL thì Repeatable Read không ngăn phantom. Nếu code của bạn chạy trên nhiều loại database, đừng phụ thuộc vào việc Repeatable Read ngăn phantom — hành vi này không di động.

Ba ý mang về

  1. Mức cô lập quyết định anomaly nào xảy ra. Đo thật: non-repeatable read xảy ra ở Read Committed (đọc 100 rồi 200), biến mất ở Repeatable Read (100 cả hai lần); phantom tương tự (3→5 ở RC, 3→3 ở RR). Mức cao hơn đóng băng snapshot nên nhất quán hơn.
  2. PostgreSQL khác chuẩn SQL ở hai điểm. Đo thật: không có dirty read kể cả khi đặt Read Uncommitted (A đọc v=100, không thấy 777 chưa commit — ánh xạ thành Read Committed); và Repeatable Read ngăn luôn phantom (mạnh hơn chuẩn). Thực tế PostgreSQL chỉ có 3 mức khác biệt.
  3. Chọn mức thấp nhất đủ dùng, và biết giới hạn. Read Committed rẻ nhưng hai SELECT có thể khác nhau; Repeatable Read nhất quán nhưng cản vacuum + có thể phải thử lại; Serializable an toàn nhất nhưng tốn nhất. Với đọc-rồi-ghi, dùng FOR UPDATE (bài 5) hoặc UPDATE nguyên tử, đừng chỉ dựa vào mức cô lập.

Nguồn

Phần sau ta đi sâu vào khác biệt thực chiến giữa Read Committed và Repeatable Read: cùng một logic đọc-rồi-tính cho kết quả khác nhau khi có ghi đồng thời, và khi nào bạn thực sự cần nâng lên Repeatable Read.