콘텐츠로 이동

(KO) PostgreSQL Vacuum & Visibility — 단순 Lazy Vacuum에서 HOT, Freeze Map, 병렬 처리까지

목차

이 서브시스템이 진화해야 했던 이유 (원초적 한계)

섹션 제목: “이 서브시스템이 진화해야 했던 이유 (원초적 한계)”

PostgreSQL은 덮어쓰지 않는(no-overwrite) MVCC 엔진이다. DELETE는 행을 지우지 않고, UPDATE는 그 자리를 수정하지 않는다. 스토리지 계층은 이전 버전의 xmax를 기록하고, 갱신이라면 새 버전을 다른 위치에 써넣는다. 현재 상태 모듈 문서 postgres-heap-am.md에서 다루듯, 모든 튜플은 힙 페이지의 ItemId 슬롯에 살고, 가시성은 스냅샷을 기준으로 xmin/xmax를 비교해 결정된다. 죽은 바이트는 무언가가 쓸어낼 때까지 그 자리에 머문다. 그 역할을 vacuum이 맡는다.

이 설계는 MVCC의 핵심 특성, 즉 읽기가 쓰기를 막지 않고 쓰기가 읽기를 막지 않는다는 보장을 준다. 각 트랜잭션이 자신만의 스냅샷을 읽기 때문이다. 그 대가로 구현은 쓰기 양에 비례해 커지는 두 가지 부채를 떠안는다.

  1. 죽은 튜플 회수. 갱신과 삭제는 공간을 누출한다. 갱신이 잦은 테이블은 배경 프로세스가 공간을 회수하지 않으면 끝없이 커진다. 인덱스/힙 스캔은 점점 길어지는 죽은 버전의 연쇄를 훑어야 한다. 핵심 난점은 “이 버전은 죽었다”는 판단이 전역 술어라는 점이다. vacuum은 클러스터 전체의 어떤 스냅샷도 그 행을 더 이상 볼 수 없음을 알아야 한다. 그 horizon(OldestXmin)은 모든 활성 백엔드에 걸쳐 계산된다.

  2. 트랜잭션 ID 순환. XID(트랜잭션 ID)는 32비트다. 가시성 비교기는 XID 공간을 2^31 horizon의 원형으로 취급하므로, 약 21억 트랜잭션 이전에 xmin이 기록된 튜플은 갑자기 미래에서 온 것처럼 보여 보이지 않게 된다. 침묵 속 데이터 손실이다. 방어책은 오래된 튜플을 동결(freeze)하는 것이다. 비교기가 항상 “과거”로 간주하는 값으로 xmin을 재기록한다. 동결은 모든 오래된 튜플을 빠짐없이 건드려야 하므로 근본적으로 전체 테이블 의무다. 그 메커니즘은 postgres-xid-wraparound-freeze.md에서 다룬다.

원초적 구현은 이 두 부채를 가장 단순한 방법으로 처리했다. 단일 스레드 프로세스가 테이블의 모든 페이지를 읽고, 죽은 line pointer를 flat 배열에 수집하고, 모든 인덱스를 순회해 해당 항목을 삭제한 뒤, 힙으로 돌아가 죽은 line pointer를 수거했다. 하위 문제 두 개, 전체 스캔 한 번, 지름길 없음. 2005년 당시의 테이블 크기에서는 통했지만, 테이블이 커지고 갱신 속도가 빨라지고 anti-wraparound 의무가 변경되지 않은 데이터까지 반복 전체 스캔을 강제하면서 한계가 드러났다.

아래의 각 시대는 그 브루트 포스 루프가 확장되지 않은 구체적인 이유에 대한 응답이다. 전체적으로 보면 네 가지 전략의 흐름이 보인다.

  • 가비지를 만들지 않는다 (HOT) — 인덱스 블로트를 피하고, vacuum 없이도 단일 페이지 prune으로 공간을 회수한다.
  • 깨끗한 페이지는 읽지 않는다 (visibility map) — 페이지별로 vacuum이 건너뛸 수 있는지 기록해, 스캔 비용이 테이블 크기가 아닌 변경량에 비례하게 한다.
  • 정적인 페이지는 다시 읽지 않는다 (all-frozen 비트) — visibility map이 원래 충족하지 못했던 anti-wraparound 스캔 의무까지 건너뜀을 확장한다.
  • 병렬화하고 살아남는다 (병렬 인덱스 vacuum, failsafe, TidStore) — 스캔이 불가피할 때 인덱스 정리를 워커에 분산하고, 비상 시 wraparound를 막기 위해 모든 비필수 작업을 던져버리고, 죽은 TID 관리의 메모리 낭비를 없앤다.
