INSERT만 하고 UPDATE·DELETE는 한 번도 일어나지 않은 로그성 테이블이 있다고 하자. 죽은(dead) 튜플이 없으니 VACUUM이 회수할 디스크 공간도 없다. 그런데 PostgreSQL 공식 문서는 이런 테이블도 반드시 주기적으로 vacuum(freeze)되어야 한다고 못박는다. 이유는 디스크 공간이 아니라, 트랜잭션마다 하나씩 소모되는 32비트짜리 숫자가 언젠가 바닥나기 때문이다.
지난 글에서 튜플의 xmin/xmax를 현재 트랜잭션 XID와 비교해 가시성을 판정하는 MVCC 구조를 봤는데, 그 비교가 "절대 순서"가 아니라 "원형(circular) 순서"라는 점은 짚지 않고 넘어갔다.
PostgreSQL은 트랜잭션마다 32비트 XID를 순차 발급하고, 튜플의 xmin(삽입 트랜잭션 XID)을 현재 트랜잭션의 스냅샷과 비교해 "이 행이 나에게 보이는가"를 판정한다. 32비트는 유한하므로 40억 개 트랜잭션을 넘기면 카운터는 다시 0부터 시작해야 한다. 그래서 공식 문서는 이 비교를 절대 순서가 아니라 modulo-2^32 산술로 정의한다 — 임의의 XID를 기준으로 "20억 개 더 과거"와 "20억 개 더 미래"가 대칭으로 존재하는 원형 공간이다.
문제는 오래 살아남은 튜플에서 터진다. 어떤 튜플의 xmin이 동결(freeze)되지 않은 채로 20억 트랜잭션 넘게 방치되면, 그 XID는 원형을 한 바퀴 돌아 "미래"로 재해석된다. 방금까지 모두에게 보이던 커밋된 행이 순식간에 "아직 시작도 안 한 미래 트랜잭션의 행"으로 뒤집혀 조회에서 사라진다.
데이터가 물리적으로 지워지는 건 아니지만 어떤 쿼리로도 접근할 수 없게 되므로, 공식 문서는 이를 "catastrophic data loss"라고 표현한다.
이 뒤집힘을 막는 방법은 오래된 튜플을 아예 순서 비교 대상에서 빼버리는 것이다. PostgreSQL은 FrozenTransactionId라는 특수 XID를 예약해두고, 비교 규칙 자체에서 이 값을 항상 "다른 모든 XID보다 과거"로 취급한다. 9.4 이전에는 xmin을 이 값으로 실제로 덮어썼지만, 이후 버전은 xmin을 그대로 두고 infomask 플래그 비트만 세워 "동결됨"을 표시한다 — 포렌식 목적으로 원래 삽입 XID를 남겨두기 위해서라고 문서는 설명한다.
여기서 자주 헷갈리는 지점이 VACUUM의 두 얼굴이다. 일반 VACUUM은 visibility map을 보고 이미 all-visible한 페이지를 건너뛴다 — 청소할 죽은 튜플이 없으니 합리적인 스킵처럼 보인다. 하지만 이 스킵 때문에 "all-visible이지만 all-frozen은 아닌" 페이지가 조용히 쌓일 수 있다. 이 페이지들을 강제로 스캔해 freeze하는 것이 aggressive VACUUM이고, 테이블의 relfrozenxid 나이가 vacuum_freeze_table_age를 넘으면 발동한다. 이 값의 상한이 0.95 × autovacuum_freeze_max_age로 캡핑되는 이유도 같은 맥락이다 — 그 이상 미루면 어차피 강제 autovacuum이 돌 것이고, 0.95배로 여유를 남겨야 그 전에 수동 개입할 틈이 생긴다.
autovacuum_freeze_max_age 기본값은 2억 트랜잭션이다. 숫자만 보면 여유로워 보이지만, 초당 1,000개 트랜잭션을 처리하는 서비스라면 2억은 대략 55시간이면 소진된다. 즉 고트래픽 환경에서는 "이틀에 한 번꼴로 특정 테이블에 강제 autovacuum이 도는" 정도의 여유밖에 없다는 뜻이고, 이 임계값에 도달하면 문서에 명시된 대로 전역 autovacuum이 꺼져 있어도 해당 테이블만은 강제로 autovacuum이 발동한다.
그래도 못 따라잡으면 상황은 단계적으로 악화된다. wraparound까지 4천만 트랜잭션이 남으면 WARNING: database ... must be vacuumed 경고가 뜨고, 3백만 트랜잭션이 남으면 새 XID 발급 자체가 거부돼 읽기 전용 트랜잭션만 시작할 수 있다. 복구는 과거엔 single-user mode로 내려가는 것이 정석이었다고 알려져 있지만, 현재 문서는 이를 비권장하고 온라인 상태에서 오래된 prepared transaction·장기 실행 트랜잭션·죽은 복제 슬롯을 먼저 정리한 뒤 VACUUM을 돌리라고 안내한다. 이 단계에서 VACUUM FULL은 오히려 새 XID를 추가로 소비해 위험을 키우므로 피해야 한다.
각 테이블이 얼마나 여유가 있는지는 직접 확인할 수 있다.
-- age가 클수록 wraparound에 가깝다
SELECT c.oid::regclass AS table_name,
greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS age
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r', 'm')
ORDER BY age DESC;
VACUUM이 필요한 이유는 죽은 튜플 회수만이 아니다. XID 비교가 원형이라는 전제에서, 오래된 튜플을 제때 얼리지(freeze) 않으면 커밋된 데이터가 "미래"로 오판되어 접근 불가능해지는 것을 막는 것도 VACUUM의 몫이다. INSERT-only 테이블이 회수할 공간이 없어도 주기적으로 vacuum되어야 하는 이유가 여기에 있다.
다음에 더 볼 만한 주제는 XID와 별도로 관리되는 MultiXact ID(relminmxid)의 wraparound와, 이번 글에서 짧게 스친 eager freeze scanning이 페이지를 미리 동결할지 판단하는 내부 실패율 알고리즘이다.