VACUUM, ANALYZE และสถิติ
PostgreSQL ไม่เคยเขียนทับ row ที่เดิม UPDATE เขียน row เวอร์ชันใหม่และทิ้งเวอร์ชันเก่าไว้ DELETE แค่ทำเครื่องหมายว่า row ถูกลบ ของที่เหลือเหล่านั้นคือ dead tuple VACUUM เก็บกู้มัน และ ANALYZE รีเฟรชสถิติที่ planner ใช้เลือก plan ทั้งสองมักรันอัตโนมัติ (autovacuum) แต่คุณต้องเข้าใจมันเพื่ออธิบาย bloat, query ช้าหลัง bulk load และความประหลาดใจเรื่อง lock
ทุกอย่างด้านล่างรันบน PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) fence sql รันจากบนลงล่างในฐานข้อมูลเปล่าหนึ่งตัว บันทึกหลาย session เป็นการรันจริงของโปรเซส psql สองหรือสามตัวที่เริ่มพร้อมกัน ตัวเลขเวลามาจาก Mac เครื่องนี้
flowchart LR U["UPDATE or DELETE"] --> D["Old version stays behind: a dead tuple"] D --> V["VACUUM: the space becomes reusable"] D --> F["VACUUM FULL: live rows are copied to a new file"] V --> R["Later inserts and updates reuse it"] F --> S["The file shrinks, ACCESS EXCLUSIVE lock held meanwhile"]
ชุดข้อมูล
หัวข้อที่มีชื่อว่า “ชุดข้อมูล”ticket ซัพพอร์ต 200 000 รายการ แต่ละ row ราว 150 byte autovacuum ถูกปิด เฉพาะ table นี้ (storage parameter) เพื่อไม่ให้อะไรมาเก็บกวาดลับหลังขณะที่เราดู ใน table จริงให้เปิดไว้ และหัวข้อถัดไปแสดงมันทำงานจริง extension pgstattuple (อยู่ในชุด contrib ของ PostgreSQL) ให้จำนวน tuple ที่แม่นยำ
-- sql/setup.sqlCREATE EXTENSION IF NOT EXISTS pgstattuple;
CREATE TABLE tickets ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, status text NOT NULL, payload text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()) WITH (autovacuum_enabled = off); -- off for THIS table only, so the demos are repeatableINSERT INTO tickets (status, payload)SELECT CASE WHEN g % 10 = 0 THEN 'closed' ELSE 'open' END, repeat('x', 100)FROM generate_series(1, 200000) AS g;ANALYZE tickets;SELECT count(*) AS rows, pg_size_pretty(pg_relation_size('tickets')) AS heap_size FROM tickets;CREATE EXTENSIONCREATE TABLEINSERT 0 200000ANALYZE rows | heap_size--------+----------- 200000 | 30 MB(1 row)MVCC ทิ้ง dead tuple ไว้
หัวข้อที่มีชื่อว่า “MVCC ทิ้ง dead tuple ไว้”update 180 000 row (ticket ที่ยังเปิด):
-- sql/dead-tuples.sqlUPDATE tickets SET updated_at = now() WHERE status = 'open';SELECT pg_stat_force_next_flush();SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_relation_size('tickets')) AS heap_sizeFROM pg_stat_user_tables WHERE relname = 'tickets';UPDATE 180000 pg_stat_force_next_flush--------------------------
(1 row)
n_live_tup | n_dead_tup | heap_size------------+------------+----------- 400000 | 180000 | 57 MB(1 row)table เพิ่มเป็นสองเท่าจาก 30 MB เป็น 57 MB: เวอร์ชันเก่า 180 000 ตัวยังอยู่ข้างเวอร์ชันใหม่ 180 000 ตัว (n_live_tup แสดง 400 000 เพราะสถิตินับเวอร์ชันใหม่ตอนเขียนและเป็นเพียงค่าประมาณจนกว่า VACUUM หรือ ANALYZE จะแก้) pgstattuple นับได้ตรง ๆ:
-- sql/pgstattuple.sqlSELECT tuple_count AS live_tuples, dead_tuple_count AS dead_tuples, round(dead_tuple_percent::numeric, 1) AS dead_pct, round(free_percent::numeric, 1) AS free_pctFROM pgstattuple('tickets'); live_tuples | dead_tuples | dead_pct | free_pct-------------+-------------+----------+---------- 200000 | 180000 | 45.7 | 0.7(1 row)45.7 % ของ table เป็น dead tuple: นี่คือ bloat ทุก sequential scan ตอนนี้อ่าน page เป็นสองเท่า และแต่ละ index มี entry ชี้ทั้งสองเวอร์ชัน (docs)
VACUUM เก็บกู้ ANALYZE แจ้งข้อมูล
หัวข้อที่มีชื่อว่า “VACUUM เก็บกู้ ANALYZE แจ้งข้อมูล”VACUUM scan table หา tuple ที่ไม่มี transaction ที่กำลังรันเห็นอีก และทำเครื่องหมายพื้นที่ให้ใช้ซ้ำได้ มันยังอัปเดต visibility map ซึ่ง index-only scan พึ่งพา VERBOSE แสดงสิ่งที่มันทำ (PROCESS_TOAST false ข้าม TOAST table ที่ว่างเพื่อให้ output อ่านง่าย):
-- sql/vacuum-verbose.sqlVACUUM (VERBOSE, PROCESS_TOAST false) tickets;SELECT pg_stat_force_next_flush();SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_relation_size('tickets')) AS heap_size, vacuum_countFROM pg_stat_user_tables WHERE relname = 'tickets';INFO: vacuuming "b2_vacuum_and_statistics.public.tickets"INFO: finished vacuuming "b2_vacuum_and_statistics.public.tickets": index scans: 1pages: 0 removed, 7308 remain, 7308 scanned (100.00% of total), 0 eagerly scannedtuples: 180000 removed, 200000 remain, 0 are dead but not yet removableremovable cutoff: 323782, which was 0 XIDs old when operation endednew relfrozenxid: 323779, which is 1 XIDs ahead of previous valuefrozen: 0 pages from table (0.00% of total) had 0 tuples frozenvisibility map: 7308 pages set all-visible, 0 pages set all-frozen (0 were all-visible)index scan needed: 3847 pages from table (52.64% of total) had 180000 dead item identifiers removedindex "tickets_pkey": pages: 1099 in total, 0 newly deleted, 0 currently deleted, 0 reusableavg read rate: 0.000 MB/s, avg write rate: 0.445 MB/sbuffer usage: 19626 hits, 0 reads, 3 dirtiedWAL usage: 16099 records, 3 full page images, 2100354 bytes, 0 buffers fullsystem usage: CPU: user: 0.04 s, system: 0.01 s, elapsed: 0.05 sVACUUM pg_stat_force_next_flush--------------------------
(1 row)
n_live_tup | n_dead_tup | heap_size | vacuum_count------------+------------+-----------+-------------- 200000 | 0 | 57 MB | 1(1 row)อ่านบรรทัดสำคัญ: tuples: 180000 removed, 200000 remain, 0 are dead but not yet removable; item pointer ที่ตาย 180 000 ตัวถูกลบจาก index ของ primary key ด้วย (index scan needed ... 180000 dead item identifiers removed); visibility map: 7308 pages set all-visible และ table ยังคง 57 MB: VACUUM ธรรมดาทำให้พื้นที่ ใช้ซ้ำได้ ไม่ได้ทำให้ไฟล์เล็กลง นั่นคือการออกแบบ และรอบ update ถัดไปพิสูจน์ว่าพื้นที่ถูกใช้ซ้ำ:
-- sql/space-reuse.sql-- another full round of updates: the freed space is reused, the file does not growUPDATE tickets SET updated_at = now() WHERE status = 'open';SELECT pg_size_pretty(pg_relation_size('tickets')) AS heap_after_second_update;SELECT tuple_count AS live_tuples, dead_tuple_count AS dead_tuples, round(free_percent::numeric, 1) AS free_pctFROM pgstattuple('tickets');UPDATE 180000 heap_after_second_update-------------------------- 57 MB(1 row)
live_tuples | dead_tuples | free_pct-------------+-------------+---------- 200000 | 180000 | 0.7(1 row)57 MB เท่าเดิมหลัง update อีก 180 000 ครั้ง (และมี dead tuple 180 000 อีกครั้งรอ vacuum รอบถัดไป) สภาวะคงที่นี้คือหน้าตาของ table ที่สุขภาพดี: ไฟล์ราวสองเท่าของข้อมูลที่ยังมีชีวิต ที่ vacuum รีไซเคิลครึ่งว่างอยู่ตลอด
ANALYZE ทำงานต่างออกไป: มัน sample table และเก็บสถิติ column ที่ planner อ่าน ทำทั้งสองพร้อมกันได้ และทุก driver ทำแบบเดียวกัน (VACUUM รันใน transaction block ไม่ได้ จึงใช้ autocommit):
-- sql/vacuum-analyze.sqlVACUUM (ANALYZE) tickets;await pool.query('VACUUM (ANALYZE) tickets');cur.execute("VACUUM (ANALYZE) tickets")_, err := conn.Exec(ctx, "VACUUM (ANALYZE) tickets")sqlx::query("VACUUM (ANALYZE) tickets") .execute(&pool) .await?;ทั้งสี่ driver รัน statement นั้นหนึ่งครั้งกับสำเนาใหม่ของ table ที่ update แล้ว และอ่าน pg_class กลับ ก่อนหน้านั้น relallvisible เป็น 0; หลังจากนั้น 7308 page (ทุก page ของ table):
$ (each driver ran VACUUM (ANALYZE) once on a fresh copy of the updated table, then read pg_class: relallvisible, reltuples)before: relallvisible = 0, reltuples = 200000Node.js (pg 8.23.0): { relallvisible: 7308, reltuples: '200000' }Python (psycopg 3.3.6): (7308, 200000)Go (pgx v5.11.0): 7308 200000Rust (sqlx 0.9.0): 7308 200000ANALYZE และ planner
หัวข้อที่มีชื่อว่า “ANALYZE และ planner”สถิติสำคัญพอ ๆ กับพื้นที่ตาย ตรงนี้ table ได้ bulk load และ query ถูกวาง plan ก่อนและหลัง ANALYZE 90 % ของ 300 000 row มี sensor = 1:
-- sql/analyze-plan.sqlCREATE TABLE readings (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, sensor int NOT NULL, value int NOT NULL) WITH (autovacuum_enabled = off);CREATE INDEX readings_sensor_idx ON readings (sensor);-- 300 000 rows, but sensor 1 has 90 % of themINSERT INTO readings (sensor, value)SELECT CASE WHEN g % 10 = 0 THEN 2 + g % 500 ELSE 1 END, g FROM generate_series(1, 300000) g;-- no ANALYZE yet: the planner has only default guessesEXPLAIN SELECT * FROM readings WHERE sensor = 1;ANALYZE readings;EXPLAIN SELECT * FROM readings WHERE sensor = 1;EXPLAIN SELECT * FROM readings WHERE sensor = 77;CREATE TABLECREATE INDEXINSERT 0 300000 QUERY PLAN-------------------------------------------------------------------------------------- Bitmap Heap Scan on readings (cost=21.12..1723.00 rows=1654 width=12) Recheck Cond: (sensor = 1) -> Bitmap Index Scan on readings_sensor_idx (cost=0.00..20.70 rows=1654 width=0) Index Cond: (sensor = 1)(4 rows)
ANALYZE QUERY PLAN----------------------------------------------------------------- Seq Scan on readings (cost=0.00..5372.00 rows=269900 width=12) Filter: (sensor = 1)(2 rows)
QUERY PLAN------------------------------------------------------------------------------------- Index Scan using readings_sensor_idx on readings (cost=0.30..5.69 rows=1 width=12) Index Cond: (sensor = 77)(2 rows)เมื่อไม่มีสถิติ planner เดา rows=1654 สำหรับ sensor = 1 (selectivity ค่าเริ่มต้น) และเลือก bitmap scan ที่ขับด้วย index; ความจริงคือ 270 000 row หลัง ANALYZE มันประมาณ rows=270430 และเปลี่ยนเป็น Seq Scan ซึ่งถูกต้องเมื่อเก้าใน 10 row เข้าเงื่อนไข sensor ที่หายาก (sensor = 77) ยังได้ index สิ่งที่ ANALYZE เก็บไว้:
-- sql/pg-stats.sqlSELECT attname, n_distinct, (most_common_vals::text::text[])[1] AS most_common, round(most_common_freqs[1]::numeric, 3) AS its_frequencyFROM pg_stats WHERE tablename = 'readings' AND attname = 'sensor';SELECT reltuples::int AS reltuples, relpages FROM pg_class WHERE relname = 'readings'; attname | n_distinct | most_common | its_frequency---------+------------+-------------+--------------- sensor | 51 | 1 | 0.900(1 row)
reltuples | relpages-----------+---------- 300000 | 1622(1 row)most_common_vals และ most_common_freqs คือสถิติที่ให้ planner แยกค่า 90 % ออกจากค่าที่หายาก ขนาด sample ควบคุมโดย default_statistics_target (ค่าเริ่มต้น 100 แสดงด้านล่าง) เพิ่มต่อ column ด้วย ALTER TABLE ... ALTER COLUMN ... SET STATISTICS เมื่อ column ที่เอียงถูกประมาณผิด
Autovacuum ทำสิ่งนี้ให้
หัวข้อที่มีชื่อว่า “Autovacuum ทำสิ่งนี้ให้”autovacuum launcher ตื่นเป็นระยะและ vacuum หรือ analyze table ที่เปลี่ยนมากพอ การตั้งค่าจาก server นี้:
-- sql/autovacuum-settings.sqlSELECT name, setting, unitFROM pg_settingsWHERE name IN ('autovacuum', 'autovacuum_naptime', 'autovacuum_vacuum_threshold', 'autovacuum_vacuum_scale_factor', 'autovacuum_vacuum_max_threshold', 'autovacuum_vacuum_insert_threshold', 'autovacuum_vacuum_insert_scale_factor', 'autovacuum_analyze_threshold', 'autovacuum_analyze_scale_factor', 'autovacuum_vacuum_cost_delay', 'autovacuum_vacuum_cost_limit', 'autovacuum_freeze_max_age', 'default_statistics_target')ORDER BY name; name | setting | unit---------------------------------------+-----------+------ autovacuum | on | autovacuum_analyze_scale_factor | 0.1 | autovacuum_analyze_threshold | 50 | autovacuum_freeze_max_age | 200000000 | autovacuum_naptime | 60 | s autovacuum_vacuum_cost_delay | 2 | ms autovacuum_vacuum_cost_limit | -1 | autovacuum_vacuum_insert_scale_factor | 0.2 | autovacuum_vacuum_insert_threshold | 1000 | autovacuum_vacuum_max_threshold | 100000000 | autovacuum_vacuum_scale_factor | 0.2 | autovacuum_vacuum_threshold | 50 | default_statistics_target | 100 |(13 rows)table ถูก vacuum เมื่อ dead tuple เกิน autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples (50 + 20 % ของ row) autovacuum_vacuum_max_threshold ของ PostgreSQL 18 (ค่าเริ่มต้น 100 000 000) ตั้งเพดานให้สูตรนั้น table พันล้าน row จึงไม่ต้องรอ dead tuple 200 ล้านอีกต่อไป (release notes, docs) การ insert กระตุ้น vacuum ของตัวเองผ่าน autovacuum_vacuum_insert_* และ ANALYZE มี threshold ของตัวเอง (50 + 10 %):
-- sql/autovacuum-threshold.sql-- vacuum threshold = autovacuum_vacuum_threshold + scale_factor * reltuples (capped by the PG 18 max threshold)SELECT c.relname, c.reltuples::int AS reltuples, (50 + 0.2 * c.reltuples)::int AS dead_tuples_needed_to_triggerFROM pg_class c WHERE c.relname IN ('tickets', 'readings'); relname | reltuples | dead_tuples_needed_to_trigger----------+-----------+------------------------------- tickets | 200000 | 40050 readings | 300000 | 60050(2 rows)นี่คือมันทำงานจริงบน table ที่เปิด autovacuum ไว้ row ที่ update 40 000 แถวสูงกว่า threshold 20 050; launcher สังเกตเห็นภายใน 15 วินาที (autovacuum_naptime เป็น 60 วินาที และ launcher ตรวจแต่ละฐานข้อมูลตามตารางนั้น การตรวจครั้งแรกจึงอาจนานถึงหนึ่งนาที):
-- one session, autovacuum left ON for this tableCREATE TABLE auto_t (id int PRIMARY KEY, v int NOT NULL);INSERT INTO auto_t SELECT g, 0 FROM generate_series(1, 100000) g;ANALYZE auto_t;UPDATE auto_t SET v = 1 WHERE id <= 40000; -- 40 000 > 50 + 0.2 * 100 000 = 20 050SELECT pg_stat_force_next_flush();SELECT n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count FROM pg_stat_user_tables WHERE relname = 'auto_t';-- psql output, right after the UPDATE n_live_tup | n_dead_tup | last_autovacuum | autovacuum_count------------+------------+-----------------+------------------ 200000 | 40000 | | 0(1 row)
-- the same query, polled every 5 s: the first poll that showed autovacuum_count = 1 was 15 s later n_live_tup | n_dead_tup | ran | autovacuum_count | analyzed------------+------------+-----+------------------+---------- 100000 | 0 | t | 1 | t(1 row)คุณ override threshold ของ table ที่ร้อนตัวเดียวด้วย storage parameter แทนการเปลี่ยนทั้ง server:
-- sql/per-table-autovacuum.sqlALTER TABLE readings SET (autovacuum_enabled = on, autovacuum_vacuum_scale_factor = 0.02);SELECT relname, reloptions FROM pg_class WHERE relname = 'readings';ALTER TABLE relname | reloptions----------+------------------------------------------------------------- readings | {autovacuum_enabled=on,autovacuum_vacuum_scale_factor=0.02}(1 row)Bloat คือพื้นที่ที่ dead tuple หรือพื้นที่ว่างที่ table คืนไม่ได้ครอบครอง มันเสียดิสก์ cache และเวลา scan pgstattuple (ข้างบน) วัดได้ตรงแต่ต้องอ่านทั้ง table; การประมาณแบบถูกคือเทียบ pg_relation_size กับ n_live_tup * ความกว้างเฉลี่ยของ row bloat มีสามสาเหตุหลัก: autovacuum ตามไม่ทันกับ table ที่ร้อน, transaction ยาวที่ขัดขวางการเก็บกู้ (ด้านล่าง) และการ delete ก้อนใหญ่ก้อนเดียว
VACUUM ธรรมดาคืนเฉพาะ page ท้าย
หัวข้อที่มีชื่อว่า “VACUUM ธรรมดาคืนเฉพาะ page ท้าย”VACUUM ธรรมดาคืนพื้นที่ให้ระบบปฏิบัติการได้เฉพาะเมื่อ page สุดท้าย ของไฟล์ว่างสมบูรณ์; พื้นที่ว่างตรงกลางยังอยู่ในไฟล์เพื่อใช้ซ้ำ สอง table ขนาด 100 000 row เท่ากัน 8 MB: ลบทุกอย่างยกเว้น 10 row ท้ายของอันหนึ่ง และทุก row ที่สองของอีกอัน:
-- sql/truncation.sqlCREATE TABLE trunc_end (id int, pad text) WITH (autovacuum_enabled = off);INSERT INTO trunc_end SELECT g, repeat('x', 50) FROM generate_series(1, 100000) g;SELECT pg_size_pretty(pg_relation_size('trunc_end')) AS before;DELETE FROM trunc_end WHERE id > 10;VACUUM trunc_end;SELECT pg_size_pretty(pg_relation_size('trunc_end')) AS after_vacuum_trailing_delete;
CREATE TABLE trunc_mid (id int, pad text) WITH (autovacuum_enabled = off);INSERT INTO trunc_mid SELECT g, repeat('x', 50) FROM generate_series(1, 100000) g;DELETE FROM trunc_mid WHERE id % 2 = 0;VACUUM trunc_mid;SELECT pg_size_pretty(pg_relation_size('trunc_mid')) AS after_vacuum_middle_delete;CREATE TABLEINSERT 0 100000 before--------- 8248 kB(1 row)
DELETE 99990VACUUM after_vacuum_trailing_delete------------------------------ 8192 bytes(1 row)
CREATE TABLEINSERT 0 100000DELETE 50000VACUUM after_vacuum_middle_delete---------------------------- 8248 kB(1 row)อันแรกหดเหลือ 8 kB อันที่สองยังเป็น 8 MB ทั้งที่ row หายไปครึ่งหนึ่ง
VACUUM FULL เขียน table ใหม่
หัวข้อที่มีชื่อว่า “VACUUM FULL เขียน table ใหม่”VACUUM FULL สร้างสำเนาใหม่ของ table ที่มีเฉพาะ row ที่ยังมีชีวิต แล้วสลับเข้ามา มันคืนพื้นที่ว่างทั้งหมด และมีราคา:
-- sql/vacuum-full.sqlSELECT pg_relation_filenode('tickets') AS filenode_before, pg_size_pretty(pg_relation_size('tickets')) AS heap_before;VACUUM FULL tickets;SELECT pg_relation_filenode('tickets') AS filenode_after, pg_size_pretty(pg_relation_size('tickets')) AS heap_after; filenode_before | heap_before-----------------+------------- 28461 | 57 MB(1 row)
VACUUM filenode_after | heap_after----------------+------------ 28473 | 30 MB(1 row)heap จาก 57 MB เหลือ 30 MB และได้ไฟล์ใหม่ (filenode 28411 เป็น 28423) ขณะรัน มันถือ lock ACCESS EXCLUSIVE: ไม่มีใครอ่านหรือเขียน table ได้ นี่คือ table 1 500 000 row ที่ลบไปครึ่งหนึ่ง (202 MB ก่อน) session A รัน VACUUM FULL; session B ลอง SELECT count(*) หลังจากนั้น 0.15 วินาที; session C ดู pg_locks ระหว่างนั้น:
-- session A: script\timing onVACUUM FULL big_t;-- session A: psql outputTiming is on.VACUUMTime: 474.164 ms
-- session B (starts 0.15 s later): script\o /dev/nullSELECT pg_sleep(0.15);\o\timing onSELECT count(*) FROM big_t;-- session B: psql outputTiming is on. count-------- 750000(1 row)
Time: 340.612 ms
-- session C (starts 0.25 s in, while A is still rewriting): script\o /dev/nullSELECT pg_sleep(0.25);\oSELECT l.mode, l.granted, a.state, left(a.query, 30) AS query, a.wait_event_type, a.wait_eventFROM pg_locks l JOIN pg_stat_activity a USING (pid)WHERE l.relation = 'big_t'::regclass ORDER BY l.granted DESC, l.pid;-- session C: psql output mode | granted | state | query | wait_event_type | wait_event---------------------+---------+--------+-----------------------------+-----------------+---------------- AccessExclusiveLock | t | active | VACUUM FULL big_t; | IO | DataFileExtend AccessShareLock | f | active | SELECT count(*) FROM big_t; | Lock | relation(2 rows)SELECT ธรรมดาของ B รอ 341 ms สำหรับการเขียนใหม่ 474 ms ของ A และ C จับได้คาหนังคาเขา: A ถือ AccessExclusiveLock, AccessShareLock ของ B เป็น granted = f รอบน Lock บน table production ที่ยุ่ง VACUUM FULL คือ outage ใช้เฉพาะ table ที่เอา offline ได้ ทางเลือกแบบ online คือ extension เช่น pg_repack
transaction ที่ถูกลืมบล็อกการเก็บกู้
หัวข้อที่มีชื่อว่า “transaction ที่ถูกลืมบล็อกการเก็บกู้”VACUUM ลบ dead tuple ได้เฉพาะที่ ไม่มี transaction ใดเห็นอีก transaction ที่เปิดอยู่และมี snapshot (transaction แบบ REPEATABLE READ ใด ๆ หรือ statement แบบ READ COMMITTED ที่เก่า) ทำให้ทุกเวอร์ชันที่สร้างหลังมันเริ่ม “ยังลบไม่ได้” แม้ client นั้นจะว่างอยู่ session A เปิด transaction อ่าน แล้วไปพักกาแฟสามวินาที; session B update 180 000 row และ vacuum สองครั้ง:
-- session A: script (a client that opened a transaction and went for a coffee)BEGIN ISOLATION LEVEL REPEATABLE READ;SELECT count(*) AS a_sees FROM tickets;\! sleep 3COMMIT;-- session A: psql outputBEGIN a_sees-------- 200000(1 row)
COMMIT
-- session B (starts 0.5 s later): scriptUPDATE tickets SET updated_at = now() WHERE status = 'open';SELECT state, left(query, 32) AS last_query, backend_xmin IS NOT NULL AS holds_snapshotFROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid();VACUUM (VERBOSE, PROCESS_TOAST false) tickets; -- while A is still open\! sleep 3.5VACUUM (VERBOSE, PROCESS_TOAST false) tickets; -- after A committed-- session B: psql output (the lines that matter)UPDATE 180000 state | last_query | holds_snapshot---------------------+----------------------------------+---------------- idle in transaction | SELECT count(*) AS a_sees FROM t | t(1 row)
tuples: 0 removed, 379996 remain, 180000 are dead but not yet removableremovable cutoff: 323711, which was 1 XIDs old when operation ended...tuples: 180000 removed, 190541 remain, 0 are dead but not yet removableremovable cutoff: 323712, which was 0 XIDs old when operation endedpg_stat_activity แสดง A เป็น idle in transaction พร้อม snapshot (backend_xmin) VACUUM ครั้งแรก scan ทุกอย่างและลบ ไม่ได้เลย: 180000 are dead but not yet removable หลัง A commit VACUUM เดิมลบได้ครบ 180 000 คูณสามวินาทีด้วย connection pool ที่รั่วหรือ batch job ที่ค้าง แล้วคุณจะได้ table ที่โตไม่มีที่สิ้นสุดขณะที่ autovacuum ทำงานแต่ไม่ได้อะไร วิธีป้องกันคือให้ transaction สั้นและตั้ง idle_in_transaction_session_timeout
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”ผู้ช่วย AI เขียน migration ลบข้อมูลที่อยากได้พื้นที่คืนด้วย และห่อทุกอย่างใน transaction เดียว “เพื่อให้ atomic”:
🐛 หา bug ในโค้ดนี้
-- AI comment: "purge closed tickets, then reclaim the space, all in one migration"BEGIN;DELETE FROM tickets WHERE status = 'closed';VACUUM tickets;COMMIT;เฉลย
VACUUM รันใน transaction block ไม่ได้ migration จึงล้มที่ statement ที่สอง หลัง DELETE (ซึ่งถูก rollback):
-- @expect-error 25001BEGIN;DELETE FROM tickets WHERE status = 'closed';VACUUM tickets;COMMIT;BEGINDELETE 20000ROLLBACKERROR: VACUUM cannot run inside a transaction blockมันล้มดัง ๆ ซึ่งเป็นผลลัพธ์ที่ดี ความเสี่ยงคือตัวรัน migration ที่กลืน error หรือ DELETE ที่ commit ไปแต่ vacuum ไม่เคยรัน commit ส่วนที่เป็น transaction ก่อน แล้ว vacuum เป็น statement แยก และแม้อย่างนั้น อย่าลืมว่าพื้นที่เพียง ใช้ซ้ำได้ ไม่ได้คืนให้ระบบปฏิบัติการ:
-- sql/stb-fixed.sqlBEGIN;DELETE FROM tickets WHERE status = 'closed';COMMIT;VACUUM tickets;BEGINDELETE 20000COMMITVACUUMMental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุก UPDATE คือ INSERT เวอร์ชันใหม่บวกเครื่องหมาย “อันนี้เก่าแล้ว” บนเวอร์ชันก่อนหน้า และทุก DELETE คือเครื่องหมายอย่างเดียว เวอร์ชันเก่าอยู่ต่อเพราะ transaction อื่นอาจยังต้องการ VACUUM คือตัวเก็บขยะ: มันปล่อยเวอร์ชันได้เมื่อ snapshot ที่เก่าที่สุดในระบบไม่ต้องการแล้ว จึงเป็นเหตุที่ session ที่ idle-in-transaction ตัวเดียวหยุดการเก็บกู้ได้ทุกที่ที่มันมองเห็น พื้นที่ที่ปล่อยกลับไปที่ free list ของ table เอง ไฟล์จึงมีขนาดเท่ายอดสูงสุด ANALYZE เป็นอีกเรื่อง คือสำมะโนที่ planner อ่าน autovacuum รันทั้งสองตามตารางที่สัมพันธ์กับปริมาณที่ table เปลี่ยน
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- อย่าปิด autovacuum ถ้า table ร้อนบวม ให้ลด threshold ของมัน (storage parameter ต่อ table) ไม่ใช่ปิดของ server
VACUUMธรรมดาไม่ทำให้ไฟล์เล็กลง ยกเว้น page ท้ายที่ว่าง;VACUUM FULLทำได้ และบล็อกทุกอย่างระหว่างนั้น- รัน
ANALYZE(หรือVACUUM (ANALYZE)) หลัง bulk load หรือ delete ก้อนใหญ่ อย่ารอ autovacuum ก่อน query หนักครั้งแรก - ระวัง session แบบ
idle in transactionและ snapshot เก่า (backend_xminในpg_stat_activity) ตั้งidle_in_transaction_session_timeout VACUUMรันใน transaction block ไม่ได้ (25001)pg_stat_user_tables(n_dead_tup,last_autovacuum,autovacuum_count) คือที่แรกที่ต้องดู; สถิติอาจช้าเล็กน้อยpg_stat_force_next_flush()flush ตัวนับของ session ตัวเอง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| Autovacuum (ค่าเริ่มต้น) | เก็บกู้พื้นที่และรีเฟรชสถิติโดยไม่ต้องดูแล | ต้องปรับบน table ที่ร้อนหรือใหญ่มาก; ถูกบล็อกโดย transaction ยาว |
VACUUM ด้วยมือ | รันทันที เช่นหลัง delete ก้อนใหญ่; ถือ lock เบา ๆ | ไม่ทำให้ไฟล์เล็กลง (เฉพาะ page ท้าย) |
VACUUM FULL | คืนพื้นที่ว่างทั้งหมดให้ OS | lock ACCESS EXCLUSIVE (B รอ 341 ms ของการเขียนใหม่ 474 ms) และเขียนใหม่ทั้งหมด |
| threshold ต่อ table ที่ต่ำลง | vacuum table ที่ร้อนเร็วขึ้น | งาน vacuum และ I/O มากขึ้น |
default_statistics_target สูงขึ้น | ค่าประมาณดีขึ้นบนข้อมูลเอียง | pg_statistic ใหญ่ขึ้น ANALYZE และการวาง plan ช้าลง |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “VACUUM ทำให้ table เล็กลง”
VACUUMธรรมดาทำให้ใช้ซ้ำได้: 57 MB ยังเป็น 57 MB และรอบ update ที่สองพอดีในไฟล์เดิม - “autovacuum จะเก็บกวาดเอง transaction ที่เปิดค้างจึงไม่เป็นไร”
VACUUMครั้งแรกลบไม่ได้เลยขณะ A เปิดอยู่:180000 are dead but not yet removable - “VACUUM FULL คือ VACUUM ที่แรงกว่า” มันคือการเขียนใหม่ใต้ lock แบบ exclusive: ผู้อ่านรอ 341 ms
- “ANALYZE สำคัญเฉพาะ table ใหญ่” มันเปลี่ยน plan ของ table 300 000 row จาก bitmap scan เป็น seq scan
- “
n_live_tupแม่นยำ” มันแสดง 400 000 สำหรับ table 200 000 row จนกว่า vacuum จะแก้