본문으로 건너뛰기

2.3 Pageserver와 Layer 파일

Pageserver는 WAL을 받아 "임의의 page를 임의의 LSN 시점으로 빠르게 재구성할 수 있는 형태"로 다시 구성합니다. 일반 PostgreSQL에서 base backup과 WAL archive가 맡던 역할을 하나의 저장 구조로 합친 셈입니다. 이 장에서는 layer 파일이 만들어지고, 읽히고, 정리되는 과정을 설명합니다.

요약

  • 들어오는 WAL은 relation과 page 단위로 나뉘어 메모리에 쌓입니다(in-memory layer).
  • 충분히 쌓이면 불변 파일로 기록됩니다. 이것이 L0 delta layer입니다.
  • L0가 여러 개 모이면 key 범위별로 재분배해 L1 delta layer를 만듭니다(compaction).
  • Image layer는 특정 LSN 시점의 key 범위 스냅샷입니다. Delta layer는 LSN 구간의 변경 기록입니다.
  • 오래된 layer는 GC가 삭제하지만, 자식 branch가 참조하는 layer는 남깁니다.
  • Layer 파일은 S3에도 올라가며, 로컬 디스크는 캐시 역할을 합니다.

WAL이 layer로 바뀌는 과정

WAL 이 layer 파일로 재정리되는 흐름

도해의 왼쪽에서 WAL 레코드가 도착 순서 그대로 들어옵니다. 갱신되는 page는 workload에 따라 완전히 무작위일 수도 있고, bulk load처럼 한 relation에 몰릴 수도 있습니다. Pageserver는 각 레코드가 어떤 key에 해당하는지 해석합니다. 이 key는 relation과 block 번호로 만든 식별자입니다. 해석한 레코드는 메모리에 key별로 모아 둡니다. 메모리는 WAL을 재정렬하는 버퍼입니다.

메모리에 충분히 쌓이면 새 파일로 기록합니다. 이 파일은 전체 key 범위를 덮지만 LSN 구간은 짧습니다. 이것이 L0입니다. L0가 여러 개 쌓이면 이들을 합친 뒤 key 범위별로 나누어 다시 씁니다. 결과 파일은 key 범위가 좁고 LSN 구간이 깁니다. 이것이 L1입니다. 로컬 디스크의 layer는 S3에도 복사됩니다.

Layer의 상태

Layer는 시간에 따라 상태가 바뀝니다.

상태
Open새 WAL 레코드를 계속 붙일 수 있는 in-memory layer
Frozen닫힌 in-memory layer. 더 받지 않고 디스크로 내려갈 차례
OnDisk디스크에 쓴 불변 layer. end LSN이 disk_consistent_lsn보다 오래되었으면 fsync까지 끝난 것

Timeline에 첫 WAL이 도착하면 in-memory layer 하나가 만들어져 layer map에 등록됩니다. 가득 차면 두 단계로 flush합니다. 먼저 layer를 닫고(freeze), 이후의 WAL을 받을 새 in-memory layer를 만듭니다. 그다음 frozen layer의 내용을 delta layer 파일로 씁니다. layer map의 항목을 교체한 뒤 메모리를 해제합니다. In-memory layer는 OOM을 피하기 위해 ephemeral 파일로 spill될 수 있습니다. Pageserver가 재시작되면 WAL에서 다시 만들어집니다.

Checkpoint라는 이름의 충돌

Neon 문서는 "in-memory layer를 디스크로 내려쓰는 일"을 checkpoint라고 부릅니다. PostgreSQL의 checkpoint와 이름은 같지만 동작은 다릅니다. PostgreSQL의 checkpoint는 dirty buffer를 데이터 파일에 반영하고 WAL에 checkpoint 레코드를 남깁니다. checkpoint_distance라는 Pageserver 설정은 현재 LSN에서 얼마나 멀어지면 in-memory layer를 flush할지 정합니다. 이 노트에서 "Pageserver checkpoint"는 layered repository의 flush를 가리킵니다. 그냥 "checkpoint"라고 쓰면 PostgreSQL의 checkpoint를 뜻합니다.

파일 이름 읽기

Layer 파일은 두 종류입니다. 이름에 key 범위와 LSN이 들어 있어 파일명만으로 어떤 데이터를 담는지 드러납니다.

Image layer는 특정 LSN 하나에서의 스냅샷입니다.

000000067F000032BE0000400000000070B6-000000067F000032BE0000400000000080B6__00000000346BC568
start key end key LSN

