명령어/DB

[PostgreSQL] 블로트 정밀 진단 3종 — pgstattuple + pg_freespacemap + pageinspect

jykim23 2026. 7. 21. 21:55
반응형

설치·접속: PostgreSQL 설치와 접속

부제: autovacuum이 도는데도 테이블이 계속 커질 때, VACUUM FULL/REINDEX를 진짜 숫자로 결정하기

pg_stat_user_tables.n_dead_tup은 통계 추정치라, 통계가 리셋됐거나 갱신이 안 되면 dead tuple을 0으로 보고한다. 실제로 페이지를 스캔해 정확한 dead_tuple_percent를 재고, 빈 공간이 페이지별로 어떻게 흩어졌는지 보고, 특정 페이지의 튜플 헤더까지 부검하는 3단 진단을 붙여본다.

문제 상황

대량 INSERT 후 절반을 DELETE하고 나머지 일부를 UPDATE한 테이블. autovacuum을 끈 상태(autovacuum_enabled=false)라 dead tuple이 그대로 쌓여 있다.

CREATE TABLE bloat (id int primary key, payload text)
  WITH (autovacuum_enabled = false);
INSERT INTO bloat SELECT g, repeat('x', 200) FROM generate_series(1, 50000) g;
DELETE FROM bloat WHERE id % 2 = 0;       -- 절반 삭제
UPDATE bloat SET payload = repeat('y', 200) WHERE id % 4 = 1;  -- 1/4 갱신

1단계 — 추정치는 못 믿는다: 먼저 통계를 본다

ANALYZE bloat;
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables WHERE relname = 'bloat';
 relname  | n_live_tup | n_dead_tup
----------+------------+------------
 bloat |      25000 |          0

DELETE 2.5만 건 + UPDATE 1.25만 건으로 dead tuple이 분명히 있는데도 n_dead_tup0이다. 이 카운터는 autovacuum/통계 수집기가 갱신하는 누적값이라, autovacuum을 껐거나 통계가 리셋되면 현실과 어긋난다. 이 숫자만 보고 "블로트 없음"이라 판단하면 오진이다.

2단계 — pgstattuple로 진짜 dead_tuple_percent 실측

CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT table_len, tuple_count, tuple_percent,
       dead_tuple_count, dead_tuple_percent, free_percent
FROM pgstattuple('bloat');
 table_len | tuple_count | tuple_percent | dead_tuple_count | dead_tuple_percent | free_percent
-----------+-------------+---------------+------------------+--------------------+--------------
  12050432 |       25000 |         48.13 |            12510 |              24.08 |        25.37

pgstattuple은 통계 추정이 아니라 실제 페이지를 전부 스캔해 튜플 단위로 센다. dead tuple이 12510개, 물리 공간의 24.08%를 죽은 튜플이 차지하고 있다. 추정치 n_dead_tup=0과 정면으로 어긋나는 진짜 숫자다. table_len은 약 12MB, live 튜플은 48%뿐이다.

3단계 — pg_freespacemap으로 빈 공간 분포 확인

CREATE EXTENSION IF NOT EXISTS pg_freespacemap;
SELECT avail, count(*) AS pages
FROM pg_freespace('bloat')
GROUP BY avail ORDER BY avail;
 avail | pages
-------+-------
     0 |     1
   128 |  1470

여기가 핵심이다. dead tuple이 24%나 되는데 FSM이 보고하는 재사용 가능 공간은 페이지당 128 바이트뿐이다(200바이트짜리 payload 한 줄도 못 들어간다). VACUUM을 아직 안 돌렸으니 죽은 튜플이 차지한 공간이 FSM에 반영되지 않은 것이다. "dead는 많은데 쓸 수 있는 빈자리는 없다" = 새 INSERT는 계속 새 페이지를 만들며 테이블이 커진다.

4단계 — pageinspect로 특정 페이지 튜플 부검

CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT lp, lp_off, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('bloat', 0))
ORDER BY lp LIMIT 12;
 lp | lp_off | t_xmin | t_xmax | t_ctid
----+--------+--------+--------+--------
  1 |   7960 |   1210 |   1212 | (0,35)
  2 |      0 |        |        |
  3 |   7728 |   1210 |      0 | (0,3)
  4 |      0 |        |        |
  5 |   7496 |   1210 |   1212 | (0,36)
  6 |      0 |        |        |
  7 |   7264 |   1210 |      0 | (0,7)
  8 |      0 |        |        |
  9 |   7032 |   1210 |   1212 | (0,37)
 10 |      0 |        |        |
 11 |   6800 |   1210 |      0 | (0,11)
 12 |      0 |        |        |

