콘텐츠로 이동

(KO) PostgreSQL 복제 슬롯 — WAL 보존과 catalog_xmin

목차

로그를 전체 스냅샷 대신 전송하는 복제 방식은 공통된 가비지 컬렉션 문제를 안고 있다. 프라이머리는 쓰기 선행 로그(WAL, write-ahead log)를 꾸준히 생성하고, 다운스트림 소비자는 자신의 속도로 그것을 읽는다. 프라이머리 입장에서는 자체 크래시 복구에 더 이상 필요 없는 오래된 로그 세그먼트를 재활용하고 싶다. 그러나 “더 이상 필요 없다”는 조건에 두 번째 절이 붙는다. 소비자도 더 이상 필요로 하지 않아야 한다. 뒤처진 스탠바이가 아직 읽어야 할 세그먼트를 프라이머리가 재활용해 버리면, 그 스탠바이는 결코 따라잡을 수 없다. 새로운 베이스 백업부터 다시 시작해야 한다.

Kleppmann의 Designing Data-Intensive Applications (5장, “Replication”)은 이것을 **로그 기반 복제(log-based replication)**의 핵심 긴장으로 정의한다. 리더의 로그는 단일 진실 원천이고, 팔로워는 그 로그를 위치 단위로 재실행한다. “리더는 모든 팔로워가 소비할 때까지 로그 항목을 보존해야 한다.” 단순한 해법처럼 보이는 “WAL 전체 영구 보존”은 정확성 버그 대신 운영 버그를 초래한다. 프라이머리 디스크가 무한정 차오른다. 반대 극단인 고정 윈도우(wal_keep_size, 아카이브 후 삭제)는 추측에 불과하다. 윈도우가 너무 작으면 잠시 연결이 끊긴 스탠바이가 윈도우 밖으로 밀려난다. 너무 크면 이미 따라잡은 소비자를 위해 디스크를 낭비한다. 시스템이 실제로 원하는 것은 피드백 채널이다. 각 소비자가 자신이 어디까지 소비했는지를 게시하면, 프라이머리는 가장 느린 소비자까지만 거슬러 올라가 정확하게 보존한다.

물리적 복제에서 이 피드백은 숫자 하나다. 어떤 스탠바이도 아직 필요로 하는 가장 오래된 WAL 위치(LSN)다. 논리적 복제(DDIA 11장, “Stream Processing” / 변경 데이터 캡처)에는 두 번째, 더 미묘한 자원이 등장한다. 논리 디코더는 WAL을 읽어 행 수준 변경을 재구성한다. 이때 변경이 발생한 당시의 시스템 카탈로그 상태, 즉 그 시점의 테이블 컬럼 레이아웃, 타입, TOAST 매핑을 참조해야 한다. 일반적인 MVCC vacuum은 실행 중인 트랜잭션이 더 이상 볼 수 없는 오래된 카탈로그 행 버전을 자유롭게 제거한다. 그러나 한 달 전 WAL을 재실행하는 논리 디코더는 한 달 전 카탈로그가 필요하다. 따라서 논리적 소비자는 오래된 WAL뿐 아니라 오래된 카탈로그 스냅샷도 고정해야 한다. 이는 카탈로그 튜플을 vacuum이 제거해서는 안 되는 트랜잭션 ID 경계(horizon)로 표현된다.

Database Internals(Petrov, 2019, WAL 장)는 생산자 측 메커니즘 어휘를 제공한다. 로그는 단조 증가 오프셋(LSN)으로 주소가 붙은 레코드의 시퀀스다. “저수위 표시(low-water mark)“가 지나간 세그먼트는 재활용된다. 내구성은 fsync 순서에 의존한다. 복제 슬롯은 정확히 소비자가 등록한 이름 있는 영속 저수위 표시다. 생산자의 재활용 로직이 반드시 존중해야 하는 표시다. 단순히 숫자를 저장하는 것 이상으로 두 가지 속성이 필요하다.

  1. 내구성. 북마크는 프라이머리 크래시에서 살아남아야 한다. 휘발성 메모리에만 존재한다면 크래시 후 재시작 시 소비자의 위치가 사라지고, 프라이머리는 소비자가 아직 필요한 WAL을 재활용할 수 있다. 따라서 슬롯은 디스크에 기록되어 복구 전에 다시 로드된다.
  2. 원자적 게시. 슬롯이 여러 개 존재할 수 있다. 보존 결정은 전체 슬롯에 대한 최솟값이다. 그 최솟값 계산과, 슬롯 생성 시 WAL을 예약하는 역방향 연산은 동시에 진행 중인 체크포인트와 안전하게 맞물려야 한다. 체크포인트가 동시에 재활용할 세그먼트를 결정 중이기 때문이다. 그렇지 않으면 슬롯이 예약한 세그먼트를 체크포인트가 이미 삭제 결정한 후일 수 있다.

이 두 속성 — 크래시 안전 영속성과 재활용기에 대한 레이스 없는 최솟값/예약 — 이 전체 엔지니어링 문제의 본질이며 소스 코드의 지배적 관심사다.

카탈로그 경계(horizon)가 WAL 경계와 왜 분리되는지 잠시 살펴볼 만하다. 이 두 숫자 설계가 PostgreSQL 슬롯의 가장 독특한 특징이기 때문이다. WAL 보존은 바이트에 관한 질문이다. 디스크의 어떤 물리 세그먼트를 unlink하면 안 되는가. 카탈로그 보존은 가시성에 관한 질문이다. pg_class, pg_attribute, pg_type 등의 어떤 역사적 행 버전이 디코딩 시점에 취한 스냅샷에서 보여야 하는가. 단위가 다르다(LSN 대 트랜잭션 ID). 소비하는 서브시스템도 다르다(WAL 재활용기 대 vacuum의 oldest-xmin). 따라서 두 경계를 하나의 값으로 표현할 수 없다. 물리 스탠바이는 첫 번째만 필요하다. WAL을 블록 단위로 재실행하며 행 내용을 해석하지 않으므로 카탈로그에 의견이 없다. 논리 디코더는 둘 다 필요하다. 변경을 읽기 위한 WAL 바이트와, 그것을 해석하기 위한 카탈로그 스냅샷. 슬롯은 두 경계를 모두 가지며, database == InvalidOid 판별자가 실제로 채워지는 필드를 선택한다.

피드백 채널 모델의 또 다른 결과는 이동 방향이 중요하다는 점이다. 소비자가 보고하는 위치는 오직 앞으로만 움직일 수 있다. 슬롯이 LSN X까지 소비했다고 확인하면 생산자는 X 아래 모든 것을 폐기할 수 있다. 그러나 이 확인을 취소할 수는 없다. 슬롯이 뒤로 이동하는 것이 허용된다면, 생산자가 이미 재활용한 WAL을 슬롯이 다시 요구하는 상황이 발생할 수 있다. 따라서 restart_lsnconfirmed_flush의 단조성은 편의가 아니라 정확성 불변(correctness invariant)이며, 전진 경로는 현재 최솟값 아래의 목표를 명시적으로 거부한다.

