개요
4편에서 PostgreSQL이 행을 제자리에서 고치지 않고 새 버전을 만든다는 것을 봤습니다. 그러면 옛 버전(dead tuple)은 언제 사라질까요? 자동으로 사라지지 않습니다. 누군가 치워야 하고, 그 일을 하는 것이 VACUUM입니다.
이 글에서 답할 질문은 다음과 같습니다.
- VACUUM은 정확히 무엇을 지우고, 몇 단계로 일하는가
- VACUUM을 해도 테이블 파일이 줄어들지 않는 이유는 무엇인가
- Free Space Map과 Visibility Map은 무엇에 쓰이는가
- dead tuple이 쌓였는데도 VACUUM이 지우지 못하는 경우는 언제인가
- autovacuum은 언제 도는가
기준 버전: PostgreSQL 18,
REL_18_STABLE커밋39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Docker에서 빌드해 실행한 결과입니다.
동작 원리
VACUUM이 하는 일
VACUUM(정확히는 VACUUM FULL이 아닌 일반 VACUUM)은 읽기와 쓰기를 막지 않고 다른 쿼리와 동시에 돌면서 다음 일을 합니다. 다만 SHARE UPDATE EXCLUSIVE 락을 잡으므로, 같은 테이블의 DDL이나 다른 VACUUM과는 동시에 돌지 않습니다.
- dead tuple 공간 회수: 아무도 볼 수 없게 된 옛 버전을 지우고 그 자리를 새 행이 쓸 수 있게 합니다.
- Visibility Map 갱신: “이 페이지의 모든 행은 모두에게 보인다"를 표시합니다.
- Free Space Map 갱신: 페이지마다 빈 공간이 얼마나 있는지 기록합니다.
- freeze: 오래된 트랜잭션 ID를 “얼려서” 번호가 한 바퀴 돌아도 문제가 없게 합니다(6편).
- 통계 갱신:
pg_class의 페이지 수, 행 수 추정값을 고칩니다.
vacuumlazy.c 맨 위 주석이 이 과정을 세 단계로 설명합니다.
- 1단계, 힙 스캔(
lazy_scan_heap()): 테이블을 앞에서부터 읽습니다. Visibility Map에서 “모두 보임"으로 표시된 페이지는 대체로 건너뜁니다(aggressive VACUUM, 아래의 eager scanning, 짧은 구간은 읽는 예외가 있습니다). 읽은 페이지마다 dead tuple을 지우고(pruning), 그 line pointer를LP_DEAD로 바꾼 뒤 주소(TID)를 메모리에 모읍니다. 모을 수 있는 양은maintenance_work_mem까지이고, autovacuum은autovacuum_work_mem(기본값 -1이면maintenance_work_mem)까지입니다. PG17부터는 이 TID를 radix tree 기반의 TidStore에 담아 예전보다 훨씬 적은 메모리로 많은 TID를 모읍니다. - 2단계, 인덱스 정리: 모은 TID를 가리키는 인덱스 항목을 모든 인덱스에서 지웁니다.
- 3단계, 힙 정리(
lazy_vacuum_heap_rel()): 힙 페이지를 다시 방문해LP_DEAD를LP_UNUSED로 바꿉니다. 이제 그 line pointer를 새 행이 쓸 수 있습니다.
2단계가 3단계보다 먼저인 이유가 있습니다. 인덱스가 아직 그 TID를 가리키는데 line pointer를 먼저 재사용하면, 인덱스가 전혀 다른 새 행을 가리키게 됩니다. 그래서 인덱스를 먼저 비우고 나서야 힙의 자리를 돌려줍니다. TID를 담을 메모리가 가득 차면 1단계를 잠시 멈추고 2, 3단계를 한 번 돌린 뒤 이어서 스캔합니다.
세 단계가 끝나면, 테이블 끝부분에 완전히 빈 페이지가 충분히 많을 때(1000페이지 이상 또는 테이블의 1/16 이상, REL_TRUNCATE_MINIMUM) 그만큼 파일을 잘라 냅니다. 이때는 잠깐 ACCESS EXCLUSIVE 락이 필요한데, 바로 잡을 수 없거나 기다리는 쿼리가 생기면 포기합니다(lazy_truncate_heap()). 이 동작은 vacuum_truncate 설정(PG18에서 전역 설정으로 추가)이나 VACUUM (TRUNCATE false)로 끌 수 있습니다. 중간에 있는 빈 페이지는 잘라 내지 않습니다. 일반 VACUUM으로 테이블 파일이 좀처럼 줄지 않는 이유가 이것입니다. 대신 비운 공간을 새 행이 다시 씁니다.
line pointer의 일생
3편에서 본 line pointer의 네 가지 상태가 여기서 모두 쓰입니다.
“지워졌지만 옛 스냅샷엔 보임” 단계가 중요합니다. DELETE나 UPDATE가 커밋되어도, 그보다 먼저 시작한 스냅샷은 여전히 옛 버전을 볼 수 있습니다(4편). VACUUM은 지금 살아 있는 모든 스냅샷 가운데 가장 오래된 것보다 먼저 지워진 튜플만 지울 수 있습니다. 이 경계를 VACUUM 출력에서는 removable cutoff라고 부르고 소스에서는 GetOldestNonRemovableTransactionId()가 계산합니다. 오래 열린 트랜잭션 하나가 테이블 전체의 정리를 막을 수 있는 이유입니다(실습 6).
pruning과 HOT: VACUUM 없이도 조금씩 치운다
VACUUM만 dead tuple을 지우는 것은 아닙니다. 일반 쿼리가 페이지를 읽다가, 그 페이지에 지울 수 있는 옛 버전이 있다는 표시(pd_prune_xid)가 있고 빈 공간이 부족해 보이면(fillfactor 목표 또는 페이지의 10% 미만) 그 자리에서 페이지 안의 dead tuple을 정리합니다. 이를 pruning이라고 합니다(heap_page_prune_opt()). pruning은 튜플 공간을 비우고, 인덱스가 가리키는 line pointer는 LP_DEAD로 남겨 둡니다. 인덱스는 건드리지 않으므로 line pointer를 완전히 돌려주는 일(LP_UNUSED)은 VACUUM이 해야 합니다.
HOT(Heap-Only Tuple) 업데이트는 여기서 한 걸음 더 나갑니다. UPDATE가 인덱스 열을 바꾸지 않고 새 버전이 같은 페이지에 들어가면, 인덱스에 새 항목을 만들지 않고 옛 버전에서 새 버전으로 체인만 잇습니다. 인덱스는 체인의 첫 줄만 가리킵니다. 나중에 옛 버전들을 정리할 때 첫 줄은 LP_REDIRECT로 남아 살아 있는 버전을 가리키고, 중간 버전은 인덱스가 가리키지 않으므로 바로 LP_UNUSED가 됩니다(실습 5). 인덱스를 고치지 않으니 UPDATE도, VACUUM도 가벼워집니다. 3편에서 본 fillfactor로 페이지에 여유를 남기는 것도 HOT가 일어날 자리를 확보하기 위해서입니다.
Free Space Map과 Visibility Map
VACUUM이 관리하는 지도 두 개가 있습니다. 둘 다 테이블 옆의 별도 fork 파일입니다(3편).
| 지도 | 파일 | 한 페이지당 | 쓰는 곳 |
|---|---|---|---|
| Free Space Map (FSM) | _fsm | 1바이트 (freespace/README) | INSERT와 UPDATE가 새 행을 넣을 페이지를 찾을 때 |
| Visibility Map (VM) | _vm | 2비트 (visibilitymapdefs.h) | VACUUM이 건너뛸 페이지를 고를 때, index-only scan이 힙을 읽지 않아도 될지 판단할 때 |
VM의 두 비트는 다음과 같습니다.
- all-visible: 이 페이지의 모든 튜플이 모든 트랜잭션에게 보입니다. dead tuple도, 커밋 안 된 튜플도 없습니다.
- all-frozen: 이 페이지의 모든 튜플이 얼려져 있습니다(6편). wraparound를 막는 VACUUM이 이 페이지를 건너뛸 수 있습니다.
페이지의 튜플이 하나라도 바뀌면 그 페이지의 비트는 바로 지워지고, 다음 VACUUM이 다시 켭니다. index-only scan은 인덱스에 필요한 열이 다 있어도, 행이 보이는지 확인하려면 원래 힙을 읽어야 합니다. 그런데 VM이 all-visible이면 그 확인을 건너뛸 수 있습니다(실습 4).
autovacuum
VACUUM을 사람이 매번 돌릴 수는 없으므로, 1편에서 본 autovacuum launcher가 주기적으로(autovacuum_naptime, 기본 1분) 테이블을 살피고 필요하면 worker를 띄웁니다. 테이블이 VACUUM 대상이 되는 조건은 다음과 같습니다(autovacuum.c).
dead tuple 수 >
autovacuum_vacuum_threshold(50) +autovacuum_vacuum_scale_factor(0.2) × 행 수
단, 이 값은 autovacuum_vacuum_max_threshold(기본 1억)를 넘지 않습니다. 이 상한은 PG18에서 새로 생겼습니다. 예전에는 10억 행 테이블이면 dead tuple이 2억 개 쌓여야 autovacuum이 돌았는데, 이제 1억 개에서 돕니다.
INSERT만 있는 테이블도 대상이 됩니다(freeze와 VM 갱신이 필요하기 때문입니다).
마지막 VACUUM 이후 INSERT 수 >
autovacuum_vacuum_insert_threshold(1000) +autovacuum_vacuum_insert_scale_factor(0.2) × 행 수 × 아직 freeze 안 된 페이지 비율
마지막 항(freeze 안 된 비율)도 PG18에서 추가되었습니다. 이 비율을 곱하면 기준값이 낮아집니다. 대부분 이미 얼려진 큰 테이블이라면 행 수 전체가 아니라 아직 얼리지 않은 부분만 기준으로 삼으므로, INSERT 기반 VACUUM이 예전보다 자주, 제때 돌게 됩니다. 이미 얼려진 페이지는 VM 덕분에 어차피 건너뜁니다.
그 밖에 PG18의 VACUUM 관련 변화는 다음과 같습니다.
- eager scanning(
vacuumlazy.c): 일반 VACUUM도 all-visible이지만 아직 얼리지 않은 페이지를 조금씩 미리 읽어 freeze합니다. 나중의 대규모 freeze 작업(6편)을 나눠 하려는 것입니다.vacuum_max_eager_freeze_failure_rate(기본 0.03)로 조절하고, VACUUM VERBOSE 출력의eagerly scanned가 그 페이지 수입니다. autovacuum_worker_slots(기본 16): autovacuum worker 자리를 미리 잡아 두는 값입니다. 이제autovacuum_max_workers는 재시작 없이 이 범위 안에서 바꿀 수 있습니다.
직접 확인해 보기
실습 환경
실습 이미지로 lab.sh가 새 컨테이너에서 처음부터 끝까지 실행했습니다(공용 함수는 labkit.sh). 원본 출력은 final-run.log에 있습니다. 페이지와 지도를 보는 데는 contrib 확장 pageinspect, pg_visibility, pg_freespacemap을 씁니다. 실습 테이블은 autovacuum이 끼어들지 않도록 autovacuum_enabled = off로 만들었습니다. 통계(pg_stat_user_tables)는 약간 늦게 반영되므로, 변경 뒤 2초 기다렸다가 조회했습니다.
docker run -d --init --name pglab --hostname pglab pg-internals:rel18-lab sleep infinity
4f4ff147becaad7b9b222ce8f3be8edfa1fcf0f445d7b4458d52ce90c61ade66
[exit=0]
pg_ctl -D $PGDATA -l /home/postgres/server.log start
psql -X -q -c "CREATE EXTENSION pageinspect" -c "CREATE EXTENSION pg_visibility" -c "CREATE EXTENSION pg_freespacemap"
psql -X -q -c "CREATE TABLE t (id int PRIMARY KEY, v int, pad text) WITH (autovacuum_enabled = off)"
psql -X -q -c "INSERT INTO t SELECT g, 0, repeat('x', 100) FROM generate_series(1, 20000) g"
sleep 2
psql -X -q -c "VACUUM (ANALYZE) t"
waiting for server to start.... done
server started
[exit=0]
실습 1. UPDATE를 하면 dead tuple이 쌓인다
psql -X <<'SQL'
SELECT pg_size_pretty(pg_relation_size('t')) AS size, pg_relation_size('t') / 8192 AS pages;
UPDATE t SET v = v + 1;
SELECT pg_size_pretty(pg_relation_size('t')) AS size, pg_relation_size('t') / 8192 AS pages;
SQL
sleep 2
psql -X -c "SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 't'"
size | pages
---------+-------
2760 kB | 345
(1 row)
UPDATE 20000
size | pages
---------+-------
5520 kB | 690
(1 row)
n_live_tup | n_dead_tup
------------+------------
20000 | 20000
(1 row)
[exit=0]
2만 행 전체를 한 번 UPDATE하자 테이블이 345페이지에서 690페이지로 정확히 두 배가 되었습니다. 새 버전 2만 개가 새로 쓰였고, 옛 버전 2만 개(n_dead_tup)는 그대로 남았기 때문입니다.
실습 2. VACUUM이 dead tuple을 정리한다
psql -X -c "VACUUM (VERBOSE, PROCESS_TOAST false) t" 2>&1
sleep 2
psql -X <<'SQL'
SELECT pg_size_pretty(pg_relation_size('t')) AS size, pg_relation_size('t') / 8192 AS pages;
SELECT n_live_tup, n_dead_tup, vacuum_count FROM pg_stat_user_tables WHERE relname = 't';
SELECT count(*) AS pages, pg_size_pretty(sum(avail)) AS free_space FROM pg_freespace('t');
SQL
INFO: vacuuming "postgres.public.t"
INFO: finished vacuuming "postgres.public.t": index scans: 1
pages: 0 removed, 690 remain, 690 scanned (100.00% of total), 0 eagerly scanned
tuples: 20000 removed, 20000 remain, 0 are dead but not yet removable
removable cutoff: 759, which was 0 XIDs old when operation ended
new relfrozenxid: 758, which is 2 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 690 pages set all-visible, 344 pages set all-frozen (0 were all-visible)
index scan needed: 345 pages from table (50.00% of total) had 20000 dead item identifiers removed
index "t_pkey": pages: 112 in total, 0 newly deleted, 0 currently deleted, 0 reusable
avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s
buffer usage: 1898 hits, 0 reads, 0 dirtied
WAL usage: 1492 records, 0 full page images, 257943 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM
size | pages
---------+-------
5520 kB | 690
(1 row)
n_live_tup | n_dead_tup | vacuum_count
------------+------------+--------------
20000 | 0 | 2
(1 row)
pages | free_space
-------+------------
690 | 2761 kB
(1 row)
[exit=0]
VACUUM VERBOSE 출력을 한 줄씩 읽어 보겠습니다.
| 출력 | 뜻 |
|---|---|
index scans: 1 | 인덱스 정리(2단계)를 한 번 했음 |
tuples: 20000 removed | dead tuple 2만 개를 지움 |
removable cutoff: 759 | 이 xid보다 먼저 지워진 튜플만 지울 수 있음. 지금은 다른 트랜잭션이 없어 가장 최신 값 |
visibility map: 690 pages set all-visible, 344 pages set all-frozen | 모든 페이지를 all-visible로 표시함. 옛 버전이 있던 앞쪽 345페이지 가운데 완전히 비워진 344페이지는 얼릴 튜플이 없으니 all-frozen까지 켜졌음. 나머지 1페이지에는 UPDATE의 새 버전 일부가 함께 들어가 있어서 제외 |
index scan needed: ... had 20000 dead item identifiers removed | 힙 345페이지의 LP_DEAD line pointer 2만 개. 이들을 가리키는 인덱스 항목을 지운 뒤 LP_UNUSED로 바꿈 |
WAL usage: 1492 records | VACUUM도 WAL을 남김 |
그런데 VACUUM 뒤에도 테이블 크기는 690페이지 그대로입니다. 지운 자리는 FSM에 빈 공간(2761kB, 테이블의 절반)으로 기록되었을 뿐, 파일은 줄지 않았습니다.
실습 3. 비운 공간은 다시 쓴다
psql -X <<'SQL'
INSERT INTO t SELECT g, 0, repeat('x', 100) FROM generate_series(20001, 30000) g;
SELECT pg_size_pretty(pg_relation_size('t')) AS size, pg_relation_size('t') / 8192 AS pages;
SQL
INSERT 0 10000
size | pages
---------+-------
5520 kB | 690
(1 row)
[exit=0]
1만 행을 새로 넣었는데 테이블은 690페이지에서 늘지 않았습니다. INSERT가 FSM에서 빈 공간이 있는 페이지를 찾아 그 자리에 넣었기 때문입니다. VACUUM의 목적은 파일을 줄이는 것이 아니라 공간을 재사용할 수 있게 만드는 것입니다.
실습 4. Visibility Map과 index-only scan
id <= 5000인 행을 바꿔 그 페이지들의 VM 비트를 지운 뒤, 인덱스만 읽으면 되는 쿼리를 실행합니다.
psql -X <<'SQL'
SELECT n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 't';
UPDATE t SET v = v + 1 WHERE id <= 5000;
SELECT pg_sleep(2);
SELECT n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 't';
SELECT * FROM pg_visibility_map_summary('t');
SET enable_seqscan = off;
SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT count(id) FROM t WHERE id <= 5000;
VACUUM t;
SELECT * FROM pg_visibility_map_summary('t');
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT count(id) FROM t WHERE id <= 5000;
SQL
n_tup_hot_upd
---------------
0
(1 row)
UPDATE 5000
pg_sleep
----------
(1 row)
n_tup_hot_upd
---------------
10
(1 row)
all_visible | all_frozen
-------------+------------
342 | 84
(1 row)
SET
SET
QUERY PLAN
-----------------------------------------------------------------------
Aggregate (actual rows=1.00 loops=1)
-> Index Only Scan using t_pkey on t (actual rows=5000.00 loops=1)
Index Cond: (id <= 5000)
Heap Fetches: 9990
Index Searches: 1
(5 rows)
VACUUM
all_visible | all_frozen
-------------+------------
690 | 170
(1 row)
QUERY PLAN
-----------------------------------------------------------------------
Aggregate (actual rows=1.00 loops=1)
-> Index Only Scan using t_pkey on t (actual rows=5000.00 loops=1)
Index Cond: (id <= 5000)
Heap Fetches: 0
Index Searches: 1
(5 rows)
[exit=0]
- VACUUM 전: all-visible 페이지가 342개뿐이라
Heap Fetches: 9990입니다. 인덱스에 있는id만 필요한데도, 행이 보이는지 확인하려고 힙을 9990번 읽었습니다. 5000행인데 9990번인 이유는 인덱스에 옛 버전 항목 5000개와 새 버전 항목 4990개가 모두 있었기 때문입니다. 10건은 같은 페이지 안의 HOT 업데이트(n_tup_hot_upd가 0에서 10으로 늘어남)라서 인덱스 항목이 새로 생기지 않았습니다. - VACUUM 후: 690페이지가 모두 all-visible이 되자
Heap Fetches: 0입니다. 힙을 전혀 읽지 않았습니다.
실습 5. HOT 업데이트와 pruning
인덱스 열(id)이 아닌 v만 세 번 바꿉니다.
psql -X <<'SQL'
CREATE TABLE hot (id int PRIMARY KEY, v int) WITH (autovacuum_enabled = off);
INSERT INTO hot VALUES (1, 0);
UPDATE hot SET v = 1 WHERE id = 1;
UPDATE hot SET v = 2 WHERE id = 1;
UPDATE hot SET v = 3 WHERE id = 1;
SELECT lp, lp_flags, lp_off, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('hot', 0));
SQL
sleep 2
psql -X <<'SQL'
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 'hot';
VACUUM hot;
SELECT lp, lp_flags, lp_off, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('hot', 0));
SQL
CREATE TABLE
INSERT 0 1
UPDATE 1
UPDATE 1
UPDATE 1
lp | lp_flags | lp_off | t_xmin | t_xmax | t_ctid
----+----------+--------+--------+--------+--------
1 | 1 | 8160 | 762 | 763 | (0,2)
2 | 1 | 8128 | 763 | 764 | (0,3)
3 | 1 | 8096 | 764 | 765 | (0,4)
4 | 1 | 8064 | 765 | 0 | (0,4)
(4 rows)
n_tup_upd | n_tup_hot_upd
-----------+---------------
3 | 3
(1 row)
VACUUM
lp | lp_flags | lp_off | t_xmin | t_xmax | t_ctid
----+----------+--------+--------+--------+--------
1 | 2 | 4 | | |
2 | 0 | 0 | | |
3 | 0 | 0 | | |
4 | 1 | 8160 | 765 | 0 | (0,4)
(4 rows)
[exit=0]
- UPDATE 세 번이 모두 HOT였습니다(
n_tup_hot_upd = 3). 같은 페이지에 버전 네 개가t_ctid로 이어진 체인(lp 1 → 2 → 3 → 4)이 생겼고, 인덱스는 lp 1만 가리킵니다. - VACUUM 뒤 lp 1은
lp_flags = 2(LP_REDIRECT)가 되었습니다.lp_off = 4는 이 경우 바이트 위치가 아니라 넘겨줄 line pointer 번호, 즉 lp 4입니다. 인덱스가 lp 1을 찾아오면 lp 4의 살아 있는 버전으로 넘겨줍니다. - 중간 버전 lp 2, 3은
LP_UNUSED(0)가 되어 바로 다시 쓸 수 있습니다. 살아 있는 버전 lp 4는 페이지 끝(8160)으로 옮겨졌습니다. 페이지 안 빈 공간을 한데 모은(정리한) 결과입니다.
실습 6. 긴 트랜잭션이 있으면 지우지 못한다
세션 A가 REPEATABLE READ 트랜잭션을 열어 둔 상태에서, 다른 세션이 5000행을 지우고 VACUUM합니다.
-- 세션 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
-- 세션 A
SELECT count(*) FROM t;
count
-------
30000
(1 row)
-- 세션 A
SELECT pg_current_snapshot();
pg_current_snapshot
---------------------
766:766:
(1 row)
psql -X -c "DELETE FROM t WHERE id > 25000"
psql -X -c "VACUUM (VERBOSE, PROCESS_TOAST false) t" 2>&1 | grep -E 'tuples:|removable cutoff'
psql -X -c "SELECT pid, state, backend_xmin, now() - xact_start > interval '0' AS in_xact FROM pg_stat_activity WHERE backend_xmin IS NOT NULL AND pid <> pg_backend_pid()"
DELETE 5000
tuples: 0 removed, 26842 remain, 5000 are dead but not yet removable
removable cutoff: 766, which was 1 XIDs old when operation ended
pid | state | backend_xmin | in_xact
-----+---------------------+--------------+---------
119 | idle in transaction | 766 | t
(1 row)
[exit=0]
tuples: 0 removed, ... 5000 are dead but not yet removable. 5000개가 dead지만 지울 수 없다는 뜻입니다(remain은 건너뛴 페이지의 행 수를 추정해 더한 값이라 실제 행 수와 조금 다릅니다). 세션 A의 스냅샷(backend_xmin = 766)은 DELETE 전의 데이터를 볼 수 있어야 하므로, VACUUM의 removable cutoff도 766에 묶였습니다.
-- 세션 A
COMMIT;
COMMIT
psql -X -c "VACUUM (VERBOSE, PROCESS_TOAST false) t" 2>&1 | grep -E 'tuples:|removable cutoff'
tuples: 5000 removed, 19106 remain, 0 are dead but not yet removable
removable cutoff: 767, which was 0 XIDs old when operation ended
[exit=0]
A가 커밋하자 removable cutoff가 767로 넘어가고, 같은 VACUUM이 이번에는 5000개를 모두 지웠습니다.
실습 6-1. 테이블 끝의 빈 페이지는 잘라 낸다
psql -X -q -c "CREATE TABLE tail (id int, pad text) WITH (autovacuum_enabled = off)"
psql -X -q -c "INSERT INTO tail SELECT g, repeat('x', 100) FROM generate_series(1, 20000) g"
psql -X -c "SELECT pg_relation_size('tail') / 8192 AS pages"
psql -X -q -c "DELETE FROM tail WHERE id > 10000"
psql -X -c "VACUUM (VERBOSE, PROCESS_TOAST false) tail" 2>&1 | grep -E 'pages:|tuples:'
psql -X -c "SELECT pg_relation_size('tail') / 8192 AS pages"
pages
-------
345
(1 row)
pages: 172 removed, 173 remain, 345 scanned (100.00% of total), 0 eagerly scanned
tuples: 10000 removed, 10000 remain, 0 are dead but not yet removable
pages
-------
173
(1 row)
[exit=0]
앞쪽 1만 행은 두고 뒤쪽 1만 행을 지웠더니, 뒤쪽 172페이지가 통째로 비었습니다. VACUUM이 이를 잘라 내 파일이 345페이지에서 173페이지로 줄었습니다(pages: 172 removed). 실습 2와 달리 빈 페이지가 파일 끝에 몰려 있었기 때문입니다.
실습 7. VACUUM FULL은 테이블을 새로 쓴다
psql -X <<'SQL'
DELETE FROM t WHERE id % 2 = 0;
VACUUM t;
SELECT pg_relation_filenode('t') AS filenode, pg_size_pretty(pg_relation_size('t')) AS size;
VACUUM FULL t;
SELECT pg_relation_filenode('t') AS filenode, pg_size_pretty(pg_relation_size('t')) AS size;
SQL
DELETE 12500
VACUUM
filenode | size
----------+---------
16442 | 5520 kB
(1 row)
VACUUM
filenode | size
----------+---------
16461 | 1728 kB
(1 row)
[exit=0]
절반을 지우고 일반 VACUUM을 해도 5520kB 그대로였지만, VACUUM FULL 뒤에는 1728kB로 줄었습니다. 파일 번호(filenode)가 16442에서 16461로 바뀐 것에서 보듯, VACUUM FULL은 아직 지울 수 없는 행까지 포함해 필요한 행만 새 파일에 다시 쓰고 옛 파일을 버립니다(rebuild_relation()). 그동안 테이블에 ACCESS EXCLUSIVE 락을 잡으므로 읽기까지 모두 막힙니다. 또 새 파일을 다 쓸 때까지 옛 파일도 남아 있으므로 테이블 크기만큼의 여유 디스크가 필요합니다. 운영 중인 큰 테이블에는 신중하게 써야 합니다.
실습 8. autovacuum은 언제 도는가
autovacuum_naptime을 1초로 줄이고, 1만 행 테이블에서 dead tuple을 기준값 아래와 위로 만들어 봅니다.
psql -X -q <<'SQL'
ALTER SYSTEM SET autovacuum_naptime = '1s';
ALTER SYSTEM SET log_autovacuum_min_duration = 0;
SELECT pg_reload_conf();
CREATE TABLE av (id int PRIMARY KEY, v int);
INSERT INTO av SELECT g, 0 FROM generate_series(1, 10000) g;
SQL
sleep 5
psql -X <<'SQL'
SELECT relname, reltuples FROM pg_class WHERE relname = 'av';
SELECT current_setting('autovacuum_vacuum_threshold')::int
+ current_setting('autovacuum_vacuum_scale_factor')::float * reltuples AS vacuum_threshold
FROM pg_class WHERE relname = 'av';
SELECT n_dead_tup, autovacuum_count, last_autovacuum IS NOT NULL AS vacuumed FROM pg_stat_user_tables WHERE relname = 'av';
UPDATE av SET v = 1 WHERE id <= 1500;
SQL
sleep 4
psql -X -c "SELECT n_dead_tup, autovacuum_count FROM pg_stat_user_tables WHERE relname = 'av'"
psql -X -q -c "UPDATE av SET v = 2 WHERE id <= 1500"
sleep 4
psql -X -c "SELECT n_dead_tup, autovacuum_count FROM pg_stat_user_tables WHERE relname = 'av'"
grep -A8 'automatic vacuum of table "postgres.public.av"' /home/postgres/server.log | grep -E 'automatic vacuum|tuples:|index scan'
pg_reload_conf
----------------
t
(1 row)
relname | reltuples
---------+-----------
av | 10000
(1 row)
vacuum_threshold
------------------
2050
(1 row)
n_dead_tup | autovacuum_count | vacuumed
------------+------------------+----------
0 | 1 | t
(1 row)
UPDATE 1500
n_dead_tup | autovacuum_count
------------+------------------
1500 | 1
(1 row)
n_dead_tup | autovacuum_count
------------+------------------
0 | 2
(1 row)
2026-09-24 03:40:42.396 UTC [274] LOG: automatic vacuum of table "postgres.public.av": index scans: 0
tuples: 0 removed, 10000 remain, 0 are dead but not yet removable
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
2026-09-24 03:40:51.454 UTC [300] LOG: automatic vacuum of table "postgres.public.av": index scans: 1
tuples: 1500 removed, 8893 remain, 0 are dead but not yet removable
index scan needed: 14 pages from table (24.14% of total) had 3000 dead item identifiers removed
[exit=0]
- 이 테이블의 VACUUM 기준값은 50 + 0.2 × 10000 = 2050입니다.
- 1만 행을 넣은 직후 autovacuum이 이미 한 번 돌았습니다(
autovacuum_count = 1). 로그의 첫 번째tuples: 0 removed기록입니다. 이때는 아직 행 수 추정값이 없어서 0으로 취급하므로(autovacuum.c), INSERT 기준값이 1000이었고 1만 행이 이를 넘었습니다. - 1500행을 바꿔 dead tuple이 1500개(기준 2050 미만)일 때는 4초를 기다려도 돌지 않았습니다.
- 1500행을 한 번 더 바꿔 누적 3000개가 되자 autovacuum이 돌았습니다(
autovacuum_count = 2).
두 번째 기록을 보면 tuples: 1500 removed인데 3000 dead item identifiers removed입니다. 두 번째 UPDATE는 바꿀 행을 인덱스로 찾아 읽었는데, 이때 첫 UPDATE의 옛 버전이 있던 페이지를 읽으면서 pruning해(heapam_handler.c) 1500개를 LP_DEAD로 만들어 두었습니다. VACUUM은 나머지 1500개만 직접 지웠고, 인덱스 항목은 둘 다 지워야 하므로 3000개입니다.
운영에서는 이렇게 나타납니다
테이블이 줄지 않는다(bloat)
실습 2, 3에서 본 것처럼 일반 VACUUM은 파일을 거의 줄이지 않습니다. dead tuple이 많이 쌓인 뒤에 VACUUM하면 그만큼의 빈 공간이 파일 안에 남는데, 이것을 bloat라고 부릅니다. 빈 공간은 새 행이 다시 쓰므로 그 자체로 문제는 아니지만, 테이블을 끝까지 읽는 쿼리(순차 스캔)는 빈 페이지까지 모두 읽어야 해서 느려집니다. bloat가 너무 커지면 다음 방법으로 파일을 줄입니다.
VACUUM FULL: 확실하지만 테이블 전체를 막습니다(실습 7).pg_repack같은 확장: 시작과 끝에만 짧게 락을 잡고 온라인으로 다시 씁니다. 기본 키나 UNIQUE 인덱스가 필요합니다.
가장 좋은 방법은 bloat가 커지기 전에 autovacuum이 자주, 제때 돌게 하는 것입니다.
큰 테이블은 autovacuum이 늦게 돈다
기준값이 50 + 0.2 × 행 수이므로, 1억 행 테이블은 dead tuple이 2000만 개 쌓여야 autovacuum이 돕니다. PG18의 autovacuum_vacuum_max_threshold(1억)로도 이 정도 테이블에는 효과가 없습니다. 자주 바뀌는 큰 테이블에는 테이블 단위로 기준을 낮춥니다.
ALTER TABLE big_orders SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 10000);
autovacuum이 도는지, 얼마나 자주 도는지는 pg_stat_user_tables의 last_autovacuum, autovacuum_count, n_dead_tup으로 확인하고, log_autovacuum_min_duration을 켜 두면 실습 8처럼 실행 기록이 로그에 남습니다.
dead but not yet removable
VACUUM 로그에 are dead but not yet removable이 크게 찍히면, 무언가가 removable cutoff를 붙잡고 있다는 뜻입니다(실습 6). 흔한 원인은 다음과 같습니다.
- 오래 열린 트랜잭션, 특히
idle in transaction세션:pg_stat_activity의backend_xmin이 오래된 세션을 찾습니다. - 사용하지 않는 replication slot: 물리 슬롯은
hot_standby_feedback을 쓸 때xmin이 모든 테이블의 정리를 붙잡고, 논리 슬롯은catalog_xmin이 시스템 카탈로그의 정리를 붙잡습니다(9편). - standby의
hot_standby_feedback = on과 standby에서 도는 긴 쿼리(9편). - 준비만 해 두고 끝내지 않은 prepared transaction(
pg_prepared_xacts).
이 경우 VACUUM을 아무리 돌려도 dead tuple이 줄지 않으므로, 원인을 먼저 없애야 합니다.
HOT를 살리는 테이블 설계
UPDATE가 많은 테이블이라면 HOT 비율(n_tup_hot_upd / n_tup_upd)을 확인해 볼 만합니다. HOT가 안 되는 이유는 보통 두 가지입니다. 자주 바뀌는 열에 인덱스가 걸려 있거나, 페이지에 새 버전을 넣을 공간이 없어서입니다. 쓰지 않는 인덱스를 지우고, 자주 바뀌는 테이블은 fillfactor를 90 이하로 낮춰 두면 HOT 비율이 올라갑니다.
정리
- VACUUM은 힙 스캔(pruning, TID 수집) → 인덱스 정리 → 힙 정리(
LP_DEAD→LP_UNUSED) 순서로 일하고, 이 순서는 인덱스가 재사용된 자리를 잘못 가리키지 않게 하려는 것입니다. - 일반 VACUUM은 공간을 재사용 가능하게 만들 뿐, 파일 끝의 빈 페이지가 아니면 파일을 줄이지 않습니다. 줄이려면
VACUUM FULL(전체 락)이나pg_repack이 필요합니다. - VACUUM은 가장 오래된 스냅샷(
removable cutoff)보다 먼저 지워진 튜플만 지울 수 있어서, 긴 트랜잭션 하나가 정리를 막습니다. - 쿼리도 페이지가 차면 그 자리에서 pruning하고, HOT 업데이트는 인덱스를 건드리지 않고 같은 페이지 안에서 버전을 잇습니다.
- FSM은 빈 공간을, VM은 all-visible, all-frozen을 페이지마다 기록합니다. VM 덕분에 VACUUM은 페이지를 건너뛰고, index-only scan은 힙을 읽지 않습니다.
- autovacuum은
50 + 0.2 × 행 수를 넘는 dead tuple, 또는 INSERT 기준을 넘으면 돕니다. PG18에서 상한(autovacuum_vacuum_max_threshold)과 freeze 비율 반영, eager scanning이 추가되었습니다.
다음 글에서는 VACUUM의 또 다른 임무인 freeze와 이를 게을리하면 생기는 트랜잭션 ID wraparound를 살펴봅니다.
참고 자료
소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)
- src/backend/access/heap/vacuumlazy.c: VACUUM 단계, eager scanning, truncate
- src/backend/access/heap/pruneheap.c: pruning과 HOT 체인 정리
- src/backend/access/heap/README.HOT: HOT 설계
- src/backend/storage/freespace/README: FSM 구조
- src/backend/access/heap/visibilitymap.c: Visibility Map
- src/backend/postmaster/autovacuum.c: autovacuum 대상 판단
PostgreSQL 18 공식 문서
실습 파일