본문으로 건너뛰기

2.2 Safekeeper와 WAL 내구성

Compute가 만든 WAL은 Pageserver로 바로 가지 않습니다. Safekeeper라는 중간 계층을 먼저 거칩니다. 여러 Safekeeper가 WAL 복사본을 보관합니다. 과반이 디스크에 flush했을 때 해당 레코드를 durable하다고 판단합니다. 이 장에서는 이런 계층이 필요한 이유를 설명합니다. 여러 Safekeeper가 하나의 일관된 WAL 이력을 유지하는 방법도 다룹니다.

요약

  • WAL proposer는 compute 안에서 실행되는 background worker이며 Safekeeper들에 WAL을 push합니다.
  • Safekeeper는 Paxos 용어로 acceptor입니다. Safekeeper끼리 WAL을 직접 복제하지 않습니다. WAL은 proposer를 거치고, 서로의 상태(peer 정보)는 storage broker를 통해 교환합니다.
  • Commit은 과반 Safekeeper가 해당 LSN까지 flush했다는 응답을 보낸 시점입니다.
  • Pageserver는 Safekeeper에서 일반 streaming replication 프로토콜로 WAL을 받습니다.
  • 여러 LSN 이름(CommitLSN, RestartLSN, FlushLSN, VCL)은 각각 다른 보장 수준을 나타냅니다.

왜 Pageserver에 바로 보내지 않는가

Neon 개발 문서의 Q&A를 재구성하면 이유는 두 가지입니다.

첫째, Pageserver는 단일 서버이므로 사라질 가능성이 있습니다. 최종 내구 저장소는 S3입니다. 하지만 매 commit마다 S3 업로드를 기다리면 지연이 지나치게 커집니다. 그래서 "최근 WAL을 잠시 안전하게 보관하는 임시 내구 저장소"가 필요합니다. Safekeeper가 그 역할을 합니다. WAL과 page가 S3까지 올라가면 Safekeeper의 WAL은 잘라낼 수 있습니다.

둘째, compute가 동시에 둘 이상 존재하는 시점이 있습니다. 하나를 내리고 하나를 올리는 교체 시점이 그렇습니다. 두 compute가 같은 timeline에 WAL을 쓰면 이력이 갈라집니다. 누가 primary인지 합의해야 합니다. Safekeeper의 consensus 프로토콜이 그 합의를 제공합니다.

구성 요소와 방향

일반 PostgreSQL의 streaming replication은 standby가 primary에 연결하는 pull 방식입니다. Neon에서는 반대로 compute 안의 WAL proposer가 Safekeeper에 연결하는 push 방식을 사용합니다. Core 변경을 줄이기 위해 proposer는 PostgreSQL 내부의 WAL sender에서 WAL을 받습니다. 받은 WAL은 그대로 broadcast합니다. 반면 Pageserver와 read replica는 Safekeeper에 연결해 WAL을 받습니다. 어느 Safekeeper든 WAL 서버 역할을 합니다. min(commitLSN, flushLSN)까지 스트리밍한 뒤 새 데이터가 도착할 때까지 멈춥니다.

개발 문서 walservice.md의 Q&A는 Safekeeper가 compute를 통해서만 메시지를 주고받는다고 설명합니다. 다만 이는 초기 설계 시점의 서술입니다. 현재는 storage broker가 Safekeeper와 Pageserver의 상태 정보를 중계합니다. 실습 compose의 storage_broker 컨테이너가 그 역할을 합니다. WAL 데이터 자체가 Safekeeper 사이를 직접 오가지 않는다는 점은 그대로입니다.

Compute 쪽 설정에서 이 연결을 확인할 수 있습니다.

{ "name": "neon.safekeepers", "value": "safekeeper1:5454,safekeeper2:5454,safekeeper3:5454" },
{ "name": "synchronous_standby_names", "value": "walproposer" },
{ "name": "max_wal_senders", "value": "10" },
{ "name": "wal_keep_size", "value": "0" }

synchronous_standby_names=walproposer는 PostgreSQL의 동기 복제 장치를 재사용하는 설정입니다. PostgreSQL은 이름이 등록된 동기 standby가 flush를 확인할 때까지 commit 응답을 보류합니다. WAL proposer가 그 standby 역할을 맡습니다. quorum이 확인한 LSN을 보고하는 시점에 commit 응답 보류를 해제합니다. wal_keep_size=0도 눈여겨볼 값입니다. Compute는 WAL을 오래 보관할 필요가 없습니다. Safekeeper로 아직 넘기지 못한 구간만 replication slot으로 보관합니다.

LSN 이름 정리

Safekeeper 프로토콜에는 서로 다른 보장 수준을 가리키는 LSN이 여러 개 등장합니다.