로그 배송 시스템은 “생산자가 무엇을 보존할지 어떻게 알 것인가”라는 문제를 풀 때 작은 설계 공간으로 수렴한다.

  • 고정 보존 윈도우. 소비자와 무관하게 마지막 N 세그먼트 / T 시간 / S 바이트를 보존한다(PostgreSQL 9.4 이전 wal_keep_segments, MySQL의 binlog_expire_logs_seconds). 단순하고 범위가 한정되지만, 윈도우 밖에 있는 소비자는 잃는다. 윈도우는 실제 소비자 진행 상황과 분리된 추측이다.
  • 소비자 등록 보존(슬롯 / 북마크). 각 소비자가 내구성 있는 마커를 등록하면 생산자는 가장 오래된 마커까지 거슬러 보존한다. PostgreSQL 복제 슬롯, Kafka 소비자 그룹 커밋 오프셋, Oracle GoldenGate 체크포인트 테이블이 여기에 속한다. 생산자의 재활용기는 모든 마커에 대한 최솟값을 취한다. 장점은 등록된 소비자를 잃지 않는다는 것이다. 단점은 등록 해제 없이 죽은 소비자가 자원을 영원히 고정한다는 것이다. 흔히 “남겨진 슬롯” 디스크 꽉 참 사고가 이 때문이다.
  • 아카이브에서 당겨 오기. 로컬 디스크에는 적극적으로 재활용하되 모든 세그먼트를 저렴한 스토리지(S3, archive_command)에 아카이브한다. 뒤처진 소비자는 아카이브에서 복원한다. 로컬 디스크를 소비자 지연에서 분리하지만 아카이브 지연과 별도의 복원 경로가 생긴다.

카탈로그 스냅샷 문제를 놓고 보면, 논리적 CDC를 수행하는 시스템은 모두 “디코더가 여전히 필요한 스키마/행 버전을 가비지 컬렉션하지 말라”는 형태의 메커니즘이 필요하다. 접근 방식은 캡처 시작 시 스키마를 스냅샷하는 방법(Debezium 방식 외부 CDC)부터 엔진 자체의 MVCC GC에 통합하는 방법까지 다양하다. PostgreSQL은 통합 경로를 택한다. 논리 슬롯의 catalog_xmin은 vacuum을 제한하는 동일한 전역 oldest-xmin 계산에 게시된다. 디코더의 요구와 vacuum의 자유가 외부 스키마 레지스트리를 거치지 않고 한 곳에서 조율된다.

잠금 구조도 관용적이다. 슬롯 집합은 소규모 고정 크기 공유 배열이다. 슬롯은 희소하고 수명이 길어 동적 해시가 정당화되지 않는다. 멤버십(어떤 배열 항목이 활성인지)을 보호하는 거친(coarse) 잠금과, 가변 필드를 보호하는 세밀한(fine-grained) 항목별 잠금이 있다. 보존 최솟값은 수평선을 바꿀 수 있는 모든 이벤트 — 슬롯 생성, 전진, 삭제, 해제 — 마다 재계산되어 소비자 서브시스템(xlog 재활용기, ProcArray의 vacuum horizon)에 좁은 세터로 게시된다.

재계산 및 게시 주기 자체도 두 가지 실행 가능한 극단이 있는 설계 선택이다. 한 극단은 모든 변경을 즉시 동기적으로 밀어내는 것이다(낮은 지연, 높은 경합). 다른 극단은 소비자 서브시스템이 요청할 때만 게으르게(lazily) 재계산하는 것이다(낮은 경합, 가능한 지연). PostgreSQL은 하이브리드를 택한다. 두 개의 뜨거운 최솟값 — 필요한 LSN과 필요한 xmin — 은 열심히(eagerly) 유지된다. 수평선을 움직이는 모든 이벤트마다 재계산되어 xlog 모듈과 ProcArray에 각각 캐시된다. WAL 재활용기와 vacuum이 높은 빈도로 이 값을 참조하며 슬롯 배열을 직접 순회하는 비용을 감당할 수 없기 때문이다. 더 차가운 논리적 재시작 LSN은 요청 시 계산된다. 호출자가 드물기 때문이다. 이 분할 — 자주 읽히는 것은 캐시, 드물게 읽히는 것은 계산 — 은 모든 공유 상태 서브시스템이 내리는 동일한 트레이드오프다. 여기서 주목할 점은 열심히 하는 재계산이 의도적으로 공유 잠금만 사용하고 순간적으로 지연된(항상 보수적인) 결과를 허용한다는 것이다. 모든 슬롯 활동을 배타 잠금 뒤에 직렬화하지 않기 위해서다.

마지막으로 언급할 구조적 결정은 생성과 소유권의 융합이다. 많은 시스템에서 “자원 생성”과 “사용을 위한 획득”은 별도 호출이다. PostgreSQL은 슬롯에서 이를 융합한다(ReplicationSlotCreate는 생성 백엔드를 활성 소유자로 남긴다). 아무도 소유하지 않는 새로 생성된 슬롯은 부채다. WAL을 고정하지만 앞으로 진행시킬 소비자가 없다. 바로 죽은 슬롯 실패 모드다. 생성자를 소유자로 만들면 슬롯의 핀이 처음부터 살아 있는 프로세스와 연결된다. 슬롯은 소유자가 명시적으로 영속화하고 해제한 후에야 독립적인 내구성 있는 객체가 된다.

PostgreSQL은 슬롯을 max_replication_slots로 크기가 결정된 고정 크기 공유 메모리 배열에 사는 ReplicationSlot 구조체로 구현한다. 내구성 상태와 휘발성 상태 사이의 분리가 타입에서 명시적이다. 디스크 저장 부분은 중첩된 ReplicationSlotPersistentData data 하위 구조체다. 그 바깥의 모든 것은 시작 시 재건된다.

// ReplicationSlotPersistentData — src/include/replication/slot.h
typedef struct ReplicationSlotPersistentData
{
NameData name; /* the slot's identifier */
Oid database; /* InvalidOid => physical, else logical */
ReplicationSlotPersistency persistency; /* PERSISTENT/EPHEMERAL/TEMPORARY */
TransactionId xmin; /* data xmin horizon */
TransactionId catalog_xmin; /* catalog xmin horizon (logical) */
XLogRecPtr restart_lsn; /* oldest WAL this slot may require */
ReplicationSlotInvalidationCause invalidated; /* RS_INVAL_NONE if valid */
XLogRecPtr confirmed_flush; /* oldest LSN the client has acked */
/* ... two_phase_at, two_phase, plugin, synced, failover ... */
} ReplicationSlotPersistentData;

두 보존 조절 장치는 restart_lsn(소비자가 아직 읽을 수 있는 가장 오래된 WAL 바이트 위치 — WAL 저수위 표시)과 catalog_xmin(소비자가 아직 필요할 수 있는 가장 오래된 카탈로그 트랜잭션 ID — vacuum horizon, 논리 전용)이다. database == InvalidOid는 두 매크로로 인코딩된 물리 슬롯과 논리 슬롯 사이의 판별자다.

// slot type discriminators — src/include/replication/slot.h
#define SlotIsPhysical(slot) ((slot)->data.database == InvalidOid)
#define SlotIsLogical(slot) ((slot)->data.database != InvalidOid)

물리 슬롯은 WAL만 고정한다. catalog_xmin은 invalid로 남긴다. 논리 슬롯은 데이터베이스 범위로 한정된다(생성된 데이터베이스만 디코딩할 수 있다). WAL과 카탈로그 스냅샷 둘 다 고정한다. 물리 슬롯도 굳이 xmin을 고정하는 이유는 핫 스탠바이 피드백(hot-standby feedback) 때문이다. 스탠바이가 실행 중인 쿼리에 아직 필요한 행을 프라이머리가 vacuum하지 않도록 요청할 수 있으며, 그 경계가 물리 슬롯의 xmin에 실린다.

persistency 필드는 세 값을 가진 열거형이며, 슬롯 내구성에 대한 작은 상태 기계를 인코딩한다.

  • RS_TEMPORARY — 슬롯은 현재 세션 동안만 존재하며 연결 해제나 오류 시 삭제된다. 실행 중에만 WAL을 보존하고 싶은 단명 소비자(예: pg_basebackup)가 사용한다.
  • RS_EPHEMERAL — 영속 슬롯을 만드는 중간 상태다. 승격 전에 해제되면 삭제된다. 생성 도중 백엔드가 크래시해도 고아 슬롯을 남기지 않는다. ReplicationSlotPersist가 완전히 초기화된 후 임시(ephemeral) 슬롯을 영속으로 승격한다.
  • RS_PERSISTENT — 크래시 안전하고 내구성 있다. 해제, 세션 종료, 재시작에도 살아남는다. 명시적인 삭제나 무효화에 의해서만 제거된다.

