PostgreSQL 이 쓰기를 거부할 때, XID wraparound
어느 날 PostgreSQL 이 모든 쓰기를 거부했다. 디스크도 CPU 도 멀쩡한데 DB 가 스스로 문을 닫았다. 그런데 이 일은 몇 주 전부터 지표에 다 보이고 있었다.
어느 날 갑자기 모든 쓰기가 실패한다. 에러는 database is not accepting commands to avoid wraparound data loss.
디스크도 CPU 도 메모리도 멀쩡하다. 복제도 정상이다. DB 가 스스로 문을 닫았다.
읽기는 된다. 그래서 서비스가 반쯤 살아 있는 것처럼 보인다. 로그인은 되는데 아무것도 저장이 안 되는, 사용자 입장에서 제일 답답한 상태다.
빠져나오는 데도 시간이 걸린다. 재시작으로는 안 풀린다. 밀린 청소를 실제로 끝내야 문이 다시 열리는데, 큰 테이블이면 그게 몇 시간에서 며칠이다.
DB 는 왜 스스로 쓰기를 막을까
PostgreSQL 의 트랜잭션 ID 는 32비트다. 약 20억(정확히는 2^31)에서 한 바퀴 돈다.
MVCC 는 "이 행을 만든 트랜잭션이 내 트랜잭션보다 과거인가"를 보고 가시성을 정한다. ID 가 한 바퀴 돌아 버리면 과거가 미래로 보인다. 방금 만든 행이 갑자기 안 보이거나, 지운 행이 되살아난다. 소리 없는 데이터 손실이고, 한번 일어나면 되돌릴 방법이 없다.
그래서 VACUUM 이 오래된 행의 ID 를 얼린다(freeze). 얼린 행은 "항상 과거"로 표시돼서 한 바퀴 도는 것과 상관없어진다. autovacuum 이 하는 일 가운데 이게 제일 중요한데, 평소엔 조용해서 아무도 안 쳐다본다.
autovacuum 이 제때 못 얼리면 남은 ID 여유가 준다. 한계에 가까워지면 PostgreSQL 은 로그에 경고를 찍고, 더 가까워지면 쓰기를 거부한다. 버그가 아니라 마지막 방어선이다. 조용한 데이터 손실보다 시끄러운 정지가 낫다는 판단이다.
이건 갑자기 오지 않는다. 몇 주, 몇 달에 걸쳐 서서히 다가온다. 지표만 보고 있었다면 반드시 미리 알 수 있었다.
age(datfrozenxid) 가 2억을 넘었다면
지금 얼마나 남았나:
SELECT datname,
age(datfrozenxid) AS xid_age,
round(age(datfrozenxid) / 2147483648.0 * 100, 1)
AS pct_used
FROM pg_database
ORDER BY xid_age DESC;
200,000,000 (기본 autovacuum_freeze_max_age) 넘으면 조사
1,000,000,000 넘으면 비상
· 테이블 단위로도 본다 — 범인은 대개 한두 개다
SELECT relname, age(relfrozenxid) AS xid_age,
last_autovacuum
FROM pg_class c
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relkind = 'r'
ORDER BY age(relfrozenxid) DESC LIMIT 10;✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.
청소가 뒤처지는 이유는 대개 정해져 있다
이걸로 실제로 멈춘 서비스들이 있다. 2015년 Sentry, 2019년 Mailchimp 의 Mandrill 이 대표적이다. Mandrill 은 복구까지 약 40시간이 걸렸다.
시작은 늘 비슷하다. 쓰기가 많은 샤드나 테이블에서 autovacuum 이 뒤처지는데, 그 지표를 보고 있는 사람이 없었다.
autovacuum 이 뒤처지는 원인은 대개 셋이다.
첫째, 오래 열린 트랜잭션. 어디까지 얼릴 수 있는지는 가장 오래된 스냅샷이 정한다. 그래서 유휴 트랜잭션 하나가 전체를 붙잡는다.
둘째, 방치된 복제 슬롯. 소비자가 죽은 논리 복제 슬롯이 남아 있으면 같은 식으로 막는다. 이건 pg_stat_activity 에 안 나타나서 찾기가 더 어렵다.
셋째, 너무 얌전한 autovacuum 설정. autovacuum_vacuum_cost_delay 와 cost_limit 은 청소가 서비스에 주는 부하를 묶어 두는 값인데, 큰 테이블에는 기본값이 지나치게 조심스럽다. 청소 속도가 쓰레기가 생기는 속도를 못 따라가면 영영 뒤처진다.
복구 과정도 알아 둘 필요가 있다. 쓰기가 막힌 뒤에는 단일 사용자 모드로 들어가 VACUUM 을 돌려야 할 때가 있고, 그동안 서비스는 완전히 멈춘다. 큰 테이블이면 몇 시간에서 며칠이 걸린다. "터지면 고치자"가 통하지 않는 종류의 문제다.
PostgreSQL 도 버전이 오를 때마다 이 부분을 손보고 있다. 실패한 청소 시도를 추적하거나 진행 상황을 볼 수 있는 뷰가 늘었다. 그래도 근본 구조는 그대로라 지표는 여전히 봐야 한다.
몇 주 전에 알아채려면
- age(datfrozenxid) 를 늘 지켜보고, 꾸준히 오르면 알림이 오게 한다. 딱 하나만 챙긴다면 이거다. 임계값은 2억(기본 autovacuum_freeze_max_age)에서 시작하되, 값보다 추세가 더 중요하다.
- 데이터베이스 전체 나이는 가장 뒤처진 테이블 하나가 정한다. 테이블 단위로도 보면 범인을 바로 짚을 수 있다.
- 뒤처지는 원인 셋, 그러니까 긴 트랜잭션과 방치된 복제 슬롯과 autovacuum 설정은 정기적으로 점검할 대상이다. 앞의 둘은 쿼리 하나로 확인된다.
- 쓰기가 많은 큰 테이블이라면 autovacuum 파라미터를 테이블별로 조정하자. cost_delay 를 줄이고 cost_limit 을 올리면 청소가 빨라진다. 전역 설정을 바꾸는 것보다 테이블 단위가 안전하다.
- autovacuum 을 끄면 이 사고로 곧장 간다. 부하 때문에 끄고 싶은 순간이 오는데, 부하가 문제라면 속도를 조절할 일이지 끌 일이 아니다.
- 큰 테이블 하나보다 작은 파티션 여럿이 청소하기 훨씬 쉽다. 파티셔닝을 검토해 볼 만하다. 오래된 파티션은 아예 얼려 두고 손대지 않을 수도 있다.
긴 트랜잭션과 버려진 복제 슬롯 찾기
무엇이 청소를 막고 있나:
· 1) 오래 열린 트랜잭션 — backend_xmin 기준
SELECT pid, age(backend_xmin) AS xmin_age,
state, now() - xact_start AS xact_age,
left(query, 50)
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC LIMIT 5;
· 2) 방치된 복제 슬롯 — 안 보이는 범인
SELECT slot_name, active, slot_type,
age(xmin) AS xmin_age
FROM pg_replication_slots;
active=false 인 슬롯이 오래 있으면 지운다
· 3) 지금 청소가 돌고 있나, 어디까지 갔나
SELECT relid::regclass, phase,
heap_blks_scanned, heap_blks_total
FROM pg_stat_progress_vacuum;
· 4) 시스템 카탈로그도 잊지 않는다
SELECT relname, age(relfrozenxid) FROM pg_class
WHERE relkind IN ('r','t','m')
ORDER BY age(relfrozenxid) DESC LIMIT 10;핵심 한 줄조용히 돌던 청소부가 멈추면 DB 가 쓰기를 거부한다.
자주 묻는 것
부하 때문에 autovacuum 을 꺼도 될까?
끄면 안 된다. 끄면 트랜잭션 ID 를 제때 얼리지 못해 결국 쓰기 거부로 곧장 간다. 부하가 문제라면 끄지 말고 테이블별로 속도를 조절하면 된다.