콘텐츠로 이동

(KO) PostgreSQL 증분 백업 — WAL 서머리, 매니페스트, pg_combinebackup

목차

데이터베이스의 물리 백업은 데이터 파일을 바이트 단위로 복사하고, 복원 시 일관된 상태에 도달하기 위해 필요한 WAL(write-ahead log, 미리-쓰기 로그)을 함께 보존하는 방식이다. 전체(full) 백업은 모든 파일을 복사한다. 비용은 변경량이 아닌 클러스터 크기에 비례한다. 하루에 1%만 변경되는 10 TB 웨어하우스도 매번 10 TB를 읽고, 전송하고, 저장해야 한다. 데이터 크기변경 크기 사이의 이 불일치가 증분 백업의 출발점이다.

**증분 백업(incremental backup)**은 이전 기준(reference) 백업 이후 변경된 데이터만 복사하고, 나중에 전체 이미지를 재구성하기 위한 메타데이터를 함께 보존한다. 설계의 핵심 질문은 하나다 — 어떤 바이트가 변경됐는지 어떻게 알 것인가? 고전적인 세 가지 접근이 있으며, 정확도와 부기 비용의 트레이드오프가 각각 다르다.

  1. 타임스탬프/mtime 비교. 기준 백업보다 수정 시각이 나중인 파일을 복사한다. 구현이 단순하지만 해상도가 낮고(1바이트 변경도 파일 전체를 복사), 클락 스큐나 mtime을 갱신하지 않는 인플레이스 쓰기에 취약하다. 8 KB 페이지 몇 개만 바뀐 1 GB 릴레이션에 파일 단위 해상도는 너무 거칠다.

  2. 블록 체크섬 비교. 라이브 파일과 기준 파일의 모든 블록을 읽어 체크섬을 비교하고, 달라진 블록만 복사한다. 페이지 단위 정확도를 갖추지만 변경 블록을 찾기 위해 데이터베이스 전체를 읽어야 한다. 전송 비용은 줄지만 읽기 비용은 줄지 않는다.

  3. 로그 기반 변경 추적. 데이터베이스는 내구성을 위해 이미 모든 페이지 수정을 WAL에 기록한다. 두 LSN 사이에 WAL을 *요약(summarize)*해 건드린 (릴레이션, 포크, 블록) 집합을 뽑으면, 데이터 파일을 읽지 않고도 변경 블록을 정확히 알 수 있다. 비용은 WAL 양, 즉 변경 속도에 비례한다. 이것이 줄이고 싶었던 바로 그 수치다.

PostgreSQL 17은 세 번째 방식을 구현했다. 이 아이디어의 뿌리는 ARIES(Mohan 1992)에 있다. WAL은 모든 변경에 대한 권위 있고 순서화된 기록이므로 “LSN ab 사이에 무엇이 변경됐는가?”라는 질문은 원칙적으로 그 구간의 로그를 재생해 답할 수 있다. 증분 백업은 로그를 변경 적용이 아닌 변경 열거를 위해 재생한다. Database Internals(Petrov, ch. 3)는 WAL을 내구성의 단일 진실 공급원으로 규정하며, 증분 백업은 크래시 복구와 스트리밍 복제에 이어 그 진실을 소비하는 세 번째 소비자다.

두 가지 파생 개념이 이를 구체화한다.

블록 참조 테이블(block reference table). 변경 요약의 구체적 형태는 각 (RelFileLocator, ForkNumber)를 LSN 범위 안에서 수정된 블록 번호 집합 및 절단 블록(더 이상 존재하지 않는 블록의 경계)으로 매핑한 맵이다. PostgreSQL은 이를 BlockRefTable이라 부른다. 밀도 있게 변경된 릴레이션의 인메모리 표현은 수정된 블록당 약 1비트로 수렴하므로, 대규모 변경 집합도 작게 유지된다.

재구성 문제. 증분 백업은 설계상 단독으로는 복원할 수 없다. 델타이기 때문이다. 복원하려면 전체 백업까지의 체인 전체가 필요하다. 재구성은 체인을 순회하며 모든 파일의 모든 블록마다 어느 백업이 권위 있는 사본을 갖는지 결정한다. 이것은 델타 계층의 최신 우선(most-recent-wins) 병합으로, 로그 구조 병합(LSM tree)이나 파일시스템 오버레이 레이어 스택과 동일한 형태다.

PostgreSQL이 선택한 설계 공간:

  1. 서머리 해상도 — 파일, 세그먼트, 블록? PostgreSQL은 블록 단위로 요약하되, 특정 백업이 아닌 LSN 범위별로 서머리를 저장한다.
  2. 차분 수행 주체 — 클라이언트인가 서버인가? WAL 서머리와 타임라인 이력이 서버에 있으므로 서버가 수행한다.
  3. 재구성 시점 — 백업 시인가 복원 시인가? PostgreSQL은 별도 프론트엔드 툴 pg_combinebackup에 재구성을 맡겨 복원 시까지 미루며, 서버 사이드는 순수하게 델타 생산만 담당한다.

증분·차분 백업은 오래된 기술이다. 대부분의 진지한 DBMS와 스토리지 시스템이 구현을 갖추고 있으며, 작은 수의 구조적 선택들로 수렴한다. PostgreSQL의 구체적 심볼을 읽기 전에 이 공유 설계 공간을 짚어 두면 각 선택이 더 선명해진다.

용어는 업계가 공유한다.

  • 차분(differential/cumulative) 백업은 마지막 전체 백업 이후의 모든 변경을 기록한다. 복원에 두 개만 필요하다(전체 + 최신 차분). 차분 파일은 시간이 지날수록 커진다.
  • 증분(incremental) 백업은 어떤 종류든 마지막 백업 이후의 변경을 기록한다. 복원에 전체 체인이 필요하다. 각 증분 파일은 작지만 체인이 길어질 수 있다.

