본문으로 건너뛰기

"WAL" 태그로 연결된 7개 게시물개의 게시물이 있습니다.

모든 태그 보기

pg_walviz WAL 시각화

· 약 3분

WAL을 공부할 때 제일 답답한 건 실체가 안 보인다는 점이었어요. record가 페이지 경계에서 쪼개진다, 세그먼트 첫 페이지에는 long header가 붙는다, 이런 문장을 문서로는 읽는데 실제 바이트가 어떻게 놓이는지는 상상에 맡겨야 했거든요.

Bertrand Drouvot(베르트랑 드루보)가 8월 13일 공개한 pg_walviz가 정확히 그 지점을 채웁니다. WAL 세그먼트 파일 하나를 읽어 브라우저에서 시각화하는 읽기 전용 도구입니다. 현재 버전은 v0.1.0-beta.1이고 저장소는 GitHub에 있습니다.

무엇을 보여주나

화면은 서로 동기화되는 네 개의 뷰로 구성됩니다.

내용
세그먼트 개요히트맵. resource manager별로 record가 세그먼트 어디에 몰려 있는지
record 조각 목록선택한 페이지에 걸친 record들, 페이지 경계에서 쪼개진 조각 포함
record 검사기선택한 record의 헤더, 물리 배치, full-page image 정보
물리 바이트색으로 구분된 원본 바이트열

히트맵에서 눈에 띄는 영역을 클릭하면 그 페이지의 record 목록이 뜨고, record를 고르면 헤더 필드와 실제 바이트가 같이 하이라이트됩니다. 페이지 번호, record 번호, 파일 오프셋, LSN을 직접 입력해서 이동할 수도 있습니다.

pg_waldump와 겹치는 도구가 아니냐는 생각이 들 수 있는데, 역할이 다릅니다. pg_waldump는 record의 논리적 내용을 사람이 읽을 텍스트로 풀어 줍니다. 어떤 rmgr가 어떤 연산을 기록했는지 보기에 좋습니다. pg_walviz는 record가 세그먼트 안에 물리적으로 어떻게 저장되는지를 보여줍니다. 페이지 헤더, record 조각, continuation record, 정렬 padding, 블록 참조가 바이트 위에 그대로 표시됩니다. 실제로 pg_walviz는 내부적으로 pg_waldump를 사용하므로 두 도구는 상하 관계에 가깝습니다.

실행 방법

PostgreSQL 서버도, 데이터 디렉터리도 필요 없습니다. 세그먼트 파일 하나와 그 파일을 만든 서버 버전에 맞는 pg_waldump 바이너리만 있으면 됩니다.

~/pg_walviz/bin/pg_walviz \
--pg-waldump /usr/pgsql-18/bin/pg_waldump \
/archive/000000010000000000000042

실행하면 로컬 웹서버가 뜨고 브라우저가 열립니다. 원격 서버에서 실행할 때는 브라우저 자동 실행을 끄고 포트를 지정합니다.

~/pg_walviz/bin/pg_walviz --no-open --port 8765 \
--pg-waldump /usr/pgsql-18/bin/pg_waldump \
/archive/000000010000000000000042

주의사항이 둘 있습니다. 현재 쓰기가 진행 중인 세그먼트는 열지 말라는 것, 그리고 WAL에는 실데이터가 들어 있으므로 로컬 밖으로 노출하지 말라는 것입니다. 특히 두 번째는 archive에서 세그먼트를 복사해 분석용 장비에서 여는 습관과 묶어 기억해 둘 만합니다. WAL은 INSERT된 행의 내용을 그대로 담고 있어서, 세그먼트 파일 하나가 곧 데이터 유출 경로가 됩니다.

어디에 쓸 만한가

첫째는 학습입니다. WAL record 헤더의 xl_prev가 무엇인지, full-page image가 왜 checkpoint 직후에 몰리는지 같은 주제는 글로 읽는 것보다 히트맵에서 직접 확인하는 쪽이 빠릅니다. checkpoint 직후 세그먼트를 열어 보면 FPI가 차지하는 공간이 시각적으로 드러나고, full_page_writes가 WAL 볼륨에 미치는 영향이 감으로 잡힙니다.

둘째는 장애 분석의 보조 도구입니다. 예전에 standby가 record with incorrect prev-link를 반복하며 멈춘 사건을 분석할 때는 pg_waldump 출력과 오프셋 계산을 손으로 맞춰 가며 recycled 세그먼트의 잔재를 추론했습니다. 그때 이 도구가 있었다면 문제 지점의 바이트를 바로 눈으로 확인했을 겁니다. 세그먼트 경계의 long page header(40바이트)와 첫 record의 위치 같은 것들이 화면에 그대로 보이니까요.

PostgreSQL 내부를 3D 도시로 만든 PGSimCity가 buffer와 프로세스를 보여주는 조감도였다면, pg_walviz는 WAL이라는 한 지점을 현미경으로 파는 도구입니다. 아직 beta라 큰 세그먼트에서 로딩이 느리고 단일 파일 검사가 권장되는 수준이지만, 방향이 좋습니다.

archive에 쌓여 있는 세그먼트 하나 골라서 열어 보세요. 문서 열 페이지보다 히트맵 한 화면이 WAL 구조를 빨리 가르쳐 줄 거예요.

참고 자료

cascading 복제 버그

· 약 4분

"requested starting point ... is ahead of the WAL flush position of this server". 이 오류를 cascading standby에서 본 적이 있다면, 그건 설정 실수가 아니었을 수도 있어요. 2013년 PostgreSQL 9.3부터 잠복해 있던 버그였습니다.

CloudNativePG 창립자 Gabriele Bartolini(가브리엘레 바르톨리니)가 8월 3일 EDB 블로그에 공개한 글이 이 버그의 발견부터 수정까지를 담고 있습니다. 발견 경위가 흥미롭습니다. 코어 해커의 코드 감사가 아니라, Kubernetes 위에서 분산 토폴로지를 선언적으로 돌리다가 드러났습니다.

증상: 재시도 없는 무한 대기

구성은 cascading replication입니다. primary에서 standby A가 받고, standby B는 A에게서 받는 구조로, 다중 리전 토폴로지에서 흔한 형태입니다.

standby B가 어떤 이유로든 streaming이 끊겨 archive recovery로 폴백했다가, WAL을 따라잡고 다시 upstream(A)으로 streaming 접속을 시도하는 순간이 문제의 무대입니다. 특정 조건에서 A가 접속을 이렇게 거부합니다.

FATAL: requested starting point 0/A000000 is ahead of
the WAL flush position of this server 0/9000000

그리고 B는 재시도 메커니즘 없이 이 상태에 머뭅니다. 사람이 개입할 때까지 replication이 서 있는 것입니다.

메커니즘: segment 단위 recovery와 record 단위 flush의 어긋남

원인은 StartReplication()에 2013년 추가된 timeline switch 로직입니다. 이 로직은 "요청한 시작 LSN이 내 WAL flush 위치보다 앞서면 거부한다"는 방어 검사를 합니다. 검사 자체는 합리적입니다. 아직 나에게 없는 WAL을 달라는 요청이니까요.

문제는 archive recovery의 진행 단위입니다. streaming은 record 단위로 흐르지만, archive recovery는 segment 파일 단위로 처리합니다. B가 archive에서 segment 하나를 다 소화하면, 다음 읽기 위치는 그다음 segment의 시작(예: 0/A000000)이 됩니다. 그런데 upstream A의 flush 위치는 record 단위로 진행 중이라 그 경계에 못 미쳐 있을 수 있습니다(예: 0/9000000 근처). B의 요청 위치가 A의 flush 위치를 기계적으로 앞서게 되는 것입니다.

타이밍이 정확히 맞아야 하는 race라서 13년 동안 드물게, 재현 불가능하게만 나타났습니다. 마주친 운영자는 대개 standby를 재기동하거나 재생성했을 것이고, 문제는 "가끔 이상해지는 replication"이라는 민담으로만 남았을 겁니다.

수정: 접속 전에 물어보고, 가까우면 기다린다

수정은 walreceiver 쪽에 들어갔습니다. START_REPLICATION을 보내기 전에 IDENTIFY_SYSTEM으로 upstream의 flush 위치를 먼저 확인하고, 내 요청 위치와의 차이가 WAL segment 하나 이내면 거부당할 요청을 던지는 대신 재시도합니다(wal_receiver_timeout 한도 안에서). upstream이 곧 그 지점까지 flush할 것이 확실한 상황이니, 잠깐 기다리면 자연히 풀리는 것입니다.