timeline
    title PostgreSQL Vacuum과 Visibility 진화
    section 브루트 포스
        8.x 기준선 : 단순 lazy vacuum : 전체 힙 스캔, 전체 인덱스 스캔, flat 죽은 TID 배열
    section 피하고 건너뛰기
        8.3 (2007) : HOT heap-only tuple : 동일 페이지 갱신 체인, 새 인덱스 항목 없음, 단일 페이지 prune
        8.4 (2009) : Visibility map : 페이지당 all-visible 비트, vacuum이 깨끗한 페이지 건너뜀, 인덱스 전용 스캔
    section 정적 데이터 건너뛰기
        9.6 (2016) : All-frozen 비트 / freeze map : VM 두 번째 비트, anti-wraparound 스캔이 동결된 페이지 건너뜀
    section 확장과 생존
        13 (2020) : 병렬 인덱스 vacuum : DSM 경유 인덱스별 병렬 워커
        14 (2021) : Wraparound failsafe : relfrozenxid가 위험 수위일 때 인덱스 vacuum 우회
        17 (2024) : TidStore 기수 저장소 : 압축 adaptive-radix 죽은 TID 저장소로 flat 배열 대체

Era 0 — 단순 lazy vacuum (8.x 기준선)

섹션 제목: “Era 0 — 단순 lazy vacuum (8.x 기준선)”

당시의 모습. 아래 최적화가 모두 없던 시절, VACUUM(7.2에서 테이블 전체 재작성 방식의 vacuum을 대체한 non-FULL “lazy” 변형)은 전체 릴레이션을 상대로 고정된 3단계 루프를 실행했다.

  1. 1단계 — 힙 스캔. 블록 0부터 끝까지 모든 힙 페이지를 읽는다. 각 페이지에서 HOT 없는 pruning 로직을 실행하고, 튜플 하나하나를 따져 OldestXmin 이전에 삭제돼 어떤 스냅샷에도 보이지 않는 line pointer를 골라낸다. 죽은 항목의 (block, offset) TID를 maintenance_work_mem으로 크기가 결정되는 in-memory 배열에 추가한다. 이 과정에서 동결 임계치보다 오래된 튜플도 동결한다.
  2. 2단계 — 인덱스 vacuum. 테이블의 인덱스마다 ambulkdelete 진입점을 호출한다. 이 호출은 전체 인덱스를 스캔해 죽은 TID 배열에 있는 TID를 가리키는 항목을 삭제한다. 가장 비용이 큰 단계다. 인덱스 크기에 비례하는 O(인덱스 크기) 연산이고, 죽은 TID 배열이 한 번 찰 때마다 실행된다.
  3. 3단계 — 힙 수거. 죽은 항목이 있던 힙 페이지를 다시 훑어 LP_DEAD line pointer를 LP_UNUSED로 전환해 공간을 재사용 가능하게 만들고 FSM(free space map, 여유 공간 맵)을 갱신한다.

힙 스캔이 끝나기 전에 죽은 TID 배열이 가득 차면 vacuum은 2단계와 3단계를 조기 실행한 뒤 힙 스캔을 재개했다. 따라서 maintenance_work_mem이 작은 환경에서 큰 테이블을 vacuum하면 각 인덱스를 한 번의 vacuum 안에서 여러 번 전체 스캔할 수 있었다.

확장되지 않은 이유. 구조적 비용 세 가지가 내재돼 있었다.

  • 1단계는 항상 O(테이블 크기). 어제 밤 열 건 갱신만 된 500 GB 테이블도 전체를 읽었다. “여기엔 변경이 없다”는 페이지별 기록이 없어서 건너뜀이 불가능했다.
  • 모든 UPDATE가 인덱스 블로트를 만들었다. 인덱스 칼럼과 무관한 칼럼을 바꿔도 새 힙 튜플과 함께 모든 인덱스에 새 항목이 생겼다. 인덱스가 힙만큼 빠르게 불어났다.
  • 죽은 TID 배열은 고정 폭 flat 배열이었다. 죽은 튜플 하나에 ItemPointerData 하나(6바이트, 나중에는 패딩 포함)가 쓰였고 maintenance_work_mem으로 상한이 정해졌다. 배열 용량을 초과하는 죽은 튜플이 발견되면 인덱스 전체 스캔이 추가로 발생했다.

게다가 anti-wraparound 의무(Era 3의 주제)는 주기적인 aggressive vacuum을 강제했다. 동결은 모든 미동결 튜플을 방문해야 하므로 어떤 페이지도 건너뛸 수 없었다. 기준선에는 “이 페이지는 이미 완전히 동결됐다”는 기록 방법이 없어서, anti-wraparound vacuum은 차갑고 정적인 수십 년 묵은 데이터도 다시 읽었다.

구체적인 사례로 고통을 실감해 보자. 인덱스 여섯 개짜리 200 GB 테이블에서 maintenance_work_mem이 약 1100만 TID를 담을 만큼 설정돼 있고 밤사이 배치 작업이 5000만 행을 삭제했다고 가정한다. 기준선 vacuum은 200 GB 힙을 전부 읽고(1단계), 5000만 건의 죽은 TID가 1100만짜리 배열을 약 다섯 번 채우므로 2단계, 즉 인덱스 여섯 개 전체 스캔을 다섯 번 실행한다. 인덱스 전체 스캔 서른 번을 하나의 vacuum 안에서 치르는 셈이다. 그 어떤 수치도 유용한 무언가에 비례하지 않는다. (테이블 크기) + (죽은 튜플 수 / 배열 용량) × (인덱스 수)에 비례할 뿐이다. 아래 각 시대는 이 곱셈의 인수 하나씩을 제거한다.