PostgreSQL의 방식은 진정한 증분이다. BASE_BACKUP ... INCREMENTAL은 업로드한 매니페스트의 이전 백업을 기준으로 찍히며, 그 이전 백업 자체가 증분이었을 수 있다. 체인 길이는 전체 백업 주기로만 제한된다.

로그에서 차분을 뽑아내는 시스템은 모두 “이 로그 윈도에서 더러워진 블록”을 기록하는 영속적인 사이드 구조를 관리한다.

  • SQL Server는 *Differential Changed Map(DCM)*을 유지한다. 익스텐트당 1비트로, 페이지가 쓰여질 때 즉시 갱신된다. 차분 백업은 DCM을 읽어 변경된 익스텐트를 파악한다.
  • Oracle RMAN블록 변경 추적 파일을 관리해 마지막 백업 이후 변경된 블록을 기록한다. 증분 백업이 모든 데이터 파일을 스캔하지 않아도 된다.
  • Db2도 마찬가지로 추적 비트맵 기반의 증분 백업을 사용한다.

PostgreSQL의 유사체는 pg_wal/summaries/WAL 서머리 파일들이다. 전용 WAL 서머라이저 백그라운드 워커가 생성하며, 이 문서에서는 그 출력을 소비하는 측만 다룬다. PostgreSQL만의 핵심 특징은 서머리가 LSN 범위로 키잉된다는 점이다. 특정 백업이 아닌 LSN 범위 0/100000000/20000000에 대한 서머리는 그 구간을 포함하는 어떤 증분 백업에도 재사용된다. 사이드 구조가 백업 일정과 분리돼 있다.

모든 현대 백업 포맷은 파일 목록, 크기, 체크섬, 일관성을 위해 필요한 WAL 범위를 담은 매니페스트를 동봉한다. 매니페스트는 세 역할을 수행한다 — 검증(pg_verifybackup), 증분 참조(다음 증분 백업이 이전 상태를 알 수 있게), 재구성(결합기가 저장된 체크섬을 재활용). PostgreSQL의 backup_manifest는 JSON 문서이며, 증분 백업에서 클라이언트는 서버에 백업을 요청하기 전에 이전 매니페스트를 업로드한다.

재구성이 백업 시 즉시 이루어지든(일부 엔터프라이즈 툴처럼 전체 이미지를 합성), 복원 시 지연되든 알고리즘은 동일하다. 블록마다 백업 체인을 최신에서 가장 오래된 순으로 스캔하고, 처음 발견되는 사본을 취한다. 체인 어디에도 블록이 없지만 파일의 절단 포인트 아래라면 제로로 채운 홀이다.

flowchart TD
  subgraph Theory["변경 추적 백업, 추상적으로"]
    A["권위 있는 변경 로그<br/>(WAL)"] --> B["서머라이저:<br/>LSN 범위별로 변경된<br/>(릴레이션, 포크, 블록) 추출"]
    B --> C["영속적 사이드 구조<br/>(WAL 서머리 파일)"]
    C --> D["백업 시:<br/>기준 백업 이후 변경 블록 차분"]
    D --> E["증분 백업<br/>(델타 + 매니페스트)"]
    E --> F["복원 시:<br/>백업 체인의 최신 우선 병합"]
    F --> G["완전한 합성 데이터 디렉터리"]
  end

PostgreSQL은 증분 백업을 네 개의 협력 요소로 나눈다. 경계가 중요하다. 이 문서의 범위는 핵심 백업 메커니즘이며, 앞 세 개는 서버에, 네 번째는 독립 프론트엔드 툴에 있다.

  1. WAL 서머라이저 (walsummarizer.c, 백그라운드 워커) — summarize_wal = on으로 게이팅되어 WAL이 생성되는 대로 블록 수준 서머리 파일을 pg_wal/summaries/에 작성한다. 이 문서의 범위 밖 — sibling 문서 참조.

  2. 매니페스트 수집 (basebackup_incremental.c + walsender.cUploadManifest) — 클라이언트가 UPLOAD_MANIFEST를 보내고 이전 백업의 backup_manifest를 스트리밍한다. 서버는 이를 IncrementalBackupInfo 객체로 파싱해 보관한다.

  3. 증분 BASE_BACKUP (basebackup.c + PrepareForIncrementalBackup / GetFileBackupMethod) — 이전 백업 시작부터 현재 백업 시작까지의 WAL 서머리를 로드해 하나의 BlockRefTable로 병합한 뒤, 릴레이션 파일별로 전체/증분/스텁을 결정해 스트리밍한다.

  4. 재구성 (pg_combinebackup, 프론트엔드 툴) — 백업 디렉터리 체인을 받아 블록을 최신 우선으로 병합하고 완전한 데이터 디렉터리를 만든다. 서버는 관여하지 않는다.

프로토콜 연결: UPLOAD_MANIFEST 후 BASE_BACKUP

섹션 제목: “프로토콜 연결: UPLOAD_MANIFEST 후 BASE_BACKUP”

클라이언트(pg_basebackup --incremental=PRIOR/backup_manifest)는 먼저 UPLOAD_MANIFEST를 실행한다. walsender가 전용 메모리 컨텍스트에 IncrementalBackupInfo를 할당하고, CopyData 패킷을 받아 매니페스트 바이트를 공급한다.

// UploadManifest — src/backend/replication/walsender.c
mcxt = AllocSetContextCreate(CurrentMemoryContext,
"incremental backup information",
ALLOCSET_DEFAULT_SIZES);
ib = CreateIncrementalBackupInfo(mcxt);
/* ... send CopyInResponse ... */
while (HandleUploadManifestPacket(&buf, &offset, ib))
;
FinalizeIncrementalManifest(ib);
/* preserve ib across the later BASE_BACKUP in CacheMemoryContext */
MemoryContextSetParent(mcxt, CacheMemoryContext);
uploaded_manifest = ib;

