Trực giác của hầu hết mọi người về UPDATE là "tìm dòng, sửa giá trị tại chỗ". Trong PostgreSQL, điều đó sai. Mỗi UPDATE không ghi đè dòng cũ — nó viết một phiên bản dòng hoàn toàn mới ở vị trí vật lý khác, và đánh dấu bản cũ là "dòng chết" chờ dọn. Cơ chế này gọi là MVCC (Multi-Version Concurrency Control), và nó là nền tảng giải thích rất nhiều hành vi của PostgreSQL: vì sao bảng phình lên, vì sao cần VACUUM, vì sao độc giả không bao giờ chặn người ghi. Bài này đo thật MVCC bằng cách nhìn vào các cột hệ thống ẩn.

UPDATE viết một dòng mới, không sửa tại chỗ

Mỗi dòng trong PostgreSQL có các cột hệ thống ẩn: ctid (vị trí vật lý — số trang, số dòng trong trang), xmin (id giao dịch tạo ra nó), xmax (id giao dịch xóa/thay nó). Xem chúng biến đổi khi UPDATE:

INSERT INTO tk VALUES (1, 100);
SELECT ctid, xmin, sodu FROM tk;   -- (0,1) xmin=102620 sodu=100

UPDATE tk SET sodu = 200 WHERE id = 1;
SELECT ctid, xmin, sodu FROM tk;   -- (0,2) xmin=102621 sodu=200

ctid đổi từ (0,1) sang (0,2) — dòng mới nằm ở vị trí vật lý khác. xmin cũng đổi (giao dịch mới tạo ra nó). Dòng cũ tại (0,1) không biến mất ngay — nó trở thành "dòng chết", với xmax được đặt trỏ tới giao dịch vừa UPDATE. Cả hai bản cùng tồn tại một thời gian.

Ảnh chụp đoạn mã SQL nền tối minh hoạ MVCC mỗi UPDATE tạo một dòng mới không sửa tại chỗ, UPDATE không ghi đè nó viết một phiên bản dòng mới INSERT INTO tk VALUES 1 100 SELECT ctid xmin xmax sodu FROM tk 0,1 xmin 102620 sodu 100 UPDATE tk SET sodu bằng 200 WHERE id bằng 1 SELECT ctid xmin xmax sodu FROM tk 0,2 xmin 102621 sodu 200 ctid đổi dòng cũ 0,1 thành dòng chết dòng mới 0,2 là bản sống, xmin xmax điều khiển ai thấy phiên bản nào mỗi dòng có xmin giao dịch tạo và xmax giao dịch xóa hoặc thay giao dịch chỉ thấy dòng nếu xmin đã commit trước snapshot của nó và xmax rỗng hoặc sau snapshot độc giả có ảnh nhất quán không khóa ghi, hệ quả dòng cũ chồng chất bảng phình bloat cập nhật cùng 1 dòng 100000 lần DO BEGIN FOR i IN 1 tới 100000 LOOP UPDATE dem SET so bằng i WHERE id bằng 1 END LOOP END 1 dòng logic nhưng bảng phình từ 8KB lên vài MB dòng chết tích lũy VACUUM dem dọn dòng chết để tái dùng không trả file về OS, HOT cập nhật cột không index rẻ hơn nhiều Heap-Only Tuple cột đổi không có index cộng trang còn chỗ dòng mới ở lại cùng trang không đụng index rẻ hơn hẳn

Hình 1: UPDATE không sửa tại chỗ — ctid đổi từ (0,1) sang (0,2), bản cũ thành dòng chết. xmin/xmax điều khiển phiên bản nào hiện với giao dịch nào, cho phép độc giả có ảnh nhất quán mà không khóa người ghi.

Vì sao làm vậy: đọc không khóa ghi

Cái giá của việc tạo dòng mới đổi lại một lợi ích lớn: độc giả không bao giờ chặn người ghi, và ngược lại. Nhờ giữ nhiều phiên bản, một giao dịch đang đọc thấy "ảnh chụp" (snapshot) nhất quán của dữ liệu tại thời điểm nó bắt đầu — trong khi một giao dịch khác đang cập nhật cùng dòng đó tạo phiên bản mới mà không đè lên bản người đọc đang xem. Cột xmin/xmax quyết định: một giao dịch thấy một phiên bản dòng nếu xmin đã commit trước snapshot của nó, và xmax rỗng hoặc thuộc giao dịch sau snapshot. Đây là điều khiến PostgreSQL xử lý đồng thời tốt mà không cần khóa đọc.

Hệ quả: bảng phình vì dòng chết

Mặt trái của MVCC: mỗi UPDATE (và DELETE) để lại một dòng chết. Cập nhật nhiều lần làm dòng chết tích lũy và bảng phình lên dù số dòng logic không đổi. Đo thật, cập nhật cùng một dòng logic 100.000 lần:

Ảnh chụp bảng kết quả đo thật nền tối MVCC mỗi UPDATE tạo dòng mới PostgreSQL 16 ctid xmin xmax pg_relation_size pg_stat_user_tables, ctid đổi sau UPDATE dòng mới vật lý sau INSERT ctid 0,1 xmin 102620 sodu 100 sau UPDATE sodu 200 ctid 0,2 xmin 102621 sodu 200 dòng không bị sửa tại chỗ bản cũ 0,1 thành dòng chết bản mới 0,2 là bản sống, bloat cập nhật cùng 1 dòng logic 100000 lần ban đầu 1 dòng 8192 byte 1 trang dòng logic 1 sau 100000 UPDATE 3544 KB 443 trang dòng logic 1 phình khoảng 443 lần cho một dòng logic vì mỗi UPDATE để lại một bản cũ dòng chết, VACUUM dọn dòng chết cộng HOT giảm bloat VACUUM dem n_dead_tup 550 xuống 0 nhưng file vẫn 3544 KB tái dùng không trả OS cộng 50000 UPDATE sau VACUUM vẫn 3544 KB tái dùng chỗ trống đã dọn HOT update cột so không index 150000 update 149337 là HOT khoảng 99,6 phần trăm, cốt lõi UPDATE trong PostgreSQL không ghi đè nó viết một phiên bản dòng mới ctid mới và đánh dấu bản cũ chết nhờ vậy độc giả có ảnh nhất quán mà không khóa người ghi MVCC cái giá dòng cũ tích lũy làm bảng phình VACUUM dọn để tái dùng và HOT giảm bloat khi cột đổi không index

Hình 2: Bảng ban đầu 8.192 byte (1 trang, 1 dòng); sau 100.000 UPDATE cùng một dòng logic, phình lên 3.544 KB (443 trang) — vẫn chỉ 1 dòng logic. VACUUM xóa dòng chết (550 → 0) nhưng giữ nguyên kích thước file. HOT update chiếm 99,6% (cột không index).

  • Ban đầu: 1 dòng, 8.192 byte (một trang).
  • Sau 100.000 UPDATE: 3.544 KB (443 trang) — vẫn chỉ 1 dòng logic. Bảng phình ~443 lần vì mỗi lần cập nhật để lại một bản cũ.

VACUUM dọn các dòng chết để không gian được tái dùng: sau VACUUM dem, n_dead_tup về 0, nhưng file vẫn 3.544 KB — VACUUM đánh dấu không gian có thể tái dùng chứ không trả về hệ điều hành. Đo thật xác nhận: cập nhật thêm 50.000 lần sau VACUUM, bảng vẫn 3.544 KB vì nó tái dùng chỗ trống đã dọn thay vì phình thêm.

HOT: giảm bloat khi cột đổi không có index

PostgreSQL có một tối ưu quan trọng gọi là HOT (Heap-Only Tuple): nếu cột được cập nhật không có index và trang hiện tại còn chỗ, phiên bản mới ở lại cùng trang và không cần cập nhật index. Đo thật cho thấy trong 150.000 lần update, 149.337 là HOT (~99,6%) — vì cột so không được index. HOT giảm mạnh chi phí (không đụng index) và giảm bloat (dọn được ngay trong trang). Đây là lý do nữa để không index những cột cập nhật thường xuyên trừ khi thật cần.

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

MVCC là lý do PostgreSQL đồng thời tốt, nhưng cần VACUUM. Đọc không khóa ghi là lợi ích lớn, nhưng dòng chết phải được dọn định kỳ, nếu không bảng phình vô hạn. Autovacuum làm việc này tự động, nhưng bảng bị cập nhật rất nhiều cần theo dõi và có thể cần chỉnh ngưỡng autovacuum — chủ đề bài sau về bloat.

Cột cập nhật thường xuyên không nên index (nếu tránh được). Vì HOT chỉ hoạt động khi cột đổi không có index, đặt index lên một cột hay thay đổi (như bộ đếm, trạng thái) làm mất HOT và tăng bloat. Cân nhắc kỹ trước khi index cột "nóng".

Không dựa vào ctid như khóa ổn định. ctid là vị trí vật lý và đổi mỗi lần UPDATE (hoặc khi VACUUM FULL sắp lại). Nó hữu ích để chẩn đoán MVCC nhưng không bao giờ dùng làm định danh bền — dùng khóa chính cho việc đó.

Ba ý mang về

  1. UPDATE trong PostgreSQL viết một dòng mới, không sửa tại chỗ: đo thật, ctid đổi từ (0,1) sang (0,2) sau một UPDATE, bản cũ thành dòng chết — đây là MVCC, cho phép độc giả có ảnh nhất quán mà không khóa người ghi.
  2. Dòng chết tích lũy làm bảng phình: đo thật, cập nhật cùng một dòng logic 100.000 lần làm bảng phình từ 8KB lên 3.544 KB (~443×) dù chỉ 1 dòng logic; VACUUM dọn dòng chết để tái dùng nhưng không trả file về hệ điều hành.
  3. HOT giảm bloat khi cột đổi không có index: đo thật 99,6% update là HOT (cột không index) — phiên bản mới ở lại cùng trang, không đụng index; vì vậy tránh index cột cập nhật thường xuyên để giữ HOT.

Phần sau ta đi sâu vào chính vấn đề bloat vừa nhắc: Phần sau đo bloat — cách phát hiện bảng phình vì dòng chết, đo tỉ lệ bloat, và các cách khắc phục từ VACUUM tới REINDEX và pg_repack.