이 수정은 8월 13일 나온 마이너 릴리스에 포함됐습니다. EDB 글은 작성 시점 기준 18.5를 예고했지만, 18.5가 회귀로 결번되면서 실제로는 18.6, 17.11, 16.15, 15.19, 14.24에 실렸습니다. cascading 구성을 운영 중이라면 이번 마이너 업데이트를 챙길 이유가 하나 더 있는 셈입니다.

13년 만에 잡힌 이유

이 이야기에서 버그 자체보다 오래 남는 것은 발견의 조건입니다.

CloudNativePG는 primary, cascading standby, archive(오브젝트 스토리지), 리전 간 복제를 하나의 선언으로 묶어 돌립니다. 같은 토폴로지가 수천 클러스터에서 반복 생성되고 파괴되니, 확률이 낮은 race도 통계적으로 반드시 걸립니다. 게다가 구성이 코드로 고정되어 있어 "그때 그 상황"을 그대로 다시 만들 수 있습니다. 실제로 이 버그는 Docker/Kind 기반 학습 환경인 cnpg-playground에서도 재현됩니다.

수동 운영의 세계에서 이 버그는 재현 불가능한 유령이었습니다. 선언적 운영의 세계에서는 재현 가능한 테스트 케이스가 됐습니다. "Kubernetes에서 데이터베이스를 돌려도 되는가"라는 오래된 논쟁에 대한 답변으로, 오퍼레이터 진영이 내놓을 수 있는 가장 실속 있는 증거가 아닐까 싶습니다. 돌려도 되는가를 넘어, 돌렸더니 코어의 13년 묵은 버그가 잡혔다는 것이니까요.

pgBackRest 종료 이후 CNPG 쪽 흐름을 계속 지켜보고 있는데, 이 사건은 그 생태계의 성숙을 보여주는 좋은 지표로 기억해 둘 만해요.

참고 자료

PlanetScale 병렬 PG 백업

· 약 4분

백업 시리즈를 쓰면서 늘 걸리던 질문이 있었어요. Barman이든 pgBackRest든 결국 인스턴스 하나를 통째로 받는 구조인데, 데이터가 수십 TB를 넘어가면 이 모델은 어디까지 버틸까 하는 것이었죠.

PlanetScale이 7월 31일 공개한 글이 그 질문의 한 답을 보여줍니다. petabyte 규모 데이터베이스를 초당 50GB 넘는 속도로, 시간 단위 안에 백업하는 구조입니다. 전제가 하나 있는데, 데이터가 이미 shard로 나뉘어 있다는 것입니다.

단일 인스턴스의 산수

출발점은 단순한 산수입니다. 32TB 데이터베이스를 초당 500MB로 받으면 약 22시간이 걸립니다. 하루 두 번 백업(RPO 12시간)이 목표라면 22시간짜리 백업으로는 산수가 안 맞습니다. 백업이 끝나기 전에 다음 백업이 시작되어야 하니까요.

속도를 올리는 데는 한계가 있습니다. 백업 읽기가 빨라질수록 production 쿼리와 I/O를 다투게 되고, 네트워크와 스토리지 처리량 상한도 있습니다. 단일 인스턴스 모델에서는 데이터가 커질수록 백업 소요 시간이 선형으로 늘어나는 걸 피할 수 없습니다.

PlanetScale의 답은 분모를 늘리는 것입니다. 데이터가 8개 shard로 나뉘어 있으면, shard마다 백업을 동시에 받아서 32TB 전체가 약 2.8시간에 끝납니다. shard가 100개면 100TB도 같은 시간입니다. 백업 소요 시간이 전체 크기가 아니라 가장 큰 shard의 크기에 묶이는 구조입니다.

여기서 논쟁이 된 선택이 하나 있습니다. 백업을 primary나 기존 replica에서 받지 않고, 백업 때마다 shard별 전용 EC2 인스턴스를 새로 띄웁니다. production 쿼리에 백업 읽기 부하를 섞지 않겠다는 선택인데, Hacker News에서는 그 비용이 타당하냐는 반론이 붙었습니다. 클라우드에서 시간 단위로 인스턴스를 빌릴 수 있으니 가능한 설계이고, 백업 시간에만 존재하는 인스턴스라 상시 replica 한 대보다 쌀 수도 있습니다. 온프레미스에서는 흉내 내기 어려운 부분입니다.

복원의 하이브리드: WAL은 S3에서, 마지막 몇 분은 primary에서

이 글에서 제가 제일 재미있게 읽은 부분은 백업이 아니라 복원 쪽입니다. 백업 전용 인스턴스는 어떻게 최신 상태를 따라잡을까요.

절차는 이렇습니다. S3에서 직전 백업을 복원하고, 그 뒤의 WAL을 replay해서 따라잡습니다. WAL 대부분은 wal-g로 아카이빙된 S3에서 가져오는데, 여기에 구멍이 하나 있습니다. PostgreSQL은 완결된 WAL segment만 archive하므로, 지금 쓰이고 있는 segment의 내용은 S3에 아직 없습니다. archive_timeout을 5분으로 설정해도 최신 몇 분은 항상 S3 밖에 있습니다.

그래서 마지막 구간만 primary에서 직접 streaming으로 받습니다. S3 replay가 대역폭을 마음껏 쓰며 대부분을 처리하고, primary는 마지막 몇 분치만 감당하니 부하가 거의 없습니다.

구간출처primary 부하
베이스 백업S3없음
WAL 대부분S3 (wal-g archive)없음
마지막 몇 분primary streaming미미

restore_command와 streaming replication을 순서대로 조합하는 것 자체는 PostgreSQL 표준 기능입니다. standby가 archive recovery에서 streaming으로 넘어가는 그 메커니즘을 백업 인스턴스 따라잡기에 그대로 쓴 것인데, 표준 부품의 좋은 재조합입니다.

Barman/pgBackRest 세계에서 보면

제가 Barman 시리즈에서 다룬 도구들과 이 구조는 층이 다릅니다. Barman과 pgBackRest는 인스턴스 하나의 백업을 잘 받는 도구이고, PlanetScale의 구조는 그 위에서 "인스턴스가 아주 많고 각각이 작다"는 전제로 짠 오케스트레이션입니다. 실제로 부품은 익숙한 것들입니다. pg_basebackup으로 시드하고, wal-g로 아카이빙하고, S3에 쌓습니다.

그래서 이 글의 교훈을 일반 조직에 그대로 가져오긴 어렵습니다. shard가 없는 32TB 단일 클러스터라면 이 구조는 시작조차 못 합니다. 대신 두 가지는 규모와 무관하게 유효합니다.

하나, 복원 리허설의 산수를 미리 해 볼 것. 우리 클러스터 크기와 스토리지 속도로 복원이 몇 시간인지, RTO와 맞는지 계산해 보면 됩니다. 22시간이라는 숫자는 백업이 아니라 복원에서 먼저 문제가 됩니다.

둘, archive의 마지막 구멍을 인지할 것. archive_timeout이 있어도 최신 변경분은 archive에 없습니다. PITR 계획이 "archive에 다 있다"를 전제한다면 그 전제는 몇 분짜리 구멍을 갖고 있는 셈이고, 이 구멍을 메우는 것이 streaming이든 동기 standby든 별도 장치여야 합니다.

subtransaction 분석에 이어 PlanetScale의 PostgreSQL 엔지니어링 글이 연달아 좋네요. Vitess로 MySQL을 sharding하던 회사가 PostgreSQL에 같은 체급의 인프라를 짓는 과정이 실시간으로 공개되고 있으니, 당분간 이 블로그의 단골 출처가 될 것 같아요.

참고 자료

PGSimCity 3D 도시

· 약 7분

DBA가 아닌 개발자에게 checkpoint 스파이크를 설명해 본 적 있다면 그 난감함을 알아요. WAL이 뭔지부터 시작해야 하고, dirty page가 왜 쌓이는지, max_wal_size가 왜 그 시점을 정하는지 순서대로 쌓아야 해요. 그림을 그려도 정적이라 "시간에 따라 이게 몰린다"는 감각이 전달되지 않아요.

PGSimCity는 그 문제를 정면으로 노립니다. PostgreSQL 내부를 탐험 가능한 3D 도시로 만들어 놓고, 시간을 흘려보내며 그 안에서 무슨 일이 벌어지는지 보여줍니다. 만든 사람은 postgres.ai의 Nikolay Samokhvalov(니콜라이 사모흐발로프)입니다.

브라우저에서 바로 열립니다. nikolays.github.io/PGSimCity에 접속하면 설치 없이 도시가 뜹니다. WebGL2를 지원하는 브라우저가 필요합니다.

저장소를 직접 빌드해 봤습니다

