PostgreSQL 8 — MVCC, mức cách ly & khóa

13 thg 7, 2026 2 lượt xem
#postgresql
#sql
#concurrency
#mvcc
#isolation
#locking

Vì sao đồng thời (concurrency) là bài toán khó

Trong một hệ thống thật, hàng trăm giao dịch chạy chồng lên nhau: người này đọc số dư trong khi người kia đang chuyển tiền, báo cáo quét cả bảng trong khi ứng dụng vẫn ghi. Nếu để mặc, kết quả sẽ hỗn loạn: đọc phải dữ liệu nửa vời, hai giao dịch ghi đè lẫn nhau, tổng tiền không khớp.

PostgreSQL giải quyết bài toán này bằng MVCC — Multi-Version Concurrency Control. Đây là nền tảng giải thích gần như mọi hành vi đồng thời của Postgres, và cũng là lý do vì sao có VACUUM. Bài này nối tiếp phần MVCC tổng quan đã nhắc ở bài kiến trúc, đi sâu vào cơ chế, các mức cách ly, và công cụ khóa mà bạn dùng hằng ngày.

MVCC: mỗi giao dịch nhìn một "ảnh chụp" riêng

Nguyên tắc cốt lõi của MVCC:

Đọc không chặn ghi, ghi không chặn đọc.

Thay vì khóa dòng khi đọc (như một số CSDL cũ), Postgres giữ nhiều phiên bản (version) của cùng một dòng. Mỗi giao dịch làm việc trên một snapshot — ảnh chụp trạng thái dữ liệu tại một thời điểm — nên nó luôn thấy một khung nhìn nhất quán, không bị dở dang bởi giao dịch khác đang chạy.

xmin / xmax — dấu vân tay của mỗi phiên bản dòng

Mỗi dòng trong Postgres có các cột hệ thống ẩn, quan trọng nhất là:

  • xmin: id của giao dịch (transaction id — xid) đã tạo phiên bản dòng này.
  • xmax: id của giao dịch đã xóa/thay thế phiên bản này (bằng 0 nếu dòng còn "sống").

Khi bạn UPDATE một dòng, Postgres không sửa tại chỗ. Nó:

  1. Đánh dấu phiên bản cũ là hết hạn (đặt xmax = xid hiện tại).
  2. Chèn một phiên bản mới (tuple mới) với xmin = xid hiện tại.

Phiên bản cũ trở thành dead tuple — vô hình với các giao dịch mới, nhưng vẫn chiếm chỗ trên đĩa cho tới khi VACUUM dọn đi. Đây chính là nguồn gốc của bloat: bảng cập nhật nhiều → sinh nhiều dead tuple → phình to nếu autovacuum không theo kịp.

Bạn có thể tự nhìn thấy các cột này:

▶ Chạy được trong SQL Builder

SELECT xmin, xmax, id, account_no, balance
FROM accounts
ORDER BY id;

DELETE chỉ đặt xmax (không xóa vật lý ngay); INSERT đặt xmin. Một dòng được coi là thấy được (visible) với một snapshot nếu xmin đã commit trước snapshot đó và xmax chưa commit (hoặc bằng 0). Cơ chế "visibility" này quyết định mỗi giao dịch nhìn thấy gì.

Điểm mấu chốt: kết quả T1 đọc lần hai phụ thuộc vào mức cách ly — dẫn ta tới phần tiếp theo.

Ba mức cách ly trong PostgreSQL

Chuẩn SQL định nghĩa 4 mức cách ly (isolation level). PostgreSQL cài đặt thực tế 3 mức phân biệt; điều quan trọng cần nhớ ngay:

PostgreSQL không có Read Uncommitted thật — yêu cầu mức này sẽ được đối xử như Read Committed. Nghĩa là dirty read không bao giờ xảy ra trong Postgres.

Mức cách lyDirty readNon-repeatable readPhantom readWrite skew
Read Uncommitted (= Read Committed)KhôngCó thểCó thểCó thể
Read Committed (mặc định)KhôngCó thểCó thểCó thể
Repeatable ReadKhôngKhôngKhông¹Có thể
SerializableKhôngKhôngKhôngKhông

¹ Chuẩn SQL cho phép phantom ở Repeatable Read, nhưng Postgres dùng snapshot isolation nên đã chặn cả phantom ở mức này.

Read Committed — mặc định

