본문으로 건너뛰기

3.2 첫 Branch 만들기

Neon 저장 계층에서 branch는 timeline입니다. 부모 timeline id와 분기 LSN을 지정해 timeline을 하나 만들면 branch가 됩니다. 그 timeline을 가리키는 compute를 하나 기동하면 접속 가능한 PostgreSQL이 됩니다. 이 장에서는 3.1에서 적재한 orders 테이블 50만 건이 있는 main에서 branch를 만듭니다. 이어서 두 compute의 쓰기가 서로 격리되는지 확인합니다.

Branch 생성: pageserver API 한 번

scripts/branch.sh는 다음 요청 하나를 보냅니다. new_timeline_id는 클라이언트가 16바이트 hex로 생성해 전달합니다. ancestor_start_lsn을 생략하면 부모의 현재 last_record_lsn이 분기점이 됩니다.

curl -X POST -H 'Content-Type: application/json' \
-d '{"new_timeline_id":"<32 hex>","ancestor_timeline_id":"<부모 timeline>","pg_version":17}' \
http://localhost:9898/v1/tenant/<tenant>/timeline/

스크립트로 실행하면 이름도 지정합니다.

./scripts/branch.sh feature-a
branch feature-a created in 0.07s
{
"timeline_id": "9ceb875982e0343fb61e3288205d91d9",
"ancestor_timeline_id": "7eac85e3d7199edb170e1e283707fcdd",
"ancestor_lsn": "0/A850840",
"last_record_lsn": "0/A850840",
"current_logical_size": 0,
"current_physical_size": 0
}
.env updated: BRANCH_TIMELINE_ID=9ceb875982e0343fb61e3288205d91d9

HTTP 왕복을 포함해 0.07초입니다. 62 MB 테이블이 있는 데이터베이스를 복제했지만 복사한 바이트는 없습니다. 응답의 current_logical_size가 0인 이유는 아직 계산 전이기 때문입니다. 잠시 뒤 status.sh로 확인하면 부모와 같은 84 MB로 나옵니다. logical size는 branch가 보는 데이터의 크기입니다. physical size는 이 timeline이 새로 만든 layer의 크기입니다. 두 값의 차이가 copy-on-write의 결과입니다.

스크립트는 .envTENANT_ID, TIMELINE_ID(main), BRANCH_TIMELINE_ID를 기록합니다. compose.yml의 compute2가 이 값을 읽습니다.

Branch용 compute 기동

docker compose --profile branch up -d compute2
./scripts/psql.sh compute2 -c "show neon.timeline_id;" -c "select count(*) from orders;"
compute2 ready in 11.9s

neon.timeline_id
----------------------------------
9ceb875982e0343fb61e3288205d91d9

count
--------
500000

compute2는 compute1과 같은 이미지와 같은 compute.sh를 사용합니다. 환경변수 TIMELINE_ID만 다릅니다. compute.shTENANT_IDTIMELINE_ID가 모두 있으면 조회를 건너뛰고 해당 timeline에 바로 연결됩니다. 둘 중 하나만 있으면 pageserver의 첫 timeline을 선택합니다. 따라서 branch에 연결할 때는 반드시 둘 다 지정합니다.

첫 접속까지 11.9초가 걸렸습니다. 이 시간에는 컨테이너 생성, safekeeper handshake, basebackup 수신, PostgreSQL 기동, compute_ctl migration 확인이 모두 포함됩니다. 같은 절차를 3.3에서 한 번 더 실행했을 때는 1.3초였습니다. 이미지가 준비된 뒤에는 대체로 수 초 안에 기동합니다. 정확한 값은 환경에 따라 달라집니다.

count(*)가 50만 건이라는 결과는 branch가 부모 데이터를 그대로 본다는 것을 확인해 줍니다. compute2는 분기 LSN 0/A850840 시점의 page를 pageserver에 요청합니다. 자식 timeline에는 해당 page의 layer가 없습니다. 따라서 pageserver는 부모 timeline의 layer에서 page를 재구성해 반환합니다.

쓰기 격리 확인

branch에 한 건을 넣고 부모에서 확인합니다.

./scripts/psql.sh compute2 -c "insert into orders(customer_id, amount, note) values (1,1,'branch-only');
select count(*) filter (where note='branch-only') as branch_rows, count(*) as total from orders;"
./scripts/psql.sh compute1 -c "select count(*) filter (where note='branch-only') as branch_rows, count(*) as total from orders;"
-- compute2 (feature-a)
branch_rows | total
-------------+--------
1 | 500001