이 단계적 접근은 유효(effective) 대 영속(persistent) xmin 규칙의 내구성 유사체다. 슬롯은 완전히 형성된 후에야 내구성 있는 자원 고정 객체가 된다. 그 이전 어느 시점에서 실패해도 핀을 누수시키지 않고 자동으로 정리된다.

공유 구조체는 영속 데이터를 휘발성 조율 필드로 감싼다.

// ReplicationSlot — src/include/replication/slot.h
typedef struct ReplicationSlot
{
slock_t mutex; /* protects the individual fields below */
bool in_use; /* is this array entry a live slot? */
pid_t active_pid; /* who is streaming this slot? 0 = nobody */
bool just_dirtied;
bool dirty; /* unsaved changes since last flush? */
TransactionId effective_xmin; /* latest xmin actually on disk */
TransactionId effective_catalog_xmin;
ReplicationSlotPersistentData data; /* the crash-safe part */
LWLock io_in_progress_lock;
ConditionVariable active_cv; /* signaled when active_pid changes */
/* logical-only candidate fields used to advance horizons lazily: */
TransactionId candidate_catalog_xmin;
XLogRecPtr candidate_xmin_lsn;
XLogRecPtr candidate_restart_valid;
XLogRecPtr candidate_restart_lsn;
XLogRecPtr last_saved_confirmed_flush;
XLogRecPtr last_saved_restart_lsn;
/* ... inactive_since ... */
} ReplicationSlot;

헤더 주석에 명시된 두 가지 잠금 개념이 접근을 지배한다.

  • ReplicationSlotControlLock(클러스터 전역 LWLock)은 in_use 플래그를 보호한다. 백엔드가 in_use를 전환할 때(생성 / 삭제)는 배타적으로 획득한다. 다른 슬롯의 데이터를 읽기 위해 배열을 스캔할 때는 공유로 획득한다.
  • 슬롯별 mutex(스핀락)은 가변 필드를 보호한다. 슬롯 소유자는 스핀락 없이 자신의 필드를 읽을 수 있지만 쓸 때는 획득해야 한다. 비소유자는 읽기에도 스핀락을 잡는다.

effective_xmin / effective_catalog_xmin 쌍과 영속 data.xmin / data.catalog_xmin의 차이가 설계에서 가장 미묘한 부분이다. 논리 디코딩에서는 “크래시 이후에도 여전히 필요한 데이터를 절대로 제거하지 않는 것이 극히 중요하다”(헤더 주석). 따라서 실제로 vacuum과 WAL 재활용을 제한하는 유효한 값은 대응하는 영속 값이 디스크에 플러시된 이후에만 전진할 수 있다. 이 보장으로 크래시는 항상 온디스크 수평선을 인메모리 수평선보다 뒤에 남긴다. 앞에 남기는 일은 없다. 물리 슬롯의 경우 조기 제거의 최악 결과가 스탠바이에서 쿼리 취소이므로, 유효 값은 단순히 영속 값을 추가 지연 없이 추적한다.

flowchart TB
  subgraph SHMEM["Shared memory: ReplicationSlotCtl"]
    A["replication_slots[0..max_replication_slots-1]<br/>고정 배열 ReplicationSlot"]
    A --> S0["slot[0] in_use<br/>data.restart_lsn / data.catalog_xmin<br/>mutex + active_pid"]
    A --> S1["slot[1] in_use ..."]
    A --> SN["slot[N] free (in_use=false)"]
  end
  S0 -->|"최솟값 restart_lsn<br/>ComputeRequiredLSN"| XLOG["xlog 재활용기<br/>XLogSetReplicationSlotMinimumLSN"]
  S0 -->|"최솟값 catalog_xmin<br/>ComputeRequiredXmin"| PROC["ProcArray vacuum horizon<br/>ProcArraySetReplicationSlotXmin"]
  S0 -.->|"체크포인트: SaveSlotToPath"| DISK["pg_replslot/&lt;name&gt;/state<br/>tmp + fsync + rename"]
  DISK -.->|"StartupReplicationSlots<br/>redo 전"| A

보존 파이프라인이 모듈의 핵심이다. 수평선을 움직일 수 있는 모든 이벤트 — 슬롯 생성 시 WAL 예약, 전진, 해제, 삭제 — 마다 PostgreSQL은 클러스터 전역 최솟값을 재계산하고 게시한다.

  • ReplicationSlotsComputeRequiredLSN()은 배열을 순회해 유효한 restart_lsn의 최솟값을 취하고 XLogSetReplicationSlotMinimumLSN()을 호출한다. 다음 체크포인트는 그 LSN 아래 세그먼트를 재활용하지 않는다.
  • ReplicationSlotsComputeRequiredXmin()은 배열을 순회해 effective_xmineffective_catalog_xmin의 최솟값을 취하고 ProcArraySetReplicationSlotXmin()을 호출한다. 이후 vacuum의 전역 oldest-xmin 계산은 그 수평선 아래를 제거하지 않는다.

후보 필드(candidate_catalog_xmin, candidate_xmin_lsn, candidate_restart_lsn, candidate_restart_valid)는 논리 슬롯의 게으른 전진 기계를 구현한다. 소비자가 논리 디코딩 서브시스템에 속한 만큼 한 문장이라도 다룰 가치가 있다. 논리 디코더가 WAL을 읽을 때, 잠재적인 새 수평선을 안전하게 커밋하기 훨씬 전에 알게 된다. 클라이언트가 해당 출력을 플러시했다고 확인할 때까지 기다려야 한다. 후보 필드가 그 잠재 수평선을 준비 상태로 둔다. “클라이언트가 candidate_xmin_lsn 이상에서 플러시를 확인하면 catalog xmin을 candidate_catalog_xmin으로 전진시켜도 안전하다. candidate_restart_valid가 지나면 restart_lsncandidate_restart_lsn으로 올라갈 수 있다.” 이 조건이 충족될 때만 유효 수평선이 이동하고 재계산이 발생한다. 이 준비 작업이 카탈로그 수평선의 유효-절대-플러시-앞-불가 불변을 지지하는 메커니즘이다. last_saved_restart_lsn이 LSN 축에서 하는 역할을 xmin 축에서 미러링한다. 이 문서의 slot.c 함수들이 이 필드를 읽지만, 설정하는 로직은 logical.cLogicalConfirmReceivedLocation에 있으며 논리 디코딩 문서로 미룬다.

두 함수 모두 의도적으로 공유 잠금을 사용하며 일시적으로 후퇴하는 결과를 허용한다. 동시 전진이 계산된 최솟값을 일시적으로 실제보다 오래되게 만들 수 있지만 이는 무해하다. GC가 지연될 뿐, 조기 제거가 일어나지는 않는다. 이것이 모듈 전체에서 반복되는 안전 비대칭이다. 모든 레이스는 너무 많이 보존하는 방향으로 해결된다. 너무 적게 보존하는 방향이 아니다.

모듈의 생명주기는 다섯 흐름으로 나뉜다. 할당(생성 / 획득 / 해제 / 삭제), 보존 계산(최솟값/예약 함수들), 영속화(체크포인트와 시작 시 저장 / 복원), 전진 / 삭제(slotfuncs.c의 SQL 인터페이스), 무효화와 페일오버(뒤처짐 감지와 슬롯 동기화). 이들은 앞서 소개한 배열과 두 잠금을 공유한다.