3단계 직렬 구조, lazy_scan_heap이 구동하는 flat 죽은 TID 배열, 인덱스별 일괄 삭제라는 골격은 오늘날 postgres-vacuum.md의 뼈대로 남아 있다. 이후의 모든 발전은 그 뼈대에 얹혀 세 비용 중 하나를 사라지게 만드는 층위다.

Era 1 — HOT: heap-only tuple (8.3, 2007)

섹션 제목: “Era 1 — HOT: heap-only tuple (8.3, 2007)”

변화. PostgreSQL 8.3은 HOT(Heap-Only Tuple, 힙 전용 튜플)을 도입했다. 이 호(arc) 전체에서 vacuum이 실행되기 전에 문제를 공격하는 유일한 레버다. 전제는 단순하다. 대부분의 갱신은 인덱스가 걸린 칼럼을 바꾸지 않는다. UPDATE가 어느 인덱스의 칼럼도 건드리지 않았다면 새 인덱스 항목을 추가할 이유가 없다. 모든 인덱스의 키가 그대로이기 때문이다. HOT는 그 갱신이 새 버전을 같은 힙 페이지에 놓고 이전 버전의 t_ctid에서 체인으로 연결하게 한다. 새 인덱스 튜플은 생성되지 않는다.

이미 존재하는 인덱스 항목은 계속 원래 line pointer(“HOT 체인 루트”)를 가리킨다. 인덱스 스캔이 루트에 도달하면 t_ctid 체인을 따라 같은 페이지 안의 버전을 앞으로 탐색해 자신의 스냅샷에 보이는 버전을 찾는다. 체인은 인덱스에 보이지 않고 하나의 힙 페이지 안에만 존재한다. HEAP_HOT_UPDATED, HEAP_ONLY_TUPLE infomask 비트, 루트 오프셋 리다이렉트, 체인 탐색 메커니즘은 postgres-heap-am.md와 트리 내부의 src/backend/access/heap/README.HOT에서 다룬다.

의미와 HOT의 두 번째 절반. HOT는 두 가지 일을 했다. 첫째, 흔한 갱신 패턴에서 인덱스가 불어나는 것을 막았다. 둘째, 더 중요하게는 vacuum의 모습을 바꾼 단일 페이지 pruning을 도입했다. 죽은 HOT 튜플은 그 자체를 가리키는 인덱스 항목이 없으므로 인덱스를 건드리지 않는 순수한 페이지 단위 작업으로 제거할 수 있다. PostgreSQL은 이런 체인을 vacuum 도중뿐 아니라 페이지를 핀하는 일반 읽기/쓰기 도중에도 기회적으로 prune한다. heap_page_prune_opt와 현재 src/backend/access/heap/pruneheap.c에 있는 pruning 로직이다. 죽은 HOT 중간 버전은 LP_REDIRECT line pointer로 압축되고, 공간이 해제되며 루트 line pointer가 재조정된다. 모두 단일 버퍼 잠금 아래서 이루어진다.

구조적 변화를 나란히 놓으면 명확하다.

flowchart LR
    subgraph before["HOT 이전 (pre-8.3) — 비인덱스 칼럼 UPDATE"]
        direction TB
        bidx["col_a 인덱스"] --> bv1["힙 v1<br/>TID (5,1)"]
        bidx2["col_a 인덱스<br/>(새 항목)"] --> bv2["힙 v2<br/>TID (5,2)<br/>같은 키, 새 TID"]
        bnote["UPDATE마다 인덱스마다<br/>항목 하나씩 추가;<br/>v1 회수는 vacuum +<br/>인덱스 bulk-delete만 가능"]
    end
    subgraph after["HOT 이후 (8.3+) — 동일 UPDATE, 인덱스 칼럼 무변경"]
        direction TB
        aidx["col_a 인덱스<br/>(항목 하나, 불변)"] --> aroot["루트 line ptr (5,1)"]
        aroot -->|"t_ctid 체인"| av2["힙 v2 (5,2)<br/>HEAP_ONLY_TUPLE<br/>인덱스 항목 없음"]
        anote["새 인덱스 항목 없음;<br/>단일 페이지 prune이<br/>인덱스 건드리지 않고<br/>죽은 체인 멤버 회수"]
    end

vacuum 관점의 수확: HOT 친화적 워크로드에서는 죽은 튜플의 상당 부분이 1단계에 도달하기도 전에 vacuum 사이 기회적 페이지 pruning으로 회수된다. 죽은 TID 배열이 더 천천히 차고, 비싼 2단계 인덱스 당 패스 실행 횟수가 줄고, 인덱스 크기는 갱신 횟수가 아닌 고유 키 값 수에 비례하게 된다. HOT는 vacuum의 3단계 구조 자체를 바꾸지 않았다. vacuum으로 흘러드는 작업량을 바꿨다.