파싱·보관된 uploaded_manifest는 이후 BASE_BACKUP ... INCREMENTAL이 도착할 때 perform_base_backup에 전달된다. incremental 옵션은 파스 타임에 검증되며, WAL 서머라이제이션이 꺼져 있으면 거부된다.

// parse_basebackup_options — src/backend/backup/basebackup.c
else if (strcmp(defel->defname, "incremental") == 0)
{
opt->incremental = defGetBoolean(defel);
if (opt->incremental && !summarize_wal)
ereport(ERROR,
(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("incremental backups cannot be taken unless WAL summarization is enabled")));
}

대형 클러스터의 매니페스트는 수 메가바이트에 달할 수 있다(파일당 하나의 엔트리). CreateIncrementalBackupInfo는 스트리밍 JSON 파서를 콜백과 함께 연결하고, 현실적 클러스터 크기에 맞춰 파일 엔트리 해시 테이블을 초기화한다.

// CreateIncrementalBackupInfo — src/backend/backup/basebackup_incremental.c
ib->manifest_files = backup_file_create(mcxt, 10000, NULL);
context = palloc0(sizeof(JsonManifestParseContext));
context->private_data = ib;
context->version_cb = manifest_process_version;
context->system_identifier_cb = manifest_process_system_identifier;
context->per_file_cb = manifest_process_file;
context->per_wal_range_cb = manifest_process_wal_range;
context->error_cb = manifest_report_error;
ib->inc_state = json_parse_manifest_incremental_init(context);

WAL 범위 콜백이 증분 로직의 핵심이다. 이전 백업이 필요로 하는 각 (tli, start_lsn, end_lsn)을 기록한다. 파일별 콜백은 경로와 크기만 보관한다(변경 여부 판단이 아닌 정상성 검사에 사용됨).

// manifest_process_wal_range — basebackup_incremental.c
range->tli = tli;
range->start_lsn = start_lsn;
range->end_lsn = end_lsn;
ib->manifest_wal_ranges = lappend(ib->manifest_wal_ranges, range);

AppendIncrementalManifestData의 스트리밍 설계(버퍼가 MAX_CHUNK = 128 KiB를 넘으려 할 때마다 파싱 단계를 트리거하고, 마지막 MIN_CHUNK = 1 KiB는 보존)는 매니페스트 전체를 한 번에 버퍼링하지 않기 위한 것이다.

변경 블록 결정: PrepareForIncrementalBackup

섹션 제목: “변경 블록 결정: PrepareForIncrementalBackup”

PrepareForIncrementalBackup이 서버 사이드의 핵심이다. 다섯 단계를 순서대로 수행한다. (1) 매니페스트의 WAL 범위를 이 서버의 타임라인 이력과 검증한다. (2) LSN 경계를 정상성 검사한다. (3) WAL 서머라이저가 따라잡기를 기다린다. (4) 필요한 LSN 범위를 커버하는 WAL 서머리 파일을 수집한다. (5) 하나의 BlockRefTable로 병합한다.

타임라인 매칭은 이 서버의 이전 상태를 대표하지 않는 백업을 기준으로 증분 백업을 찍는 것을 방지한다.

// PrepareForIncrementalBackup — basebackup_incremental.c
expectedTLEs = readTimeLineHistory(backup_state->starttli);
/* ... match each manifest WAL range's TLI into expectedTLEs ... */
if (tlep[i] == NULL)
ereport(ERROR,
(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("timeline %u found in manifest, but not in this server's history",
range->tli)));

LSN 범위가 확정되면 서머라이제이션이 백업 시작 포인트에 도달하기를 기다린 뒤, 서머리 파일을 수집·필터링하고 각 릴레이션 포크의 블록 참조를 인메모리 테이블로 스트리밍한다.

// PrepareForIncrementalBackup — basebackup_incremental.c (summary merge)
WaitForWalSummarization(backup_state->startpoint);
all_wslist = GetWalSummaries(0, earliest_wal_range_start_lsn,
backup_state->startpoint);
/* ... per-timeline FilterWalSummaries + WalSummariesAreComplete check ... */
ib->brtab = CreateEmptyBlockRefTable();
foreach(lc, required_wslist)
{
/* open summary, read each relation fork ... */
while (BlockRefTableReaderNextRelation(reader, &rlocator, &forknum,
&limit_block))
{
BlockRefTableSetLimitBlock(ib->brtab, &rlocator, forknum, limit_block);
while ((nblocks = BlockRefTableReaderGetBlocks(reader, blocks,
BLOCKS_PER_READ)) != 0)
for (i = 0; i < nblocks; ++i)
BlockRefTableMarkBlockModified(ib->brtab, &rlocator,
forknum, blocks[i]);
}
}

필요한 LSN 범위의 서머리가 하나라도 빠져 있으면 백업은 조용히 불완전한 델타를 만들지 않고 명확하게 실패한다.