Mỗi câu lệnh trong giao dịch thấy một snapshot mới, chụp tại thời điểm câu lệnh đó bắt đầu. Vì vậy:

  • Bạn không bao giờ thấy dữ liệu chưa commit của người khác.
  • Nhưng hai câu SELECT trong cùng một giao dịch có thể ra kết quả khác nhau nếu giữa chúng có giao dịch khác commit (non-repeatable read).

Đây là mức phù hợp cho phần lớn ứng dụng web CRUD: đơn giản, ít xung đột, không phải retry.

-- (minh hoạ — cần 2 phiên chạy song song, không chạy trong sandbox)
-- Phiên A:
BEGIN;                                  -- Read Committed
SELECT balance FROM accounts WHERE id = 1;   -- -> 100
-- Phiên B (giữa chừng): UPDATE accounts SET balance = 50 WHERE id = 1; COMMIT;
SELECT balance FROM accounts WHERE id = 1;   -- -> 50 (đã đổi!)
COMMIT;

Repeatable Read — snapshot cố định

Snapshot được chụp một lần ở câu lệnh đầu tiên và giữ nguyên cho cả giao dịch. Mọi câu SELECT sau đó thấy đúng khung nhìn đó — không non-repeatable read, không phantom. Postgres cài đặt mức này bằng snapshot isolation.

Đánh đổi: nếu giao dịch của bạn cố UPDATE một dòng mà người khác đã sửa & commit sau snapshot của bạn, Postgres báo lỗi:

ERROR: could not serialize access due to concurrent update

→ Ứng dụng phải bắt lỗi và thử lại (retry) giao dịch. Rất hợp cho báo cáo/tổng hợp cần đọc nhiều lần một khung nhìn ổn định.

Serializable — SSI, an toàn tuyệt đối

Mức cao nhất, cài đặt bằng SSI (Serializable Snapshot Isolation). Postgres đảm bảo kết quả của các giao dịch chạy đồng thời tương đương như thể chúng chạy tuần tự — chặn cả write skew (xem dưới). Cơ chế: Postgres theo dõi các phụ thuộc đọc/ghi giữa giao dịch; nếu phát hiện chu trình nguy hiểm, nó hủy một giao dịch với:

ERROR: could not serialize access due to read/write dependencies among transactions

→ Lại là mô hình retry. Serializable đắt hơn nhưng giải phóng lập trình viên khỏi việc tự đặt khóa thủ công cho các bất biến phức tạp.

Các hiện tượng bất thường (anomalies)

Hiểu các "anomaly" giúp chọn đúng mức cách ly:

  • Dirty read — đọc dữ liệu chưa commit. Không xảy ra trong Postgres.
  • Non-repeatable read — đọc lại cùng một dòng ra giá trị khác (do người khác UPDATE & commit). Bị chặn từ Repeatable Read.
  • Phantom read — chạy lại cùng một truy vấn có điều kiện, xuất hiện thêm/bớt dòng (do người khác INSERT/DELETE). Bị chặn từ Repeatable Read (trong Postgres).
  • Lost update — hai giao dịch cùng đọc rồi ghi đè nhau (ví dụ cùng đọc balance=100, cùng cộng rồi ghi → mất một cập nhật). Read Committed không chặn; giải quyết bằng Repeatable Read (sẽ báo lỗi serialize) hoặc SELECT ... FOR UPDATE.
  • Write skew — hai giao dịch đọc cùng một tập dữ liệu, mỗi bên ghi một chỗ khác nhau, riêng lẻ thì hợp lệ nhưng kết hợp lại vi phạm bất biến. Ví dụ kinh điển: quy định "luôn có ít nhất 1 bác sĩ trực"; hai bác sĩ cùng đọc thấy "còn 2 người trực", mỗi người tự xin nghỉ → cuối cùng còn 0 người. Chỉ Serializable chặn được write skew.

Khóa (locking): khi MVCC là chưa đủ

MVCC lo phần đọc. Khi cần điều phối ghi, bạn dùng khóa tường minh.

Khóa dòng — SELECT ... FOR UPDATE

SELECT ... FOR UPDATE khóa các dòng đọc được, chặn giao dịch khác sửa/khóa chúng cho tới khi bạn commit. Đây là cách kinh điển chống lost update ở mức Read Committed:

-- (minh hoạ — pattern chuyển tiền an toàn)
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- khóa dòng
-- kiểm tra đủ số dư trong ứng dụng...
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