설계를 형성하고 vacuum이 오늘날에도 존중하는 중요한 제약이 하나 있다. HOT 갱신은 페이지에 새 버전을 놓을 자리가 있을 때만 같은 페이지에 머물 수 있다. 페이지가 꽉 차면 체인이 끊긴다. 다음 갱신은 다른 페이지로 흘러가고 새 인덱스 항목을 만든다. 갱신이 잦은 테이블에서 fillfactor 조정이 중요한 이유다. 각 페이지에 여유 공간을 남겨야 HOT를 유지하는 갱신 비율이 올라간다. vacuum도 체인을 prunable하게 유지하는 데 참여한다. 페이지를 처리할 때 죽은 HOT 체인 멤버를 LP_REDIRECT/LP_UNUSED line pointer로 압축해 루트 포인터가 항상 살아 있는 튜플에 도달하도록 하고, 인덱스 항목이 아직 참조하는 line pointer는 절대 제거하지 않는다. 교차 참조: postgres-heap-am.md(HOT 체인, pruning, line-pointer 상태)와 postgres-vacuum.md(pruning이 힙 스캔과 통합되는 방식).

Era 2 — Visibility map: 깨끗한 페이지 건너뛰기 (8.4, 2009)

섹션 제목: “Era 2 — Visibility map: 깨끗한 페이지 건너뛰기 (8.4, 2009)”

변화. PostgreSQL 8.4는 visibility map(VM, 가시성 맵)을 추가했다. 릴레이션의 작은 별도 fork로, 힙 페이지당 비트 하나가 있다. 비트는 해당 페이지의 모든 튜플이 모든 트랜잭션에 보일 때(죽은 튜플 없음, 진행 중인 것도 없음) 설정된다. 핵심은 단순하다. 페이지가 all-visible로 표시돼 있으면 vacuum 1단계는 그 페이지를 아예 읽지 않고 건너뛸 수 있다. _vm fork, visibilitymap_set / visibilitymap_get_status API, 힙 페이지의 PD_ALL_VISIBLE 플래그와의 충돌 안전 조율 전체는 postgres-visibility-map.md에서 다룬다.

이것이 이 호(arc) 전체에서 가장 중요한 확장성 변화다. VM이 생기면서 1단계의 O(테이블 크기) 바닥이 깨졌다. VM이 있으면 힙 스캔 비용이 테이블 크기가 아닌 마지막 vacuum 이후 수정된 페이지 수에 비례한다. 오래된 페이지가 모두 all-visible인 1 TB append-mostly 테이블을 1 GB 테이블만큼 저렴하게 vacuum할 수 있다.

두 번째 수확: 인덱스 전용 스캔. VM 비트는 실행기에도 정보를 준다. 페이지가 all-visible이면 인덱스 스캔이 일치하는 항목을 찾았을 때 가시성 확인을 위해 힙을 방문할 필요가 없다. 인덱스 항목만으로 신뢰할 수 있다. 이것이 인덱스 전용 스캔이다. 실행기 작업은 9.2에 추가됐지만 8.4 VM 인프라에 기반한다. VM이 vacuum 내부가 아닌 스토리지 엔진에 사는 이유다. nodeIndexonlyscan 소비자 측은 postgres-visibility-map.md를 참조한다.

wraparound를 아직 해결하지 못한 이유. Era 3이 존재하는 이유가 되는 한계다. 8.4 VM은 비트가 하나였다. all-visible은 “죽은 것도 진행 중인 것도 없다”는 뜻이다. “모든 것이 동결됐다”는 의미가 아니다. anti-wraparound(aggressive) vacuum이 실행될 때, 즉 오래된 튜플을 동결해 relfrozenxid를 전진시켜야 할 때, all-visible 비트만으로는 “여기서 동결이 필요 없다”고 믿을 수 없었다. all-visible 페이지에도 xmin이 오래됐지만 아직 동결되지 않은 튜플이 있을 수 있기 때문이다. 안전을 위해 aggressive vacuum은 VM을 무시하고 모든 페이지를 읽었다. 건너뜀 최적화는 일반 vacuum에는 도움이 됐지만 테이블이 가장 큰 압박을 받는 상황에서 정확히 사라졌다. 거대하고 차갑고 정적인 테이블의 anti-wraparound 스캔은 여전히 매번 모든 페이지를 다시 읽었다.

vacuum의 구조적 변화:

flowchart LR
    subgraph e2before["Pre-8.4 lazy_scan_heap"]
        direction TB
        b0["블록 0..N 순회"] --> b1["페이지 읽기 (항상)"]
        b1 --> b2["Prune, 죽은 TID 수집,<br/>오래된 튜플 동결"]
        b2 --> b3["비용: O(테이블 크기)<br/>매 vacuum"]
    end
    subgraph e2after["8.4+ lazy_scan_heap (VM 적용)"]
        direction TB
        a0["블록 0..N 순회"] --> a1{"VM all-visible<br/>비트 설정?"}
        a1 -->|"예, 일반 vacuum"| a2["페이지 건너뜀"]
        a1 -->|"아니오, 또는 aggressive"| a3["페이지 읽기,<br/>prune, 수집, 동결"]
        a3 --> a4["비용: O(수정된 페이지)<br/>단 aggressive는<br/>여전히 전체 읽기"]
    end