소개 글을 쓰려면 실제로 돌려 보는 게 맞습니다. 클론해서 테스트와 빌드를 통과시켜 보고 확인한 사실을 먼저 적습니다.

git clone https://github.com/NikolayS/PGSimCity.git
cd PGSimCity
npm install
npm run typecheck # 통과, 오류 없음
npm test # 48개 파일, 360개 테스트 통과 (10.4초)
npm run build # 2.69초
npm run dev # localhost:5173
항목확인값
버전0.12.0
라이선스Apache-2.0
TypeScript 소스107개 파일, 60,517줄
테스트48개 파일, 360개 통과
런타임 의존성three 0.185, @electric-sql/pglite 0.5.4
빌드 결과 최대 청크city 1.17MB (gzip 392KB)

README에는 테스트가 234개로 적혀 있는데 실측은 360개였습니다. 최근 커밋이 확인 시점과 같은 날짜였으니 문서가 코드를 못 따라가는 중입니다. 그만큼 활발하다는 뜻으로 읽으면 됩니다.

의존성이 얇은 게 눈에 띕니다. 번들 런타임 의존성이 Three.js 하나뿐이고, 시뮬레이션 코드는 Three.js를 import하지 않습니다. 시뮬레이션과 렌더링이 SimState에서만 만나는 구조라, 도식 없이도 시뮬레이션 로직만 따로 테스트할 수 있습니다. 360개 테스트가 붙어 있는 이유가 여기에 있습니다.

도시가 표현하는 것

지구가 PostgreSQL의 구성요소에 대응합니다.

  • 클라이언트 영역: 들어오는 connection
  • backend 구역: connection 하나당 프로세스 하나, 각자의 private memory
  • buffer pool: shared_buffers를 샘플링한 프레임 격자
  • 스토리지: heap 파일, B-tree, TOAST
  • WAL 지구: 쓰기가 디스크에 먼저 도착하는 경로
  • 유지보수 구역: checkpointer, autovacuum launcher와 worker, background writer
  • replication 구역: standby와 WAL receiver

색이 상태를 나타냅니다. WAL은 호박색, dirty page는 빨강, 청소 작업은 보라색입니다. 도시를 내려다보다가 빨간 블록이 넓어지면 dirty page가 쌓이는 중이라는 걸 눈으로 압니다.

소스를 검사하다 예상 밖의 지구를 하나 발견했습니다. src/world/continuity.ts에 백업과 복구 쪽 오브젝트가 따로 들어 있습니다.

archive.gate archive 소유권 경계
timeline.yard timeline 전환 조차장
object.store 오브젝트 스토리지
backup.vault 백업 금고
recovery.clock recovery_target_time
restore.winch restore_command

PITR을 도시 구조물로 옮겨 놓은 것입니다. recovery_target_time이 시계로, restore_command가 권양기로 서 있습니다. Barman이나 pgBackRest로 PITR을 설명할 때 "timeline이 갈라진다"는 말이 잘 안 통했다면 이 조차장을 보여주는 편이 빠르겠습니다.

투어 14장

T를 누르면 안내 투어가 시작됩니다. 순서가 곧 커리큘럼입니다.

  1. 클라이언트가 접속한다
  2. connection 하나에 프로세스 하나
  3. 쿼리가 plan이 된다
  4. 페이지 읽기, 캐시
  5. 페이지란 실제로 무엇인가
  6. 쓰기: 페이지보다 WAL이 먼저 디스크로
  7. commit, 그리고 fsync의 값
  8. checkpoint, 그리고 스파이크
  9. MVCC: update는 시체를 남긴다
  10. autovacuum이 치운다
  11. vacuum이 못 치울 때: horizon
  12. standby로 streaming
  13. lag, 그리고 네 개의 LSN
  14. 도시 전체를 다시

저는 9장에서 11장까지가 한 묶음으로 붙어 있는 게 좋아요. update가 옛 버전을 남기고, autovacuum이 그걸 치우고, 그런데 horizon 때문에 못 치우는 경우까지 세 장으로 이어집니다. 이 순서를 말로 설명하면 늘 세 번째에서 막히는데, 도시에서는 vacuum worker가 왔다 갔는데 빨간 블록이 그대로 남아 있는 장면으로 보여줍니다.

13장의 "네 개의 LSN"은 sent_lsn, write_lsn, flush_lsn, replay_lsn입니다. replication lag을 볼 때 어느 단계에서 밀리는지 구분해야 하는데, 이 넷의 간격을 도시 안 거리로 보여주는 방식입니다.

시나리오 13종

투어와 별도로 상황을 직접 재현하는 시나리오가 있습니다. 소스에서 확인한 목록입니다.

시나리오재현하는 상황
steady-state평상시 부하
checkpoint-stormcheckpoint 몰림
cache-thrashbuffer pool 히트율 붕괴
bloat-and-vacuumdead tuple 누적과 회수
xmin-horizonidle 트랜잭션이 vacuum을 막음
lock-pileupACCESS EXCLUSIVE 락 뒤에 줄이 쌓임
replication-lagstandby 지연
wal-floodWAL 급증
index-vs-seqscan인덱스 스캔과 순차 스캔
no-bgwriterbackground writer 없는 상태
connection-stormconnection 폭주
logical-replicationlogical replication 흐름
full-page-writesfull page write 부하

각 시나리오는 knob 값 묶음과 시간축 해설(beats)로 되어 있습니다. xmin-horizon 하나를 열어 보면 이런 식입니다.

knobs: tps 900, writeRatio 0.8, updateRatio 0.9,
longRunningXact: true, autovacuum: true, ...

beats:
0초 누군가 BEGIN을 입력했다
14초 horizon이 얼어붙었다
30초 autovacuum은 여전히 돈다
48초 worker가 헛도는 것을 본다
66초 브레이크 없는 bloat
86초 어디를 봐야 하나
108초 풀어 준다
126초 그리고 예방한다

30초 지점 해설이 정확합니다. launcher는 여전히 worker를 보내고, worker는 테이블까지 가서 heap 전체를 스캔하고 I/O를 태우는데 회수하는 건 거의 없습니다. 모니터링에는 vacuum이 도는 것으로 보이고 테이블은 반대를 말합니다. 86초 지점에서는 pg_stat_activitystate = 'idle in transaction'으로 걸러 xact_start 순으로 정렬하라고 알려주고, 버려진 replication slot과 hot_standby_feedback이 켜진 standby의 장기 쿼리도 같은 메커니즘이라고 덧붙입니다.

이 대목은 어제 쓴 MVCC 비용 비교 글에서 직접 실험으로 확인한 내용과 그대로 겹칩니다. VACUUM VERBOSE0 removed, 400000 are dead but not yet removable을 뱉는 장면, 범인이 pg_stat_activity에만 있는 게 아니라 replication slot과 prepared transaction에도 있다는 지점까지 같습니다. 실측으로 확인한 것을 시각 자료로 다시 설명할 수 있게 됐습니다.

모델인가 에뮬레이터인가

저자는 이게 PostgreSQL 모델이고 에뮬레이터가 아니라고 밝힙니다. PostgreSQL 소스가 돌아가지 않고, 숫자는 사람이 눈으로 따라갈 수 있게 축척을 조정했습니다. buffer pool도 shared_buffers 전체가 아니라 1,024개 프레임 샘플입니다.

다만 빌드 결과를 보다가 청크 하나가 눈에 걸렸습니다.

dist/assets/real-postgres-runtime-BHGmqr-J.js 539.32 kB

소스를 열어 보니 @electric-sql/pglite를 import하고 pglite.wasm, initdb.wasm을 함께 번들합니다. PGlite는 PostgreSQL을 WASM으로 컴파일한 것입니다.

import { PGlite } from '@electric-sql/pglite'
import initdbWasmUrl from '.../pglite/dist/initdb.wasm?url'
import pgliteWasmUrl from '.../pglite/dist/pglite.wasm?url'

즉 구분이 필요합니다. 도시 시뮬레이션은 모델이지만, Query Lab 쪽은 브라우저 안에서 진짜 PostgreSQL을 띄워 실제 실행 계획을 받아 옵니다. accounts, orders, events, sessions 테이블에 인덱스까지 붙인 시드 스키마가 코드에 들어 있습니다. 도시의 움직임은 모형이고, 쿼리 실습은 실물입니다.

이 조합이 영리합니다. 실제 PostgreSQL을 8KB 페이지 단위로 시각화하면 사람 눈에는 아무것도 안 보입니다. 그래서 보여주는 층은 축척을 조정한 모델로 두고, 정확성이 중요한 실행 계획은 실물 엔진에 맡겼습니다.

