3.3 과거 LSN에서 Branch 만들기
timeline 생성 요청에 ancestor_start_lsn을 넣으면 부모의 과거 시점에서 분기합니다. Neon 문서에서 instant restore나 time travel이라고 부르는 기능이 저장 계층에서 이렇게 동작합니다. 잘못된 DELETE를 실행한 뒤 삭제 직전 LSN에서 branch를 만들어 데이터를 되살리는 시나리오를 통해 확인합니다.
사고 전 LSN 기록
실제 사고에서는 삭제 직전 LSN을 미리 알 수 없으므로 시각을 기준으로 LSN을 찾습니다. 그 방법은 뒤에서 다루고, 먼저 LSN을 아는 상황에서 흐름을 익힙니다.
./scripts/psql.sh compute1 -c "select count(*) as before_total from orders;" -c "select pg_current_wal_flush_lsn() as safe_lsn;"
before_total
--------------
501000
safe_lsn
-----------
0/D902000
사고 발생
./scripts/psql.sh compute1 -c "\timing on" \
-c "delete from orders where customer_id < 5000;" \
-c "select count(*) as after_delete from orders;"
DELETE 250877 Time: 1079.393 ms
after_delete
--------------
250123
25만 건이 사라졌고 트랜잭션은 커밋되었습니다. 일반 PostgreSQL이라면 이 시점부터 백업과 WAL archive로 PITR을 수행합니다. 그런 다음 별도 인스턴스에 복구합니다.
삭제 직전 LSN에서 branch
feature-a compute를 중지하고 같은 compute2 슬롯을 복구용 branch에 연결합니다.
docker compose --profile branch stop compute2
./scripts/branch.sh rescue-before-delete --at-lsn 0/D902000
docker compose --profile branch up -d compute2
./scripts/psql.sh compute2 -c "show neon.timeline_id;" \
-c "select count(*) as rescued_total, count(*) filter (where customer_id < 5000) as rescued_deleted_rows from orders;"
branch rescue-before-delete created in 0.13s
{
"timeline_id": "be2f70e318f31e16f55facced3a655bb",
"ancestor_timeline_id": "7eac85e3d7199edb170e1e283707fcdd",
"ancestor_lsn": "0/D902000",
...
}
compute2 (rescue) ready in 1.3s
rescued_total | rescued_deleted_rows
---------------+----------------------
501000 | 250877
삭제된 250,877건이 그대로 남아 있습니다. 백업 파일을 풀거나 WAL을 replay하는 단계는 없었습니다. pageserver는 요청받은 page를 0/D902000 시점으로 재구성합니다. 그 시점의 page 버전을 만드는 데 필요한 image layer와 delta layer가 아직 GC되지 않고 남아 있기 때문입니다.
.env에서 BRANCH_TIMELINE_ID만 바뀌었으므로 compose는 compute2 컨테이너를 새 환경으로 다시 만듭니다. 이번 기동은 1.3초였습니다. 3.2의 11.9초와 차이가 큽니다. 첫 기동에서는 이미지 레이어 준비와 네트워크 생성이 함께 진행된 것으로 보입니다.
복구 데이터를 main으로 되돌리기
Neon에는 branch를 부모로 머지하는 기능이 없습니다. 복구 branch에서 필요한 행을 읽어 main에 다시 넣는 작업은 사용자가 해야 합니다. 두 compute는 호스트의 서로 다른 포트에 있습니다. 따라서 pg_dump로 옮기거나, 규모가 작으면 COPY로도 충분합니다.
# 복구 branch에서 삭제된 행만 추출
./scripts/psql.sh compute2 -c "\copy (select * from orders where customer_id < 5000) to stdout" > /tmp/rescued.tsv
# main에 적재
./scripts/psql.sh compute1 -c "\copy orders from stdin" < /tmp/rescued.tsv
id가 bigserial이므로 원래 값 그대로 들어가며 primary key 충돌도 없습니다. 삭제 이후 main에 새로 들어온 행과 sequence가 겹치는지는 실제 상황에서 따로 확인해야 합니다.
Neon 클라우드 서비스의 neon branches restore는 이 과정을 반대로 수행합니다. 현재 branch를 과거 시점의 새 timeline으로 교체하고, 원래 timeline은 _old_ 접미사 이름으로 남깁니다. 그 명령은 4.4에서 다룹니다. 자체 호스팅 compose에는 그런 control plane이 없습니다. 따라서 여기서는 branch를 만들고 데이터를 옮기는 방식만 가능합니다.
시각으로 LSN 찾기
pageserver는 commit 기록의 타임스탬프를 기준으로 LSN을 역산하는 API를 제공합니다.
. ./.env # branch.sh 가 기록한 TENANT_ID, TIMELINE_ID 를 현재 셸로 읽는다
curl -s "localhost:9898/v1/tenant/$TENANT_ID/timeline/$TIMELINE_ID/get_lsn_by_timestamp?timestamp=2026-09-07T06:48:30Z"
{"lsn":"0/EC38470","kind":"present"}
kind는 요청 시각과 commit 기록의 관계를 알려 줍니다. 값은 present, past, future, nodata 네 가지이고, 이 노트의 실습에서는 앞의 세 값을 확인했습니다.
| kind | 의미 | 실습에서 본 예 |
|---|---|---|
| present | 해당 시각 직전 commit의 LSN을 찾음 | 삭제 진행 중이던 06:48:30 → 0/EC38470 |
| past | 요청 시각이 보존 history보다 앞섬. 가장 오래된 LSN 반환 | 기동 전 시각 06:40:00 → 0/14E8F20 |
| future | 요청 시각 이후 commit이 없음. 검색된 마지막 commit LSN 반환 | 마지막 쓰기 뒤 시각 → 0/F4E5478 |
| nodata | 판단할 commit 기록이 없음 | 실습에서 미확인 |
present로 나온 LSN을 --at-lsn에 넘기면 시각을 기준으로 복구할 수 있습니다. 정밀도는 commit 단위입니다. 트랜잭션 도중의 시각을 지정하면 해당 트랜잭션이 커밋되기 전 상태가 나옵니다.
실패 사례: 아직 오지 않은 LSN
부모의 last_record_lsn보다 앞선 LSN을 지정하면 pageserver는 해당 WAL이 도착하기를 기다리다가 실패합니다.
./scripts/branch.sh bad-future --at-lsn 0/FFFFFFFF
create failed: HTTP 406
{"msg":"Timed out while waiting for WAL record at LSN 1/0 to arrive, last_record_lsn 0/F4E5478 disk consistent LSN=0/F4D9190, WalReceiver status: ..."}
406은 openapi 명세에서 "영구적으로 만족할 수 없는 요청이므로 재시도하지 말라"는 뜻입니다. 0/FFFFFFFF가 1/0으로 표시된 것은 LSN이 8바이트 정렬 위치로 올림된 결과입니다.
반대로 너무 오래된 LSN도 실패합니다. GC는 gc_horizon(기본 64 MB)과 pitr_interval(이 실습 tenant 기본값 7일) 바깥의 layer를 지웁니다. 그 뒤에는 해당 시점의 page를 재구성할 재료가 없습니다. 클라우드 서비스의 history window(Free는 최대 6시간 또는 변경량 1 GB 중 먼저 도달하는 범위, Launch 최대 7일, Scale 최대 30일)는 이 보존 범위를 사용자에게 보여 주는 설정입니다.
연습 문제
- main에서
UPDATE orders SET amount = 0 WHERE id <= 100을 실행하기 직전 시각을 기록합니다.get_lsn_by_timestamp로 LSN을 찾아 branch를 만든 뒤 원래 금액이 복구되는지 확인합니다. pitr_interval을 짧게 바꾸면 어느 시점부터 과거 LSN branch가 실패하는지 실험합니다. 설정 변경은PUT /v1/tenant/config에{"tenant_id": "<tenant>", "pitr_interval": "10m"}형태의 본문을 보내는 방식이고, 조회는GET /v1/tenant/{tenant}/config입니다. 실제 GC는gc_period(기본 1시간)마다 실행됩니다.PUT .../do_gc로 강제 실행하는 방법을 openapi에서 찾아봅니다.- 복구 branch에서 main으로 데이터를 옮길 때
pg_dump --data-only --table=orders와\copy의 차이를 비교합니다.