교차 참조: postgres-visibility-map.md(fork와 비트 의미론)와 postgres-vacuum.md(lazy_scan_heap이 VM을 참조하는 방식, 일반 vs. aggressive 구분).

Era 3 — All-frozen 비트 / freeze map: 동결된 페이지 건너뛰기 (9.6, 2016)

섹션 제목: “Era 3 — All-frozen 비트 / freeze map: 동결된 페이지 건너뛰기 (9.6, 2016)”

변화. PostgreSQL 9.6은 visibility map을 페이지당 비트 하나에서 로 확장했다. 기존 all-visible 비트와 새로운 all-frozen 비트다. all-frozen 비트는 페이지의 모든 튜플이 단순히 보이는 것을 넘어 완전히 동결된 상태, 즉 anti-wraparound 스캔이 처리해야 할 미동결 xmin이 없을 때 설정된다. 이 두 번째 비트가 있는 맵을 freeze map이라 부르기도 한다. 비트 정의(VISIBILITYMAP_ALL_VISIBLE, VISIBILITYMAP_ALL_FROZEN)와 페이지당 2비트 패킹은 postgres-visibility-map.md에서 상세히 다룬다.

Era 2의 빠진 절반. Era 2의 한계를 상기해 보자. aggressive, anti-wraparound vacuum은 all-visible 비트 하나만으로는 믿을 수 없어서 동결이 필요한 것이 없는지 확인하려고 전체 테이블을 다시 읽었다. all-frozen 비트가 정확히 그 간격을 메운다. 이제 aggressive vacuum은 페이지마다 “이 페이지가 이미 all-frozen인가?”를 물을 수 있고, 그렇다면 건너뛸 수 있다. 동결된 페이지에는 정의상 동결할 것이 남아 있지 않기 때문이다. anti-wraparound 의무가 처음으로 전체 테이블 크기가 아닌 미동결 데이터 양에 비례하게 됐다.

이것이 기준선을 무너뜨린 바로 그 병리, 즉 거대하고 대부분 차가운 테이블에 엄청난 효과를 가져왔다. 오래된 페이지가 동결되고 all-frozen으로 표시되면 이후 모든 anti-wraparound vacuum이 그 페이지들을 건너뛴다. 수억 트랜잭션마다 수 시간짜리 전체 읽기를 강제받던 5 TB 과거 데이터 테이블이 이제는 최근 쓰기를 받은 페이지만 읽는다.

역사적으로 주목할 일회성 비용과 전환 주름이 있었다. 기존 페이지에는 all-frozen 비트가 설정돼 있지 않았으므로 9.6으로 업그레이드 후 첫 번째 aggressive vacuum은 여전히 모든 것을 읽고 동결해 비트를 채워야 했다. 그 이후부터 건너뜀이 누적된다. 이후 개선(9.6과 다음 릴리스들의 vacuum_freeze_min_age / 기회적 동결 튜닝)은 vacuum이 이미 all-visible 페이지를 방문할 때 all-frozen 비트를 일찍 설정하도록 밀어붙여, 차가운 페이지가 더 빨리 동결-건너뜀 가능 상태에 도달하게 했다.

충돌 안전 세부 사항이 all-frozen 비트를 믿고 건너뛸 만큼 신뢰할 수 있게 만든다. 여기서 틀린 비트는 조용한 손상을 의미한다. 동결이 필요한 페이지를 건너뛰는 것이기 때문이다. VM 비트는 그 자체로 권위를 갖지 않는다. 힙 페이지 자체(PD_ALL_VISIBLE)에 기록된 사실의 캐시다. 두 곳은 조율된 WAL 로깅 아래 갱신되므로 충돌 후 맵이 페이지를 동결됐다고 주장하는데 힙이 동의하지 않는 상황이 생기지 않는다. 건너뜀은 항상 지속적으로 기록된 진실만큼만 적극적이다. 전체 조율 프로토콜은 postgres-visibility-map.md에 있다. 이 호(arc)에서 중요한 점은 all-frozen 비트가 힌트가 아닌 정확성 계약이라는 것이다. aggressive vacuum이 동결된 페이지를 건너뛸 수 있는 것은 계약이 거기에 동결할 것이 없음을 보장하기 때문이다.

flowchart LR
    subgraph f1["8.4–9.5 VM: 비트 하나"]
        direction TB
        v1["페이지당:<br/>ALL_VISIBLE 비트"] --> v2{"Vacuum 모드?"}
        v2 -->|일반| v3["all-visible 페이지 건너뜀"]
        v2 -->|"aggressive<br/>(anti-wraparound)"| v4["모든 페이지 읽기<br/>동결 결정에<br/>all-visible 믿을 수 없음"]
    end
    subgraph f2["9.6+ VM: 비트 둘"]
        direction TB
        w1["페이지당:<br/>ALL_VISIBLE + ALL_FROZEN"] --> w2{"Vacuum 모드?"}
        w2 -->|일반| w3["all-visible 페이지 건너뜀"]
        w2 -->|"aggressive<br/>(anti-wraparound)"| w4["ALL_FROZEN 페이지 건너뜀;<br/>미동결 페이지만 읽음"]
    end