한계와 쓸 자리

버전이 0.12.0입니다. 저자도 0.x 초기 단계임을 명시하고, PostgreSQL 정확성은 문서와 소스로 3라운드 검토했다고 밝힙니다. 기억에 의존해 만들지 않았다는 뜻이고, 그래도 프로덕션 판단 근거로 쓸 물건은 아닙니다. 축척을 조정한 숫자를 실제 튜닝 값으로 옮기면 안 됩니다.

쓸 자리는 분명합니다. 신입 교육 첫 주에 개념 지도를 잡아 주는 용도, 장애 회고에서 "이때 이런 일이 있었다"를 비개발자에게 설명하는 용도, DB를 운영해 본 적 없는 개발자에게 idle_in_transaction_session_timeout이 왜 필수인지 납득시키는 용도입니다. 마지막 항목은 특히 말로 하면 잔소리로 들리는데, worker가 헛도는 장면을 2분간 같이 보면 설명이 끝납니다.

라이선스는 Apache-2.0이라 사내 교육 자료로 가져다 쓰기에도 걸림이 없습니다. 정적 번들이니 npm run build 결과를 사내 어디든 올려 둘 수 있습니다. 다만 PostgreSQL은 PostgreSQL Community Association of Canada의 상표이고 이 프로젝트는 공식 지원을 받지 않는 독립 교육 프로젝트라는 점, 그리고 이름과 달리 Electronic Arts와 무관하다는 점은 저자가 직접 밝혀 둔 대로 함께 알려 주는 게 좋겠습니다.

참고

이 블로그의 관련 글로는 MVCC 비용은 어디에 숨었나PostgreSQL 18의 autovacuum_worker_slots가 있어요.

MVCC 엔진별 비용 비교

· 약 13분

PostgreSQL을 오래 운영하면 vacuum이 미워지는 날이 와요. dead tuple이 안 줄고, autovacuum은 계속 도는데 테이블은 커지고, VACUUM FULL은 락 때문에 걸 수 없어요. 그럴 때 검색하면 나오는 글이 CMU Andy Pavlo(앤디 파블로)의 The Part of PostgreSQL We Hate the MostUber의 2016년 MySQL 전환기예요. 둘 다 틀린 말을 하지는 않아요.

그런데 이 비판들에는 공통으로 빠진 게 있습니다. 바로 비교 대상입니다. PostgreSQL의 MVCC가 나쁘다면, 나쁘지 않은 MVCC는 어디에 있는지 궁금해집니다.

이 질문을 정면으로 다룬 글이 최근 나왔습니다. boringSQL의 Radim Marek(라딤 마렉)이 쓴 PostgreSQL's MVCC is bad. So is everyone else's.입니다. 이 글은 그 논의를 출발점으로 삼되, 원문에 없는 두 가지를 채웁니다. 하나는 Docker에 PostgreSQL 18.4를 띄워 직접 재본 수치이고, 다른 하나는 엔진별로 같은 문제를 진단하는 쿼리입니다.

모든 MVCC 구현이 답해야 하는 네 가지 질문

원문이 제시한 프레임이 좋아서 그대로 빌려 옵니다. 어떤 엔진이든 다중 버전을 구현하려면 네 가지를 결정해야 합니다.

  1. 옛 버전을 어디에 두는가
  2. 버전 체인은 어느 방향을 가리키는가
  3. 인덱스는 무엇을 가리키는가
  4. 누가, 언제 치우는가

PostgreSQL의 답은 이렇습니다. 옛 버전은 테이블 안에 두고, 체인은 과거에서 미래로 향하고, 인덱스는 물리 위치(ctid)를 가리키며, 청소는 백그라운드 프로세스가 나중에 합니다.

네 번째 질문이 특히 중요합니다. 청소를 나중에 하면 쓰레기가 눈에 보이고, 청소를 트랜잭션이 직접 하면 그 트랜잭션이 느려집니다. 어느 쪽도 공짜가 아닙니다.

PostgreSQL이 치르는 값을 직접 재봤다

논의를 수치 없이 하면 감상이 됩니다. Docker에 PostgreSQL 18.4를 띄우고 직접 측정했습니다. 프로덕션 서버가 아닌 노트북 위 컨테이너라서, 절대값보다 비율을 보면 됩니다.

docker run -d --name mvcclab -e POSTGRES_PASSWORD=lab postgres:18

테이블은 100만 행이고, 컬럼은 id(PK)와 텍스트 4개, last_seen 타임스탬프로 구성했습니다. 이 중 10만 행을 UPDATE하면서 생성되는 WAL 양과 HOT update 비율을 측정했습니다. 측정 전마다 CHECKPOINT를 실행해 full page write 조건을 맞췄습니다.

write amplification은 인덱스 개수가 아니라 페이지 여유가 결정한다

구성UPDATE 대상 컬럼생성 WALHOT 비율heap 변화
보조 인덱스 없음, fillfactor 100last_seen116 MB0%73 MB → 80 MB
보조 인덱스 4개, fillfactor 100last_seen218 MB0%73 MB → 80 MB
보조 인덱스 4개, fillfactor 70last_seen81 MB100%104 MB → 104 MB
보조 인덱스 4개, fillfactor 100c1 (인덱스 컬럼)228 MB0%73 MB → 81 MB

두 번째 줄이 흔히 인용되는 그 비용입니다. 인덱스 컬럼을 건드리지도 않았는데 보조 인덱스 4개를 붙였다는 이유만으로 WAL이 116MB에서 218MB로 뜁니다. 새 tuple이 다른 페이지에 만들어지면 ctid가 바뀌고, 모든 인덱스가 그 새 위치를 따라가야 하기 때문입니다.

흥미로운 건 세 번째 줄입니다. 인덱스 4개를 그대로 둔 채 fillfactor만 70으로 낮추면 WAL이 81MB로 떨어집니다. 인덱스가 아예 없는 첫 번째 줄(116MB)보다도 적습니다. 페이지에 30% 여유가 생기니 새 버전이 같은 페이지 안에 들어가고, HOT update 조건이 성립해 인덱스를 아예 건드리지 않습니다. 원문 실험에서는 HOT 비율이 66%였는데, 이 실험은 행이 작아 100%가 나왔습니다.

정리하면 write amplification을 좌우하는 진짜 변수는 같은 페이지에 새 버전이 들어갈 자리가 남아 있느냐입니다. 인덱스를 줄이는 것보다 fillfactor를 조정하는 편이 대개 현실적입니다. 대신 heap이 73MB에서 104MB로 커집니다. 디스크를 미리 내주고 WAL과 인덱스 갱신을 아끼는 거래입니다.

네 번째 줄은 이 거래가 통하지 않는 경우입니다. 인덱스가 걸린 컬럼 자체를 바꾸면 HOT 조건이 깨지므로 fillfactor를 아무리 낮춰도 소용없습니다. 인덱스 크기도 107MB에서 129MB로 함께 부풉니다.

같은 UPDATE를 fillfactor 70 테이블에 네 번 반복해도 HOT 비율은 100%를 유지했고 WAL은 90MB 근처에서 평평했습니다. heap도 104MB에서 늘지 않았습니다. 여유 공간이 소진되어 HOT이 깨지는 지점은 이 워크로드에서는 오지 않았습니다.

bloat는 커밋했을 때와 롤백했을 때가 다르다

원문에도 있는 실험인데, 실제로 돌려 보니 원문이 다루지 않은 갈래가 나왔습니다.

100만 행을 UPDATE한 뒤 ROLLBACK합니다.

BEGIN;
UPDATE t SET last_seen = now(); -- 2847 ms
ROLLBACK; -- 1.0 ms

롤백은 1밀리초에 끝납니다. PostgreSQL은 롤백할 때 되돌릴 게 없습니다. 새로 쓴 tuple을 "이 트랜잭션은 실패했다"고 표시하면 그만입니다. 상수 시간입니다. 그런데 테이블은 89MB에서 178MB로 두 배가 됐고 dead tuple이 100만 개 생겼습니다. 커밋하지 않은 작업이 디스크를 두 배로 쓴 것입니다.

여기서 갈립니다.

ROLLBACK 케이스: 89MB → 178MB → VACUUM → 89MB
COMMIT 케이스: 89MB → 178MB → VACUUM → 178MB

롤백한 경우에는 일반 VACUUM만으로 파일이 89MB로 돌아갑니다. 롤백으로 죽은 tuple은 전부 테이블 뒤쪽에 새로 붙은 것들이라 연속 구간을 이루고, vacuum이 꼬리를 잘라 OS에 반납하기 때문입니다.

