Bỏ qua để đến nội dung
Knowledge Hub

MVCC trong PostgreSQL: xmin, xmax và snapshot Trung cấp

Thời gian đọc ước tính: 7 phút

Một request sửa số dư từ 100 thành 70. Cùng lúc, request khác mở trang hiển thị số dư. Nếu chỉ có một ô nhớ cho con số này, reader phải chọn: chờ writer xong, hoặc có nguy cơ thấy 70 dù writer rollback.

PostgreSQL chọn cách thứ ba: MVCC. Nhiều phiên bản của cùng một row cùng tồn tại một thời gian; mỗi reader chọn phiên bản hợp lệ với mốc đọc của mình. Hệ quả: SELECT thường không chờ writer, đổi lại mỗi UPDATE để lại rác cho VACUUM dọn.

  • Mỗi UPDATE tạo một tuple mới trong heap thay vì ghi đè tuple cũ. Header cố định của tuple khoảng 23 byte, gồm xmin (XID của transaction tạo version) và xmax (XID của transaction xoá, cập nhật hoặc khoá version), và t_ctid trỏ tới version mới hơn.
  • Snapshot trả lời câu hỏi “tại thời điểm đọc, transaction nào đã commit, transaction nào còn đang chạy?”. Nó không phải bản copy của bảng.
  • Visibility của tuple là kết quả đối chiếu xmin/xmax với snapshot (và trạng thái commit của các XID đó).
Heap page của bảng accounts, row id = 1
slot 1 v1 balance = 100 xmin = 100 xmax = 205 t_ctid -> slot 2
slot 2 v2 balance = 70 xmin = 205 xmax = 0 t_ctid -> slot 2
(transaction 205 là transaction chạy UPDATE)

Ai thấy version nào:

Người đọc Trạng thái của tx 205 Thấy Lý do
Chính tx 205 đang chạy v2 = 70 Transaction luôn thấy thay đổi của chính nó
Snapshot lấy khi 205 chưa commit chưa commit v1 = 100 xmin = 100 đã commit; xmax = 205 chưa commit nên chưa có hiệu lực
Snapshot lấy sau khi 205 commit đã commit v2 = 70 xmax = 205 đã commit nên v1 coi như đã bị thay; xmin = 205 đã commit nên v2 hợp lệ
Bất kỳ ai, nếu 205 rollback đã abort v1 = 100 xmax = 205 của transaction abort bị bỏ qua; v2 không bao giờ hiện

Quy tắc rút gọn (đủ để suy luận, bỏ qua các trường hợp phụ như subtransaction): một version nhìn thấy được khi xmin đã commit theo snapshot (hoặc là transaction của chính mình) và xmax bằng 0, hoặc chưa commit, hoặc đã abort.

Một UPDATE tạo phiên bản mới; reader chọn theo snapshot
Một UPDATE tạo phiên bản mới; reader chọn theo snapshotBước 1/5: Ban đầu row có version v1 = 100 đã commit. Reader đọc v1.Session ghiCác versionSession đọcv1 = 100
Các version → Session đọcv1 = 100
Bước 1/5

Ban đầu row có version v1 = 100 đã commit. Reader đọc v1.

  1. Ban đầu chỉ có v1 = 100 đã commit.
  2. UPDATE tạo v2 = 70. v1 chưa bị xoá vật lý; writer thấy v2.
  3. Trước COMMIT, reader thường vẫn chọn v1, nên không thấy dữ liệu chưa commit.
  4. Sau COMMIT, một SELECT bắt đầu sau đó ở Read Committed thấy 70. SELECT đã bắt đầu trước đó, hoặc transaction Repeatable Read giữ snapshot cũ, vẫn thấy 100.
  5. v1 chỉ được thu hồi khi không còn snapshot nào cần nó. Transaction đọc mở lâu cản việc này, kể cả khi nó chỉ đọc.
Mốc Writer thấy SELECT mới ở Read Committed Reader Repeatable Read (snapshot lấy trước UPDATE)
Trước UPDATE 100 100 100
UPDATE xong, chưa commit 70 100 100
Sau COMMIT 70 70 100

