0

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 UPDATE thực chất là một lệnh INSERT dòng mới mang dữ liệu mới và đánh dấu xóa dòng cũ.

  • Mỗi câu lệnh DELETE thự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:

  1. xmin (Creation XID): ID của Transaction đã thực hiện câu lệnh INSERT để tạo ra phiên bản tuple này.

  2. xmax (Deletion XID):

    • Mặc định bằng 0 nếu dòng còn sống và chưa có ai can thiệp.

    • Nếu dòng bị DELETE hoặ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 ý: xmax còn được tận dụng để lưu ID của Transaction đang giữ khóa SELECT ... FOR UPDATE).

  3. 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.

  4. ctid (Physical Pointer): Trỏ về vị trí vật lý (block, offset). Nếu dòng bị update, ctid củ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: Transaction 1052301 đã 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 extension pageinspect để đọ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_xmax của Tuple 1 đã được điền giá trị 1052302 (Transaction thực hiện lệnh UPDATE).

  • Trường t_ctid củ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ố:

Snapshot Format: XMIN:XMAX:[xip_list]\text{Snapshot Format: } \mathbf{XMIN : XMAX : [xip\_list]}

  • 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ó XID<XMIN\text{XID} < \text{XMIN} đề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ó XID≥XMAX\text{XID} \ge \text{XMAX} đề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ữa XMIN và 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:

  1. Transaction tạo ra dòng này (xmin) có hợp lệ không?

    • Nếu xmin chưa commit (đang chạy dở dang trong xip_list hoặc là tương lai ≥XMAX\ge \text{XMAX}): Không nhìn thấy.

    • Nếu xmin bị ROLLBACK (Abort): Không nhìn thấy.

    • Nếu xmin đã COMMIT và có trước mốc Snapshot: Thỏa mãn bước 1, xét tiếp bước 2.

  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 →\rightarrow NHÌN THẤY TUPLE.

    • Nếu xmax bị ROLLBACK: Thao tác xóa bị hủy bỏ →\rightarrow 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 →\rightarrow NHÌN THẤY TUPLE.

    • Nếu xmax đã COMMIT trước thời điểm Snapshot: Dòng này đã thực sự bị xóa →\rightarrow 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ấu xmin chắc chắn đã commit.

  • HEAP_XMIN_INVALID: Đánh dấu xmin đã bị abort/rollback.

  • HEAP_XMAX_COMMITTED: Đánh dấu xmax chắc chắn đã commit.

  • HEAP_XMAX_INVALID: Đánh dấu xmax đã 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 (O(1)O(1)). 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 = INSERT dòng mới + Đánh dấu xóa dòng cũ; DELETE = Đánh dấu xmax.

  • 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí