MVCC trong Postgres: vì sao UPDATE không ghi đè, mà mọi bảng update-nóng đều đi kèm bloat
MVCC (Multi-Version Concurrency Control) là mô hình concurrency lõi của Postgres, mô tả trong docs chương "Concurrency Control": mỗi transaction nhìn thấy một snapshot dữ liệu nhất quán tại thời điểm nó bắt đầu, và các phiên bản mới hơn của cùng một row do transaction khác tạo ra là vô hình với nó. Hệ quả trực tiếp — và cũng là chỗ dev đầu tiên "vấp" khi làm việc thật — là UPDATE không ghi đè vào chỗ cũ: nó tạo một tuple version mới trong heap, đánh dấu tuple cũ là "đã chết cho ai đó sau", rồi để VACUUM dọn sau. Cả bảng update-nóng trở nên phình dần (table bloat), index tương ứng cũng phình theo, autovacuum không theo kịp trong workload cao là lý do một cluster "đang chạy êm" chậm dần trong vài tuần mà không có deploy nào thay đổi.
Cơ chế hoạt động
Mỗi row trong Postgres — chính xác hơn là mỗi tuple trong heap — được kèm theo các system column không lộ mặc định trong SELECT * nhưng luôn tồn tại: xmin (xid của transaction đã tạo tuple này), xmax (xid của transaction đã xoá/update tuple này; 0 nếu chưa có), cmin/cmax (command id trong transaction đó), ctid (địa chỉ vật lý (block_number, item_offset) trong heap), và tableoid. Đây là những cột thật, có thể SELECT trực tiếp — và là dữ liệu duy nhất cần để quyết định "row version này có visible với snapshot của tôi không".
Một snapshot gồm xmin (xid nhỏ nhất còn đang chạy), xmax (xid kế tiếp sẽ cấp), và xip (danh sách xid đang in-progress). Luật visibility (đơn giản hoá từ src/backend/utils/time/heapam_visibility.c): tuple visible nếu t_xmin đã commit trước snapshot và không nằm trong xip, và t_xmax chưa tồn tại hoặc chưa commit / nằm trong xip. Đây là cách Postgres tách read khỏi write mà không cần shared lock trên row — readers và writers không block nhau, đúng khẩu hiệu "readers do not block writers, writers do not block readers" của docs.
INSERT viết một tuple mới với t_xmin = current_xid, t_xmax = 0. DELETE không xoá byte nào: chỉ set t_xmax = current_xid trên tuple cũ. UPDATE là "DELETE + INSERT" ở tầng heap:
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE TABLE demo (id int PRIMARY KEY, val text);
INSERT INTO demo VALUES (1, 'a');
-- Trước UPDATE: 1 tuple, xmax = 0
SELECT lp, t_xmin, t_xmax, t_ctid,
(t_infomask & 256) > 0 AS xmin_committed,
(t_infomask & 1024) > 0 AS xmax_committed
FROM heap_page_items(get_raw_page('demo', 0));
UPDATE demo SET val = 'b' WHERE id = 1;
-- Sau UPDATE: 2 tuple trên cùng page
-- - tuple cũ: t_xmax = xid_update, t_ctid trỏ sang tuple mới
-- - tuple mới: t_xmin = xid_update, t_xmax = 0
SELECT lp, t_xmin, t_xmax, t_ctid,
(t_infomask & 256) > 0 AS xmin_committed,
(t_infomask & 1024) > 0 AS xmax_committed
FROM heap_page_items(get_raw_page('demo', 0));
Tuple cũ vẫn nằm nguyên trong page, chỉ được gắn "đã bị thay thế" qua t_xmax và t_ctid. Sau khi mọi snapshot cũ hơn xmax biến mất khỏi cluster, tuple đó trở thành dead tuple và được VACUUM đánh dấu line pointer là LP_DEAD, giải phóng slot cho tuple mới tái sử dụng. Chú ý: VACUUM bình thường không trả space về OS — chỉ tạo free space bên trong file để tuple mới dùng lại. Muốn trả về OS phải VACUUM FULL (hoặc pg_repack online), cả hai cần rewrite bảng.
Có một tối ưu quan trọng tên HOT (Heap-Only Tuple) update, mô tả trong src/backend/access/heap/README.HOT của source tree: nếu UPDATE không đụng bất kỳ cột nào có index, và tuple mới fit trong cùng page với tuple cũ, Postgres skip việc thêm entry mới vào mọi index — index cũ vẫn trỏ tới line pointer cũ, line pointer đó redirect qua t_ctid tới tuple mới. HOT là lý do UPDATE lên cột không index rẻ hơn UPDATE cột có index. Đây cũng là mấu chốt của một tranh luận nổi tiếng: bài "Why Uber Engineering Switched from Postgres to MySQL" phàn nàn khi UPDATE không HOT được, mọi secondary index phải thêm entry mới trỏ tới tuple mới → write amplification lớn và WAL nở theo số lượng index.
Vấn đề gặp trong production
Failure mode 1: tưởng UPDATE ghi đè, ngạc nhiên với table size. Dev quen với mental model "UPDATE thay đổi row tại chỗ" (đúng với MySQL InnoDB in-place update ở nhiều case) nhìn pg_relation_size('users') sau khi chạy UPDATE users SET last_seen = now() cho toàn bộ bảng và thấy file gấp đôi. Không có bug: đó là MVCC chạy đúng cách. Mỗi row bị UPDATE sinh một tuple version mới; tuple cũ chưa được VACUUM (hoặc không thể VACUUM được vì horizon bị kéo).
-- Sai model: "chạy lệnh này thì table size như cũ vì chỉ đổi 1 cột"
UPDATE users SET last_seen_at = now(); -- N rows → sinh N dead tuple
-- Đúng: hiểu rằng UPDATE full-table = INSERT N tuple + đánh dấu N tuple cũ chết
-- Cần theo bằng VACUUM (hoặc chấp nhận bloat cho tới lần autovacuum kế tiếp)
UPDATE users SET last_seen_at = now();
VACUUM (ANALYZE) users;
Update toàn bảng cho một cột "chỉ để refresh" là anti-pattern trên bảng lớn — nếu cần trạng thái sống theo time, đặt cột đó ra bảng phụ nhỏ hoặc dùng in-memory store, đừng thay đổi cả heap chỉ để cập nhật một field ít giá trị.
Failure mode 2: bảng update-nóng bloat vì autovacuum không theo kịp. Autovacuum quyết định vacuum một bảng khi n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * n_live_tup (defaults 50 + 0.2 * n_live_tup theo Postgres docs). Trên bảng có hàng triệu row và churn cao (sessions, job_queue, counters), 20% dead trước khi autovacuum khởi động là quá muộn: bảng đã phình gấp rưỡi, plan chuyển từ index scan sang seq scan, latency tăng dần.
-- Sai: mặc định scale_factor 0.2 cho bảng 10M rows nghĩa là đợi 2M dead tuple mới vacuum
ALTER TABLE sessions SET (autovacuum_enabled = true); -- không đổi ngưỡng
-- Đúng: hạ scale_factor cho bảng update-nóng để vacuum sớm và thường xuyên
ALTER TABLE sessions SET (
autovacuum_vacuum_scale_factor = 0.02, -- 2% dead là vacuum
autovacuum_vacuum_threshold = 1000,
autovacuum_analyze_scale_factor = 0.02
);
Số 0.02 không phải hằng số kinh điển — nó là con số điều chỉnh theo bảng, ý tưởng là dead tuple ratio thấp hơn tương ứng với churn cao hơn. Xem pg_stat_user_tables để biết n_dead_tup / n_live_tup thực tế trên bảng và điều chỉnh.
Failure mode 3: transaction dài kéo xmin horizon, chặn mọi autovacuum. Đây là "kẻ giết bloat" nghiêm trọng nhất và luôn xuất hiện trong postmortem MVCC. Một backend idle in transaction vài giờ giữ một snapshot cũ; backend_xmin của nó thành xmin horizon của cả cluster. Autovacuum thấy dead tuple với t_xmax mới hơn horizon → không dám tuyên bố "dead cho mọi snapshot" → bỏ qua. Bảng sessions sinh dead tuple với tốc độ hàng nghìn/giây nhưng last_autovacuum không nhúc nhích, n_dead_tup tăng đều tới hàng triệu. Đây là câu chuyện gần như y hệt trong nhiều blog engineering (Heroku, GitLab, Cybertec) về Postgres bloat: nguyên nhân số một không phải "autovacuum quá chậm" mà là "có ai đó kéo horizon".
-- Cách xác định thủ phạm
SELECT pid, state, usename, application_name,
now() - xact_start AS xact_age,
backend_xmin,
left(query, 100) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 10;
-- Hàng rào cứng: đóng connection idle in transaction quá lâu
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();
Failure mode 4: index bloat theo heap bloat, còn tệ hơn. Mỗi tuple version mới trong heap sinh entry mới trong mọi B-tree index (trừ khi HOT). Sau khi tuple cũ được vacuum, entry index vẫn có thể chưa được dọn — index có thể phình gấp nhiều lần khi update pattern kỳ quặc. Câu chuyện Uber đã public rằng với bảng có nhiều secondary index, mỗi non-HOT update ghi WAL cho tất cả các index → replication tới replica cũng nhân theo. Hậu quả: một bảng có heap 20GB nhưng tổng size index 60GB không hiếm gặp. Đo bằng pgstattuple hoặc query size trực tiếp:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT relname,
pg_size_pretty(pg_relation_size(oid)) AS table_size,
pg_size_pretty(pg_indexes_size(oid)) AS indexes_size
FROM pg_class
WHERE relname IN ('sessions','users','orders');
-- Cho một index cụ thể
SELECT * FROM pgstatindex('sessions_user_id_idx');
Failure mode 5: xid wraparound do autovacuum bị chặn quá lâu. xid là số 32-bit; Postgres phải "freeze" tuple cũ (đóng dấu xmin cũ là "committed forever") trước khi xid nhảy vòng, nếu không tuple cũ sẽ đột nhiên trông như "trong tương lai" và biến mất khỏi visibility. Autovacuum có mode đặc biệt gọi là "wraparound protection" chạy kể cả khi bảng không thoả threshold thường. Khi horizon bị kéo lâu (failure mode 3) hoặc autovacuum bị disable, cluster có thể tới ngưỡng autovacuum_freeze_max_age và autovacuum vào chế độ aggressive không thể bị block. Nếu vẫn không kịp, Postgres shutdown khẩn cấp với ERROR: database is not accepting commands to avoid wraparound data loss — cluster stop nhận write cho tới khi vacuum-to-freeze xong, đây là kịch bản tệ nhất của MVCC.
Cách debug và monitor
Triệu chứng cần nhận diện:
-
pg_relation_size(t)lớn hơn nhiều so vớin_live_tup * avg_row_size→ heap bloat. - Plan chuyển từ Index Scan sang Seq Scan trên bảng update-nóng dù index vẫn tồn tại — planner thấy heap fetch tốn quá.
-
pg_stat_user_tables.n_dead_tup / n_live_tuptăng đều dù autovacuum enabled. -
last_autovacuumcũ đáng ngờ trên bảng có churn cao. - Log Postgres có
WARNING: oldest xmin is far in the past— cảnh báo horizon bị kéo, wraparound approaching.
Các view/tool cần thuộc:
-- 1. Dead tuple count và last vacuum time
SELECT schemaname, relname,
n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_vacuum, last_autovacuum,
last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
-- 2. Horizon hiện tại và ai đang giữ
SELECT pg_snapshot_xmin(pg_current_snapshot()) AS cluster_xmin,
(SELECT min(backend_xmin) FROM pg_stat_activity WHERE backend_xmin IS NOT NULL) AS oldest_backend_xmin;
-- 3. Xem trực tiếp tuple version trong heap (pageinspect)
SELECT lp, lp_off, t_xmin, t_xmax, t_ctid, t_infomask, t_infomask2
FROM heap_page_items(get_raw_page('sessions', 0))
ORDER BY lp;
-- 4. Age của bảng để canh wraparound
SELECT c.relname,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;
Extension bổ trợ (đều là contrib, đi kèm Postgres):
-
pageinspect—heap_page_items(),get_raw_page()để đọc tuple version thô, thấyt_xmin/t_xmax/t_ctidtừng byte. Dùng khi cần chứng minh "UPDATE có tạo tuple mới hay không". -
pgstattuple—pgstattuple('table'),pgstatindex('idx'),pgstattuple_approxcho bảng lớn. Trả radead_tuple_percent,free_percent— con số chính thức để đánh giá bloat. -
pg_visibility—pg_visibility_map()để kiểm visibility map, giải thích vì sao index-only scan không hit hoặc autovacuum không skip page.
Metric cần đưa lên monitoring (postgres_exporter của Prometheus phơi sẵn):
-
pg_stat_user_tables_n_dead_tuptheo bảng — alert khidead_tup / live_tupvượt ngưỡng (vd 20% cho bảng update-nóng). -
pg_stat_activityoldest transaction age — alert khi có transaction chạy quá vài phút, vì đó là proxy cho horizon bị kéo. -
xid age of oldest table — alert khi
age(relfrozenxid)tiến gầnautovacuum_freeze_max_age. -
Tỉ lệ
n_tup_hot_upd / n_tup_updtừpg_stat_user_tables— thấp bất thường nghĩa là HOT không hoạt động, cần xemfillfactorcủa bảng và index nào đang "chặn HOT".
Rule phòng ngừa cứng:
- Với bảng update-nóng, hạ
autovacuum_vacuum_scale_factorvàautovacuum_analyze_scale_factorxuống 0.01–0.05 tuỳ churn. - Đặt
fillfactor< 100 (vd 80–90) cho bảng có nhiều UPDATE để dành chỗ trống trong page cho HOT update hoạt động. - Đặt
idle_in_transaction_session_timeoutvàstatement_timeoutở mức cluster để không transaction nào bỏ lửng kéo horizon. - Không disable autovacuum cho bảng nào, kể cả tạm; hạ ngưỡng thay vì tắt.
- Alert cứng trên
age(relfrozenxid)— wraparound là sự cố không có "cứu vãn nhẹ".
Tradeoff
MVCC đổi lấy concurrency cao (readers không block writers và ngược lại, snapshot consistency mà không cần shared read lock trên row) bằng cách chấp nhận không gian: mỗi UPDATE/DELETE để lại tuple cũ, và toàn bộ hệ thống phụ thuộc vào một background process (VACUUM) chạy đủ thường xuyên và đủ mạnh. Cái được cụ thể: đọc luôn nhất quán, không có "read lock" chặn write, replica có thể đọc song song primary mà không cần đồng bộ khoá. Cái mất cụ thể: table + index bloat nếu vacuum không theo kịp, mỗi UPDATE ghi nhiều WAL hơn in-place update (đặc biệt khi non-HOT — Uber blog nêu rõ hiện tượng này), snapshot dài giữ horizon làm dead tuple không dọn được, xid 32-bit cần freezing trước wraparound. So với in-place update của InnoDB: Postgres đơn giản hơn ở rollback (chỉ flip bit trong pg_xact, không có undo log để replay ngược) và snapshot isolation tự nhiên; đắt hơn ở write amplification và ở việc phải vận hành autovacuum. Quy tắc thực tế: coi VACUUM là load-bearing infrastructure ngang với WAL — hạ scale_factor cho bảng update-nóng, đặt fillfactor để chừa chỗ cho HOT, đặt idle_in_transaction_session_timeout để không ai kéo horizon, alert trên n_dead_tup và age(relfrozenxid); đừng thiết kế schema "sạch bảng bằng UPDATE mass" trên table lớn.
Câu hỏi phỏng vấn
MVCC trong Postgres là gì, và vì sao
UPDATEtrên một bảng lớn có thể làm cả service chậm dần trong vài ngày mà không có deploy nào thay đổi?
MVCC là cơ chế mỗi transaction làm việc trên một snapshot dữ liệu nhất quán tại thời điểm nó bắt đầu, và mỗi row có nhiều tuple version vật lý cùng tồn tại trong heap. Mỗi tuple mang system column xmin (xid đã tạo nó), xmax (xid đã update/delete nó, 0 nếu còn sống), ctid (địa chỉ vật lý), và snapshot của transaction là {xmin, xmax, xip} quyết định tuple nào visible. INSERT ghi tuple mới, DELETE chỉ set xmax, UPDATE = "DELETE + INSERT" ở tầng heap — không có ghi đè in-place. Đó là cách readers không block writers và ngược lại. Cái giá là mọi UPDATE/DELETE để lại "dead tuple" trong heap, và một background process VACUUM (thường là autovacuum) phải chạy đủ thường xuyên để dọn, nếu không table + index bloat. Kịch bản "chậm dần vài ngày không do deploy" thường có gốc từ hai chỗ: (1) một transaction dài — thường là idle in transaction do bug application quên COMMIT/ROLLBACK hoặc một cron job giữ transaction qua HTTP call — kéo xmin horizon toàn cluster, khiến autovacuum không được phép tuyên bố tuple mới hơn horizon là dead, dead_tup chất đống trên bảng update-nóng (sessions, job_queue); (2) UPDATE trên cột có secondary index buộc mỗi index thêm entry mới, WAL nở, replication amplification — điểm mà Uber engineering blog đã public khi chuyển từ Postgres sang MySQL. Cách xử lý: đo pg_stat_activity để tìm transaction dài và backend_xmin cổ nhất, đặt idle_in_transaction_session_timeout, hạ autovacuum_vacuum_scale_factor cho bảng update-nóng, đặt fillfactor để tuple mới ở lại cùng page (kích hoạt HOT update, tránh ghi index), alert trên n_dead_tup / n_live_tup và age(relfrozenxid). Điểm ăn điểm là (a) nêu được đầy đủ tuple layout xmin/xmax/ctid và luật visibility, (b) chỉ ra HOT là điều kiện quan trọng để tránh index bloat, (c) gắn cơ chế với hậu quả production cụ thể — horizon bị kéo, wraparound, migration bị index bloat — chứ không chỉ định nghĩa "MVCC = nhiều version".
Hands-on
Mục tiêu: quan sát tận mắt UPDATE tạo tuple version mới trên heap qua pageinspect; ép xmin horizon bị kéo để thấy n_dead_tup tăng mà autovacuum đứng yên; so sánh HOT vs non-HOT update để thấy write amplification lên index; đo bloat bằng pgstattuple.
Dựng Postgres:
docker run -d --name pg-mvcc \
-e POSTGRES_PASSWORD=pw -p 5439:5432 \
postgres:16 \
-c autovacuum_naptime=5s \
-c log_autovacuum_min_duration=0 \
-c log_min_messages=info
sleep 5
docker exec -i pg-mvcc psql -U postgres <<'SQL'
CREATE EXTENSION pageinspect;
CREATE EXTENSION pgstattuple;
CREATE TABLE demo (
id int PRIMARY KEY,
val text
) WITH (fillfactor = 80);
INSERT INTO demo(id, val) VALUES (1, 'a'), (2, 'b'), (3, 'c');
SQL
Bước 1 — Nhìn tuple version xuất hiện sau UPDATE:
docker exec -it pg-mvcc psql -U postgres
-- Trước UPDATE: 3 tuple, xmax = 0
SELECT lp, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('demo', 0));
-- UPDATE 1 row
UPDATE demo SET val = 'a2' WHERE id = 1;
-- Sau UPDATE: 4 tuple. Tuple lp=1 (cũ) có t_xmax = xid vừa chạy,
-- t_ctid trỏ tới lp=4 (tuple mới)
SELECT lp, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('demo', 0));
-- Đọc system column trực tiếp
SELECT ctid, xmin, xmax, id, val FROM demo ORDER BY ctid;
Bước 2 — HOT vs non-HOT update. Dựng bảng có 1 index trên cột không đụng update, và 1 index trên cột bị update:
CREATE TABLE hot_demo (
id int PRIMARY KEY,
name text,
score int
) WITH (fillfactor = 80);
CREATE INDEX idx_hot_name ON hot_demo(name);
CREATE INDEX idx_hot_score ON hot_demo(score);
INSERT INTO hot_demo SELECT g, 'u'||g, g % 100
FROM generate_series(1, 100000) g;
VACUUM (ANALYZE) hot_demo;
-- Reset stat
SELECT pg_stat_reset_single_table_counters('hot_demo'::regclass);
-- HOT-eligible: update cột không có index
UPDATE hot_demo SET score = score + 0 WHERE id <= 10000; -- fake update
-- Đo tỉ lệ HOT
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables WHERE relname = 'hot_demo';
-- Non-HOT: update cột có index → mọi row phải thêm entry trong idx_hot_score
SELECT pg_stat_reset_single_table_counters('hot_demo'::regclass);
UPDATE hot_demo SET score = score + 1 WHERE id <= 10000;
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables WHERE relname = 'hot_demo';
-- hot_pct sẽ tụt so với case trước
Bước 3 — Xmin horizon bị kéo, autovacuum đứng yên. Session A mở transaction rồi để nguyên:
# Terminal 1
docker exec -it pg-mvcc psql -U postgres
-- Session A
BEGIN;
SELECT 1; -- snapshot đã lấy; backend giờ ở 'idle in transaction'
Terminal 2, sinh churn vào bảng khác:
docker exec -it pg-mvcc psql -U postgres
-- Session B
CREATE TABLE churn (id int PRIMARY KEY, v text);
INSERT INTO churn SELECT g, 'v0' FROM generate_series(1, 50000) g;
VACUUM (ANALYZE) churn;
-- 20 vòng update toàn bảng → sinh nhiều dead tuple
DO $$
BEGIN
FOR i IN 1..20 LOOP
UPDATE churn SET v = 'v' || i;
END LOOP;
END $$;
-- Quan sát: n_dead_tup lớn, last_autovacuum không nhúc nhích
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables WHERE relname = 'churn';
-- Ai đang giữ horizon
SELECT pid, state, now() - xact_start AS age, backend_xmin, left(query, 80) AS q
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;
-- Xem bloat qua pgstattuple
SELECT * FROM pgstattuple('churn') \gx
Đóng Session A:
-- Session A
COMMIT;
Đợi vài giây rồi kiểm lại Session B:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables WHERE relname = 'churn';
-- last_autovacuum sẽ được cập nhật; n_dead_tup giảm
Bước 4 — Chứng minh VACUUM không trả space về OS:
-- Kích thước hiện tại
SELECT pg_size_pretty(pg_relation_size('churn')) AS size_before;
VACUUM churn;
SELECT pg_size_pretty(pg_relation_size('churn')) AS size_after_vacuum;
-- Gần như không giảm — VACUUM chỉ tạo free space bên trong file
VACUUM FULL churn;
SELECT pg_size_pretty(pg_relation_size('churn')) AS size_after_full;
-- Giảm mạnh, nhưng VACUUM FULL cần ACCESS EXCLUSIVE lock — không dùng được online
Bước 5 — Điều chỉnh threshold cho bảng update-nóng:
ALTER TABLE churn SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_analyze_scale_factor = 0.02,
fillfactor = 85
);
-- Xác nhận reloption đã apply
SELECT reloptions FROM pg_class WHERE relname = 'churn';
Dọn:
docker rm -f pg-mvcc
Sau bài này ba câu xmin, xmax, ctid không còn là cột hệ thống trừu tượng — chúng là bằng chứng vật lý cho mỗi tuple version, và mọi thứ về bloat, autovacuum, HOT, wraparound đều xoay quanh việc dead tuple đó có được dọn kịp hay không.
Top comments (0)