커밋한 경우는 다릅니다. 죽는 건 테이블 앞쪽 여기저기에 흩어진 옛 버전입니다. vacuum은 그 자리를 재사용 가능하게 표시할 뿐 파일을 줄이지 못합니다. 178MB를 회수하려면 VACUUM FULL이 필요하고, 그건 ACCESS EXCLUSIVE 락을 잡습니다.

다만 이 178MB가 무한히 자라지는 않습니다. 같은 UPDATE를 두 번, 세 번 반복하고 매번 vacuum을 돌려도 파일은 178MB에서 멈췄습니다. vacuum이 표시해 둔 자리를 다음 UPDATE가 재사용하기 때문입니다. 전면 갱신 워크로드에서 bloat는 대략 두 배 지점으로 수렴합니다. 흔히 걱정하는 "방치하면 무한정 커진다"는 vacuum이 제때 못 도는 경우의 이야기이지, MVCC 구조 자체의 결론은 아닙니다.

열어 둔 트랜잭션 하나가 정리를 통째로 막는다

PostgreSQL의 실패 모드 중 운영에서 가장 자주 만나는 것입니다. 다른 세션에서 트랜잭션을 열어 놓고 방치한 상태로 dead tuple을 만든 뒤 VACUUM VERBOSE를 실행했습니다.

tuples: 0 removed, 600000 remain, 400000 are dead but not yet removable
removable cutoff: 861, which was 2 XIDs old when operation ended

40만 개가 죽었는데 하나도 회수되지 않았습니다. dead but not yet removable, 이 문구가 나오면 원인은 거의 항상 누군가 오래 붙들고 있는 스냅샷입니다. 그 트랜잭션을 종료하고 다시 실행하면 이렇게 바뀝니다.

tuples: 400000 removed, 200000 remain, 0 are dead but not yet removable
index scan needed: 6897 pages from table (66.67% of total) had 400000 dead item identifiers removed

주의할 건 이 트랜잭션이 문제의 테이블을 건드리지 않아도 상관없다는 점입니다. backend_xmin을 잡고 있는 한 데이터베이스 전체의 정리가 그 지점에서 멈춥니다. 점심 먹으러 가면서 커밋하지 않고 자리를 뜬 세션 하나가 무관한 테이블의 vacuum을 막습니다.

다른 엔진은 이 비용을 어디로 보냈나

여기까지가 PostgreSQL이 내는 청구서입니다. 다른 엔진이라고 비용을 피하지는 못합니다. 비용이 없는 게 아니라 다른 항목으로 냅니다.

Oracle과 InnoDB는 옛 버전을 테이블 밖 undo 영역에 둡니다. 테이블은 행마다 한 버전만 유지하니 깔끔하고, 보조 인덱스는 물리 위치 대신 논리 키를 가리키므로 위치 변경에 따라올 필요가 없습니다. vacuum도 없고 freeze 의식도 없습니다. 대신 세 가지가 따라옵니다. 우선 롤백이 정직하게 비쌉니다. PostgreSQL이 1밀리초에 끝낸 100만 행 롤백을 undo 방식은 한 건씩 되돌려야 합니다. 읽기도 비싸집니다. 옛 스냅샷을 보려면 undo 체인을 거슬러 올라가 행을 재구성해야 합니다. 그리고 오래 도는 읽기 쿼리가 undo를 소진하면 그 쿼리를 죽입니다. Oracle에서 20년 넘게 DBA를 괴롭혀 온 ORA-01555: snapshot too old가 바로 그것입니다.

이 대비가 중요합니다. PostgreSQL은 오래된 reader를 위해 쓰레기를 쌓아 두고 견딥니다. Oracle은 쓰레기를 정리하고 reader를 죽입니다. 어느 쪽이 나은지는 워크로드가 정합니다.

SQL Server는 기본값이 아예 MVCC가 아닙니다. 잠금으로 격리를 만들고, RCSI를 켜야 행 버전 관리가 시작됩니다. 그때 옛 버전은 tempdb의 version store로 갑니다. 문제는 tempdb가 인스턴스 전체 공용이라는 점입니다. 데이터베이스 하나에서 오래 열린 스냅샷이 tempdb를 부풀리면 같은 인스턴스의 다른 데이터베이스까지 함께 멈춥니다. 폭발 반경이 데이터베이스 경계를 넘습니다. Microsoft가 2019년 ADR을 내놓으며 버전을 사용자 데이터베이스로 되돌린 이유가 이것이고, 그 과정에서 얻으려 한 상수 시간 abort는 PostgreSQL이 처음부터 갖고 있던 성질입니다.

MongoDB의 WiredTiger는 버전을 메모리 캐시에 델타로 들고 있다가 캐시를 넘치면 WiredTigerHS.wt 히스토리 저장소로 흘려보냅니다. 디스크의 dead tuple은 없지만 캐시 압력이 대신 옵니다. eviction이 따라가지 못하면 애플리케이션 스레드가 직접 eviction 작업을 떠맡아 전체 노드가 느려집니다.

CockroachDB나 YugabyteDB 같은 LSM 계열은 버전을 타임스탬프가 붙은 키로 저장하고 compaction으로 정리합니다. "vacuum이 없다"고 홍보하지만 compaction이 곧 vacuum입니다. 대신 GC 윈도를 넘긴 읽기는 실패합니다. CockroachDB의 gc.ttlseconds 기본값은 오랫동안 25시간이었는데 스케줄 백업이 들어온 뒤 4시간으로 낮아졌으니, 버전에 따라 창이 얼마나 좁은지 확인하고 써야 합니다. 삭제가 많은 테이블에서 tombstone이 쌓여 빈 테이블 스캔이 점점 느려지는 것도 이 계열의 특징입니다.

Kubernetes를 쓰면 매일 만지는 etcd도 사실상 같은 구조입니다. (key, revision)으로 버전을 쌓고 수동 compaction으로 회수합니다. 회수된 revision을 다시 읽으려 하면 etcdserver: mvcc: required revision has been compacted가 나오고, 백엔드 쿼터 2GB를 넘기면 제어 평면 쓰기가 거부됩니다. defrag는 VACUUM FULL과 같은 자리에 있는 의식입니다.

엔진옛 버전 위치인덱스가 가리키는 것abort 비용오래 열린 reader의 결과
PostgreSQL heap테이블 안물리 위치 (ctid)상수 시간bloat 누적, vacuum 정지
Oracle / InnoDBundo 영역논리 키작업량 비례ORA-01555, undo 폭증
SQL Server RCSItempdb클러스터링 키상수 시간 (ADR 이후)tempdb 증가, 인스턴스 전체 위험
WiredTiger캐시, 히스토리 저장소RecordId상수 시간캐시 압력, 노드 지연
LSM 계열타임스탬프 키논리 키상수 시간GC 윈도 초과 오류

같은 사고, 엔진별로 다른 진단 쿼리

위 표의 마지막 열은 결국 하나의 사건입니다. 누군가 트랜잭션을 오래 열어 둔 것입니다. 엔진마다 증상과 확인 방법이 다를 뿐입니다.

PostgreSQL에서는 backend_xmin을 봅니다. 아래 쿼리는 위 실험에서 실제로 범인을 찾는 데 사용한 것입니다.

SELECT pid, state,
backend_xmin,
age(backend_xmin) AS xid_age,
now() - xact_start AS tx_age,
left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;

주의할 게 있습니다. 범인이 pg_stat_activity에만 있는 게 아닙니다. 비활성 replication slot과 준비된 트랜잭션도 똑같이 xmin을 붙듭니다. 셋을 같이 확인해야 합니다.

-- 비활성 replication slot
SELECT slot_name, slot_type, active, xmin, catalog_xmin
FROM pg_replication_slots
WHERE NOT active OR xmin IS NOT NULL;

-- 잊혀진 2PC 트랜잭션
SELECT gid, prepared, owner FROM pg_prepared_xacts;

MySQL InnoDB에서 같은 증상은 history list length로 나타납니다.

SHOW ENGINE INNODB STATUS\G
-- TRANSACTIONS 절의 "History list length" 값을 확인한다

SELECT trx_id, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id, LEFT(trx_query, 60) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started;

history list length가 계속 늘면 purge가 밀리고 있다는 뜻이고, 원인은 PostgreSQL과 똑같이 오래 열린 트랜잭션입니다. 격리 수준을 READ COMMITTED로 낮추면 InnoDB가 유지해야 할 히스토리가 줄어 완화되는 경우가 있습니다.

Oracle에서는 undo 사용량과 retention을 봅니다.

SELECT begin_time, undoblks, maxquerylen, ssolderrcnt
FROM v$undostat
ORDER BY begin_time DESC
FETCH FIRST 12 ROWS ONLY;