-- compute1 (main)
branch_rows | total
-------------+--------
0 | 500000

반대 방향도 확인합니다. 부모에 1,000건을 넣어도 branch에는 보이지 않습니다.

./scripts/psql.sh compute1 -c "insert into orders(customer_id, amount, note) select 2,2,'main-only' from generate_series(1,1000);
select count(*) filter (where note='main-only') as main_rows, count(*) as total from orders;"
./scripts/psql.sh compute2 -c "select count(*) filter (where note='main-only') as main_rows, count(*) as total from orders;"
-- compute1 (main)
main_rows | total
-----------+--------
1000 | 501000

-- compute2 (feature-a)
main_rows | total
-----------+--------
0 | 500001

branch는 분기 시점의 부모를 봅니다. 이후 부모에 쌓이는 WAL은 자식 timeline의 읽기 경로에서 무시됩니다. 분기 이후 두 timeline은 각자의 WAL 스트림을 safekeeper에 보냅니다. pageserver는 timeline별로 layer를 따로 쌓습니다. 이 구조는 2.4에서 설명합니다.

상태 다시 보기

timeline_id name ancestor ancestor_lsn last_record_lsn logical_MB physical_MB
9ceb875982e0343fb61e3288205d91d9 feature-a 7eac85e3 0/A850840 0/D8A6C48 84 0
7eac85e3d7199edb170e1e283707fcdd main - - 0/D901F28 84 22.1

두 가지를 확인합니다.

첫째, feature-a의 last_record_lsn이 분기점 0/A850840에서 0/D8A6C48로 약 50 MB 앞서 있습니다. INSERT 한 건으로 생길 규모는 아닙니다. compute 기동 직후 compute_ctl이 수행한 초기화 작업과 첫 checkpoint에서 나온 WAL로 보입니다. 이 노트에서는 원인을 더 분석하지 않았습니다. branch 하나를 기동할 때마다 이 정도 WAL이 safekeeper를 거쳐 pageserver로 흐른다는 점만 기억합니다.

둘째, feature-a의 physical size는 아직 0입니다. 50 MB의 WAL은 pageserver의 in-memory layer에 있습니다. checkpoint_distance(256 MB)에 이르지 않았고 checkpoint_timeout(기본 10분)도 지나지 않아 pageserver가 디스크 layer로 flush하지 않았습니다. logical size 84 MB는 부모에서 상속한 값입니다.

실패 사례: compute가 엉뚱한 timeline에 붙는다

.envTENANT_ID가 없는 상태에서 TIMELINE_ID만 전달하면 compute.sh는 else 분기로 들어갑니다. 그런 다음 pageserver의 timeline 목록에서 첫 항목을 선택합니다. 목록 순서는 보장되지 않습니다. 따라서 main에 연결될 수도 있고, 다른 branch에 연결될 수도 있습니다. 증상은 "branch에 넣은 데이터가 main에서도 보인다"입니다. 원인은 두 compute가 같은 timeline을 가리키는 것입니다. 이때 두 compute가 같은 timeline에 WAL을 쓰려고 하면 safekeeper quorum에서 더 높은 term을 얻은 proposer가 상대를 fence합니다. 데이터가 깨지지는 않습니다. 밀려난 compute는 재시작해 다시 election을 시도하므로, 재시작 정책에 따라 두 compute가 번갈아 밀려나는 상황이 반복됩니다.

확인 방법은 각 compute에서 show neon.timeline_id를 실행해 서로 다른 값인지 확인하는 것입니다.

연습 문제

  1. feature-a에서 ALTER TABLE orders ADD COLUMN status text DEFAULT 'new'를 실행하고 main의 스키마가 그대로인지 \d orders로 비교합니다.
  2. branch에서 CREATE INDEX를 실행한 뒤 status.sh의 physical size 변화를 관찰합니다. WAL이 checkpoint_distance(256 MB)에 이르거나 checkpoint_timeout(기본 10분)이 지나기 전까지 0으로 남는 이유를 설명합니다. 두 값은 GET /v1/tenant/{tenant}/configeffective_config에서 확인합니다.
  3. feature-a에서 다시 branch를 만들어(PARENT_TIMELINE_ID=<feature-a id> ./scripts/branch.sh feature-a-1) 3단계 트리를 만들고 status.sh의 ancestor 열을 확인합니다.

참고