이름누가 계산
FlushLSN해당 Safekeeper가 로컬 디스크에 flush한 위치각 Safekeeper. 재시작 시 로컬 WAL 파일을 스캔해 재계산
CommitLSNquorum이 flush를 확인한 위치. 이 LSN까지는 commit으로 인정proposer. 정렬한 flushLSN 배열에서 [nSafekeepers - quorum] 위치 값
RestartLSN모든 Safekeeper가 받은 위치. 이전 WAL은 어느 노드에서든 지워도 됨proposer. 메시지 큐 head의 LSN
VCL (Volume Complete LSN)이 LSN 이전의 모든 레코드가 확실히 존재한다고 보장할 수 있는 최대 위치새 proposer가 recovery 시점에 quorum의 (epoch, flushLSN) 최대값으로 계산

Safekeeper 3대 중 quorum이 2라면 CommitLSN은 정렬된 flushLSN 세 개 중 두 번째로 큰 값입니다. 가장 큰 값을 가진 한 대만 받은 레코드는 아직 commit이 아닙니다.

Pageserver 쪽에도 별도 LSN이 있습니다. last_record_lsn은 마지막으로 처리한 WAL 레코드 끝입니다. disk_consistent_lsn은 로컬 디스크에 fsync를 완료한 위치입니다. remote_consistent_lsn은 S3 업로드를 완료한 위치입니다. Compute의 pg_current_wal_flush_lsn()부터 Pageserver의 remote_consistent_lsn까지 차례로 뒤처집니다. 이는 정상 상태입니다.

Handshake와 Epoch

새 compute가 기동하거나 PostgreSQL이 재시작되면 proposer는 새 선거를 시작합니다. 절차는 다음과 같습니다.

  1. 서버 정보(WAL segment 크기, system_id 등)를 모든 Safekeeper에 broadcast합니다.
  2. 각 Safekeeper의 상태(term, epoch, flushLSN, restartLSN)를 받습니다.
  3. Quorum만큼 응답이 모이면 NodeId(max(term)+1, server.uuid)를 제안합니다.
  4. 제안된 NodeId가 저장된 값 이상이면 Safekeeper가 수락합니다. 그런 다음 control file에 기록합니다. 더 작으면 거절합니다.
  5. Quorum이 수락하면 handshake가 끝나고 recovery 단계로 넘어갑니다.

Term은 선거 회차입니다. epoch는 "어느 세대의 WAL 이력인가"를 나타냅니다. 둘은 증가 규칙이 다릅니다. Safekeeper는 투표 직후 epoch를 올리지 않습니다. 새 proposer로부터 max(flushLSN, VCL)을 넘는 레코드를 실제로 받은 뒤에야 epoch를 바꿉니다. 이전 세대의 레코드를 모두 복구한 뒤 새 세대로 넘어간다는 뜻입니다.

Recovery: 갈라진 WAL을 하나로 맞추기

Handshake가 끝난 proposer는 quorum의 max(restartLSN)max(flushLSN)을 비교합니다. 두 값이 다르면 recovery가 필요합니다. 가장 앞선 Safekeeper에서 해당 구간의 WAL을 내려받습니다. 그런 다음 뒤처진 Safekeeper들에 다시 보냅니다.

프로토콜 문서의 예시를 살펴봅니다. Safekeeper 세 대 S1, S2, S3가 있습니다. Si(N)은 epoch N을 뜻합니다. Ri(x)는 LSN i에 자원 x를 기록한 레코드입니다.

S1(1): R1(a)
S2(1): R1(a),R2(b)
S3(1): R1(a),R2(b),R3(c),R4(d) <- offline

S3가 꺼진 상태에서 새 proposer가 quorum (S1, S2)를 선택하면 VCL은 2입니다. S2의 R2를 S1에 복사합니다. 그런 다음 새 레코드 R5를 R3 위치에 쓰기 시작합니다.

S1(2): R1(a),R2(b),R3(e)
S2(2): R1(a),R2(b),R3(e)
S3(1): R1(a),R2(b),R3(c),R4(d) <- offline

이제 어떤 quorum을 선택하더라도 VCL은 3입니다. S3의 epoch가 1로 더 작으므로 S3의 R3(c), R4(d)는 비교 대상에서 제외됩니다. S3가 돌아오면 R3(c)를 R3(e)로 덮어씁니다. R4까지 덮어쓴 뒤에야 S3의 epoch를 3으로 맞춥니다.

S1(3): R1(a),R2(b),R3(e),R4(f)
S2(3): R1(a),R2(b),R3(e),R4(f)
S3(3): R1(a),R2(b),R3(e),R4(f)

여기서 (epoch, flushLSN) 쌍으로 비교한다는 점이 중요합니다. flushLSN만 보면 S3가 가장 앞섭니다. 그러면 잘못된 이력이 유지됩니다. Epoch를 먼저 비교하므로 오래된 세대의 레코드는 새 세대보다 우선할 수 없습니다.