교차 참조: postgres-visibility-map.md(두 비트 레이아웃)와 postgres-xid-wraparound-freeze.md(동결이 relfrozenxid를 전진시키는 방법, all-frozen 비트가 aggressive 스캔 건너뜀을 허용하는 계약인 이유).

Era 4 — 병렬 인덱스 vacuum (13, 2020)

섹션 제목: “Era 4 — 병렬 인덱스 vacuum (13, 2020)”

변화. Era 1~3은 모두 측을 공략한다. 가비지를 드물게 만들고, 힙 스캔을 건너뛸 수 있게 한다. 그러나 기준선에서 가장 비싼 단일 단계는 종종 2단계였다. 죽은 힙 TID를 가리키는 항목을 삭제하기 위해 모든 인덱스를 스캔하는 것이다. 인덱스가 열두 개인 테이블이라면 열두 번의 직렬 전체 인덱스 스캔이다. PostgreSQL 13은 이것을 병렬화했다. 수동 VACUUM(그리고 테이블에 대한 VACUUM의 암묵적 vacuum 부분, 기본적으로 autovacuum은 아님)이 배경 워커를 띄워 각 인덱스 하나씩 맡겨 동시에 일괄 삭제를 할 수 있다.

구현체는 전용 모듈 src/backend/commands/vacuumparallel.c로, DSM(dynamic shared memory, 동적 공유 메모리) 안에 ParallelVacuumState를 설정한다. 리더는 파라미터와 공유 죽은 TID 저장소를 DSM 세그먼트에 복사하고 워커를 띄운다. 각 워커가 처리할 인덱스를 가져간다. 워커 하나를 쓸 만큼 크지 않거나 AM이 병렬 일괄 삭제를 실행할 수 없다고 선언한 인덱스는 여전히 리더가 처리한다. 병렬성은 인덱스 간이지 하나의 인덱스 내부가 아니다. 각 인덱스는 여전히 단일 프로세스가 정리한다. 따라서 속도 향상은 (충분히 큰) 인덱스 수에 의해 제한된다. postgres-vacuum.md의 병렬 vacuum 섹션에서 다룬다.

구조적 변화는 2단계를 누가 실행하느냐에 있다. 단계 자체는 그대로다.

flowchart TB
    subgraph s1["Pre-13: 직렬 인덱스 vacuum"]
        direction TB
        l1["리더 (단일 프로세스)"] --> i1["인덱스 1 일괄 삭제"]
        i1 --> i2["인덱스 2 일괄 삭제"]
        i2 --> i3["... 인덱스 N"]
        i3 --> i4["Wall time ~ 전체 인덱스 합산"]
    end
    subgraph s2["13+: DSM 경유 병렬 인덱스 vacuum"]
        direction TB
        l2["리더: DSM에<br/>ParallelVacuumState 설정"] --> p1["워커 A: 인덱스 1"]
        l2 --> p2["워커 B: 인덱스 2"]
        l2 --> p3["리더: 작은 / 안전하지 않은 인덱스"]
        p1 --> p4["Wall time ~ 가장 느린 인덱스"]
        p2 --> p4
        p3 --> p4
    end

의미. vacuum이 처음으로 엄격한 단일 스레드에서 벗어났다. 큰 인덱스가 많은 테이블, OLAP 지향 또는 append-heavy 스키마에서 지배적 비용이 sum(인덱스 스캔)에서 max(인덱스 스캔)으로 이동했다. 전체 I/O가 줄지는 않지만 wall-clock 시간이 줄어든다. 유지 보수 창이 제한될 때 중요하다.

두 가지 설계 선택이 안전성과 경계를 보장한다. 리더는 적격 인덱스 수(및 사용자가 요청한 PARALLEL 차수)까지만 워커를 요청하므로, 인덱스가 하나뿐인 테이블은 아무것도 얻지 못하고 불필요한 DSM 설정 비용도 치르지 않는다. 죽은 TID 저장소는 공유돼야 하므로 병렬 인식 할당자와 함께 DSM에 산다. 이것이 이 시대와 TidStore 시대(아래)가 호환돼야 했던 이유다. 워커가 반복 처리하는 공유 저장소가 리더가 직렬로 사용하는 추상화와 동일하다. 적용 범위도 명확하다. 병렬 vacuum은 명시적 VACUUM 호출의 속성이지 autovacuum의 속성이 아니다. autovacuum은 클러스터 전체에 걸친 런처의 비용 균형 조정이 예측 가능하도록 의도적으로 테이블당 단일 프로세스를 유지한다. 교차 참조: postgres-vacuum.md(병렬 상태, 워커 할당)와 src/backend/commands/vacuumparallel.c.