ssolderrcntORA-01555 발생 횟수이고 maxquerylen이 가장 오래 돈 쿼리의 길이입니다. 이 둘이 함께 오르면 undo 보존 기간과 실제 쿼리 시간이 어긋나고 있다는 신호입니다.

SQL Server에서는 tempdb version store를 봅니다.

SELECT DB_NAME(database_id) AS db,
reserved_page_count,
reserved_space_kb / 1024 AS reserved_mb
FROM sys.dm_tran_version_store_space_usage
ORDER BY reserved_space_kb DESC;

이 값이 계속 오르면서 줄지 않으면 어딘가 스냅샷이 붙들려 있습니다. 인스턴스 공용 자원이므로 다른 데이터베이스 담당자도 같이 곤란해진다는 점에서 PostgreSQL보다 대응이 급합니다.

PostgreSQL 쿼리 세 개는 위 실험 환경에서 직접 실행해 확인했고, 나머지 엔진은 벤더 문서를 근거로 정리했습니다. 엔진을 옮겨도 물어야 할 질문은 하나로 같습니다. 지금 가장 오래 열려 있는 트랜잭션은 무엇이고, 그것이 무엇을 붙들고 있는지 물어야 합니다.

PostgreSQL만 하지 않는 선택 하나

목록을 늘어놓다 보면 PostgreSQL이 특히 나빠 보이지만, 하나는 짚어 둘 만합니다. 위에 나열한 엔진 대부분은 어느 시점에 reader를 죽입니다. Oracle은 ORA-01555, WiredTiger는 타임스탬프 기반 거부, LSM 계열은 GC 윈도 초과, etcd는 compacted revision. FoundationDB는 아예 트랜잭션 수명을 5초로 강제합니다.

PostgreSQL은 기본 설정에서 읽기 쿼리를 취소하지 않습니다. 대신 그 쿼리가 볼지도 모르는 쓰레기를 계속 들고 있습니다. 결함으로 볼 일은 아닙니다. 선택입니다.

실제로 PostgreSQL도 한 번은 반대편을 시도했습니다. 9.6에 들어온 old_snapshot_threshold가 그것으로, 설정한 시간이 지나면 옛 스냅샷을 무효화하고 snapshot too old 오류를 던졌습니다. 그런데 이 기능은 vacuum이 아직 보이는 행을 지워 버릴 수 있는 정합성 문제를 안고 있었고, 고칠 계획이 서지 않은 채 시간이 흐르다 PostgreSQL 17에서 제거됐습니다. 더 나은 구현이 나오면 다시 들어올 여지는 남겨 뒀습니다.

즉 PostgreSQL은 다른 엔진의 실패 모드를 흉내 냈다가, 제대로 못 하겠으면 안 하는 쪽을 택했습니다.

남은 문제와 지금 진행 중인 것

32비트 XID는 PostgreSQL만의 짐이 맞습니다. InnoDB의 DB_TRX_ID는 6바이트, 즉 48비트입니다. Oracle SCN도 원래 48비트였는데 12.2.0.1부터 compatible을 12.2로 올리면 상한이 2^63으로 넓어집니다. PostgreSQL은 여전히 32비트라 주기적으로 freeze를 돌려야 하고, 몇 달 동안 아무도 건드리지 않은 페이지까지 다시 써야 합니다. 2015년 Sentry의 wraparound 장애처럼 크게 터진 사례도 있습니다.

64비트 XID 패치는 Postgres Professional을 중심으로 수년째 논의 중이고 대부분의 상용 fork에는 이미 들어가 있지만, 본류에는 아직 없습니다. table access method 계층에서 처리해야 한다는 방향에는 합의가 있는 상태입니다.

저장 엔진 자체를 바꾸려는 시도도 이어집니다. zheap은 사실상 멈췄고, 지금 가장 활발한 것은 Supabase가 인수한 OrioleDB입니다. undo 기반으로 heap을 대체하는 extension이고 2026년 현재 퍼블릭 베타입니다. 벤치마크에서 최대 5.5배를 주장하지만 프로덕션 사용은 권장하지 않습니다.

당장 손에 잡히는 개선은 vacuum 쪽에서 옵니다. PostgreSQL 18은 autovacuum_worker_slotsautovacuum_max_workers를 분리해 재시작 없이 worker 수를 조정하게 만들었고, 19에서는 autovacuum이 카탈로그 순서 대신 테이블별 우선순위 점수로 대상을 고르도록 바뀝니다. pg_stat_autovacuum_scores 뷰와 가중치 파라미터가 함께 들어옵니다. 청소 자체를 없애지는 못해도, 언제 무엇부터 치울지는 계속 정교해지는 중입니다.

정리

MVCC 비용은 보존됩니다. 없앤 엔진은 없고, 어디로 보낼지만 다릅니다. PostgreSQL은 테이블 안에 쌓아 두고 나중에 치우는 쪽을 골랐습니다. 그 대가가 bloat와 vacuum 튜닝이고, 그 대신 얻은 것이 상수 시간 롤백과 취소당하지 않는 읽기 쿼리입니다.

그래서 "PostgreSQL의 MVCC는 나쁘다"는 문장은 절반만 맞습니다. 정확히 말하면 PostgreSQL은 시끄럽게 실패하고 수동으로 튜닝해야 하는 조합을 골랐습니다. undo 계열은 조용히 실패하고 자동으로 정리하지만, 실패할 때는 사용자 쿼리를 죽입니다.

다음에 누가 어떤 데이터베이스가 다중 버전 문제를 해결했다고 하면 네 가지를 물어보면 됩니다. 옛 버전은 어디에 사는지, 체인은 어느 방향인지, 인덱스는 무엇을 가리키는지, 누가 언제 치우는지 물으면 됩니다. 그리고 마지막으로 하나 더 묻습니다. 누군가 트랜잭션을 열어 놓고 점심을 먹으러 가면 무슨 일이 생기는지 묻습니다.

참고

이 블로그의 관련 글로는 PostgreSQL 18의 autovacuum_worker_slotsPostgreSQL은 왜 이건 아직 못 할까가 있어요.

prev-link 복구 오류

· 약 7분

secondary 하나가 복구를 못 끝내고 같은 로그만 뱉고 있었어요. WAL을 archive에서 한 번 당겨오고, 곧바로 record 하나를 읽다 실패하고, 다시 같은 파일을 당겨오는 흐름이 2초 간격으로 무한히 돌았어요.

2026-07-24 13:41:10 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028
2026-07-24 13:41:12 KST LOG: restored log file "00000068...0067F..." from archive
2026-07-24 13:41:12 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028
2026-07-24 13:41:15 KST LOG: restored log file "00000068...0067F..." from archive
2026-07-24 13:41:15 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028

디스크가 깨진 것처럼 보이지만 그건 아니었습니다. 이 메시지는 대부분 "여기가 이 timeline에서 유효한 WAL의 끝"이라는 신호입니다. 문제는 secondary가 그 끝에서 다음 timeline으로 못 건너가고 제자리를 맴돌았다는 점입니다.

이 로그가 정확히 어디서 나오나

두 줄은 서로 다른 주체가 찍습니다.

restored log file ... from archive는 recovery 중인 서버가 restore_command로 archive에서 WAL 세그먼트 하나를 가져왔다는 뜻입니다. 우리 환경은 pgBackRest가 archive 역할이라, 이 줄은 pgBackRest에서 세그먼트를 복원해 왔다는 기록입니다.

record with incorrect prev-link A at B는 WAL을 읽는 xlogreader가 찍습니다. PostgreSQL의 WAL record는 헤더에 xl_prev라는 필드를 들고 있는데, 바로 앞 record가 어디서 끝났는지를 가리키는 back-link입니다. reader는 record를 하나 읽을 때마다 "이 record의 xl_prev가 방금 내가 읽은 record의 끝과 같은가"를 확인합니다. 어긋나면 이 메시지를 남기고 그 자리를 유효한 WAL의 끝으로 간주합니다.

메시지의 두 LSN은 순서대로 이렇게 읽습니다. at 뒤(67F/40000028)가 문제의 record가 놓인 위치이고, 앞(67F/7BAB8)이 그 record에 적혀 있던 xl_prev 값입니다. 즉 67F/40000028에 있는 record는 자기 앞 record가 67F/7BAB8에서 끝났다고 주장하는데, reader가 실제로 그 지점까지 읽어온 맥락과 맞지 않는 것입니다.