Delta layer는 LSN 구간을 갖습니다.

000000067F000032BE0000400000000020B6-000000067F000032BE0000400000000030B6__000000578C6B29-0000000057A50051
start key end key start LSN end LSN

Key 범위가 전체(000...000-FFF...FFF)인 delta 파일이 L0이고, 일부 범위만 덮는 파일이 L1입니다. Level은 별도로 저장되지 않으며 key 범위로만 구별합니다. 읽기 경로는 L0와 L1을 다르게 취급하지 않습니다.

Delta layer에는 해당 key 범위에서 해당 LSN 구간 동안 변경된 key의 값만 들어 있습니다. 대부분은 WAL 레코드이고 일부는 page image입니다. 변경되지 않은 key는 흔적이 없습니다. 반대로 Image layer는 그 범위의 모든 key를 담습니다. 여기에 없는 key는 해당 LSN에 존재하지 않는 것으로 확정됩니다.

파일은 tenant와 timeline별 디렉터리에 놓입니다.

.neon/tenants/<tenant_id>/timelines/<timeline_id>/<layer 파일들>

Tenant는 한 고객 또는 한 프로젝트에 해당하는 repository이고, timeline은 그 안의 branch 하나입니다. 실습 환경에서 Pageserver 컨테이너의 /data/.neon/tenants/ 아래를 직접 확인하는 방법은 3.4에 있습니다.

Page 하나를 재구성하는 순서

GetPage@LSN 요청이 오면 Pageserver는 다음 순서로 찾습니다.

  1. 최근 in-memory layer에 요청 LSN 이하의 버전이 있으면 바로 재구성해 응답합니다.
  2. 없으면 layer map에서 해당 key와 LSN을 덮는 layer 파일을 찾습니다.
  3. Delta layer만 있으면 그 이전 시점의 base image가 필요합니다. Image layer 또는 더 오래된 delta를 조회해 base image를 확보합니다.
  4. Base image에 요청 LSN까지의 WAL 레코드를 순서대로 적용합니다.

문서의 단순화 표기를 빌려 예를 들어 보겠습니다. main/orders_200은 LSN 200 시점의 image이고, main/orders_200_300은 200~300 구간의 delta입니다. LSN 250의 page는 orders_200의 image에 200~250 레코드를 적용해 얻습니다. 이 레코드는 orders_200_300 안에 있습니다.

4단계의 WAL 적용은 Pageserver가 직접 하지 않습니다. Rust로 모든 redo 함수를 다시 쓰지 않고 별도 프로세스에 위임합니다. 이 프로세스는 PostgreSQL 바이너리를 --wal-redo 플래그로 실행하며, Compute와 같은 바이너리입니다. WAL 레코드 해석은 복잡하며 악의적으로 만든 레코드가 있을 수도 있습니다. 따라서 이 redo 프로세스는 Linux seccomp로 격리되어 있습니다. Redo 프로세스는 요청받은 page 하나만 복원하면 됩니다. 따라서 같은 레코드가 변경하는 다른 page는 건너뛰도록 XLogReadBufferForRedo()가 수정되어 있습니다.

Layer map과 branch

Layer map은 timeline마다 어떤 layer가 있는지 추적하는 자료구조입니다. 개발 문서 pageserver-storage.md는 이를 단순 배열로 설명하지만, 이는 문서 작성 시점의 서술입니다. 현재 소스(pageserver/src/tenant/layer_map.rs)는 coverage tree 기반 구조로 layer를 찾습니다. 이 구조는 key와 LSN 범위를 색인합니다. 문서와 코드에는 작성 시점의 차이가 있습니다. 따라서 이 노트는 구조 설명에서는 문서를, 성능 관련 판단에서는 코드를 기준으로 삼습니다. 중요한 성질은 읽기 코드가 ancestor를 인식한다는 점입니다. 현재 timeline에서 key를 찾지 못하면 부모 timeline으로 이동해 계속 찾습니다. 이 기능 덕분에 Branch는 데이터를 복사하지 않고도 부모의 내용을 모두 볼 수 있습니다. 세부 동작은 2.4에서 다룹니다.

Garbage Collection