ReplicationSlotCreate(slot.c)가 진입점이다. 이름을 검증한 후 ReplicationSlotAllocationLock 아래서(두 백엔드가 같은 빈 항목을 잡거나 같은 디렉터리를 놓고 싸우지 않도록 모든 슬롯 생성/정리를 직렬화) 이름 충돌과 빈 슬롯을 스캔하고, 영속 및 휘발성 필드를 초기화하고, 슬롯을 디스크에 기록한다. 그런 다음에만 배타적 ReplicationSlotControlLock 아래서 in_use를 설정한다.

// ReplicationSlotCreate — src/backend/replication/slot.c
LWLockAcquire(ReplicationSlotAllocationLock, LW_EXCLUSIVE);
LWLockAcquire(ReplicationSlotControlLock, LW_SHARED);
for (i = 0; i < max_replication_slots; i++)
{
ReplicationSlot *s = &ReplicationSlotCtl->replication_slots[i];
if (s->in_use && strcmp(name, NameStr(s->data.name)) == 0)
ereport(ERROR, (errcode(ERRCODE_DUPLICATE_OBJECT),
errmsg("replication slot \"%s\" already exists", name)));
if (!s->in_use && slot == NULL)
slot = s; /* first free entry */
}
LWLockRelease(ReplicationSlotControlLock);
if (slot == NULL)
ereport(ERROR, (errcode(ERRCODE_CONFIGURATION_LIMIT_EXCEEDED),
errmsg("all replication slots are in use"), ...));
/* ... memset(&slot->data, 0, ...); set name/database/persistency ... */
CreateSlotOnDisk(slot); /* materialize before marking in_use */
LWLockAcquire(ReplicationSlotControlLock, LW_EXCLUSIVE);
slot->in_use = true; /* now visible to scanners */

slot->data.database = db_specific ? MyDatabaseId : InvalidOid가 생성 시점에 물리 대 논리를 결정하는 줄이다. 새로 생성된 슬롯은 활성으로도 표시된다(active_pid = MyProcPid, MyReplicationSlot = slot). 생성 백엔드가 즉시 소유한다. 생성과 획득이 융합된다.

ReplicationSlotAcquire는 재연결 경로다(pg_replication_slot_advance, 시작하는 walsender 등이 사용). 이름으로 검색한 후 슬롯을 주장(active_pid = MyProcPid)하거나, 다른 PID가 보유 중이면 해제될 때까지 슬롯의 active_cv 조건 변수에서 대기한다. nowait가 요청되면 ERRCODE_OBJECT_IN_USE로 오류를 낸다. 체크포인터와의 레이스를 닫기 위해 소유권 획득 이후 무효화를 확인한다.

// ReplicationSlotAcquire — src/backend/replication/slot.c
MyReplicationSlot = s; /* the slot is ours now */
/* check invalidation AFTER acquiring to avoid a race with the checkpointer */
if (error_if_invalid && s->data.invalidated != RS_INVAL_NONE)
ereport(ERROR,
errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("can no longer access replication slot \"%s\"",
NameStr(s->data.name)),
errdetail("This replication slot has been invalidated due to \"%s\".",
GetSlotInvalidationCauseName(s->data.invalidated)));

ReplicationSlotRelease가 역방향이다. persistency에 대한 분기가 세 값 열거형의 핵심이다. RS_EPHEMERAL 슬롯(아직 완성되지 않은 영속 슬롯)은 해제 시 삭제된다. RS_PERSISTENT 슬롯은 단순히 비활성으로 표시(active_pid = 0)되고 살아남는다. 임시 슬롯도 마찬가지지만 세션 종료 시 사라진다. 초기 카탈로그 스냅샷을 위해 일시적으로 xmin을 제한하던 슬롯을 해제하면 그 제한이 지워지고 xmin 수평선이 재계산된다.

ReplicationSlotDropPtr가 내구성 있는 제거를 수행한다. 슬롯 디렉터리를 <name>.tmp로 이름 변경하고(원자적인 “더 이상 유효한 슬롯이 아님” 단계), 부모를 fsync하고, 제어 잠금 아래서 in_use를 지운 후 두 보존 최솟값을 재계산한다. 삭제된 슬롯은 더 이상 아무것도 고정하지 않기 때문이다.

// ReplicationSlotDropPtr — src/backend/replication/slot.c
if (rename(path, tmppath) == 0) { /* atomic invalidation on disk */
START_CRIT_SECTION();
fsync_fname(tmppath, true);
fsync_fname(PG_REPLSLOT_DIR, true);
END_CRIT_SECTION();
}
LWLockAcquire(ReplicationSlotControlLock, LW_EXCLUSIVE);
slot->active_pid = 0;
slot->in_use = false; /* entry returns to the free pool */
LWLockRelease(ReplicationSlotControlLock);
/* Slot is dead and doesn't prevent resource removal anymore, recompute limits. */
ReplicationSlotsComputeRequiredXmin(false);
ReplicationSlotsComputeRequiredLSN();

ReplicationSlotReserveWal이 새 슬롯의 restart_lsn을 설정한다. 앵커 선택은 슬롯 유형과 복구 상태에 따라 다르다. 물리 슬롯은 마지막 체크포인트 redo 포인터에 앵커를 둔다(베이스 백업이 재실행을 시작할 위치). 프라이머리의 논리 슬롯은 현재 삽입 포인터에 앵커를 두고 스탠바이 스냅샷을 기록해 디코딩에 일관된 시작점을 제공한다.

// ReplicationSlotReserveWal — src/backend/replication/slot.c
LWLockAcquire(ReplicationSlotAllocationLock, LW_EXCLUSIVE); /* serialize vs checkpoint */
if (SlotIsPhysical(slot))
restart_lsn = GetRedoRecPtr();
else if (RecoveryInProgress())
restart_lsn = GetXLogReplayRecPtr(NULL);
else
restart_lsn = GetXLogInsertRecPtr();
SpinLockAcquire(&slot->mutex);
slot->data.restart_lsn = restart_lsn;
SpinLockRelease(&slot->mutex);
ReplicationSlotsComputeRequiredLSN(); /* prevent WAL removal ASAP */
XLByteToSeg(slot->data.restart_lsn, segno, wal_segment_size);
if (XLogGetLastRemovedSegno() >= segno) /* lost the race: segment already gone */
elog(ERROR, "WAL required by replication slot %s has been removed concurrently", ...);

여기서 배타적 ReplicationSlotAllocationLock이 체크포인터와 연동하는 잠금이다(체크포인터는 슬롯을 플러시하는 동안 공유로 보유한다). 예약이 먼저 일어나면 체크포인트가 새 restart_lsn을 봐야 한다. 체크포인트가 먼저 일어나면 예약은 그 체크포인트의 redo 포인터 이후에 위치한다. 사후 XLogGetLastRemovedSegno 확인이 남은 창을 잡는다.

ReplicationSlotsComputeRequiredLSN이 WAL 저수위 표시 집계자다. 영속 슬롯 미묘점에 주목할 필요가 있다. 라이브 restart_lsn이 아니라 last_saved_restart_lsn을 사용한다. 더 새로운 restart_lsn이 플러시되기 전에 프라이머리가 크래시하면 두 값 사이의 세그먼트가 여전히 필요하기 때문이다.

// ReplicationSlotsComputeRequiredLSN — src/backend/replication/slot.c
for (i = 0; i < max_replication_slots; i++) {
/* ... read restart_lsn, last_saved_restart_lsn, persistency, invalidated under mutex ... */
if (invalidated) continue; /* invalidated slots pin nothing */
if (persistency == RS_PERSISTENT) {
if (last_saved_restart_lsn != InvalidXLogRecPtr &&
restart_lsn > last_saved_restart_lsn)
restart_lsn = last_saved_restart_lsn; /* be conservative across crash */
}
if (restart_lsn != InvalidXLogRecPtr &&
(min_required == InvalidXLogRecPtr || restart_lsn < min_required))
min_required = restart_lsn;
}
XLogSetReplicationSlotMinimumLSN(min_required);

