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 도 버전이 오를 때마다 이 부분을 손보고 있다. 실패한 청소 시도를 추적하거나 진행 상황을 볼 수 있는 뷰가 늘었다. 그래도 근본 구조는 그대로라 지표는 여전히 봐야 한다.

몇 주 전에 알아채려면

  1. age(datfrozenxid) 를 늘 지켜보고, 꾸준히 오르면 알림이 오게 한다. 딱 하나만 챙긴다면 이거다. 임계값은 2억(기본 autovacuum_freeze_max_age)에서 시작하되, 값보다 추세가 더 중요하다.
  2. 데이터베이스 전체 나이는 가장 뒤처진 테이블 하나가 정한다. 테이블 단위로도 보면 범인을 바로 짚을 수 있다.
  3. 뒤처지는 원인 셋, 그러니까 긴 트랜잭션과 방치된 복제 슬롯과 autovacuum 설정은 정기적으로 점검할 대상이다. 앞의 둘은 쿼리 하나로 확인된다.
  4. 쓰기가 많은 큰 테이블이라면 autovacuum 파라미터를 테이블별로 조정하자. cost_delay 를 줄이고 cost_limit 을 올리면 청소가 빨라진다. 전역 설정을 바꾸는 것보다 테이블 단위가 안전하다.
  5. autovacuum 을 끄면 이 사고로 곧장 간다. 부하 때문에 끄고 싶은 순간이 오는데, 부하가 문제라면 속도를 조절할 일이지 끌 일이 아니다.
  6. 큰 테이블 하나보다 작은 파티션 여럿이 청소하기 훨씬 쉽다. 파티셔닝을 검토해 볼 만하다. 오래된 파티션은 아예 얼려 두고 손대지 않을 수도 있다.

긴 트랜잭션과 버려진 복제 슬롯 찾기

무엇이 청소를 막고 있나:

코드
· 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 를 제때 얼리지 못해 결국 쓰기 거부로 곧장 간다. 부하가 문제라면 끄지 말고 테이블별로 속도를 조절하면 된다.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 운영에서 실제로 터지는 문제를 한 편씩 적는다. 같은 실수를 두 번 하지 않으려고 쓰는 기록이다.

트랜잭션·락·DB 운영 글 21편 모두 보기 →