Layer 파일은 계속 늘어나므로 삭제 기준이 필요합니다. Pageserver는 branch 끝에서 "충분히 최근"인 LSN까지 PITR과 branching을 허용합니다. 그보다 오래된 버전은 삭제할 수 있다고 봅니다. 이 경계가 GC horizon이며 기본값은 64 MB(gc_horizon, 개발 문서의 DEFAULT_GC_HORIZON)입니다. GC 기준은 이것 하나만이 아닙니다. 바이트 기준 gc_horizon, 시간 기준 pitr_interval, 자식 branch의 ancestor_lsn(retain point)을 함께 고려합니다. 이 세 cutoff 중 가장 보수적인 지점 이전만 삭제합니다. 서비스 플랜의 history window는 이 중 pitr_interval에 해당합니다. 실습 tenant의 기본값은 gc_horizon 64 MB와 pitr_interval 7일입니다. gc_period는 1시간, checkpoint_distance는 256 MB, compaction_threshold는 10입니다(3.4).

단일 branch 예시입니다. Branch 끝이 LSN 525이고 horizon이 150이면 GC 경계는 375입니다.

main/orders_100 DELETE
main/orders_100_200 DELETE
main/orders_200 DELETE
main/orders_200_300 DELETE
main/orders_300 KEEP (orders_300_400의 base로 필요)
main/orders_300_400 KEEP (horizon보다 새로움)
main/orders_400 KEEP
main/orders_400_500 KEEP
main/orders_500 KEEP
main/customers_100 DELETE
main/customers_100_200 DELETE
main/customers_200 KEEP (더 새로운 버전이 없음)

여기서 두 가지 규칙이 드러납니다. Horizon보다 오래된 layer라도 이후의 delta를 적용하기 위한 base라면 남깁니다. 또한 어떤 relation에 더 새로운 layer가 없으면 유일한 버전이므로 남깁니다.

Branch가 있으면 조건이 하나 더 추가됩니다. 자식 branch의 분기점(ancestor_lsn) 상태를 재구성하는 데 필요한 부모 layer는 horizon과 관계없이 남깁니다. 분기점 이전의 모든 layer를 남기는 것은 아닙니다. 문서 예시에서도 분기점 상태 재구성에 필요하지 않은 오래된 layer는 삭제 대상입니다. 분기점 이후의 부모 변경은 자식이 참조하지 않습니다. 따라서 자식 때문에 유지되지 않습니다. 오래 유지되는 branch가 많으면 부모의 오래된 layer도 계속 남습니다. 이 때문에 저장량이 줄지 않습니다.

Compaction의 역할

Compaction은 L0 파일 여러 개를 읽어 key 범위별 L1으로 재배치하는 배경 작업입니다. L0는 key 전체를 덮으므로 어떤 key를 찾든 모든 L0를 열어야 합니다. L0가 수십 개 쌓이면 읽기 비용이 커집니다. L1으로 바꾸면 한 key는 소수의 파일에만 존재합니다. 개발 문서 pageserver-storage.md는 page 버전 정리와 image 생성을 "작성 시점에는 구현되지 않았다"고 설명합니다. 하지만 이는 오래된 서술입니다. 저장소의 pageserver-compaction.md에 따르면 현재 compaction은 두 단계로 나뉩니다. 첫 단계에서는 L0를 L1으로 재분할합니다. 다음 단계에서는 L1 위에 image layer를 만들고 불필요한 오래된 page 버전을 제거합니다.

실패 사례

Bulk load 직후에는 대량의 L0가 쌓여 compaction이 처리 속도를 따라가지 못하기도 합니다. 이때 임의 page를 읽으려면 여러 L0를 검사해야 하므로 지연이 늘어납니다. 증상은 "load가 끝났는데도 한동안 조회가 느리다"로 나타납니다. 반대로 GC horizon을 크게 잡으면 PITR 범위는 넓어지지만 저장량도 늘어납니다. 두 값은 성능과 비용 사이의 균형을 조정하는 변수입니다.

연습 문제

  1. Image layer 파일명 하나와 delta layer 파일명 하나를 골라 key 범위와 LSN을 읽고, 어느 것이 L0인지 판정합니다.
  2. Horizon이 100이고 branch 끝이 LSN 600일 때 위 예시 파일 목록에서 무엇이 삭제 대상인지 표를 다시 만듭니다.
  3. Redo 프로세스를 PostgreSQL 바이너리로 두는 대신 Rust로 다시 쓰지 않은 이유를 두 가지 적습니다.

심화 체크

  • Delta layer만 있고 image layer가 없는 relation의 page를 재구성하려면 어디까지 조회해야 하는가?
  • 자식 branch가 존재할 때 부모 layer의 GC가 어떻게 달라지는지 설명할 수 있는가?
  • Pageserver 재시작 후 in-memory layer는 어떻게 복원되는가?

참고