ReplicationSlotsComputeRequiredXmin이 카탈로그 수평선 유사체다. effective_xmineffective_catalog_xmin의 최솟값을 집계한다(유효-대-영속 규칙에 따라 디스크 플러시 값). 이를 vacuum이 전역 oldest-xmin을 읽는 ProcArray에 밀어 넣는다.

// ReplicationSlotsComputeRequiredXmin — src/backend/replication/slot.c
for (i = 0; i < max_replication_slots; i++) {
/* ... read effective_xmin, effective_catalog_xmin, invalidated under mutex ... */
if (invalidated) continue;
if (TransactionIdIsValid(effective_xmin) &&
(!TransactionIdIsValid(agg_xmin) ||
TransactionIdPrecedes(effective_xmin, agg_xmin)))
agg_xmin = effective_xmin;
if (TransactionIdIsValid(effective_catalog_xmin) &&
(!TransactionIdIsValid(agg_catalog_xmin) ||
TransactionIdPrecedes(effective_catalog_xmin, agg_catalog_xmin)))
agg_catalog_xmin = effective_catalog_xmin;
}
ProcArraySetReplicationSlotXmin(agg_xmin, agg_catalog_xmin, already_locked);

ReplicationSlotsComputeLogicalRestartLSN은 논리 전용 변형이다(물리 슬롯 건너뜀). 디코딩에 특별히 필요한 가장 오래된 WAL이 필요한 호출자가 사용하며, 캐시 대신 요청 시 계산된다.

영속화: 체크포인트 시 더티 플러시, redo 전 복원

섹션 제목: “영속화: 체크포인트 시 더티 플러시, redo 전 복원”

ReplicationSlotMarkDirty로 영속 필드가 변경될 때마다 슬롯이 더티 표시된다. 슬롯 생성과 영속화 시점에 ReplicationSlotSaveSaveSlotToPath로 명시적으로 플러시된다. 대부분의 플러시는 매 체크포인트마다 CheckPointReplicationSlots가 게으르게 처리한다. 모든 사용 중인 슬롯을 순회하며 공유 ReplicationSlotAllocationLock 아래서(in_use를 고정하기에 충분하고, 획득을 진행할 만큼 약한) 각 슬롯에 SaveSlotToPath를 호출한다.

// CheckPointReplicationSlots — src/backend/replication/slot.c
LWLockAcquire(ReplicationSlotAllocationLock, LW_SHARED);
for (i = 0; i < max_replication_slots; i++) {
ReplicationSlot *s = &ReplicationSlotCtl->replication_slots[i];
if (!s->in_use) continue;
sprintf(path, "%s/%s", PG_REPLSLOT_DIR, NameStr(s->data.name));
if (is_shutdown && SlotIsLogical(s)) { /* force-flush confirmed_flush at shutdown */
SpinLockAcquire(&s->mutex);
if (s->data.invalidated == RS_INVAL_NONE &&
s->data.confirmed_flush > s->last_saved_confirmed_flush) {
s->just_dirtied = true;
s->dirty = true;
}
SpinLockRelease(&s->mutex);
}
if (s->last_saved_restart_lsn != s->data.restart_lsn)
last_saved_restart_lsn_updated = true;
SaveSlotToPath(s, path, LOG);
}
LWLockRelease(ReplicationSlotAllocationLock);
if (last_saved_restart_lsn_updated)
ReplicationSlotsComputeRequiredLSN(); /* WAL can now be recycled further */

SaveSlotToPath가 원자적 쓰기 작업자다. !dirty면 건너뛴다. 스핀락 아래서 slot->data를 체크섬이 붙은 ReplicationSlotOnDisk 레코드에 복사하고, state.tmp에 쓰고, fsync한 후 state로 이름 변경하고 디렉터리를 fsync한다. 쓰기 중 크래시가 일어나도 이전의 좋은 state 파일이 그대로 남는다는 것이 쓰기-임시-후-이름변경 관용구의 보장이다. 꼬리 부분에서 두 last_saved_* 필드를 업데이트하고 더티 비트를 지운다. 단, I/O 중에 아무도 슬롯을 다시 더티로 만들지 않았을 때만 더티 비트를 지운다. 그래서 just_dirtieddirty와 별도 플래그로 존재한다.

// SaveSlotToPath — src/backend/replication/slot.c (tail)
if (rename(tmppath, path) != 0) { /* ... unlink, release io lock, ereport ... */ return; }
START_CRIT_SECTION();
fsync_fname(path, false);
fsync_fname(dir, true);
fsync_fname(PG_REPLSLOT_DIR, true);
END_CRIT_SECTION();
/* Successfully wrote; unset dirty unless somebody dirtied again already, */
/* and remember the flushed confirmed_flush / restart_lsn. */
SpinLockAcquire(&slot->mutex);
if (!slot->just_dirtied)
slot->dirty = false;
slot->last_saved_confirmed_flush = cp.slotdata.confirmed_flush;
slot->last_saved_restart_lsn = cp.slotdata.restart_lsn;
SpinLockRelease(&slot->mutex);
LWLockRelease(&slot->io_in_progress_lock);

여기서 last_saved_restart_lsn = cp.slotdata.restart_lsn을 설정하는 순간이 ReplicationSlotsComputeRequiredLSN의 보수적인 “저장된 값 사용” 규칙이 완화를 허용받는 시점이다. 성공적인 플러시 이후에야 저장된 값이 라이브 값을 따라잡는다. 체크포인트 루프가 last_saved_restart_lsn != data.restart_lsn을 관찰할 때마다 필요한 LSN을 재계산하는 이유다. RestoreSlotFromDisk가 역방향으로, StartupReplicationSlots가 크래시 복구 전에 호출한다. 슬롯이 redo가 재활용할 것을 검토하기 전에 WAL을 고정할 수 있도록 하기 위해서다.

// StartupReplicationSlots — src/backend/replication/slot.c
replication_dir = AllocateDir(PG_REPLSLOT_DIR);
while ((replication_de = ReadDir(replication_dir, PG_REPLSLOT_DIR)) != NULL) {
/* skip ".", ".."; rmtree leftover "*.tmp" dirs from a crash mid-create/drop */
if (pg_str_endswith(replication_de->d_name, ".tmp")) { rmtree(path, true); ... continue; }
RestoreSlotFromDisk(replication_de->d_name); /* validate magic/version/CRC, fill array */
}
FreeDir(replication_dir);
if (max_replication_slots <= 0) return;
ReplicationSlotsComputeRequiredXmin(false); /* republish horizons from restored slots */
ReplicationSlotsComputeRequiredLSN();

RestoreSlotFromDisk는 magic/version/length/CRC 불일치 시 PANIC을 낸다. 손상된 슬롯 파일은 복구 불가능하므로 시작을 중단해야 한다. 복원 시 휘발성 effective_*last_saved_* 필드를 영속화된 값에서 채운다. “유효한 값은 절대 온디스크 값보다 앞서지 않는다”는 불변을 깨끗한 기반에서 재확립한다.

slotfuncs.c가 SQL 호출 가능 계층이다. pg_replication_slot_advance는 목표 LSN을 실제 플러시/재실행된 값으로 제한하고, 슬롯을 획득하고, 후진을 거부하고, 물리 또는 논리 전진기에 디스패치한다.