반대로 epoch가 바뀌기 전에 crash가 발생하면 어떻게 될까요. 초기 상태에서 recovery 도중 crash가 발생했다고 가정합니다. 이때 S1, S2는 R2까지 맞춰진 상태입니다. 다음 선거에서 S3가 quorum에 포함되면 세 대가 모두 epoch 1입니다. 따라서 가장 앞선 S3의 VCL 4가 선택됩니다. S1, S2는 S3의 R3(c), R4(d)를 받습니다. 어느 쪽이든 "quorum이 확인한 레코드는 잃지 않는다"는 성질은 유지됩니다. 어느 이력이 유지될지는 crash 시점에 따라 달라집니다. 하지만 그 레코드들은 아직 commit 응답을 받지 못한 것들입니다.

정상 운영 루프

Recovery가 끝나면 proposer는 단순한 루프에 들어갑니다. PostgreSQL에서 WAL 메시지를 받아 큐에 넣습니다. 그런 다음 각 Safekeeper에 순서대로 보냅니다. 큐 원소마다 어느 Safekeeper가 받았는지 비트마스크로 기록합니다.

  • 모든 Safekeeper가 받은 원소는 큐에서 제거되고 RestartLSN이 전진합니다.
  • 응답으로 받은 flushLSN들을 정렬해 CommitLSN을 갱신합니다. PostgreSQL에 알려 commit 응답 보류를 해제합니다.
  • Safekeeper와 연결이 끊기면 같은 NodeId로 재연결을 시도합니다.

프로토콜 문서가 밝힌 한계도 있습니다. 메시지 큐는 메모리에만 있고 디스크로 spill되지 않습니다. 어떤 Safekeeper가 오랫동안 뒤처지면 큐가 증가합니다. 로컬 데이터를 잃은 Safekeeper는 외부 장치로 복구한다고 가정합니다.

Backpressure

Compute와 Safekeeper의 LSN이 Pageserver보다 크게 앞서면 문제가 생깁니다. Compute가 evict한 page를 다시 요청할 때 Pageserver는 해당 LSN까지의 WAL을 기다립니다. 그 WAL을 받기 전에는 응답하지 못합니다. 따라서 뒤처진 만큼 page 요청이 대기합니다. 심하면 timeout이 발생합니다.

그래서 compute에는 세 가지 lag 한도가 있습니다.

{ "name": "max_replication_write_lag", "value": "500MB" },
{ "name": "max_replication_flush_lag", "value": "10GB" }
설정기준이 되는 Pageserver 위치
max_replication_write_lagWAL을 받은 위치(write)
max_replication_flush_lag로컬 디스크에 flush한 위치
max_replication_apply_lag반영(apply)까지 끝낸 위치

Compute의 현재 LSN(pg_current_wal_flush_lsn())과 Pageserver가 보고한 위치의 차이를 계산합니다. 이 차이가 한도를 넘으면 쓰기를 수행하는 backend가 대기합니다. Pageserver가 따라올 때까지 대기가 이어집니다. PostgreSQL의 ProcessInterrupts()에 추가된 hook이 이 차단을 구현합니다. 이 hook은 core 패치 항목 중 하나입니다. Safekeeper는 feedback 메시지로 Pageserver 수신 지표를 compute에 알립니다. 같은 경로로 logical size도 전달하며 Free 플랜의 용량 제한에 사용합니다.

실패 사례

Safekeeper 3대 중 2대가 동시에 중지되면 quorum이 성립하지 않습니다. 따라서 쓰기가 멈춥니다. 읽기는 Pageserver가 이미 받은 범위 안에서 계속됩니다. 1대만 중지되면 쓰기는 계속됩니다. 다만 RestartLSN이 전진하지 못해 메시지 큐가 증가합니다. 중지된 노드가 복구되면 recovery 트래픽이 발생합니다.

Pageserver가 오래 멈추면 backpressure로 쓰기 처리량이 급격히 감소합니다. 이때 증상은 "commit이 느리다"가 아닙니다. "특정 INSERT/UPDATE가 간헐적으로 몇 초씩 멈춘다"에 가깝습니다. 원인을 찾으려면 compute와 Pageserver의 LSN 차이를 먼저 확인해야 합니다.

연습 문제

  1. Safekeeper 5대에 quorum 3인 구성을 가정하고, flushLSN이 [100, 120, 130, 130, 150]일 때 CommitLSN을 계산합니다.
  2. 실습 환경(3.1)에서 safekeeper 한 컨테이너를 중지한 뒤 쓰기가 계속되는지, 두 개를 중지하면 어떻게 되는지 관찰합니다. 관찰 후 다시 기동합니다.
  3. synchronous_standby_names를 비우면 어떤 보장이 깨지는지 설명합니다.

심화 체크

  • Epoch와 term의 증가 시점이 왜 달라야 하는지 설명할 수 있는가?
  • 새 proposer가 VCL을 max(flushLSN)으로 잡는 이유는 무엇인가? 더 작은 값을 쓰면 무엇을 잃는가?
  • Backpressure 세 한도 중 어느 것이 가장 먼저 걸리는 것이 일반적인가?

참고