Các biến thể:

  • FOR NO KEY UPDATE — khóa nhẹ hơn, cho phép khóa khóa-ngoại tham chiếu song song (Postgres dùng ngầm khi UPDATE cột không phải khóa).
  • FOR SHARE / FOR KEY SHARE — khóa chia sẻ: cho người khác cùng đọc-khóa nhưng không cho sửa.

SKIP LOCKED & NOWAIT — hàng đợi công việc

Mặc định FOR UPDATE chờ khi dòng đang bị khóa. Hai tùy chọn đổi hành vi:

  • NOWAIT — báo lỗi ngay nếu dòng đang bị khóa (không chờ).
  • SKIP LOCKEDbỏ qua các dòng đang bị khóa, chỉ lấy dòng rảnh.

SKIP LOCKED là công cụ tuyệt vời để làm hàng đợi job trong chính Postgres: nhiều worker cùng lấy việc mà không giẫm chân nhau.

-- (minh hoạ — mỗi worker lấy 1 job chưa ai xử lý)
BEGIN;
SELECT id, payload FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- ... xử lý rồi UPDATE status = 'done' ...
COMMIT;

Khóa bảng & khóa ngầm

Ngoài khóa dòng, có khóa mức bảng (LOCK TABLE), và nhiều lệnh DDL (như ALTER TABLE) lấy khóa nặng ACCESS EXCLUSIVE — chặn cả đọc lẫn ghi, nên phải cẩn thận khi đổi schema trên bảng nóng ở production.

Deadlock

Khi hai giao dịch chờ khóa của nhau theo vòng, ta có deadlock. Postgres tự phát hiện (sau deadlock_timeout, mặc định 1 giây) và hủy một giao dịch với ERROR: deadlock detected. Cách phòng: luôn khóa các dòng theo cùng một thứ tự (ví dụ luôn khóa account id nhỏ trước), giữ giao dịch ngắn. Xem thêm cách soi khóa ở bài Giám sát.

Advisory lock — khóa do ứng dụng tự định nghĩa

pg_advisory_lock(key) cho phép khóa theo một con số tùy ý, không gắn với dòng/bảng nào — hữu ích để đảm bảo "chỉ một tiến trình chạy job X tại một thời điểm" trên toàn cụm. Nhớ nhả khóa (pg_advisory_unlock) hoặc dùng bản xact (tự nhả khi hết giao dịch).

Use case thực tế

1) Hàng đợi job không cần Redis/Kafka. Một dịch vụ vừa phải có thể dùng bảng jobs + FOR UPDATE SKIP LOCKED (như ví dụ trên). Nhiều worker poll song song, mỗi job chỉ được một worker nhận, không cần thêm hạ tầng. Khi tải lớn hơn mới cân nhắc Kafka.

2) Chuyển tiền an toàn. Với Read Committed, dùng SELECT ... FOR UPDATE để khóa cả hai tài khoản (theo thứ tự id tăng dần để tránh deadlock) rồi mới trừ/cộng. Hoặc nâng lên SERIALIZABLE và bọc logic retry — code sạch hơn, để Postgres tự bắt xung đột.

3) Bất biến nghiệp vụ phức tạp. Khi quy tắc kiểu "tổng hạn mức của nhóm không vượt X" bị đe dọa bởi write skew, đừng cố tự khóa từng dòng — dùng Serializable và retry khi gặp lỗi serialization. Đây đúng là tình huống SSI được sinh ra để xử lý.

Ghi nhớ

  • MVCC: đọc không chặn ghi; mỗi UPDATE sinh phiên bản mới + dead tuple → cần VACUUM. Cột hệ thống xmin/xmax cho biết version.
  • PostgreSQL có 3 mức cách ly thật; không có dirty read. Mặc định là Read Committed.
  • Read Committed: snapshot theo câu lệnh. Repeatable Read: snapshot theo giao dịch (snapshot isolation). Serializable: SSI, chặn cả write skew.
  • Repeatable Read và Serializable có thể fail với lỗi serialize → ứng dụng phải retry.
  • Chống lost update ở Read Committed bằng SELECT ... FOR UPDATE.
  • FOR UPDATE SKIP LOCKED = hàng đợi job gọn nhẹ ngay trong Postgres.
  • Tránh deadlock bằng cách khóa theo thứ tự nhất quán và giữ giao dịch ngắn; tránh idle in transaction (xem Giám sát).
  • Đặt logic phức tạp trong CSDL? Xem PL/pgSQL để hiểu hàm/trigger chạy trong ngữ cảnh giao dịch nào.

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.

13 thg 7, 2026 3