// pg_replication_slot_advance — src/backend/replication/slotfuncs.c
if (!RecoveryInProgress())
moveto = Min(moveto, GetFlushRecPtr(NULL)); /* can't advance past durable WAL */
else
moveto = Min(moveto, GetXLogReplayRecPtr(NULL));
ReplicationSlotAcquire(NameStr(*slotname), true, true);
if (XLogRecPtrIsInvalid(MyReplicationSlot->data.restart_lsn))
ereport(ERROR, ...); /* never reserved WAL */
minlsn = OidIsValid(MyReplicationSlot->data.database)
? MyReplicationSlot->data.confirmed_flush /* logical: consumed point */
: MyReplicationSlot->data.restart_lsn; /* physical: restart point */
if (moveto < minlsn) ereport(ERROR, ...); /* monotonic: forward only */
endlsn = OidIsValid(MyReplicationSlot->data.database)
? pg_logical_replication_slot_advance(moveto)
: pg_physical_replication_slot_advance(moveto);
ReplicationSlotsComputeRequiredXmin(false); /* horizons may have moved */
ReplicationSlotsComputeRequiredLSN();
ReplicationSlotRelease();

물리 전진기는 단순하다. restart_lsn을 앞으로 이동시키고, 슬롯을 더티로 표시하고, 이 물리 슬롯 위치를 기반으로 하는 논리 페일오버 walsender를 깨운다.

// pg_physical_replication_slot_advance — src/backend/replication/slotfuncs.c
if (startlsn < moveto) {
SpinLockAcquire(&MyReplicationSlot->mutex);
MyReplicationSlot->data.restart_lsn = moveto;
SpinLockRelease(&MyReplicationSlot->mutex);
ReplicationSlotMarkDirty(); /* persisted at next checkpoint */
PhysicalWakeupLogicalWalSnd();
}

논리 전진기는 LogicalSlotAdvanceAndCheckSnapState(logical.c에 있으며 논리 디코딩 문서로 미룸)에 위임한다. 논리 슬롯 전진은 confirmed_flush와 카탈로그 수평선을 안전하게 이동시킬 만큼 디코딩을 재실행한다는 의미이기 때문이다. create_physical_replication_slotcreate_logical_replication_slot이 생성 래퍼다. pg_drop_replication_slotReplicationSlotDrop을 호출한다.

pg_get_replication_slotspg_replication_slots 시스템 뷰 뒤의 읽기 경로다. 공유 제어 잠금 아래서 배열을 스캔하고 각 사용 중인 슬롯의 restart_lsn, catalog_xmin, confirmed_flush, wal_status, invalidation_reason, inactive_since, synced / failover 플래그를 투영한다. 운영자가 pg_wal을 채우기 전에 뒤처지거나 무효화된 슬롯을 발견하는 데 사용하는 컬럼들이다. 뒤에 나오는 Beyond 섹션에서 논의하는 안전 이야기의 관찰 가능성 절반이다. 슬롯 메커니즘은 상태를 검사할 수 있기 때문에 프로덕션에서 안전하다.

ReplicationSlotsDropDBSlotsDROP DATABASE 상호작용을 처리한다. 논리 슬롯은 데이터베이스 범위로 한정되므로, 데이터베이스를 삭제하면 먼저 해당 데이터베이스의 모든 논리 슬롯을 삭제해야 한다. 이 함수는 배열을 반복하고, 물리 슬롯과 다른 데이터베이스 슬롯을 건너뛰고, 각 일치 항목마다 acquire-then-ReplicationSlotDropAcquired 경로를 재사용한다.

// ReplicationSlotsDropDBSlots — src/backend/replication/slot.c
for (i = 0; i < max_replication_slots; i++) {
ReplicationSlot *s = &ReplicationSlotCtl->replication_slots[i];
if (!s->in_use) continue;
if (!SlotIsLogical(s)) continue; /* only logical slots are db-specific */
if (s->data.database != dboid) continue; /* not our database */
/* NB: intentionally including invalidated slots */
SpinLockAcquire(&s->mutex);
active_pid = s->active_pid;
if (active_pid == 0) { /* claim it so we can drop it */
MyReplicationSlot = s;
s->active_pid = MyProcPid;
}
SpinLockRelease(&s->mutex);
/* ... if still active in another backend, bail out (rare); else DropAcquired ... */
}

동반 함수 ReplicationSlotsCountDBSlotsdropdb가 오류를 낼지 진행할지 결정하기 위해 호출하는 사전 확인이다. 목표 데이터베이스에 속하는 슬롯 수(그 중 활성 슬롯 수)를 센다.

무효화와 페일오버 슬롯 (슬롯 동기화)

섹션 제목: “무효화와 페일오버 슬롯 (슬롯 동기화)”

너무 뒤처지거나, 데이터베이스가 삭제 중이거나, 타임아웃을 초과해 유휴 상태인 슬롯은 조용히 시스템을 손상시키는 대신 무효화된다. InvalidateObsoleteReplicationSlots(체크포인터가 max_slot_wal_keep_size 초과 시 구동)는 슬롯을 반복하며 InvalidatePossiblyObsoleteSlot을 호출한다. 슬롯 뮤텍스 아래서 DetermineSlotInvalidationCause로 원인을 결정하고, 다른 PID가 보유 중이 아니면 그 자리에서 무효로 표시한다.

// InvalidatePossiblyObsoleteSlot — src/backend/replication/slot.c
SpinLockAcquire(&s->mutex);
restart_lsn = s->data.restart_lsn;
if (s->data.invalidated == RS_INVAL_NONE)
invalidation_cause = DetermineSlotInvalidationCause(possible_causes, s,
oldestLSN, dboid, snapshotConflictHorizon,
&inactive_since, now);
if (invalidation_cause == RS_INVAL_NONE) { SpinLockRelease(&s->mutex); ... break; }
if (active_pid == 0) {
MyReplicationSlot = s;
s->active_pid = MyProcPid;
s->data.invalidated = invalidation_cause; /* RS_INVAL_WAL_REMOVED, _HORIZON, ... */
if (invalidation_cause == RS_INVAL_WAL_REMOVED) {
s->data.restart_lsn = InvalidXLogRecPtr; /* it pins nothing now */
s->last_saved_restart_lsn = InvalidXLogRecPtr;
}
*invalidated = true;
}
SpinLockRelease(&s->mutex);

네 가지 원인이 있다. RS_INVAL_WAL_REMOVED(필요한 WAL이 보존 한도를 초과함), RS_INVAL_HORIZON(필요한 카탈로그 행이 제거됨 — 프라이머리가 vacuum한 스탠바이에서 발생), RS_INVAL_WAL_LEVEL(wal_level이 슬롯이 필요로 하는 수준 아래로 떨어짐), RS_INVAL_IDLE_TIMEOUT(idle_replication_slot_timeout). 무효화되면 슬롯은 두 보존 집계자 모두에서 건너뛰어진다. 더 이상 자원을 고정하지 않는다. 의도적인 트레이드오프다. 뒤처진 소비자를 잃고 프라이머리를 보호한다.

페일오버 슬롯 / 슬롯 동기화. failover = true로 생성된 논리 슬롯은 물리 스탠바이에 동기화할 후보다. 페일오버 후 승격된 스탠바이가 논리 슬롯을 올바른 위치에 이미 보유하고 있어 논리 소비자가 데이터 손실 없이 재연결할 수 있다. pg_sync_replication_slots스탠바이에서 실행되며, 프라이머리에 연결해 프라이머리의 페일오버 슬롯을 로컬 슬롯에 복사한다.

// pg_sync_replication_slots — src/backend/replication/slotfuncs.c
if (!RecoveryInProgress())
ereport(ERROR, errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("replication slots can only be synchronized to a standby server"));
ValidateSlotSyncParams(ERROR);
load_file("libpqwalreceiver", false);
wrconn = walrcv_connect(PrimaryConnInfo, false, false, false, app_name.data, &err);
SyncReplicationSlots(wrconn); /* the actual copy loop lives in slotsync.c */
walrcv_disconnect(wrconn);