flowchart TD
  C1["pg_basebackup --incremental=PRIOR/backup_manifest"] --> C2["UPLOAD_MANIFEST<br/>(이전 매니페스트 스트리밍)"]
  C2 --> S1["CreateIncrementalBackupInfo<br/>+ 스트리밍 JSON 파싱"]
  S1 --> C3["BASE_BACKUP ... INCREMENTAL"]
  C3 --> S2["PrepareForIncrementalBackup"]
  S2 --> S2a["WAL 범위를<br/>타임라인 이력과 대조"]
  S2a --> S2b["WaitForWalSummarization<br/>(시작 LSN)"]
  S2b --> S2c["GetWalSummaries +<br/>FilterWalSummaries +<br/>WalSummariesAreComplete"]
  S2c --> S2d["하나의 BlockRefTable로 병합<br/>(ib->brtab)"]
  S2d --> S3["sendDir: 릴레이션 파일별<br/>GetFileBackupMethod"]
  S3 --> M1["BACK_UP_FILE_FULLY"]
  S3 --> M2["BACK_UP_FILE_INCREMENTALLY<br/>(헤더 + 변경 블록)"]
  M1 --> S4["sendFile 바이트 스트리밍"]
  M2 --> S4
  S4 --> OUT["base.tar + backup_manifest<br/>(이번 백업)"]

서버 사이드 코드를 탑다운으로 추적한다 — 매니페스트 수집, 파일별 결정, 온더와이어 증분 형식 — 그리고 재구성을 위해 pg_combinebackup 프론트엔드로 넘어간다. 심볼이 내구적 앵커이며, 끝의 포지션 힌트 테이블이 각 심볼을 2026-06-05 기준 (파일, 라인)에 고정한다.

매니페스트 수집: CreateIncrementalBackupInfo와 스트리밍 파서

섹션 제목: “매니페스트 수집: CreateIncrementalBackupInfo와 스트리밍 파서”

UploadManifest(walsender.c)가 프로토콜 진입점이다. IncrementalBackupInfo를 생성하고, CopyData 패킷 하나씩 스트리밍 JSON 파서를 구동하며, 완료 후 파이널라이즈한다. 객체는 이후 BASE_BACKUP까지 살아남도록 CacheMemoryContext로 재부모화된다.

// UploadManifest — src/backend/replication/walsender.c
mcxt = AllocSetContextCreate(CurrentMemoryContext,
"incremental backup information",
ALLOCSET_DEFAULT_SIZES);
ib = CreateIncrementalBackupInfo(mcxt);
/* ... CopyInResponse, then loop feeding bytes ... */
while (HandleUploadManifestPacket(&buf, &offset, ib))
;
FinalizeIncrementalManifest(ib);
MemoryContextSetParent(mcxt, CacheMemoryContext);
uploaded_manifest = ib;

AppendIncrementalManifestData의 버퍼링 규칙이 임의 크기 매니페스트를 한꺼번에 메모리에 올리지 않게 한다. 누적 버퍼가 MAX_CHUNK를 넘으려 하면 그 시점까지 파싱하되, 마지막 MIN_CHUNK 바이트는 보존한다. 청크 경계에 체크섬 라인이 걸치는 것을 막기 위해서다.

// AppendIncrementalManifestData — src/backend/backup/basebackup_incremental.c
if (ib->buf.len > MIN_CHUNK && ib->buf.len + len > MAX_CHUNK)
{
/* Parse all but the last MIN_CHUNK bytes of data we have so far. */
json_parse_manifest_incremental_chunk(
ib->inc_state, ib->buf.data, ib->buf.len - MIN_CHUNK, false);
/* Now shift the data that hasn't yet been parsed to the start of
* the buffer. */
memmove(ib->buf.data, ib->buf.data + (ib->buf.len - MIN_CHUNK),
MIN_CHUNK + 1);
ib->buf.len = MIN_CHUNK;
}

차분 로직을 구동하는 유일한 콜백은 manifest_process_wal_range다. 파일별 콜백은 경로와 크기만 기록하며, GetFileBackupMethod의 존재 확인에만 쓰인다. WAL 범위가 서버에 이전 백업이 걸쳐 있는 타임라인/LSN 윈도를 알려 주고, PrepareForIncrementalBackup이 이를 이 서버의 타임라인 이력과 검증한다.

“무엇이 변경됐는가”를 “이 파일을 어떻게 전송할 것인가”로 변환하는 함수다. basebackup.csendDir에서 릴레이션 세그먼트마다 한 번 호출된다. 조기 반환 순서가 최적화가 아닌 정확성 제약을 인코딩한다.

첫째, 두 개의 무조건 전체 전송 케이스 — 잘못된 크기, 그리고 WAL에 기록되지 않는 FSM(free-space map) 포크:

// GetFileBackupMethod — src/backend/backup/basebackup_incremental.c
if ((size % BLCKSZ) != 0 || size / BLCKSZ > RELSEG_SIZE)
return BACK_UP_FILE_FULLY;
/*
* The free-space map fork is not properly WAL-logged, so we need to
* backup the entire file every time.
*/
if (forknum == FSM_FORKNUM)
return BACK_UP_FILE_FULLY;

둘째, “이 파일이 이전 백업에 있었는가?” 확인. 이전 매니페스트에 없던 파일은 델타로 보낼 수 없다. 현재 백업 시작 이후 생성된 파일도 WAL 서머리 커버리지가 없으므로 전체 전송해야 한다. 일반 경로와 INCREMENTAL.* 경로 모두 탐색한다(이전 백업이 이 세그먼트를 증분으로 저장했을 수 있으므로).

// GetFileBackupMethod — basebackup_incremental.c
if (backup_file_lookup(ib->manifest_files, path) == NULL)
{
char *ipath;
ipath = GetIncrementalFilePath(dboid, spcoid, relfilenumber,
forknum, segno);
if (backup_file_lookup(ib->manifest_files, ipath) == NULL)
return BACK_UP_FILE_FULLY;
}

셋째, BlockRefTable 조회. 엔트리가 없으면 이 릴레이션 포크에 WAL에 기록된 변경이 없다는 뜻이므로, 블록 수 0인 증분 스텁(헤더만)으로 보낼 수 있다. 엔트리가 있으면 변경된 블록 집합과 절단 limit_block을 얻는다.

