MVCC trong PostgreSQL: xmin, xmax và snapshot Trung cấp
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”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ô hình: version, snapshot, visibility
Phần tiêu đề “Mô hình: version, snapshot, visibility”- Mỗi
UPDATEtạo một tuple mới trong heap thay vì ghi đè tuple cũ. Header cố định của tuple khoảng 23 byte, gồmxmin(XID của transaction tạo version) vàxmax(XID của transaction xoá, cập nhật hoặc khoá version), vàt_ctidtrỏ 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/xmaxvới snapshot (và trạng thái commit của các XID đó).
Heap page sau một UPDATE (XID minh hoạ)
Phần tiêu đề “Heap page sau một UPDATE (XID minh hoạ)”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.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”v1 = 100Ban đầu row có version v1 = 100 đã commit. Reader đọc v1.
- Ban đầu chỉ có v1 = 100 đã commit.
UPDATEtạo v2 = 70. v1 chưa bị xoá vật lý; writer thấy v2.- Trước
COMMIT, reader thường vẫn chọn v1, nên không thấy dữ liệu chưa commit. - Sau
COMMIT, mộtSELECTbắ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. - 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.
MVCC không có nghĩa là không có lock
Phần tiêu đề “MVCC không có nghĩa là không có lock”- Hai writer sửa cùng row vẫn chờ nhau (xem row-level locks).
SELECT ... FOR UPDATEghi 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
xmaxkhá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ênxmin/xmax; chúng không phải thời gian sửa và không phải audit log.
Vì sao PostgreSQL phình bảng
Phần tiêu đề “Vì sao PostgreSQL phình bảng”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.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo một session (bảng TEMP, không để lại gì):
docker compose -f examples/docker-compose.yml up -d postgresdocker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-mvcc/mvcc-demo.sqlScript 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):
-- ACREATE 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
-- BSELECT balance FROM mvcc_shared WHERE id = 1; -- 100
-- ACOMMIT;-- BSELECT balance FROM mvcc_shared WHERE id = 1; -- 70 (Read Committed)DROP TABLE mvcc_shared;Trade-offs
Phần tiêu đề “Trade-offs”Đ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/DELETEtạ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.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist review code
Phần tiêu đề “Checklist review code”- Không có business logic nào đọc
xmin/xmaxnhư thời gian sửa, cờ xoá hay audit log. - Transaction (kể cả chỉ đọc) không mở lâu; báo cáo dùng Repeatable Read có
statement_timeout. - 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.
- 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. - Không rải
SELECT ... FOR UPDATEtrê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óm tắt
Phần tiêu đề “Tóm tắt”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?