동기화된 슬롯은 data.synced = true를 가지며 스탠바이에서 직접 소비할 수 없다. 페일오버 시 실제 슬롯으로 승격되기 위해서만 존재한다. ReplicationSlotCreate 가드레일이 일치하는 불변을 강제한다. 스탠바이에서 생성된 슬롯에는 페일오버를 활성화할 수 없고(캐스케이딩 동기화 없음), 임시 슬롯에도 활성화할 수 없다(임시는 동기화되지 않음). 단, 슬롯 동기화 워커 자체가 생성자일 때는 예외다. 상세한 동기화 워커 루프(slotsync.c)는 postgres-wal-sender-receiver.mdpostgres-logical-decoding.md로 미룬다.

flowchart LR
  C["pg_create_*_replication_slot"] --> CR["ReplicationSlotCreate<br/>빈 항목 찾기, in_use 설정"]
  CR --> RW["ReplicationSlotReserveWal<br/>restart_lsn 설정"]
  RW --> CL["ComputeRequiredLSN / Xmin<br/>수평선 게시"]
  ADV["pg_replication_slot_advance"] --> AQ["ReplicationSlotAcquire<br/>슬롯 소유"]
  AQ --> MV["restart_lsn / confirmed_flush 전진<br/>MarkDirty"]
  MV --> CL
  CKPT["CheckPointReplicationSlots"] --> SV["SaveSlotToPath<br/>tmp+fsync+rename"]
  SV --> CL
  INV["InvalidateObsoleteReplicationSlots<br/>max_slot_wal_keep_size 초과"] --> IPO["InvalidatePossiblyObsoleteSlot<br/>data.invalidated 설정"]
  IPO --> CL
  CL --> XLOGR["xlog 재활용기: 필요한 WAL 건너뜀"]
  CL --> VAC["vacuum: catalog_xmin 준수"]

위치 힌트 (2026-06-05 기준, REL_18 273fe94)

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
ReplicationSlotPersistentData (struct)src/include/replication/slot.h70
ReplicationSlot (struct)src/include/replication/slot.h155
SlotIsPhysical / SlotIsLogical (macros)src/include/replication/slot.h228
ReplicationSlotCtlData (struct)src/include/replication/slot.h234
ReplicationSlotsShmemSizesrc/backend/replication/slot.c186
ReplicationSlotsShmemInitsrc/backend/replication/slot.c204
ReplicationSlotCreatesrc/backend/replication/slot.c353
SearchNamedReplicationSlotsrc/backend/replication/slot.c509
ReplicationSlotAcquiresrc/backend/replication/slot.c589
ReplicationSlotReleasesrc/backend/replication/slot.c716
ReplicationSlotDropsrc/backend/replication/slot.c846
ReplicationSlotDropPtrsrc/backend/replication/slot.c978
ReplicationSlotPersistsrc/backend/replication/slot.c1120
ReplicationSlotsComputeRequiredXminsrc/backend/replication/slot.c1145
ReplicationSlotsComputeRequiredLSNsrc/backend/replication/slot.c1227
ReplicationSlotsComputeLogicalRestartLSNsrc/backend/replication/slot.c1297
ReplicationSlotsCountDBSlotssrc/backend/replication/slot.c1376
ReplicationSlotsDropDBSlotssrc/backend/replication/slot.c1434
ReplicationSlotReserveWalsrc/backend/replication/slot.c1565
InvalidatePossiblyObsoleteSlotsrc/backend/replication/slot.c1833
InvalidateObsoleteReplicationSlotssrc/backend/replication/slot.c2061
CheckPointReplicationSlotssrc/backend/replication/slot.c2121
StartupReplicationSlotssrc/backend/replication/slot.c2199
CreateSlotOnDisksrc/backend/replication/slot.c2260
SaveSlotToPathsrc/backend/replication/slot.c2321
RestoreSlotFromDisksrc/backend/replication/slot.c2484
pg_physical_replication_slot_advancesrc/backend/replication/slotfuncs.c465
pg_logical_replication_slot_advancesrc/backend/replication/slotfuncs.c501
pg_replication_slot_advancesrc/backend/replication/slotfuncs.c510
pg_create_physical_replication_slotsrc/backend/replication/slotfuncs.c65
pg_create_logical_replication_slotsrc/backend/replication/slotfuncs.c169
pg_drop_replication_slotsrc/backend/replication/slotfuncs.c218
pg_get_replication_slotssrc/backend/replication/slotfuncs.c236
pg_sync_replication_slotssrc/backend/replication/slotfuncs.c895

REL_18_STABLE 커밋 273fe94(PG 18.x)의 /data/hgryoo/references/postgres 대상으로 검증했다. 방법은 인용한 영역을 커버하는 slot.cslotfuncs.c 전체를 읽고, src/include/replication/slot.h의 구조체/열거형/매크로 정의와 교차 확인하고, 인용한 모든 심볼이 명시된 행에 존재함을 확인했다.

  • 구조체 형상 확인. ReplicationSlotPersistentDatarestart_lsn, xmin, catalog_xmin, confirmed_flush와 PG 최근 필드 two_phase, two_phase_at, synced, failover를 가진다. 휘발성 ReplicationSloteffective_xmin / effective_catalog_xmin, last_saved_restart_lsn, last_saved_confirmed_flush, inactive_since를 가진다. 모두 REL_18에 존재하며 18 이전 이름이 없다.
  • 무효화 원인 확인. ReplicationSlotInvalidationCauseRS_INVAL_NONE 외에 정확히 2의 거듭제곱 네 가지 원인을 가지며 RS_INVAL_MAX_CAUSES == 4다. slot.c:124StaticAssertDeclSlotInvalidationCauses 테이블 길이를 강제한다. RS_INVAL_IDLE_TIMEOUT은 PG 18 추가분이며 존재한다.
  • 잠금 모델 확인. ReplicationSlotControlLock(스캔에 공유, in_use 전환에 배타), ReplicationSlotAllocationLock(생성/예약에 배타, 체크포인트 플러시 루프에 공유), 슬롯별 mutex 스핀락이 헤더의 문서화된 2계층 모델과 일치한다.
  • 유효-대-영속 불변 확인. effective_xmin에 대한 헤더 주석과 ReplicationSlotsComputeRequiredXmin 내부의 effective_*(not data.*) 사용, 영속 슬롯에 대한 ReplicationSlotsComputeRequiredLSN 내부의 last_saved_restart_lsn(not 라이브 restart_lsn) 사용이 “온디스크 수평선은 절대 인메모리 수평선보다 앞서지 않는다”는 안전 속성을 함께 확인한다.
  • 조작된 심볼 없음. 인용된 모든 함수(ReplicationSlotCreate, ReplicationSlotReserveWal, CheckPointReplicationSlots, StartupReplicationSlots, InvalidatePossiblyObsoleteSlot, pg_replication_slot_advance, pg_sync_replication_slots 등)가 두 소스 파일의 직접 grep으로 확인되었다. SyncReplicationSlots, LogicalSlotAdvanceAndCheckSnapState, PhysicalWakeupLogicalWalSnd는 호출 대상으로 참조되지만 각각 slotsync.c / logical.c / walsender.c에 정의되어 있다(범위 밖; 교차 참조됨).
  • 범위 경계. 이 문서는 slotsync.c 워커 루프의 내용, reorder-buffer/snapbuild 내부, 또는 walsender 스트리밍 프로토콜을 주장하지 않는다. 해당 내용은 교차 참조된 문서로 미룬다.

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

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

PostgreSQL의 슬롯은 넓은 설계 공간에서 특정 위치를 차지한다. 인접 시스템과 비교하면 본질적인 것과 부수적인 것이 뚜렷해진다.