// GetFileBackupMethod — basebackup_incremental.c
brtentry = BlockRefTableGetEntry(ib->brtab, &rlocator, forknum,
&limit_block);
if (brtentry == NULL)
{
if (size == 0)
return BACK_UP_FILE_FULLY;
*num_blocks_required = 0;
*truncation_block_length = size / BLCKSZ;
return BACK_UP_FILE_INCREMENTALLY; /* 헤더만 있는 스텁 */
}

넷째, 90% 휴리스틱. 변경 블록 수가 파일의 90%를 넘으면 증분 인코딩이 이득이 없으므로 전체 전송한다. 그렇지 않으면 블록 번호를 정렬·세그먼트 상대 주소로 변환하고, truncation_block_length를 limit block과 세그먼트 크기에 맞게 조정한다.

// GetFileBackupMethod — basebackup_incremental.c
nblocks = BlockRefTableEntryGetBlocks(brtentry, start_blkno, stop_blkno,
relative_block_numbers, RELSEG_SIZE);
/* If we'd need to send 90% of the blocks anyway, send the whole file. */
if (nblocks * BLCKSZ > size * 0.9)
return BACK_UP_FILE_FULLY;
qsort(relative_block_numbers, nblocks, sizeof(BlockNumber),
compare_block_numbers);
if (start_blkno != 0)
for (i = 0; i < nblocks; ++i)
relative_block_numbers[i] -= start_blkno;
*num_blocks_required = nblocks;
*truncation_block_length = size / BLCKSZ;
/* ... clamp truncation_block_length to [relative_limit, RELSEG_SIZE] ... */
return BACK_UP_FILE_INCREMENTALLY;

sendDir는 반환값을 소비한다. BACK_UP_FILE_INCREMENTALLY면 tar 멤버 이름을 INCREMENTAL.<name>으로 바꾸고 statbuf.st_size를 증분 파일 크기로 조정한 뒤 sendFile을 호출한다.

// sendDir (증분 분기) — src/backend/backup/basebackup.c
method = GetFileBackupMethod(ib, lookup_path, dboid, relspcoid,
relfilenumber, relForkNum,
segno, statbuf.st_size,
&num_blocks_required,
relative_block_numbers,
&truncation_block_length);
if (method == BACK_UP_FILE_INCREMENTALLY)
{
statbuf.st_size = GetIncrementalFileSize(num_blocks_required);
snprintf(tarfilenamebuf, sizeof(tarfilenamebuf), "%s/INCREMENTAL.%s",
path + basepathlen + 1, de->d_name);
tarfilename = tarfilenamebuf;
}
flowchart TD
  G0["GetFileBackupMethod(path, segno, size)"] --> G1{"size가 BLCKSZ 배수가 아니거나<br/>RELSEG_SIZE 초과?"}
  G1 -- yes --> FULL["BACK_UP_FILE_FULLY"]
  G1 -- no --> G2{"forknum == FSM_FORKNUM?"}
  G2 -- yes --> FULL
  G2 -- no --> G3{"이전 매니페스트에 경로 있음<br/>(일반 또는 INCREMENTAL.*)?"}
  G3 -- no --> FULL
  G3 -- yes --> G4{"이 relfilenode에 대한<br/>BlockRefTable 엔트리?"}
  G4 -- "없음 (WAL 변경 없음)" --> STUB["INCREMENTALLY<br/>num_blocks = 0 (스텁)"]
  G4 -- 있음 --> G5["GetBlocks → nblocks"]
  G5 --> G6{"nblocks*BLCKSZ > 0.9*size?"}
  G6 -- yes --> FULL
  G6 -- no --> INC["INCREMENTALLY<br/>헤더 + 변경 블록"]

incremental_blocks != NULL이면 sendFile은 블록 데이터 앞에 헤더를 기록한다. 헤더는 리틀 엔디언 uint32 세 개 — INCREMENTAL_MAGIC(0xd3ae1f0d), 블록 수, 절단 블록 길이 — 에 이어 상대 블록 번호 배열이고, 블록 데이터가 BLCKSZ 경계에 맞게 패딩될 수 있다.

// sendFile (증분 헤더) — src/backend/backup/basebackup.c
if (incremental_blocks != NULL)
{
unsigned magic = INCREMENTAL_MAGIC;
size_t header_bytes_done = 0;
push_to_sink(sink, &checksum_ctx, &header_bytes_done,
&magic, sizeof(magic));
push_to_sink(sink, &checksum_ctx, &header_bytes_done,
&num_incremental_blocks, sizeof(num_incremental_blocks));
push_to_sink(sink, &checksum_ctx, &header_bytes_done,
&truncation_block_length, sizeof(truncation_block_length));
push_to_sink(sink, &checksum_ctx, &header_bytes_done,
incremental_blocks,
sizeof(BlockNumber) * num_incremental_blocks);
/* ... pad to BLCKSZ if num_incremental_blocks > 0 ... */
}

데이터 루프는 목록에 있는 블록만 읽으며, 각각 relative_blkno * BLCKSZ로 시크한다. 블록 중간의 짧은 읽기는 동시 절단으로 간주하고 루프를 종료한다 — WAL 재생이 복원 시 꼬리를 정리한다.

// sendFile (증분 데이터 루프) — src/backend/backup/basebackup.c
relative_blkno = incremental_blocks[ibindex++];
cnt = read_file_data_into_buffer(sink, readfilename, fd,
relative_blkno * BLCKSZ, /* seek */
BLCKSZ, /* one block */
relative_blkno + segno * RELSEG_SIZE,
verify_checksum,
&checksum_failures);
if (cnt < BLCKSZ)
break; /* transient truncation; WAL replay will fix it */

SQL에서 서머리 조회: walsummaryfuncs.c