페이지 0을 바이트 단위로 열어 보면 세 종류의 라인 포인터가 보인다. lp_off=0인 짝수 lp(2, 4, 6…)는 이미 정리된/죽은 라인 포인터 — DELETE된 짝수 id 행들이다. t_xmax=1212가 찍힌 튜플(lp 1, 5, 9)은 트랜잭션 1212에 의해 삭제 표시된 것으로, t_ctid가 자기 자신이 아니라 (0,35) 같은 새 위치를 가리킨다 = UPDATE된 헌 버전이 새 튜플로 이어진 흔적이다. t_xmax=0인 튜플(lp 3, 7, 11)만 살아있는 현재 버전이다. dead_tuple_percent 24%가 실제로 어떤 모습인지 눈으로 확인한 셈이다.

5단계 — VACUUM 후 변화, 그리고 결정

VACUUM bloat;
SELECT dead_tuple_count, dead_tuple_percent, free_percent, table_len
FROM pgstattuple('bloat');

SELECT avail, count(*) AS pages
FROM pg_freespace('bloat') GROUP BY avail ORDER BY avail;
 dead_tuple_count | dead_tuple_percent | free_percent | table_len
------------------+--------------------+--------------+-----------
                0 |                  0 |        49.45 |  12050432

 avail | pages
-------+-------
  4032 |  1470
  5728 |     1

VACUUM은 dead tuple을 0으로 만들고 FSM을 갱신했다. 이제 페이지당 재사용 가능 공간이 1284032 바이트로 뛰었다(새 INSERT가 빈자리를 재사용할 수 있다). 하지만 table_len은 12MB 그대로다 — VACUUM은 공간을 반납하지 않고 재사용 가능 상태로만 표시한다. free_percent가 49%라는 건 이 테이블이 반은 텅 빈 채로 디스크를 물고 있다는 뜻이다.

여기서 결정이 갈린다. 이 빈 공간을 곧 새 INSERT가 채운다면 VACUUM으로 충분하다. 하지만 워크로드가 더는 INSERT하지 않아 이 49%가 영원히 낭비될 거라면, 디스크를 실제로 반납하는 VACUUM FULL이 답이다.

VACUUM FULL bloat;
SELECT table_len, tuple_count, dead_tuple_count, free_percent
FROM pgstattuple('bloat');
 table_len | tuple_count | dead_tuple_count | free_percent
-----------+-------------+------------------+--------------
   6029312 |       25000 |                0 |          1.8

VACUUM FULL이 테이블을 통째로 재작성해 table_len이 12MB → 6MB로 반토막 났고 free_percent는 1.8%로 떨어졌다. 대신 이 명령은 ACCESS EXCLUSIVE 락으로 테이블 전체를 잠그므로(운영 중 조회·쓰기 모두 차단) 유지보수 창에서만 돌려야 한다.

결론 — 언제 이 조합을 쓰나

"테이블이 계속 커진다"는 막연한 느낌을 세 도구로 숫자화한다. pgstattuple로 진짜 dead_tuple_percent와 free_percent를 재고(추정치 n_dead_tup에 속지 않는다), pg_freespace로 그 빈 공간이 재사용 가능한 상태인지 페이지별로 확인하고, pageinspect로 의심 페이지의 튜플이 실제로 죽었는지·UPDATE 체인인지 부검한다. 그 위에서 "VACUUM이면 충분(빈자리를 곧 재사용)" vs "VACUUM FULL 필요(공간 반납)"를 추측이 아니라 free_percent·table_len 변화로 결정한다.

이렇게도 쓴다

전체 스캔이 부담스러운 대형 테이블은 근사판으로 빠르게 판단한다. (조합: pgstattuple_approx)

-- visibility map을 활용해 전체 스캔을 건너뛰는 근사치
SELECT approx_free_percent, approx_tuple_count, dead_tuple_percent
FROM pgstattuple_approx('bloat');

 

인덱스 블로트는 pgstatindex의 leaf_fragmentation으로 본다. (조합: pgstatindex → REINDEX)

-- leaf_fragmentation이 높으면 REINDEX 후보
SELECT index_size, leaf_pages, avg_leaf_density, leaf_fragmentation
FROM pgstatindex('bloat_pkey');

 

특정 페이지 하나의 여유만 콕 집어 확인한다. (조합: pg_freespace 단일 페이지)

SELECT pg_freespace('bloat', 0);   -- blkno 0의 avail 바이트

 

페이지 헤더 레벨에서 pruning/all-visible 상태를 본다. (조합: pageinspect page_header)

SELECT lower, upper, special, pagesize, flags
FROM page_header(get_raw_page('bloat', 0));

 

infomask 플래그를 사람이 읽는 이름으로 해독한다. (조합: heap_tuple_infomask_flags)

SELECT t_ctid, flags
FROM heap_page_items(get_raw_page('bloat', 0)) p,
     heap_tuple_infomask_flags(p.t_infomask, p.t_infomask2)
WHERE p.t_xmax <> 0 LIMIT 5;

 

전체 테이블·인덱스 블로트를 한 번에 훑어 우선순위를 매긴다. (조합: 대상 릴레이션 순회)

SELECT c.relname,
       (pgstattuple(c.oid)).dead_tuple_percent AS dead_pct,
       (pgstattuple(c.oid)).free_percent AS free_pct
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
ORDER BY dead_pct DESC LIMIT 10;
반응형