변화. PostgreSQL 14는 wraparound failsafe(순환 안전 장치)를 추가했다. relfrozenxid(또는 relminmxid)가 위험한 임계치를 넘어 오래됐을 때, vacuum이 인덱스 vacuum과 힙 정리 작업을 중단하고 동결을 향해 질주하게 하는 비상 모드다. vacuum_failsafe_age / vacuum_multixact_failsafe_age GUC로 제어하며, 힙 스캔 중 주기적으로 확인한다. 한 번 발동되면 전역 플래그 VacuumFailsafeActive가 설정되고, 이후 vacuum은 선택적이고 시간이 걸리는 단계들, 즉 인덱스 일괄 삭제, 인덱스 정리, 비용 지연 스로틀링을 건너뛰어 가능한 한 빠르게 relfrozenxid를 전진시킨다. REL_18 트리에서 이것은 src/backend/access/heap/vacuumlazy.clazy_check_wraparound_failsafe로, 죽은 TID 저장소 할당 전(초기)과 스캔 도중 반복적으로 호출된다.

존재해야 했던 이유. 이전의 모든 시대는 일반적인 경우를 더 빠르게 만들었지만, 장애 경우, 즉 autovacuum에도 불구하고 테이블의 가장 오래된 XID가 2^31 wraparound 벽으로 기어가는 상황은 아무도 다루지 않았다. 역사적으로 데이터베이스는 손상을 피하기 위해 맨 마지막 순간에 새 XID 발급을 거부하고 셧다운했다. 하드 중단이다. failsafe는 압력 해제 밸브다. 인덱스를 꼼꼼히 정리하고 비용 지연을 지키면서 시계가 다 돌아가도록 두는 대신, vacuum은 비필수적인 모든 것을 던져버리고 데이터 손실을 실제로 막는 단 하나, 즉 동결과 relfrozenxid 전진에 집중한다. 인덱스 블로트와 정지된 비용 지연 예산은 나중에 복구할 수 있다. wraparound 셧다운은 복구할 수 없다. 메커니즘과 anti-wraparound autovacuum과의 상호작용은 postgres-vacuum.mdpostgres-autovacuum.md에서 다룬다. failsafe가 맞서는 XID 한계 사다리는 postgres-xid-wraparound-freeze.md에 있다.

구조적으로 이것은 새 단계가 아니라 기존 단계들을 관통하는 단락이다. 4 GB 스캔 세그먼트 상단에서 확인되는 불리언으로, 한 번 설정되면 vacuumlazy.c!VacuumFailsafeActive 가드가 인덱스 단계를 우회하게 만든다. 여기의 어떤 시대보다도 코드 변경량이 가장 작고, 운영 안전성 측면에서는 아마도 레버리지가 가장 크다.

Era 6 — TidStore: 죽은 TID 기수 저장소 (17, 2024)

섹션 제목: “Era 6 — TidStore: 죽은 TID 기수 저장소 (17, 2024)”

변화. PostgreSQL 17은 Era 0 이후 사실상 그대로였던 flat 고정 폭 죽은 TID 배열을 TidStore로 교체했다. 죽은 튜플 ID를 위한 compact, adaptive-radix-tree 기반 저장소다. 관련 코드는 src/backend/access/common/tidstore.csrc/include/access/tidstore.h다. vacuumlazy.c에서 vacrel->dead_items는 이제 VacDeadItems 배열이 아닌 TidStore *이며, VacDeadItemsInfo 보조 구조체가 카운트와 메모리 예산을 추적한다.

flat 배열이 사라져야 했던 이유. 이전 배열은 죽은 튜플 하나당 완전한 ItemPointerData를 저장했고 maintenance_work_mem으로 엄격하게 제한됐다(역사적으로 최대 약 1 GB, 즉 얼마나 많은 메모리를 줘도 배열은 고정된 INT_MAX 경계 원소 수를 초과할 수 없었다). 두 가지 결과가 따랐다.

  • 메모리 낭비. 죽은 TID는 페이지별로 몰린다. 같은 블록 번호를 공유하는 죽은 오프셋이 많다. (block, offset) 쌍의 flat 배열은 오프셋마다 블록 번호를 다시 저장했다. 기수 저장소는 블록 번호를 키로 삼고 페이지 내 오프셋을 비트맵에 패킹한다. 죽은 튜플 100개짜리 페이지는 100개의 6바이트 항목 대신 키 하나와 작은 비트맵이면 된다. 현실의 부푼 테이블에서 메모리 사용량이 여러 배 줄어든다.
  • 1 GB 상한과 추가 인덱스 스캔. 아무리 많은 maintenance_work_mem을 줘도 배열은 하드 상한을 넘을 수 없었다. 상한보다 더 많은 죽은 튜플을 찾은 vacuum은 조기 플러시를 해야 했다. 2단계(전체 인덱스)와 3단계를 실행하고 배열을 비운 뒤 재개하는, 추가 전체 인덱스 스캔을 치르는 방식이다. TidStore의 압축은 같은 메모리에 훨씬 더 많은 죽은 TID를 담아 배열이 덜 차고 그 추가 인덱스 패스들이 대부분 사라지게 한다. PostgreSQL 17은 또한 vacuum이 예전 1 GB 실질 한도를 넘는 메모리를 실제로 쓸 수 있도록 했다.