섹션 제목: “SQL에서 서머리 조회: walsummaryfuncs.c”

백업 경로가 사용하는 BlockRefTable 리더가 진단용 SQL 함수로도 노출된다. pg_available_wal_summaries는 파일 목록을 반환하고, pg_wal_summary_contents는 파일 하나를 열어 (relfilenode, fork, block) 행을 하나씩 반환한다. 절단 포인트를 나타내는 합성 limit_block 행도 포함된다.

// pg_wal_summary_contents — src/backend/backup/walsummaryfuncs.c
io.file = OpenWalSummaryFile(&ws, false);
reader = CreateBlockRefTableReader(ReadWalSummary, &io,
FilePathName(io.file),
ReportWalSummaryError, NULL);
while (BlockRefTableReaderNextRelation(reader, &rlocator, &forknum,
&limit_block))
{
/* emit limit_block row if BlockNumberIsValid(limit_block) ... */
/* then loop over blocks, MAX_BLOCKS_PER_CALL at a time ... */
}

PrepareForIncrementalBackupib->brtab에 병합하는 데이터와 동일한 데이터를 사용자에게 노출한다. “왜 이 파일이 전체 전송됐는가?”를 추적할 때 가장 유용한 도구다.

재구성: pg_combinebackupreconstruct_from_incremental_file

섹션 제목: “재구성: pg_combinebackup과 reconstruct_from_incremental_file”

증분 백업을 복원 가능하게 만드는 곳이 프론트엔드 툴이다. 출력 파일마다 reconstruct_from_incremental_file은 최신 증분 파일의 헤더를 읽어 재구성된 길이를 알아내고, 블록별 sourcemap(어느 파일이 이 블록을 갖는가)과 offsetmap(바이트 오프셋)을 구성한다. 최신 파일에 있는 블록이 먼저 확보된다.

// reconstruct_from_incremental_file — src/bin/pg_combinebackup/reconstruct.c
latest_source = make_incremental_rfile(input_filename);
source[n_prior_backups] = latest_source;
block_length = find_reconstructed_block_length(latest_source);
sourcemap = pg_malloc0(sizeof(rfile *) * block_length);
offsetmap = pg_malloc0(sizeof(off_t) * block_length);
for (i = 0; i < latest_source->num_blocks; ++i)
{
BlockNumber b = latest_source->relative_block_numbers[i];
sourcemap[b] = latest_source;
offsetmap[b] = latest_source->header_length + (i * BLCKSZ);
}

make_incremental_rfilesendFile이 기록한 형식의 리더다. INCREMENTAL_MAGIC을 검증하고 서버의 GetIncrementalHeaderSize와 동일한 방식으로 header_length를 계산한다. 이것이 서버와 프론트엔드가 바이트 단위로 호환성을 유지하는 방법이다.

// make_incremental_rfile — src/bin/pg_combinebackup/reconstruct.c
read_bytes(rf, &magic, sizeof(magic));
if (magic != INCREMENTAL_MAGIC)
pg_fatal("file \"%s\" has bad incremental magic number (0x%x, expected 0x%x)",
filename, magic, INCREMENTAL_MAGIC);
read_bytes(rf, &rf->num_blocks, sizeof(rf->num_blocks));
read_bytes(rf, &rf->truncation_block_length,
sizeof(rf->truncation_block_length));
/* ... read relative_block_numbers[] ... */
rf->header_length = sizeof(magic) + sizeof(rf->num_blocks) +
sizeof(rf->truncation_block_length) +
sizeof(BlockNumber) * rf->num_blocks;

체인 순회는 최신에서 가장 오래된 이전 백업으로 내려간다. 파일의 전체 사본을 만나면 truncation_block_length 아래에서 아직 미확보된 블록을 그 전체 파일에서 채우고 멈춘다. 더 오래된 소스는 이미 확보된 블록을 덮을 수 없다(최신 우선).

// reconstruct_from_incremental_file (전체 파일 소스) — reconstruct.c
blocklength = sb.st_size / BLCKSZ;
for (b = 0; b < latest_source->truncation_block_length; ++b)
{
if (sourcemap[b] == NULL && b < blocklength)
{
sourcemap[b] = s; /* fill from the full file */
offsetmap[b] = b * BLCKSZ;
}
}
/* ... then break: no older source can override these ... */

순회 후에도 NULL로 남은 블록이 절단 길이 아래 있으면 제로로 채운다. 서버가 확장했지만 WAL에 기록하지 않은 블록이다. write_reconstructed_file이 이를 0으로 구체화한다.

flowchart TD
  R0["reconstruct_from_incremental_file(출력 파일)"] --> R1["make_incremental_rfile(최신):<br/>magic, num_blocks,<br/>truncation_block_length"]
  R1 --> R2["block_length =<br/>find_reconstructed_block_length"]
  R2 --> R3["최신 증분 파일의 블록<br/>sourcemap에 확보"]
  R3 --> R4{"이전 백업 체인<br/>최신 → 가장 오래된 순 순회"}
  R4 -- "증분 레이어" --> R5["절단 길이 아래 미확보 블록<br/>이 레이어에서 채우기"]
  R5 --> R4
  R4 -- "전체 파일 발견" --> R6["남은 블록을 전체 파일에서<br/>채우고 종료"]
  R6 --> R7["절단 길이 아래 NULL 블록<br/>= 제로 홀"]
  R7 --> R8["write_reconstructed_file:<br/>각 블록을 소스에서 복사"]

포지션 힌트 (2026-06-05 기준, REL_18 273fe94)

