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.

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:

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