Mở đầu loạt "PostgreSQL nội bộ và đồng thời" này là khái niệm nền tảng nhất, thứ chi phối mọi hành vi đồng thời của PostgreSQL: MVCC — Multi-Version Concurrency Control. Nếu bạn từng thắc mắc vì sao trong PostgreSQL một truy vấn SELECT dài không bao giờ bị kẹt vì ai đó đang UPDATE, hay vì sao bảng cứ phình to dù số dòng không đổi, câu trả lời đều nằm ở MVCC.

Bài này (phần 1) không nói lý thuyết suông mà mở hai phiên psql chạy song song trên pg-lab, xem metadata ẩn của mỗi hàng, và quan sát trực tiếp điều kinh điển: đọc không chặn ghi, ghi không chặn đọc.

Cơ chế: mỗi hàng có nhiều phiên bản

Ý tưởng cốt lõi của MVCC: thay vì khóa hàng để một lúc chỉ một giao dịch chạm vào, PostgreSQL giữ nhiều phiên bản của cùng một hàng. Mỗi giao dịch thấy phiên bản hợp lệ với thời điểm của nó (snapshot). Để làm được, mỗi hàng mang vài cột ẩn:

  • xmin: id giao dịch đã tạo phiên bản này.
  • xmax: id giao dịch đã xóa/thay thế phiên bản này (0 nghĩa là còn sống).
  • ctid: vị trí vật lý của hàng (block, offset).

Cơ chế MVCC PostgreSQL: mỗi hàng mang metadata ẩn xmin là id giao dịch tạo ra phiên bản này, xmax là id giao dịch xóa hoặc thay thế phiên bản này 0 là còn sống, ctid là vị trí vật lý của hàng block offset, SELECT ctid xmin xmax từ account để xem trực tiếp; UPDATE không sửa tại chỗ mà tạo phiên bản mới UPDATE bằng chèn hàng mới phiên bản mới cộng đánh dấu hàng cũ là chết, hàng cũ 0,2 xmax bằng txA bị txA đánh dấu xóa, hàng mới 0,3 xmin bằng txA phiên bản mới txA tạo, reader cũ vẫn đọc hàng cũ reader mới đọc hàng mới không ai chờ ai; snapshot mỗi giao dịch thấy ảnh chụp nhất quán khi A UPDATE nhưng chưa commit B đọc thấy phiên bản cũ vì phiên bản mới của A chưa hiện B không bị chặn A không bị chặn đọc và ghi chạy song song đây là nền tảng PostgreSQL hiếm khi dùng khóa đọc read lock

Hình 1: Mỗi hàng mang xmin/xmax/ctid ẩn. UPDATE không sửa tại chỗ mà tạo phiên bản mới (ctid khác, xmin mới) và đánh dấu bản cũ bằng xmax. Reader cũ vẫn đọc bản cũ, reader mới đọc bản mới — không ai chặn ai.

-- Xem truc tiep metadata an cua moi hang
SELECT ctid, xmin, xmax, * FROM account;
--  ctid  |  xmin  | xmax | id | owner | balance
-- (0,1)  | 857290 |   0  |  1 | an    |    100

Đo thật: A update chưa commit, B vẫn đọc được

Mình tạo bảng account (id=1, balance=100), rồi chạy hai phiên psql song song trên pg-lab:

  • Phiên A: BEGIN; UPDATE balance=999; rồi giữ 6 giây chưa commit.
  • Phiên B: trong lúc đó, SELECT đọc balance.

Kết quả thật từ pg-lab:

Bảng kết quả đo thật MVCC với 2 phiên đồng thời pg-lab postgresql 16.15 2 phiên psql account id 1 balance 100: kịch bản A UPDATE balance 999 giữ 6 giây chưa commit B đọc, A BEGIN txid 857292 UPDATE balance 999 chưa COMMIT ngủ 6 giây, B trong lúc A chưa commit SELECT balance; B đọc không bị chặn thấy phiên bản cũ rồi phiên bản mới sau commit, thời điểm A chưa commit ctid 0,2 xmin 857291 xmax 857292 balance B thấy 100 cũ, thời điểm A đã commit ctid 0,3 xmin 857292 xmax 0 balance B thấy 999 mới. Trong lúc A chưa commit hàng cũ 0,2 có xmax 857292 bị A đánh dấu xóa nhưng B vẫn đọc được balance 100 read không bị write chặn, sau khi A commit B thấy hàng mới 0,3 với xmin 857292 A tạo balance 999 UPDATE đã tạo phiên bản mới ở ctid khác không sửa tại chỗ. Badge output thật màu xanh

Hình 2: Kết quả thật. Khi A chưa commit, B đọc được balance=100 (bản cũ, ctid 0,2 đã có xmax=857292). Sau khi A commit, B thấy balance=999 (bản mới, ctid 0,3, xmin=857292). Read không bị write chặn; UPDATE tạo phiên bản mới.