섹션 제목: “포지션 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일라인
INCREMENTAL_MAGICsrc/include/backup/basebackup_incremental.h20
MIN_CHUNK / MAX_CHUNKbasebackup_incremental.c39
manifest_process_wal_rangebasebackup_incremental.c138
CreateIncrementalBackupInfobasebackup_incremental.c152
AppendIncrementalManifestDatabasebackup_incremental.c194
FinalizeIncrementalManifestbasebackup_incremental.c227
PrepareForIncrementalBackupbasebackup_incremental.c263
GetIncrementalFilePathbasebackup_incremental.c625
GetFileBackupMethodbasebackup_incremental.c663
GetIncrementalHeaderSizebasebackup_incremental.c881
GetIncrementalFileSizebasebackup_incremental.c909
parse_basebackup_optionsbasebackup.c698
perform_base_backupbasebackup.c234
sendFilebasebackup.c1573
read_file_data_into_bufferbasebackup.c96
push_to_sinkbasebackup.c102
pg_available_wal_summarieswalsummaryfuncs.c32
pg_wal_summary_contentswalsummaryfuncs.c69
UploadManifestsrc/backend/replication/walsender.c667
HandleUploadManifestPacketwalsender.c733
reconstruct_from_incremental_filesrc/bin/pg_combinebackup/reconstruct.c88
make_incremental_rfilereconstruct.c456
find_reconstructed_block_lengthreconstruct.c439
  • 증분 파일 헤더는 INCREMENTAL_MAGIC(0xd3ae1f0d), 블록 수, 절단 블록 길이, 상대 블록 번호 배열로 구성된다. sendFile(서버 라이터, basebackup.c)과 make_incremental_rfile(프론트엔드 리더, reconstruct.c) 모두 같은 방식으로 header_length를 계산하며, 매직 상수는 src/include/backup/basebackup_incremental.h에 한 번만 정의된다.

  • FSM 포크는 항상 전체 전송된다. GetFileBackupMethod에서 if (forknum == FSM_FORKNUM) return BACK_UP_FILE_FULLY;로 확인. 소스 주석: “The free-space map fork is not properly WAL-logged.” 메인 포크가 증분으로 전송되더라도 FSM이 변경됐다면 FSM은 전체 전송된다.

  • WAL에 기록된 변경이 없는 릴레이션은 건너뛰지 않고 헤더만 있는 스텁으로 전송된다. GetFileBackupMethod에서 BlockRefTableGetEntryNULL을 반환하고 파일이 비어 있지 않으면 *num_blocks_required = 0으로 BACK_UP_FILE_INCREMENTALLY를 반환한다. 스텁이 pg_combinebackup에 “이 파일은 변경 없음 — 이전 백업에서 전체를 가져가라”를 알린다.

  • 90% 임계값이 증분 파일을 전체 전송으로 다운그레이드한다. GetFileBackupMethod에서 if (nblocks * BLCKSZ > size * 0.9) return BACK_UP_FILE_FULLY;로 확인. 소스 주석은 이 임계값이 설정 불가능하며, 모든 블록이 변경된 파일은 항상 전체 전송된다고 명시한다.

  • summarize_wal이 꺼져 있으면 증분 백업이 거부된다. parse_basebackup_options에서 ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE(“incremental backups cannot be taken unless WAL summarization is enabled”)로 확인.

  • 매니페스트는 마지막 MIN_CHUNK 바이트를 보존하며 증분 파싱된다. AppendIncrementalManifestData에서 버퍼가 MAX_CHUNK = 128 KiB를 넘으면 json_parse_manifest_incremental_chunk를 트리거하고, 마지막 MIN_CHUNK = 1 KiB를 버퍼 앞으로 memmove한다. 청크 경계에 닫는 체크섬 라인이 걸치지 않도록 하기 위해서다.

  • PrepareForIncrementalBackup은 서머라이제이션을 기다리고, 필요한 서머리가 없으면 실패한다. WaitForWalSummarization(backup_state->startpoint)이 서머라이저가 백업 시작 LSN에 도달할 때까지 블로킹하고, 이후 GetWalSummaries + 타임라인별 FilterWalSummaries로 파일을 수집한다. 완전성 검사에서 갭이 발견되면 불완전한 델타를 만들지 않고 오류를 반환한다.

  • 재구성은 백업 체인의 최신 우선 병합이며, 홀은 제로로 채운다. reconstruct_from_incremental_file에서 확인: 최신 증분이 자신의 블록을 먼저 확보하고, 순회가 더 오래된 레이어에서 미확보 블록을 채우며, 전체 파일에서 멈춘다. truncation_block_length 아래에서 NULL로 남은 블록은 제로 채운 확장으로 처리된다.

  • WAL 서머라이저 내부 (walsummarizer.c 백그라운드 워커, pg_wal/summaries/ 파일 포맷, BlockRefTable 온디스크 인코딩)는 이 문서 범위 밖이며 postgres-archiving-walsummary.md에서 다룬다. 이 문서는 서머리를 PrepareForIncrementalBackup이 소비하는 입력으로만 다룬다.

  • 전체(비증분) BASE_BACKUP 흐름 — bbsink 파이프라인, perform_base_backup, tar 프레이밍, 압축 싱크, 백업 매니페스트 라이터 — 은 postgres-backup-basebackup.md에서 다룬다. 이 문서는 sendDir/sendFile의 증분 분기만 다룬다.

  • WAL 자체의 진실 공급원 (rmgr 디스패치, 레코드 포맷, redo)은 postgres-wal-records-rmgr.mdpostgres-recovery-redo.md에서 다룬다.

  • 코드 읽기 이상의 검증 없음: 멀티 링크 체인의 실제 엔드-투-엔드 복원(pg_combinebackup이 시작 가능한 클러스터를 만드는 것)은 이번 리비전에서 실행하지 않고 코드만 읽었다.

PostgreSQL 너머 — 비교 설계와 연구 프론티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”