MySQL binlog + GTID. MySQL은 생산자에 소비자별 마커를 등록하지 않는다. 바이너리 로그는 시간/크기 정책(binlog_expire_logs_seconds, max_binlog_size)으로 보존되고, 복제본은 자체 위치(파일+오프셋, 또는 GTID 집합)를 복제본 측에서 추적한다. 생산자는 소비자를 모르는 무상태 구조다. 이것이 “고정 보존 윈도우” 설계다. 프라이머리가 단순해진다(공유 배열 없음, 최솟값 계산 없음, fsync할 슬롯 파일 없음). 그러나 윈도우보다 오래 연결이 끊긴 복제본은 다시 클론해야 한다. catalog_xmin 유사체가 없다. MySQL의 ROW 형식 binlog는 충분한 컬럼 메타데이터를 포함하고(복제본도 스키마를 보유) 역사적 카탈로그 고정이 필요하지 않기 때문이다. PostgreSQL은 절대-소비자-손실-금지 보장을 얻기 위해 슬롯 부기 비용을 의도적으로 지불한다. catalog_xmin이라는 두 번째 비용도 지불하는데, 이는 논리 디코딩이 로그에 스키마를 포함하는 대신 라이브 카탈로그를 대조해 변경을 재구성하기 때문이다.

Oracle GoldenGate / LogMiner. Oracle의 redo/아카이브 로그는 RMAN 보존 정책으로 유지된다. CDC 도구는 자체 메타데이터 테이블에서 체크포인트(SCN)를 추적한다. “마이너가 필요한 것을 vacuum하지 말라”는 문제는 Oracle의 undo 보존과 명시적 LogMiner 딕셔너리 스냅샷으로 처리된다. GC 계산에 수평선을 공급하지 않는다. 아키텍처 대비는 MySQL과 동일하다. 소비자 위치가 엔진의 재활용기 외부에 있다. 엔진의 GC는 소비자를 모르며, CDC 도구가 보존 위험을 소유한다.

Kafka 소비자 오프셋. Kafka는 가장 깔끔한 “소비자 등록 마커” 유사체다. 각 소비자 그룹이 오프셋을 커밋하고, 로그 보존은 시간/크기 정책에 선택적 컴팩션 정책을 더한 것이다. 그러나 주목할 점은 Kafka가 기본적으로 가장 느린 소비자까지 거슬러 보존하지 않는다는 것이다. 보존 한도를 초과해 뒤처진 소비자는 조용히 메시지를 잃는다(auto.offset.reset). PostgreSQL의 슬롯은 더 엄격하다. 프라이머리는 슬롯을 보호하기 위해 WAL을 무제한으로 보존한다. 그래서 max_slot_wal_keep_size와 슬롯 무효화가 탈출구로 존재한다. PostgreSQL 설계는 Kafka(소비자를 조용히 희생 가능)와 순진한 무제한 슬롯(프라이머리 디스크 꽉 참) 사이에 위치한다. 무효화는 희생을 명시적이고 관찰 가능하게 만든다(pg_replication_slots.invalidation_reason).

죽은 슬롯 디스크 꽉 참 실패 모드. 가장 흔한 슬롯 운영 사고 — 잊혀지거나 크래시한 소비자가 WAL을 고정해 프라이머리의 pg_wal이 꽉 차고 데이터베이스가 중단되는 일 — 가 여러 릴리스에 걸친 기능 아크를 이끌었다. max_slot_wal_keep_size(핀을 제한하고 위반 시 무효화), idle_replication_slot_timeout(너무 오래 비활성인 슬롯 무효화), inactive_since 필드와 모니터링 컬럼이 추가되어 운영자가 피해를 입기 전에 뒤처진 슬롯을 발견할 수 있다. 연구/엔지니어링 교훈은 소비자 등록 보존 마커는 제한된 재정의와 관찰 가능성이 짝을 이룰 때만 프로덕션에서 안전하다는 것이다. 마커 단독으로는 함정이다.

분산 시스템 문제로서의 페일오버 슬롯. 논리 슬롯을 스탠바이에 동기화하는 것(슬롯 동기화, PG 17+)은 어려운 분산 문제의 작은 사례다. 데이터 손실이나 중복 처리 없이 리더 교체를 거쳐 소비자 진행 상태를 복제하고 일관되게 유지하는 것이다. PostgreSQL 해법은 페일오버 슬롯의 restart_lsn/catalog_xmin/confirmed_flush를 스탠바이에 복사하되 스탠바이의 동기화된 슬롯이 프라이머리 슬롯보다 뒤에만 지연되도록(절대 앞서지 않도록) 하는 것이다. 크래시를 거치는 유효-대-영속 분할을 지배하는 “경계를 넘어 보수적으로”라는 동일한 불변이 노드 간에 적용된다. 이 공간의 활성 연구 프론티어에는 동기 슬롯 동기화의 조율 비용 절감(크리티컬 패스에 왕복을 추가함)과 단일 선형 confirmed_flush가 소비자 진행 상황을 더 이상 포착하지 못하는 멀티-액티브 토폴로지로 논리 복제를 확장하는 것이 포함된다.

추상화가 새는 곳. catalog_xmin 메커니즘은 논리 복제를 vacuum에 예상치 못한 방식으로 결합한다. 바쁜 논리 슬롯이 클러스터 전체 카탈로그 vacuum을 차단해 복제된 테이블과 거리가 먼 카탈로그 팽창을 일으킬 수 있다. 라이브 카탈로그를 대조해 변경을 재구성하는 데 따르는 대가다. 로그에 스키마를 포함하는 시스템(MySQL ROW 형식, Debezium 스키마 히스토리)은 더 큰 로그와 별도 스키마 저장소라는 비용을 치르고 이 결합을 피한다. 버그가 아니라 진정한 미해결 트레이드오프다.

  • 소스 트리. REL_18_STABLE 커밋 273fe94(PG 18.x)의 /data/hgryoo/references/postgres:
    • src/backend/replication/slot.c — 슬롯 생명주기, 보존 계산, 영속화, 무효화, 스탠바이 슬롯 헬퍼.
    • src/backend/replication/slotfuncs.c — SQL 호출 가능 생성 / 삭제 / 전진 / 복사 / 동기화 함수와 pg_get_replication_slots.
    • src/include/replication/slot.hReplicationSlot, ReplicationSlotPersistentData, ReplicationSlotCtlData, persistency 및 invalidation 열거형, SlotIsPhysical / SlotIsLogical.
  • 교과서 이론.
    • Kleppmann, Designing Data-Intensive Applications (2017), 5장(복제 — 리더 로그 보존)과 11장(스트림 처리 / 변경 데이터 캡처 — 스키마 인식 변경 재구성). KB 참고문헌에 수록됨(raw/system/textbooks/).
    • Petrov, Database Internals (2019), WAL 장(LSN 주소 로그, 세그먼트 재활용 저수위 표시, fsync 순서 내구성). knowledge/research/dbms-general/database-internals.md에 수록됨.
  • 이 KB 내 교차 참조.
    • postgres-wal-sender-receiver.md — 슬롯을 소비하고 confirmed_flush를 구동하는 walsender/walreceiver 전송; 슬롯 동기화 워커.
    • postgres-logical-decoding.md — reorder buffer, snapshot builder, 논리 슬롯의 수평선을 이동시키는 LogicalSlotAdvanceAndCheckSnapState.
    • postgres-overview-replication-ha.md — WAL 스트림의 다른 복제-HA 소비자들 사이에서 슬롯의 위치.
    • postgres-xlog-wal.md / postgres-checkpoint.mdXLogSetReplicationSlotMinimumLSN을 읽고 슬롯을 플러시하는 WAL 재활용기와 체크포인트.
    • postgres-procarray.md / postgres-vacuum.mdReplicationSlotsComputeRequiredXmin을 존중하는 ProcArray oldest-xmin 계산.