PostgreSQL Bài 29: Cơ chế MVCC (Multi-Version Concurrency Control) hoạt động ra sao?
Chào mừng bạn bước sang Phần 5: Transaction, Concurrency & Locking.
Trong các hệ quản trị cơ sở dữ liệu cổ điển, giải pháp đơn giản nhất để đảm bảo tính cô lập (Isolation) khi nhiều kết nối cùng truy cập một dòng là dùng khóa (Locking): ai muốn đọc phải đợi người ghi xong, ai muốn ghi phải đợi người đọc xong. Cách làm này triệt tiêu khả năng xử lý song song (Concurrency) và biến cơ sở dữ liệu thành nút thắt cổ chai lớn nhất hệ thống.
PostgreSQL giải quyết bài toán này bằng MVCC (Multi-Version Concurrency Control - Kiểm soát tương tranh đa phiên bản) với triết lý thiết kế cốt lõi:
"Readers never block writers, and writers never block readers."
(Tiến trình đọc không bao giờ chặn tiến trình ghi, và tiến trình ghi không bao giờ chặn tiến trình đọc).
Bài học này sẽ đi sâu vào cấu trúc vật lý của các phiên bản dòng (Tuple Versions), các trường metadata ẩn quản lý vòng đời transaction, và thuật toán xác định tính nhìn thấy (Visibility Rules) thông qua Snapshot.
1. Bản chất của MVCC: Snapshot Isolation
Khi nhiều giao dịch (Transactions) diễn ra đồng thời, thay vì khóa bảng hoặc khóa dòng tại chỗ, PostgreSQL tạo ra nhiều phiên bản độc lập của cùng một dòng trên các trang đĩa (Heap Pages).
-
Mỗi câu lệnh
UPDATEthực chất là một lệnhINSERTdòng mới mang dữ liệu mới và đánh dấu xóa dòng cũ. -
Mỗi câu lệnh
DELETEthực chất là đánh dấu dòng cũ đã chết (Dead Tuple) mà không xóa vật lý ngay lập tức. -
Mỗi Transaction khi bắt đầu sẽ nhìn vào database thông qua một bức ảnh chụp trạng thái logic (Snapshot). Dữ liệu mà Transaction đó nhìn thấy là một trạng thái nhất quán tại một thời điểm cố định trong quá khứ, hoàn toàn miễn nhiễm với các thay đổi đang được ghi bởi các Transaction khác.
CƠ CHẾ ĐỌC - GHI ĐỒNG THỜI CỦA MVCC:
Dòng dữ liệu ban đầu: [ Tuple v1: balance = 100 ]
│
┌─────────────────────────┴─────────────────────────┐
▼ ▼
Transaction A (Ghi): Transaction B (Đọc):
UPDATE balance = 200 SELECT balance FROM accounts;
├── Đánh dấu v1 đã cũ ├── Nhìn thấy v1 qua Snapshot
└── Chèn thêm [ Tuple v2: balance = 200 ] └── Đọc ra 100 (Không bị block!)
2. Các cột hệ thống ẩn phục vụ MVCC trong Tuple Header
Như đã tìm hiểu ở Bài 21 về cấu trúc 23-byte của HeapTupleHeader, PostgreSQL gắn trực tiếp các trường metadata vào từng dòng để xác định vòng đời của nó đối với các Transaction ID (XID):
┌────────────────────────────────────────────────────────┐
│ CÁC TRƯỜNG MVCC TRONG HEAP TUPLE HEADER: │
│ ├── t_xmin (4 bytes) : Transaction ID sinh ra dòng này│
│ ├── t_xmax (4 bytes) : Transaction ID xóa/sửa dòng này│
│ ├── t_cid (4 bytes) : Thứ tự lệnh trong Transaction │
│ ├── t_ctid (6 bytes) : Con trỏ trỏ tới bản mới nhất │
│ └── t_infomask (4B) : Cờ trạng thái commit/abort │
└────────────────────────────────────────────────────────┘
Chi tiết ý nghĩa các trường:
-
xmin(Creation XID): ID của Transaction đã thực hiện câu lệnhINSERTđể tạo ra phiên bản tuple này. -
xmax(Deletion XID):-
Mặc định bằng
0nếu dòng còn sống và chưa có ai can thiệp. -
Nếu dòng bị
DELETEhoặc bịUPDATE, ID của Transaction thực hiện lệnh xóa/sửa sẽ được ghi đè vào đây. -
(Lưu ý:
xmaxcòn được tận dụng để lưu ID của Transaction đang giữ khóaSELECT ... FOR UPDATE).
-
-
t_cid(Command Identifier): Số thứ tự các câu lệnh SQL bên trong cùng một Transaction (bắt đầu từ 0), giúp transaction nhìn thấy chính những thay đổi do các câu lệnh trước đó của chính nó tạo ra. -
ctid(Physical Pointer): Trỏ về vị trí vật lý(block, offset). Nếu dòng bị update,ctidcủa tuple cũ trỏ sang tuple mới.
3. Thực hành soi trực tiếp xmin, xmax qua vòng đời dòng dữ liệu
Hãy mở terminal psql để quan sát cách PostgreSQL biến đổi các trường này qua từng câu lệnh:
SQL
CREATE SCHEMA IF NOT EXISTS mvcc_lab;
CREATE TABLE mvcc_lab.accounts (
id INT PRIMARY KEY,
owner_name TEXT,
balance NUMERIC(10, 2)
);
Bước 1: Khởi tạo dòng (INSERT)
SQL
-- Lấy XID hiện tại và chèn dòng mới
INSERT INTO mvcc_lab.accounts VALUES (1, 'Alice', 1000.00);
-- Truy vấn các cột ẩn:
SELECT xmin, xmax, ctid, id, owner_name, balance FROM mvcc_lab.accounts;
Kết quả:
Plaintext
xmin | xmax | ctid | id | owner_name | balance
---------+------+-------+----+------------+---------
1052301 | 0 | (0,1) | 1 | Alice | 1000.00
-
xmin = 1052301: Transaction1052301đã sinh ra dòng này. -
xmax = 0: Dòng đang tồn tại hợp lệ, chưa bị đánh dấu xóa. -
ctid = (0,1): Nằm ở Page 0, Line Pointer 1.
Bước 2: Cập nhật dữ liệu (UPDATE)
Bây giờ, tiến hành cập nhật số dư của Alice:
SQL
UPDATE mvcc_lab.accounts SET balance = 1500.00 WHERE id = 1;
SELECT xmin, xmax, ctid, id, owner_name, balance FROM mvcc_lab.accounts;
Kết quả câu lệnh SELECT thông thường:
Plaintext
xmin | xmax | ctid | id | owner_name | balance
---------+------+-------+----+------------+---------
1052302 | 0 | (0,2) | 1 | Alice | 1500.00
-
Dòng hiển thị hiện tại có
xmin = 1052302(do lệnh UPDATE sinh ra) và nằm ở vị trí mới(0,2). -
Vậy dòng cũ
(0,1)đã biến đi đâu? Hãy dùng extensionpageinspectđể đọc dữ liệu thô dưới đĩa:
SQL
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT
lp AS item_offset,
t_xmin,
t_xmax,
t_ctid,
t_data
FROM heap_page_items(get_raw_page('mvcc_lab.accounts', 0));
Kết quả đọc trực tiếp từ Page 0:
Plaintext
lp | t_xmin | t_xmax | t_ctid | t_data
----+---------+---------+--------+--------------------------
1 | 1052301 | 1052302 | (0,2) | ... Alice ... 1000.00 ...
2 | 1052302 | 0 | (0,2) | ... Alice ... 1500.00 ...
-
Tuple số 1 vẫn còn nằm nguyên trên đĩa!
-
Trường
t_xmaxcủa Tuple 1 đã được điền giá trị1052302(Transaction thực hiện lệnh UPDATE). -
Trường
t_ctidcủa Tuple 1 đổi thành(0,2), trỏ thẳng sang Tuple số 2 mới sinh ra.
Bước 3: Xóa dữ liệu (DELETE)
SQL
DELETE FROM mvcc_lab.accounts WHERE id = 1;
Khi chạy xong:
-
Câu lệnh
SELECT * FROM mvcc_lab.accounts;sẽ trả về 0 dòng. -
Tuy nhiên, trong trang đĩa 8KB, Tuple số 2 vẫn tồn tại với trường
t_xmaxđược gán bằng XID của lệnh DELETE vừa chạy. Nó chính thức trở thành Dead Tuple.
4. Cấu trúc Snapshot và Quy tắc xác định tính nhìn thấy (Visibility Check)
Khi một Transaction chạy một câu truy vấn, PostgreSQL cấp cho nó một Snapshot có cấu trúc đại số:
-
XMIN: Transaction ID nhỏ nhất vẫn còn đang hoạt động (Active/In-progress) tại thời điểm chụp snapshot. Mọi transaction có đều được xem là đã kết thúc (đã commit hoặc abort). -
XMAX: Transaction ID đầu tiên chưa được cấp phát (Unassigned XID). Mọi transaction có đều được xem là chưa xuất hiện trong tương lai. -
xip_list(Active XID List): Danh sách các transaction đang chạy dở dang tại thời điểm chụp nằm giữaXMINvàXMAX.
TRỤC THỜI GIAN TRANSACTION ID (XID):
ĐÃ KẾT THÚC (Quá khứ) ĐANG CHẠY DỞ (xip_list) TƯƠNG LAI
───────────────────────────────┬───────────────────────────────────────┬──────────────────────► XID
▼ ▼
XMIN XMAX
(Mọi transaction < XMIN) (Mọi transaction >= XMAX)
Đều đã Commit/Abort Chưa bắt đầu lúc Snapshot chụp
Thuật toán Visibility Check cơ bản:
Một tuple có hợp lệ để Transaction hiện tại nhìn thấy hay không phụ thuộc vào 2 câu hỏi logic:
-
Transaction tạo ra dòng này (
xmin) có hợp lệ không?-
Nếu
xminchưa commit (đang chạy dở dang trongxip_listhoặc là tương lai ): Không nhìn thấy. -
Nếu
xminbịROLLBACK(Abort): Không nhìn thấy. -
Nếu
xminđãCOMMITvà có trước mốc Snapshot: Thỏa mãn bước 1, xét tiếp bước 2.
-
-
Transaction xóa dòng này (
xmax) có hiệu lực không?-
Nếu
xmax = 0: Dòng chưa từng bị xóa NHÌN THẤY TUPLE. -
Nếu
xmaxbịROLLBACK: Thao tác xóa bị hủy bỏ NHÌN THẤY TUPLE. -
Nếu
xmaxđang chạy dở hoặc xảy ra sau thời điểm Snapshot: Dòng này bị xóa trong tương lai, ở thời điểm hiện tại nó vẫn tồn tại NHÌN THẤY TUPLE. -
Nếu
xmaxđãCOMMITtrước thời điểm Snapshot: Dòng này đã thực sự bị xóa KHÔNG NHÌN THẤY.
-
5. Cờ Hint Bits và Tối ưu hóa hiệu năng kiểm tra Commit Log
Trong lý thuyết, mỗi lần kiểm tra xem một xmin hay xmax đã commit hay chưa, PostgreSQL phải tra cứu vào cấu trúc CLOG (Commit Log - thư mục pg_xact) trên đĩa hoặc RAM buffer.
Nếu một bảng có 10 triệu dòng, việc tra cứu CLOG 10 triệu lần cho mỗi câu query sẽ gây nghẽn nghiêm trọng.
Giải pháp: Hint Bits
Để giải quyết bài toán này, PostgreSQL sử dụng các bit cờ gọi là Hint Bits nằm trong trường t_infomask của Tuple Header:
-
HEAP_XMIN_COMMITTED: Đánh dấuxminchắc chắn đã commit. -
HEAP_XMIN_INVALID: Đánh dấuxminđã bị abort/rollback. -
HEAP_XMAX_COMMITTED: Đánh dấuxmaxchắc chắn đã commit. -
HEAP_XMAX_INVALID: Đánh dấuxmaxđã bị abort/rollback.
CƠ CHẾ HOẠT ĐỘNG CỦA HINT BITS:
Lần đầu tiên có query đọc qua Tuple:
├── Kiểm tra CLOG ──► Xác nhận Transaction đã COMMIT.
└── Bật cờ HEAP_XMIN_COMMITTED trực tiếp vào Tuple Header trên trang đĩa.
Từ lần thứ hai trở đi:
└── Engine chỉ cần nhìn vào Hint Bits trong Header và quyết định ngay lập tức
(Hoàn toàn không cần chạm vào CLOG nữa!).
6. Bảng đối chiếu: MVCC trên PostgreSQL vs InnoDB (MySQL) / Oracle
Không phải cơ sở dữ liệu nào cũng triển khai MVCC giống nhau. Việc hiểu kiến trúc của PostgreSQL so với các RDBMS khác giúp bạn đưa ra lựa chọn đúng đắn:
| Tiêu chí | PostgreSQL (In-place Heap Storage) | MySQL InnoDB / Oracle (Undo Log) |
|---|---|---|
| Nơi lưu các bản ghi cũ | Lưu trực tiếp trên Data Page chung với bảng chính (Heap). | Lưu trong vùng nhớ/tập tin riêng biệt gọi là Undo Tablespace / Undo Log. |
| Thao tác UPDATE | Thêm Tuple mới vào Page + Đổi con trỏ. | Ghi đè dữ liệu mới tại chỗ, đẩy bản ghi cũ ra Undo Log. |
| Thao tác ROLLBACK | Cực nhanh (). Chỉ cần đánh dấu trạng thái transaction là ABORT trong CLOG. |
Chậm hơn, phải đọc ngược Undo Log để khôi phục lại dữ liệu cũ. |
| Hệ quả phân mảnh (Bloat) | Dữ liệu cũ (Dead Tuples) nằm lẫn lộn trên Data Page, bắt buộc phải có VACUUM để dọn rác. | Không làm nở bảng chính, nhưng Undo Log có thể bị phình to nếu transaction chạy dài. |
7. Tóm tắt & Bài tiếp theo
-
MVCC cho phép nhiều tiến trình đọc và ghi đồng thời mà không khóa lẫn nhau.
-
UPDATE=INSERTdòng mới + Đánh dấu xóa dòng cũ;DELETE= Đánh dấuxmax. -
Mọi dữ liệu cũ không biến mất ngay mà biến thành Dead Tuples nằm trực tiếp trên Data Pages.
-
Snapshot gồm
xmin:xmax:[xip_list]đóng vai trò là "chiếc gương thời gian" quyết định một tuple có hợp lệ hay không. -
Hint Bits giúp lưu vết trạng thái Commit trực tiếp vào dòng để tránh việc tra cứu CLOG lặp lại.
Bài 30 xem tiếp: Bốn cấp độ cô lập giao dịch (Transaction Isolation Levels): Read Committed, Repeatable Read, Serializable — chúng ta sẽ tìm hiểu cách các mức cô lập kiểm soát thời điểm chụp Snapshot, cơ chế bảo vệ khỏi các hiện tượng bất thường (Dirty Read, Non-repeatable Read, Phantom Read, Write Skew), và bản chất của thuật toán SSI (Serializable Snapshot Isolation) trong PostgreSQL.
All rights reserved