PostgreSQL의 설계는 증분 백업 설계 공간의 특정 지점에 자리한다. 대조가 선택을 명확하게 한다.

블록 변경 추적: 쓰기 시 비트맵 vs. 로그 기반 요약. Oracle RMAN의 블록 변경 추적 파일과 SQL Server의 Differential Changed Map은 페이지가 더러워질 때 즉시, 쓰기 경로에서 갱신된다. 차분 읽기는 빠르지만 모든 쓰기에 작은 고정 비용이 따르고, 추적 구조가 라이브 데이터 파일과 결합된다. PostgreSQL은 반대로 이미 내구성을 위해 쓰던 WAL을 요약함으로써 나중에 변경 집합을 도출한다. 비용이 핫 쓰기 경로에서 벗어나 백그라운드 워커로 이동하고, 추적 아티팩트(WAL 서머리 파일)는 데이터 파일과 백업 일정 모두에서 분리된다. 그 대신 지연이 있다. 서머라이저가 시작 LSN까지 따라잡기를 기다려야 하므로 PrepareForIncrementalBackupWaitForWalSummarization을 호출해야 하는 이유가 바로 이것이다.

즉시 vs. 지연 재구성. 많은 엔터프라이즈 툴과 Oracle의 점진적 갱신 백업은 백업 시 합성 전체 이미지를 롤 포워드한다. 복원은 항상 단일 전체 사본이다. PostgreSQL은 재구성을 pg_combinebackup의 복원 시로 완전히 미룬다. 서버 사이드가 순수한 델타 생산자로 남는다. 프라이머리에서 합성 전체본을 유지하기 위한 읽기 증폭이 없다. 다만 복원 시 병합 작업이 체인 길이에 비례한다. reconstruct_from_incremental_file의 최신 우선 오버레이는 LSM 트리의 키 조회나 오버레이 파일시스템 레이어 스택에서 파일 해석과 구조적으로 동일하다. 블록을 보유한 가장 최신 레이어가 이기고, “툼스톤”(여기서는 truncation_block_length 클램프)이 오래된 레이어가 기여할 수 있는 범위를 한정한다.

해상도와 WAL 로깅 가정. 블록 단위 추적은 완전히 WAL에 기록되는 데이터에만 유효하다. FSM 예외(forknum == FSM_FORKNUM → 전체 전송)가 그 가정이 깨지는 지점을 드러낸다. 언로그 또는 최소 로그 객체를 다른 채널로 증분 캡처할 수 있는지는 연구 프론티어 질문이다. 현재 PostgreSQL은 단순히 전체 전송한다. “현재 백업 시작 이후 생성된 파일” 가드는 로그 기반 변경 추적이 단순한 블록 내용 변화가 아닌 동일 이름으로 삭제 후 재생성되는 네임스페이스 변화에도 방어해야 함을 보여 준다.

문헌의 위치. 개념적 뼈대는 ARIES(Mohan et al., 1992)에 있다. WAL이 모든 페이지 변경에 대한 권위 있고 순서화된, 재생 가능한 기록이라는 점이 “두 LSN 사이에 무엇이 변경됐는가?”를 잘 정의된 질문으로 만든다. Database Internals(Petrov, 2019)는 WAL을 복구, 복제, 그리고 이제 백업 차분까지 소비하는 단일 진실 공급원으로 규정한다. 증분 백업은 redo 정보의 세 번째 소비자로 이해할 수 있다. 크래시 복구는 변경을 적용하고, 물리 복제는 스트리밍하며, 증분 백업은 열거한다. 현재 활성 프론티어는 주로 운영 차원이다. 체인 길이 자동 제한, pg_combinebackup 병렬화, 온디스크 델타와 아카이브 델타가 동일한 바이트가 되도록 델타 인식 오브젝트 스토리지와의 통합이 그것이다.

  • PostgreSQL 소스 (REL_18_STABLE, commit 273fe94, 2026-06-05):
    • src/backend/backup/basebackup_incremental.c — 매니페스트 수집 (CreateIncrementalBackupInfo, AppendIncrementalManifestData), PrepareForIncrementalBackup, 파일별 결정 GetFileBackupMethod.
    • src/backend/backup/basebackup.cparse_basebackup_options(summarize_wal 전제 조건), sendDir의 증분 분기, sendFile의 증분 헤더 + 블록 시크 루프.
    • src/backend/backup/walsummaryfuncs.c — SQL 호출 가능 pg_available_wal_summaries / pg_wal_summary_contents 진단 함수.
    • src/backend/replication/walsender.cUploadManifest / HandleUploadManifestPacket(UPLOAD_MANIFEST 프로토콜).
    • src/bin/pg_combinebackup/reconstruct.creconstruct_from_incremental_file, make_incremental_rfile, sourcemap/offsetmap 구성, 최신 우선 체인 순회.
    • src/include/backup/basebackup_incremental.hINCREMENTAL_MAGIC, FileBackupMethod, 서버 사이드 선언.
  • 이론 / 교과서 앵커:
    • C. Mohan et al., ARIES: A Transaction Recovery Method… (1992) — WAL이 모든 페이지 변경의 권위 있고 재생 가능한 기록. knowledge/research/dbms-general/dbms-papers/aries.md 참조.
    • A. Petrov, Database Internals (2019), ch. 3 및 복구 논의 — WAL이 복구, 복제, 백업 차분 전반에 걸친 단일 진실 공급원. knowledge/research/dbms-general/database-internals.md 수록.
  • sibling 코드 분석 문서 (이 트리): postgres-archiving-walsummary.md (서머리를 생산하는 WAL 서머라이저), postgres-backup-basebackup.md (전체 백업 파이프라인과 bbsink 체인), postgres-wal-records-rmgr.mdpostgres-recovery-redo.md (기반 변경 기록으로서의 WAL).