MVCC & VACUUM
Tiếp đi: MVCC. Vì sao trong PostgreSQL người đọc không bị chặn bởi người ghi?
Bài 6.1 có nói REPEATABLE READ "dùng một snapshot". Snapshot đó có được là nhờ MVCC (Multi-Version Concurrency Control).
1. Bài toán
Trên shop-app, job báo cáo chạy 2 phút để tính doanh thu từ orders. Cùng lúc, API checkout liên tục UPDATE products.stock và INSERT orders. Ta muốn:
- Báo cáo thấy một bức ảnh nhất quán, không lẫn dữ liệu giữa chừng.
- Checkout không phải chờ báo cáo đọc xong.
2. Vấn đề
Nếu chỉ có một bản của mỗi row, muốn đọc nhất quán thì phải khóa đọc (shared lock), lúc đó writer phải chờ reader, reader chờ writer. Hệ thống bận sẽ nghẽn.
3. Giải pháp: giữ nhiều phiên bản row
PostgreSQL không ghi đè row khi UPDATE. Nó đánh dấu phiên bản cũ là "hết hạn" và thêm phiên bản mới. Mỗi phiên bản (tuple) có 2 cột ẩn:
| Cột | Ý nghĩa |
|---|---|
xmin | ID transaction đã tạo ra phiên bản này |
xmax | ID transaction đã xóa/thay thế nó (0 = còn sống) |
Mỗi transaction (hoặc mỗi câu lệnh ở READ COMMITTED) có một snapshot: danh sách transaction nào đã commit lúc đó. Một tuple "nhìn thấy được" nếu xmin đã commit trong snapshot và xmax chưa.
4. Từng bước
Bước 1: Nhìn xmin/xmax
SELECT xmin, xmax, ctid, id, stock FROM products WHERE id = 42;
-- xmin | xmax | ctid | id | stock
-- 900 | 0 | (0,5) | 42 | 10
BEGIN; -- txid 950
UPDATE products SET stock = 9 WHERE id = 42;
SELECT xmin, xmax, ctid, stock FROM products WHERE id = 42;
-- 950 | 0 | (0,7) | 9 ← phiên bản mới, vị trí mới
COMMIT;
Bước 2: Session khác đang đọc thấy gì
-- Session R (REPEATABLE READ, bắt đầu trước txid 950)
SELECT stock FROM products WHERE id = 42; -- vẫn 10: thấy tuple (0,5) xmin=900, xmax=950 chưa commit trong snapshot của R
Bước 3: Đếm dead tuple
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
Bước 4: Chỉnh autovacuum cho bảng nóng (bảng products bị UPDATE stock liên tục)
ALTER TABLE products SET (
autovacuum_vacuum_scale_factor = 0.02, -- mặc định 0.2 = đợi 20% bảng chết mới dọn
autovacuum_vacuum_threshold = 1000
);
Bước 5: Phía Spring Boot: không để transaction treo lâu
// application.yml
spring:
datasource:
hikari:
connection-init-sql: SET idle_in_transaction_session_timeout = '60s'
// Không làm việc chậm (gọi HTTP, gửi mail) bên trong @Transactional
@Transactional(readOnly = true, timeout = 30)
public Report revenueReport(LocalDate day) { ... }
5. Cách hoạt động
UPDATE products SET stock=9 WHERE id=42 (tx 950)
page của products
┌──────────────────────────────────────────────┐
│ (0,5) id=42 stock=10 xmin=900 xmax=950 │ ← phiên bản cũ
│ (0,7) id=42 stock=9 xmin=950 xmax=0 │ ← phiên bản mới
└──────────────────────────────────────────────┘
Reader có snapshot cũ → thấy (0,5)
Reader có snapshot mới → thấy (0,7)
Không ai chờ ai.
Khi KHÔNG còn transaction nào cần (0,5):
(0,5) thành DEAD TUPLE → VACUUM đánh dấu chỗ trống để tái dùng
(VACUUM thường không trả dung lượng cho OS; VACUUM FULL thì có nhưng khóa cả bảng)
Lưu ý: writer vẫn chặn writer. Hai UPDATE cùng một row thì cái sau phải chờ (row lock, bài 6.5).
6. So sánh
| VACUUM | VACUUM FULL | autovacuum | |
|---|---|---|---|
| Làm gì | Đánh dấu chỗ của dead tuple để tái dùng, cập nhật visibility map | Viết lại toàn bộ bảng, thu hồi dung lượng | Tự chạy VACUUM/ANALYZE theo ngưỡng |
| Khóa | Nhẹ, đọc ghi bình thường | ACCESS EXCLUSIVE: chặn cả SELECT | Nhẹ |
| Dùng khi | Thủ công sau batch lớn | Hiếm; thường dùng pg_repack thay thế | Luôn bật |
Vì sao transaction dài có hại:
09:00 Ai đó BEGIN; SELECT ...; rồi quên (idle in transaction)
09:00 → 15:00 checkout UPDATE stock hàng triệu lần
VACUUM: "transaction 09:00 có thể còn cần các phiên bản cũ" → KHÔNG được dọn
15:00 products từ 50MB phình thành 3GB (bloat), index cũng phình, query chậm
7. Khi nào cần để ý
- Bảng UPDATE/DELETE nhiều: theo dõi
n_dead_tup, chỉnh autovacuum theo từng bảng. - Không tắt autovacuum. Nó còn chống transaction ID wraparound (ID 32-bit quay vòng).
- Báo cáo dài nên chạy trên replica, hoặc chia nhỏ.
- Không giữ transaction mở trong lúc chờ user hay gọi service ngoài.
- Xóa hàng loạt dữ liệu cũ → cân nhắc partition rồi DROP partition (bài 6.9) thay vì DELETE.
orders lên chục triệu row, query "đơn của user 7 tuần này" chạy 3 giây. Index giúp gì, và đọc EXPLAIN thế nào? Bài 6.4.Vậy MVCC có phải là optimistic locking luôn không? Nghe giống @Version.
Không hẳn, hai thứ ở hai tầng khác nhau:
| MVCC | @Version (optimistic lock) | |
|---|---|---|
| Tầng | Bên trong PostgreSQL, tự động | Ứng dụng (Hibernate), bạn khai báo |
| Mục đích | Reader không chặn writer, cho snapshot nhất quán | Phát hiện lost update giữa các request |
| Chặn lost update? | Ở READ COMMITTED: không | Có |
MVCC giúp bạn đọc không bị chặn. Còn lost update khi đọc vào Java rồi ghi lại vẫn cần lock, @Version hoặc atomic UPDATE như bài 4.3.10. Đừng nhầm với persistence context của Hibernate (bài 4.3.2): đó là bản sao entity trong bộ nhớ JVM, không phải phiên bản row trong DB.
UPDATE nào cũng tạo row mới thì index cũng phải cập nhật mỗi lần à?
Đúng là có nguy cơ đó, nhưng PostgreSQL có tối ưu HOT update (Heap-Only Tuple): nếu
- không cột nào được index bị thay đổi, và
- page hiện tại còn chỗ trống cho phiên bản mới,
thì phiên bản mới nằm cùng page, index không cần thêm entry. Vì vậy: đừng index cột bị UPDATE liên tục như stock nếu không thật sự cần, và với bảng nóng có thể đặt fillfactor < 100 để chừa chỗ.
ALTER TABLE products SET (fillfactor = 90);
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 'products';
Phỏng vấn hỏi MVCC và VACUUM thì trả lời sao?
Q1. MVCC trong PostgreSQL hoạt động thế nào?
🇻🇳 Tiếng Việt:
PostgreSQL không ghi đè row khi update mà tạo một phiên bản mới. Mỗi phiên bản có xmin là transaction tạo ra nó và xmax là transaction xóa nó. Mỗi câu lệnh hoặc transaction có một snapshot, và chỉ thấy những phiên bản hợp lệ với snapshot đó. Nhờ vậy reader không chặn writer và writer không chặn reader; chỉ hai writer cùng sửa một row mới phải chờ nhau.
🇬🇧 English:
PostgreSQL does not overwrite a row on update; it creates a new version. Each version has xmin, the transaction that created it, and xmax, the transaction that deleted it. Each statement or transaction has a snapshot and only sees versions visible to that snapshot. So readers do not block writers and writers do not block readers; only two writers on the same row have to wait.
Q2. VACUUM là gì, vì sao cần?
🇻🇳 Tiếng Việt:
Phiên bản cũ sau khi không còn transaction nào cần sẽ thành dead tuple. VACUUM dọn chúng để tái sử dụng chỗ, cập nhật visibility map và freeze transaction ID cũ để tránh wraparound. Autovacuum tự chạy theo ngưỡng; với bảng update nhiều em hạ scale factor cho bảng đó. VACUUM FULL thu hồi dung lượng nhưng khóa cả bảng nên em ưu tiên pg_repack.
🇬🇧 English:
Old versions that no transaction needs anymore become dead tuples. VACUUM cleans them so the space can be reused, updates the visibility map and freezes old transaction IDs to prevent wraparound. Autovacuum runs it based on thresholds; for heavily updated tables I lower the scale factor per table. VACUUM FULL reclaims disk space but locks the whole table, so I prefer pg_repack.
Q3. Vì sao transaction chạy lâu gây hại?
🇻🇳 Tiếng Việt:
Transaction mở lâu, kể cả idle in transaction, giữ một snapshot cũ nên VACUUM không được dọn các dead tuple mới hơn snapshot đó. Bảng và index bị bloat, query chậm dần. Nó còn giữ lock lâu. Em đặt idle_in_transaction_session_timeout, transaction timeout, không gọi service ngoài trong transaction và chạy báo cáo dài trên replica.
🇬🇧 English:
A long-running transaction, including one idle in transaction, holds an old snapshot, so VACUUM cannot remove dead tuples newer than it. Tables and indexes bloat and queries slow down. It also holds locks for a long time. I set idle_in_transaction_session_timeout and transaction timeouts, avoid external calls inside transactions, and run long reports on a replica.
Cách nhớ ngắn gọn
- UPDATE = đánh dấu cũ (xmax) + thêm mới (xmin).
- Snapshot quyết định thấy phiên bản nào → reader và writer không chặn nhau.
- Phiên bản cũ không ai cần → dead tuple → VACUUM dọn.
- Transaction treo lâu → VACUUM bó tay → bloat.
Tự kiểm tra
xmin và xmax là gì?
ID transaction đã tạo ra phiên bản row và ID transaction đã xóa/thay thế nó.
Trong MVCC, ai vẫn phải chờ ai?
Writer chờ writer khi cùng sửa một row. Reader và writer không chờ nhau.
VACUUM thường có trả dung lượng đĩa cho OS không?
Thường không; nó đánh dấu chỗ để tái dùng. VACUUM FULL hoặc pg_repack mới thu hồi.
Một session "idle in transaction" 6 tiếng gây ra gì?
VACUUM không dọn được dead tuple mới hơn snapshot của nó → bloat, query chậm, có thể giữ lock.
HOT update cần điều kiện gì?
Không đổi cột nào có index và page còn chỗ cho phiên bản mới.