Đọc từng dòng:

  • Trong lúc A chưa commit: B đọc được hàng ở ctid (0,2), xmin=857291, xmax=857292, balance=100. Chú ý xmax đã bị đặt = 857292 (txid của A) — nghĩa là A đã đánh dấu hàng này sẽ bị thay thế. Nhưng vì A chưa commit, B vẫn thấy phiên bản cũ này hợp lệ và đọc được ngay — không bị chặn một mili giây nào.
  • Sau khi A commit: B đọc lại, giờ thấy hàng ở ctid (0,3) (vị trí vật lý khác), xmin=857292 (do A tạo), xmax=0 (còn sống), balance=999.

Điều này chứng minh hai sự thật cốt lõi của MVCC:

  1. Đọc không bị ghi chặn: B SELECT chạy bình thường trong khi A đang UPDATE dở dang. Trong nhiều database dùng khóa (lock-based), B sẽ phải chờ A xong — PostgreSQL thì không.
  2. UPDATE không sửa tại chỗ: thay vì ghi đè balance của hàng (0,2), PostgreSQL tạo một hàng mới (0,3) cho phiên bản mới, và đánh dấu hàng cũ bằng xmax. Hai phiên bản cùng tồn tại một lúc, phục vụ hai snapshot khác nhau.

Vì sao điều này quan trọng với backend

MVCC là lý do PostgreSQL xử lý tốt tải đọc-ghi hỗn hợp cao: một báo cáo chạy 5 phút không khóa cả bảng, giao dịch ghi vẫn tiếp tục song song. Nó cũng là nền tảng để hiểu mọi bài sau của loạt này: các mức cô lập (isolation levels, bài 2-4) chính là quy tắc chọn snapshot nào; bloat (bài 7) là hệ quả của việc UPDATE để lại hàng chết; VACUUM (bài 8) là cơ chế dọn những hàng chết đó. Hiểu MVCC là chìa khóa mở toàn bộ hành vi đồng thời của PostgreSQL.

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

MVCC đổi tốc độ đọc-ghi song song lấy không gian và công dọn dẹp. Vì UPDATE/DELETE không xóa ngay mà để lại hàng chết (dead tuple), bảng phình to theo số lần cập nhật — gọi là bloat (bài 7). PostgreSQL cần tiến trình VACUUM (thường tự động) dọn các hàng chết này và tái dùng không gian. Đây là cái giá của "đọc không chặn ghi": bạn trả bằng đĩa và chu kỳ vacuum, thay vì trả bằng việc chờ khóa. Với bảng cập nhật rất nhiều, cấu hình autovacuum đúng là việc quan trọng.

xmax được đặt ngay cả khi chưa commit — hành vi hiển thị phụ thuộc trạng thái giao dịch. Như demo cho thấy, hàng cũ có xmax=txA trước khi A commit. Việc B có thấy phiên bản nào là do PostgreSQL so snapshot của B với trạng thái commit của các giao dịch trong xmin/xmax (qua cấu trúc commit log / clog). Nếu A rollback thay vì commit, hàng mới (0,3) trở thành rác và hàng cũ (0,2) vẫn là bản hợp lệ. Nghĩa là xmin/xmax chỉ là manh mối; quyết định hiển thị còn dựa trên giao dịch đã commit hay chưa.

Giao dịch mở quá lâu cản trở VACUUM dọn dẹp. Một giao dịch sống rất lâu (ví dụ một kết nối "quên đóng" giữ BEGIN) khiến PostgreSQL không dám dọn các hàng chết mà giao dịch đó có thể cần thấy — vì snapshot của nó vẫn còn hiệu lực. Hậu quả: bloat tích tụ, bảng phình, hiệu năng giảm, dù autovacuum vẫn chạy. Đây là một trong những sự cố production kinh điển của PostgreSQL: theo dõi pg_stat_activity để bắt các giao dịch idle in transaction sống lâu.

Ba ý mang về

  1. MVCC giữ nhiều phiên bản mỗi hàng, nên đọc không chặn ghi. Đo thật: khi A UPDATE balance=999 chưa commit, B vẫn đọc được balance=100 (bản cũ) không bị chặn. Mỗi hàng mang xmin (tx tạo), xmax (tx xóa), ctid (vị trí) — xem trực tiếp bằng SELECT ctid, xmin, xmax.
  2. UPDATE tạo phiên bản mới, không sửa tại chỗ. Đo thật: sau UPDATE, hàng chuyển từ ctid (0,2) sang (0,3) với xmin mới = txid của A; hàng cũ bị đánh dấu xmax. Hai phiên bản cùng tồn tại, phục vụ hai snapshot khác nhau.
  3. Cái giá của MVCC là bloat và vacuum. Hàng chết tích tụ theo số lần cập nhật nên bảng phình to, cần VACUUM dọn (bài 8); giao dịch mở quá lâu cản trở dọn dẹp. MVCC là nền tảng để hiểu isolation (bài 2-4), bloat (bài 7), vacuum (bài 8).

Nguồn

Phần sau ta mổ xẻ bốn mức cô lập giao dịch: đọc bẩn, đọc lặp sai (non-repeatable read), phantom — và demo thật từng bất thường (anomaly) xuất hiện hay biến mất ở Read Committed, Repeatable Read, Serializable.