저장소는 병렬 인덱스 vacuum(Era 4)의 DSM에서도 같은 구조가 쓰이므로 두 개선이 함께 작동한다. 병렬 워커들은 flat 배열이 아닌 공유 TidStore를 반복 처리한다.

flowchart LR
    subgraph t1["Pre-17: flat 죽은 TID 배열"]
        direction TB
        a1["VacDeadItems[]<br/>죽은 튜플 하나당<br/>ItemPointerData 하나"] --> a2["오프셋마다<br/>블록 반복"]
        a2 --> a3["원소 수 하드 상한<br/>(실질 ~1 GB 한도)"]
        a3 --> a4["부푼 테이블에서 조기 채움<br/>=> 인덱스 전체 스캔 추가"]
    end
    subgraph t2["17+: TidStore 기수 저장소"]
        direction TB
        b1["TidStore<br/>블록 번호 키<br/>adaptive radix tree"] --> b2["오프셋은<br/>페이지당 비트맵으로 패킹"]
        b2 --> b3["수 배 적은 메모리<br/>예전 1 GB 한도 초과 가능"]
        b3 --> b4["훨씬 덜 채워짐<br/>=> 인덱스 패스 감소"]
    end

교차 참조: postgres-vacuum.md(dead_items_alloc, TidStore/VacDeadItemsInfo 쌍, 저장소가 2단계/3단계를 구동하는 방식)와 src/backend/access/common/tidstore.c.

REL_18(커밋 273fe94, PG 18.x)에서 vacuum-and-visibility 스택은 Era 0의 동일한 3단계 lazy_scan_heap 골격 위에 위의 모든 시대가 층층이 쌓인 결과다.

  • 가비지를 만들지 않는다 — HOT 갱신과 기회적 단일 페이지 pruning(pruneheap.c)이 죽은 튜플과 인덱스 항목의 축적 자체를 막는다. → postgres-heap-am.md.
  • 깨끗한/정적인 페이지는 읽지 않는다 — 두 비트 visibility map(visibilitymap.c)이 일반 vacuum은 all-visible 페이지를, aggressive vacuum은 all-frozen 페이지를 건너뛰게 해서 죽은 튜플 스캔과 anti-wraparound 스캔 모두 테이블 크기가 아닌 변경량에 비례하게 한다. → postgres-visibility-map.md.
  • 불가피한 것을 확장한다 — 인덱스를 정리해야 할 때 vacuumparallel.c가 일괄 삭제를 DSM 워커에 분산하고, 워커가 소비하는 죽은 TID는 flat 배열이 아닌 메모리 효율적인 TidStore(tidstore.c)에 산다. → postgres-vacuum.md.
  • 최악의 경우를 살아낸다vacuumlazy.clazy_check_wraparound_failsaferelfrozenxid가 위험할 만큼 오래됐을 때 VacuumFailsafeActive를 설정해 인덱스 작업을 우회하고 동결을 향해 질주하게 해, 잠재적 wraparound 셧다운을 복구 가능한 블로트로 전환한다. → postgres-vacuum.md, postgres-xid-wraparound-freeze.md, postgres-autovacuum.md.

이 모든 것을 둘러싼 오케스트레이션, 즉 언제 테이블을 vacuum할지 결정하고 autovacuum이 꺼져 있어도 anti-wraparound vacuum을 강제하는 런처/워커 모델은 postgres-autovacuum.md에 있다.

PG19 다음 단계. 동결 의무를 테이블 크기 스캔에서 분리하는 방향의 흐름이 이어진다. 방금 출시된 PG19 시대의 작업은 relfrozenxid를 계속 전진시키면서 더 적은 페이지를 방문하는 더 적극적이고 점진적인 동결 전략을 추구해, aggressive anti-wraparound vacuum의 발생 빈도를 더욱 줄인다. 이는 앞으로의 방향을 가리키는 메모로만 받아들인다. 위에서 설명한 설계가 현재 REL_18의 동작이다.

  • 릴리스 노트 — 8.3(HOT), 8.4(visibility map), 9.6(freeze map / all-frozen 비트), 13(병렬 VACUUM), 14(vacuum 비상 / wraparound failsafe, vacuum_failsafe_age), 17(vacuum 죽은 TID 메모리 / TidStore) PostgreSQL 릴리스 노트.
  • 현재 상태 모듈 문서 (메커니즘 설명, 여기서 재도출하지 않음):
  • 핵심 소스 파일 (REL_18, 커밋 273fe94 기준)
    • src/backend/access/heap/vacuumlazy.clazy_scan_heap, lazy_check_wraparound_failsafe, VacuumFailsafeActive, dead_items.
    • src/backend/access/heap/visibilitymap.c — VM fork get/set.
    • src/backend/access/heap/pruneheap.c — HOT pruning / heap_page_prune_opt.
    • src/backend/commands/vacuumparallel.cParallelVacuumState, DSM 설정.
    • src/backend/access/common/tidstore.c, src/include/access/tidstore.h — 기수 죽은 TID 저장소.
    • src/backend/access/heap/heapam.c, src/backend/access/heap/README.HOT — HOT 메커니즘.