“Commit rồi mà vẫn thấy giá trị cũ” thường là do snapshot, không phải replication lag: kiểm tra isolation level và thời điểm lấy snapshot trước. Snapshot của Repeatable Read lấy ở câu lệnh đầu tiên không phải lệnh điều khiển transaction, không phải ở chữ BEGIN.

  • Hai writer sửa cùng row vẫn chờ nhau (xem row-level locks).
  • SELECT ... FOR UPDATE ghi lên row để đánh dấu đã khoá, nên gây disk write ngay cả khi bạn chỉ “đọc” (tài liệu). Dùng nó trên nhiều row nóng làm dirty page và tăng WAL.
  • Đây là lý do xmax khác 0 không có nghĩa row đã bị xoá: nó còn có thể ghi một row lock hoặc thuộc về transaction đã rollback. Đừng viết business logic dựa trên xmin/xmax; chúng không phải thời gian sửa và không phải audit log.

Version cũ nằm ngay trong heap, cùng chỗ với dữ liệu sống. Một số engine khác (InnoDB, Oracle) giữ version cũ ở vùng undo riêng; đó là khác biệt kiến trúc giải thích vì sao bảng PostgreSQL ghi nhiều cần VACUUM thường xuyên. Chi phí cụ thể và cách chỉnh nằm ở VACUUM, autovacuum và bloat.

Demo một session (bảng TEMP, không để lại gì):

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
docker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-mvcc/mvcc-demo.sql

Script in balance, xmin, xmax, ctid trước UPDATE, trong transaction, sau rollback và sau commit. XID và ctid đổi theo từng lần chạy; chỉ cần kiểm tra chuỗi 100, 70 trong transaction, 100 sau rollback, 70 sau commit.

Muốn thấy hai snapshot cùng lúc, dùng bảng thường (không dùng TEMP, vì mỗi session có bảng tạm riêng):

-- A
CREATE TABLE mvcc_shared (id int PRIMARY KEY, balance int);
INSERT INTO mvcc_shared VALUES (1, 100);
BEGIN; UPDATE mvcc_shared SET balance = 70 WHERE id = 1; -- dừng, chưa COMMIT
-- B
SELECT balance FROM mvcc_shared WHERE id = 1; -- 100
-- A
COMMIT;
-- B
SELECT balance FROM mvcc_shared WHERE id = 1; -- 70 (Read Committed)
DROP TABLE mvcc_shared;

Điều MVCC mang lại

  • Reader thường đọc bản đã commit trong khi writer chưa commit: đọc không chặn ghi, ghi không chặn đọc.
  • Snapshot mô tả chính xác dữ liệu một statement hoặc transaction được thấy.
  • Rollback không buộc reader nào phải sửa kết quả đã trả.

Giá phải trả và giới hạn

  • UPDATE/DELETE tạo version cần dọn: VACUUM và bloat là chi phí thường trực.
  • Snapshot giữ lâu làm chậm việc thu hồi version cũ.
  • MVCC không thay lock cho hai writer cùng row, và không tự ngăn write skew ở Repeatable Read.
  1. Không có business logic nào đọc xmin/xmax như thời gian sửa, cờ xoá hay audit log.
  2. Transaction (kể cả chỉ đọc) không mở lâu; báo cáo dùng Repeatable Read có statement_timeout.
  3. Thấy “commit rồi mà vẫn đọc giá trị cũ”: kiểm tra isolation level và thời điểm lấy snapshot trước khi nghi replication lag.
  4. Cần nhiều truy vấn cùng một mốc dữ liệu: gói trong BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY, không ghép kết quả từ nhiều snapshot Read Committed.
  5. Không rải SELECT ... FOR UPDATE trên nhiều row nóng chỉ để “đọc cho chắc”: nó ghi lên row, làm dirty page và tăng WAL.

Tự kiểm tra: (1) Writer cập nhật 100 thành 70 nhưng chưa commit: writer và reader thường thấy gì, và xmax của v1 là gì? (2) Vì sao reader Repeatable Read có thể vẫn thấy 100 sau khi writer commit? (3) Vì sao một transaction chỉ đọc mà mở lâu vẫn làm bảng phình?