여기서 위치 67F/40000028이 힌트를 줍니다. WAL 세그먼트는 기본 16MB이고, 오프셋 0x40000028은 세그먼트 경계에서 딱 0x28(40바이트) 들어간 자리입니다. 40바이트는 세그먼트 첫 페이지의 long page header 크기와 같습니다. 다시 말해 이 record는 어느 세그먼트의 맨 첫 record 자리에 있습니다. 그 자리의 xl_prev가 한참 앞인 67F/7BAB8(같은 논리 파일에서 500KB쯤 되는 지점)를 가리킨다는 건, 그 세그먼트 앞부분이 과거에 쓰이고 아직 새 내용으로 덮이지 않은 recycled 세그먼트의 잔재라는 신호에 가깝습니다. PostgreSQL은 성능을 위해 WAL 파일을 지우지 않고 이름만 바꿔 재사용하는데, reader가 그 옛 바이트열을 그럴듯한 record로 오독하면 이런 back-link 불일치가 나옵니다. xlogreader 소스메일링 리스트 논의에서도 같은 진단이 나옵니다.

어쩌다 이 상태가 됐나

먼저 분명히 해두면, 아래 순서는 사후에 되짚어 본 유추입니다. 운영 중 이것저것 만지다 이 상태에 도달했고, 어느 한 단계가 범인이라고 특정하지는 못했습니다. 다만 secondary가 이 지경이 되기까지 밟은 대략의 경로는 남겨 둡니다. HA는 pg_auto_failover로 묶여 있고 백업은 pgBackRest로 받는 환경이었습니다.

순서한 일부수 효과
1최초 primary/secondary 구성timeline 시작
2failover 발생옛 secondary 승격, timeline 증가
3옛 secondary 노드 drop구 노드 제거
4새 secondary add (pgBackRest 백업본으로 seed)과거 시점 데이터로 출발
5다시 failovertimeline 한 번 더 증가

failover는 standby를 primary로 승격시키면서 새 timeline을 엽니다. 이때 .history 파일이 만들어져 "몇 번 timeline은 어느 LSN에서 갈라져 나왔다"를 기록하고, 뒤따르는 standby는 이 history를 보고 분기 지점을 넘어 새 timeline의 WAL로 갈아탑니다. 갈아타려면 recovery_target_timeline이 최신을 따라가도록 서 있어야 하고, 분기 지점 이후의 WAL과 history 파일이 archive에 제대로 올라와 있어야 합니다.

이 증상 자체는 문서화가 잘 되어 있습니다. PostgreSQL 코어 개발자 Michael Paquier(미하엘 파키에)는 같은 로그를 두고 "현재 timeline에서 유효한 WAL의 끝을 가리키는 것이며, 과거에 recycled 세그먼트로 쓰였던 영역을 읽을 때 마주칠 수 있다"고 설명합니다. 같은 스레드에는 두 가지가 더 나옵니다. 하나는 이 오류가 archive_mode가 켜져 있을 때 주로 나타난다는 관찰이고, 다른 하나는 여러 번 승격을 거친 노드를 다시 붙일 때 반복해서 겪었다는 보고입니다. archive에서 WAL을 당겨오고, failover를 여러 번 돌린 우리 상황과 겹치는 대목입니다.

그 틀에 우리 경로를 얹으면 이렇게 읽힙니다. 과거 백업본에서 출발한 secondary가 두 번째 failover로 timeline이 또 올라간 뒤, 자기가 따라가던 timeline의 끝에 도달하고도 다음 timeline으로 건너갈 연결을 archive에서 매끄럽게 잇지 못한 것으로 보입니다. 분기 지점 근처 세그먼트에 recycled 잔재가 남아 있었고, reader가 그 자리를 67F/40000028의 깨진 record로 읽었다는 그림입니다. 단정이 아니라 관측된 증상과 문서화된 메커니즘을 맞춰 본 해석입니다.

왜 하필 무한 루프였나

멈추지 않고 도는 게 이 현상의 성격을 잘 보여줍니다.

recovery는 다음 WAL을 기다리다 restore_command를 다시 부르고, archive는 같은 세그먼트를 또 건네주고, reader는 같은 자리에서 같은 back-link 불일치를 만납니다. 넘어가야 할 새 timeline의 이력을 집어오지 못하는 한, 이 고리는 스스로 풀리지 않습니다. 로그의 LSN이 67F/40000028로 매번 똑같이 고정돼 있는 게 그 증거였습니다. 앞으로 나아가는 복구라면 LSN이 조금씩이라도 커집니다.

이 대목에서 disk corruption과 헷갈리기 쉬운데, 구분 기준은 단순합니다. 같은 세그먼트를 반복 restore하면서 같은 LSN에 멈춰 있으면 timeline 경계 문제이고, 서로 다른 위치에서 읽기 자체가 깨지면 그때 물리 손상을 의심합니다.

어떻게 걷어냈나

pg_rewind나 recovery_target_timeline=latest 조정으로 붙여보는 길이 먼저 떠오릅니다. 다만 메일링 리스트에도 나오듯 pg_rewind가 timeline 충돌은 정리해도 이 back-link 오류 자체를 없애주지 못하는 경우가 있습니다. 게다가 이 노드는 4번에서 이미 과거 백업본으로 새로 붙인 상태였습니다. 어중간하게 되살리기보다 현재 timeline 기준으로 깨끗이 다시 seed하는 편이 빨랐습니다.

그래서 pg_auto_failover의 노드 재구축으로 정리했습니다.

# 꼬인 secondary 노드를 데이터까지 완전히 제거
pg_autoctl drop node --destroy

# 현재 primary 기준으로 secondary 새로 구축
pg_autoctl create postgres

--destroy는 monitor에서 노드를 지우는 데 그치지 않고 데이터 디렉토리까지 함께 정리합니다. 이어서 pg_autoctl create postgres가 현재 primary의 최신 timeline을 기준으로 base backup을 다시 받아 복제를 새로 세웁니다. recycled 잔재를 담고 있던 옛 데이터가 통째로 사라지니 back-link 불일치도 같이 사라졌고, 복제는 정상으로 따라붙었습니다.

남는 메모

재구축이 정공법은 아닐 수 있습니다. 원인을 LSN 단위로 끝까지 파고들면 어느 failover에서 archive에 어떤 공백이 생겼는지 특정할 수도 있었을 것입니다. 다만 pg_auto_failover처럼 오케스트레이션이 노드 수명주기를 쥐고 있는 환경에서는 백업본에서 노드를 다시 붙이는 비용이 낮은 만큼, 포렌식보다 재구축이 대체로 실용적이었습니다.

정리하자면 이래요. standby가 archive만 반복해서 restore하면서 고정된 LSN에 record with incorrect prev-link로 멈춰 있으면, 물리 손상보다 timeline 경계를 못 넘은 상황을 먼저 의심하는 게 맞아요. 그리고 failover를 여러 번 돌리는 사이에 과거 백업본으로 노드를 되붙이는 조합은 timeline 이력이 엉키기 딱 좋은 지점이라, 그 앞뒤로는 archive에 history 파일과 분기 이후 WAL이 온전히 올라와 있는지 한 번 더 확인해 둘 만해요.

참고

PG19 동적 wal_level

· 약 7분

PostgreSQL 19에서 wal_level이 의미를 바꿨어요: 더 이상 "이 서버가 항상 쓰는 WAL 레벨"이 아니라 하한값이며, 실제 effective level은 그 위에서 slot 상태에 따라 자동으로 움직여요. logical replication slot이 하나 생기면 effective level이 logical로 올라가고, 마지막 slot이 사라지면 다음 checkpoint에서 내려가요.

이 글에서는 The Build의 Christophe Pettus가 다룬 "The wal_level You Set Is Not the wal_level You Get"을 한국어로 풀고, cascading standby와 archived WAL에서 운영자가 한 번은 부딪힐 자리들을 함께 살펴봅니다.

보험성 wal_level=logical 의 비용

기존 PostgreSQL에서 wal_level은 셋 중 하나로 못 박혀 있었습니다 — minimal, replica, logical. 변경하려면 서버를 재시작해야 했습니다.

운영 패턴이 자연스럽게 굳어졌습니다. 지금 당장 logical replication을 쓰지 않더라도, 나중에 쓸 가능성이 조금이라도 있으면 일단 logical로 잡아둡니다. 그래야 그날 새벽에 슬롯 하나 만들면서 재시작을 잡을 일이 없습니다. "혹시 모르니까" 라는 안전망입니다.

이 안전망에는 비용이 붙습니다.

  • wal_level = logical은 모든 변경에 대해 추가 메타데이터를 WAL에 적습니다. row의 이전 이미지, replica identity 정보, 다중행 변경의 stream 표식 등입니다.
  • 실제로 logical replication slot이 단 하나도 없어도 적힙니다. "지금 안 쓰는데 적어두는 값"이 분 단위로 디스크에 흐릅니다.
  • 운영 환경에 따라 다르지만, replica 대비 WAL 볼륨이 10~30% 더 늘어나는 케이스가 흔히 보고됩니다.
  • WAL 볼륨이 늘면 archive 비용, streaming replication 대역폭, PITR 복구 시간이 같이 늘어납니다.

"The right thing has corners." — Christophe Pettus, The Build

PostgreSQL 19는 이 안전망의 비용을 덜기 위해 wal_level을 "고정 레벨"에서 "최소 보장"으로 바꿨습니다.

PG19의 configured wal_level과 effective wal_level

PostgreSQL 19에서 서버는 두 개의 WAL level을 가집니다.

configured wal_levelpostgresql.conf에 적힌 값입니다. 여전히 재시작 파라미터이며, "이 서버가 어떤 경우에도 떨어지지 않을 하한값"을 뜻합니다. effective wal_level은 서버가 실제로 지금 WAL에 쓰는 레벨이며, configured 값보다 위로 올라갈 수 있습니다.

규칙은 단순합니다.

상태effective wal_level
logical replication slot이 하나 이상 살아있음logical
streaming replication 연결만 있음replica 또는 configured 중 높은 쪽
아무것도 없음configured 값 그대로

즉, configured를 replica로 잡아두고도 logical replication slot을 만드는 순간 서버가 알아서 logical로 올라갑니다. 마지막 slot이 사라지면 다음 checkpoint에서 다시 내려갑니다.

상태 전이도

중요한 지점은 두 군데입니다 — logical 진입 시점은 forced checkpoint 직후이고, 다시 내려가는 시점은 그다음 일반 checkpoint입니다. 두 시점은 비대칭입니다.

동작 단계

올라갈 때와 내려갈 때를 따로 봅니다.

올라갈 때: logical로의 전이

  1. 운영자(또는 publication 생성 시 PostgreSQL)가 첫 logical replication slot 생성을 요청합니다.
  2. 서버는 effective level이 logical 미만이면 즉시 forced checkpoint를 돕니다.
  3. 이 checkpoint 직후의 LSN을 "guaranteed LSN"으로 기록하고, slot은 그 지점부터 시작합니다.
  4. 그 LSN 이후의 WAL은 logical 레벨 메타데이터를 함께 적기 시작합니다.

내려갈 때: replica로의 하강

  1. 마지막 logical slot이 사라집니다.
  2. 서버는 다음 일반 checkpoint까지 그대로 logical을 유지합니다. 즉시 내리지 않습니다.
  3. 그 checkpoint가 돌고 나면 effective level이 configured 값으로 복귀합니다.
  4. 이후 WAL은 다시 replica 메타데이터로 가벼워집니다.

이 비대칭은 의도된 설계입니다. 올라갈 때는 slot이 막 만들어진 WAL을 읽지 못하면 안 되므로 checkpoint를 즉시 실행합니다. 내려갈 때는 별도 checkpoint를 잡아 운영 영향을 만들 이유가 없습니다.

운영 영향

가장 큰 효과는 WAL 볼륨 절감입니다.

  • "혹시 모르니까 logical"로 잡아둔 클러스터는, configured를 replica로 내리고 PG19로 올리면 그대로 WAL이 줄어듭니다. 실제로 logical을 쓸 일이 생기면 그 순간 자동으로 올라가니 운영 부담은 늘지 않습니다.
  • archive 비용, streaming replication 대역폭, PITR 복구 시간이 모두 따라 줄어듭니다.
  • 클라우드 관리형 PostgreSQL에서는 archive 저장 비용이 매월 청구되는 항목이라 체감이 큽니다.

부차 효과는 logical replication을 사용 중인 동안에만 그 비용을 낸다는 점입니다. 일회성 마이그레이션을 위해 logical을 켰다가 작업을 마치면 비용이 자동으로 사라집니다.

함정과 주의사항

네 가지를 짚습니다.

첫째, cascading standby의 비대칭입니다. primary와 standby는 각자 effective level을 따로 가집니다. primary의 마지막 logical slot이 사라져도 standby는 자신의 슬롯이 살아있는 한 effective logical을 유지합니다. primary가 보내는 WAL은 그새 replica로 내려갈 수 있지만, standby는 자기 슬롯을 위해 그 WAL을 logical로 받아야 합니다. 이 경계는 운영자에게 보이지 않는 곳에서 정렬됩니다. PG19가 알아서 잘 처리하지만, replication lag를 분석할 때 헷갈리지 않으려면 "두 노드의 effective level은 따로 움직인다"는 사실을 머리에 둬야 합니다.

둘째, archive에 mixed-level WAL이 섞입니다. 같은 archive 디렉토리에 replica 시기의 WAL과 logical 시기의 WAL이 함께 쌓입니다. PostgreSQL은 segment마다 metadata로 어떤 레벨인지 표시해두지만, "이 archive는 통째로 logical이다" 같은 가정을 두고 작성한 PITR script가 있다면 다시 봐야 합니다.

셋째, prepared transaction입니다. 2-phase commit 자체의 동작은 변하지 않습니다. 다만 prepared transaction이 많은 환경에서는 logical 전이 시점의 forced checkpoint가 의외로 오래 걸릴 수 있습니다. 테스트 환경에서 한 번은 의도적으로 재현해 보고 timeout 설정을 검토하는 편이 좋습니다.

넷째, 첫 slot 생성 시 checkpoint 대기입니다. 새로 만든 publication이 logical slot을 생성하면 그 호출이 forced checkpoint를 기다립니다. 평소 checkpoint 부담이 큰 시스템에서는 수십 초가 걸릴 수도 있습니다. 자동화 script가 CREATE SUBSCRIPTION 호출에 짧은 timeout을 걸어뒀다면 PG19 업그레이드 직후 한 번 흔들릴 가능성이 있습니다.

PITR / archive script 점검 포인트

PostgreSQL 19로 올라가기 전에 한 번씩 확인할 자리들입니다.

archive level과 관련해서는 script가 "이 archive는 logical 레벨"임을 전제로 메타데이터를 파싱하지 않는지 확인합니다. slot 생성 timeout은 자동화 도구의 CREATE SUBSCRIPTION / pg_create_logical_replication_slot 호출에 30초 미만으로 설정되어 있지 않은지 살펴봅니다.

WAL 볼륨 모니터링에서는 PG19로 올린 직후 WAL 생성률이 줄어드는 변화를 정상으로 인식하는지 확인합니다. 감소를 장애로 판단해 알람을 보내지 않도록 해야 합니다. cascading 구성에서는 primary, intermediate, leaf의 effective level이 각각 어떻게 정렬되는지 그림으로 한 번 그려두는 편이 좋습니다.

"혹시 모르니까"라는 비용

logical replication을 본격적으로 쓰기 시작한 계기는 zero-downtime upgrade와 CDC 파이프라인입니다. 한 번 켜놓으면 끝나는 것이 아니라 "혹시 다음 마이그레이션 때 또 쓸지 모르니까" wal_level = logical 그대로 두는 패턴이 굳어졌습니다. 그 사이 WAL 볼륨이 10~30% 더 흐르고, archive 비용과 streaming replication 대역폭이 같이 늘어납니다 — 평소에는 의식하지 못하지만 PITR 복구 시간을 재 보면 한 번씩 보여요. PostgreSQL 19의 동적 wal_level은 이 "보험 비용"을 시점성 비용으로 바꿉니다. 일회성 마이그레이션이 끝나면 slot만 정리해도 effective level이 자동으로 내려가고 WAL이 가벼워집니다. 운영자가 wal_level을 내릴지 말지 회의에 올릴 일이 없어진다는 점이 PG19가 일상에 주는 가장 큰 차이입니다.

정리

PG18 이하PG19
wal_level 의 의미고정값하한값 (floor)
logical 전환 비용항상 부담slot 있는 동안만
WAL 볼륨 (logical 미사용 시)풀 부담replica 수준
첫 logical slot 생성즉시forced checkpoint 후
마지막 slot 제거 후 하강n/a다음 checkpoint

PostgreSQL 19는 운영자가 "혹시 모르니까"라는 이유로 영구히 짊어졌던 비용을 시점성 비용으로 바꿔, logical replication이 필요한 그 순간에만 그 비용을 내게 해요. 기본 동작이 더 똑똑해진 변화지만, cascading standby와 archive처럼 effective level이 노드별로 따로 움직이는 곳에서는 모서리가 한 번씩 보여요.

참고 자료