콘텐츠로 이동

(KO) PostgreSQL 동기 복제 — synchronous_commit과 대기 큐

목차

postgres-wal-sender-receiver.md에서 설명한 비동기 스트리밍 복제(asynchronous streaming replication)는 WAL이 스탠바이에 어떻게 도달하는지 다룬다. 그러나 그 설계는 의도적으로 주 서버의 COMMIT을 스탠바이의 진행 상황과 연결하지 않는다. 주 서버는 자신의 WAL을 플러시한 뒤 클라이언트에 성공을 반환하고, 스탠바이는 네트워크와 I/O가 허용하는 속도로 따라온다. 이 분리가 비동기 복제를 빠르게 만드는 요인이고, 동시에 데이터를 잃을 수 있게 만드는 요인이다. 주 서버의 디스크와 스탠바이가 로컬 플러시와 원격 수신 사이의 시간 창에서 모두 사라진다면, 이미 확인된 커밋이 소실된다.

동기 복제는 그 시간 창을 커밋을 붙잡아 두는 방식으로 닫는다. 주 서버가 커밋 WAL 레코드를 로컬에 플러시한 뒤 클라이언트에 “커밋됨”을 즉시 알리지 않는다. 대신 설정된 스탠바이 집합이 같은 LSN을 보유하고 있음(또는 플러시했음, 또는 적용했음)을 확인할 때까지 기다린다. 그 후에야 백엔드가 반환한다. 대가는 지연(latency)이다. 동기 커밋은 로컬 fsync에 더해 주 서버→스탠바이 네트워크 왕복을 최소 한 번 지불한다.

이것이 모든 복제된 로그 시스템이 직면하는 고전적인 내구성 대 지연(durability-vs-latency) 트레이드오프다. Kleppmann의 Designing Data-Intensive Applications(ch. 5, “Replication”)는 이를 동기 팔로워(synchronous follower) 대 비동기 팔로워로 정의한다. 동기 팔로워는 최신 복사본을 보장하지만 팔로워가 느리거나 다운되면 리더를 블로킹한다. 비동기 팔로워는 리더를 절대 블로킹하지 않지만, 페일오버 시 최근 확인된 쓰기를 잃을 수 있다. Kleppmann이 제시하는 실용적인 중간 지점인 세미 동기(semi-synchronous) — 팔로워 하나는 동기로, 나머지는 비동기로 — 는 FIRST 1 (...)로 구성된 PostgreSQL synchronous_standby_names의 형태와 정확히 일치한다.

동기 복제 설계가 고정해야 할 세 가지 독립 축이 있으며, PostgreSQL은 이 셋을 모두 설정으로 노출한다.

  1. 어떤 이벤트가 “확인”으로 간주되는가? 스탠바이에는 단조롭게 강해지는 세 시점이 있다. WAL 바이트가 OS에 기록(write) 됐는가(페이지 캐시에만 있을 수 있음), 영속 스토리지에 플러시(flush) 됐는가(fsync), 스탠바이의 데이터 페이지에 적용(apply) 됐는가. 플러시까지 기다리면 스탠바이 크래시에서 데이터 손실이 없음을 보장한다. 적용까지 기다리면 스탠바이에서의 읽기가 커밋과 인과적으로 일치함도 보장한다.

  2. 스탠바이는 몇 개, 어떤 것을 기준으로 하는가? 스탠바이가 여럿일 때 주 서버는 전부, k개 중 아무거나(정족수), 또는 우선순위 상위 k개를 요구할 수 있다. 정족수는 구성원 하나의 손실을 허용하고, 우선순위는 결정적인 페일오버 순서를 제공한다.

  3. 정책이 어디에 있는가? 주 서버가 내구성 요구 사항을 스탠바이에 위임하거나(각 스탠바이가 자신이 확인해야 함을 앎), 모든 정책을 주 서버에 둘 수 있다(스탠바이는 그냥 스트림하고 응답하며, 주 서버의 트랜잭션 내구성 요구 사항을 모름). PostgreSQL은 두 번째를 택했다. syncrep.c 파일 헤더는 이 설계를 명시한다. “대기/해제에 관한 모든 로직을 주 서버에 격리한다. … 스탠바이는 주 서버 트랜잭션의 내구성 요구 사항을 완전히 알지 못한다.”

이론적 토대는 postgres-xlog-wal.md에서 설명하는 LSN-바이트-오프셋 모델과 같다. 커밋은 XactLastRecEnd에서 끝나는 WAL 레코드를 생성하고, “스탠바이가 커밋을 확인했다”는 것은 “스탠바이의 보고된 write/flush/apply LSN이 해당 값에 도달하거나 초과했다”는 뜻으로 축약된다. 따라서 동기 복제는 LSN에서 대기하는 문제이며, 이 모듈 전체는 LSN 순서 대기 큐와 큐 헤드의 LSN이 충족됐는지 결정하는 정책으로 구성된다.

동기 모드를 제공하는 복제 데이터베이스는 소수의 구조적 선택으로 수렴한다. 이를 먼저 정리해 두면, PostgreSQL의 구체적 기호들이 공유된 설계 공간 안의 한 지점으로 읽힌다.

로컬 내구성 이후의 커밋 경로 삽입

섹션 제목: “로컬 내구성 이후의 커밋 경로 삽입”

대기는 항상 로컬 WAL 플러시 이후에 삽입된다. 커밋 중인 트랜잭션은 이미 주 서버에서 내구성이 있다. 남은 것은 원격 내구성을 확인하는 일이다. 이 순서는 크래시 의미론에 중요하다. 주 서버가 대기 중에 크래시하더라도, 클라이언트가 확인을 받지 못했더라도 트랜잭션은 로컬에서 복구 가능하다. PostgreSQL은 이 호출을 정확히 여기에 배치한다. RecordTransactionCommit이 XLOG를 플러시하고 CLOG를 표시한 뒤 SyncRepWaitForLSN을 호출한다.

LSN을 키로 하는 공유 메모리 대기 구조

섹션 제목: “LSN을 키로 하는 공유 메모리 대기 구조”

커밋 중인 백엔드는 스탠바이의 진행 상황을 바쁜 폴링(busy-polling)으로 확인할 수 없다. 잠들어서 깨어나야 한다. 보편적인 패턴은 “프로세스 P가 LSN L을 기다린다”를 기록하는 공유 메모리 구조와, 각 진행 보고 시 LSN에 도달한 모든 대기자를 해제하는 깨우는 쪽(복제 수신자/송신자)이다. 구조를 LSN 순서로 유지하면 “조건이 충족된 모두 깨우기”가 처음으로 충족되지 않은 대기자에서 멈추는 단일 헤드 탐색으로 바뀐다. 응답당 O(전체-대기자)가 아닌 O(깨어난 수)다.

“확인됨”이 여러 의미를 가지므로(write / flush / apply) 성숙한 설계는 수준당 별도의 큐를 유지한다. 적용을 기다리는 백엔드는 단순한 플러시 보고로 깨어나서는 안 된다. PostgreSQL은 NUM_SYNC_REP_WAIT_MODE == 3개의 독립적인 큐와 세 개의 별도 “여기까지 해제됨” 커서를 유지한다.

”충분한” 스탠바이를 위한 정책

섹션 제목: “”충분한” 스탠바이를 위한 정책”

N개의 후보 스탠바이와 k 요건이 있을 때, 설계는 동기화된 위치 — 적어도 k개 스탠바이에 있음이 보장된 LSN — 를 정의해야 한다. 정족수(any k)의 경우 k번째로 큰 보고 LSN이다. 우선순위(first k)의 경우 k개 모두 해당 위치에 있어야 하므로 상위 k개 중 가장 작은(최솟값) LSN이다. PostgreSQL은 둘 다 구현한다. 정족수에는 SyncRepGetNthLatestSyncRecPtr, 우선순위에는 SyncRepGetOldestSyncRecPtr이다.

클라이언트에 거짓말할 수 없는 취소

섹션 제목: “클라이언트에 거짓말할 수 없는 취소”

대기 중에 블로킹된 백엔드는 민감한 위치에 있다. 트랜잭션은 이미 로컬에 커밋됐다. 대기가 중단되더라도(쿼리 취소, 백엔드 종료, 포스트마스터 사망) 시스템은 클라이언트에 “중단됨”을 알려서는 안 된다. 그것은 거짓이다. 표준 해결책은 “로컬에 커밋됐지만 복제되지 않았을 수 있다”고 명시하는 경고를 내보내고 진행하는 것이다. 상황을 트랜잭션 중단으로 전환하지 않는다.

개념PostgreSQL 이름
커밋 경로 대기 진입점SyncRepWaitForLSN(XactLastRecEnd, true) (RecordTransactionCommit에서)
내구성 수준 GUCsynchronous_commit (SyncCommitLevel 열거형)
내부 대기 모드SyncRepWaitMode (SYNC_REP_WAIT_WRITE/FLUSH/APPLY)
스탠바이 선택 GUCsynchronous_standby_names
파싱된 선택 정책SyncRepConfigData (syncrep_method, num_sync, member_names)
우선순위 방식FIRST k (...)SYNC_REP_PRIORITY
정족수 방식ANY k (...)SYNC_REP_QUORUM
LSN 순서 대기 큐WalSndCtl->SyncRepQueue[NUM_SYNC_REP_WAIT_MODE]
백엔드별 대기 레코드MyProc->waitLSN, ->syncRepState, ->syncRepLinks
”여기까지 해제됨” 커서WalSndCtl->lsn[mode]
대기자 깨우기 (walsender 측)SyncRepReleaseWaitersSyncRepWakeQueue
동기화된 위치 계산SyncRepGetSyncRecPtr
후보 스탠바이 스냅샷SyncRepGetCandidateStandbys
walsender별 동기 우선순위WalSnd->sync_standby_priority
설정 초기화 플래그WalSndCtl->sync_standbys_status (SYNC_STANDBY_INIT/DEFINED)

동기 복제는 두 개의 사용자 노출 파라미터로 설정되며, 각각 서로 다른 질문에 답한다.

synchronous_commit은 *“얼마나 강한 확인을 원하는가?”*에 답한다. xact.hSyncCommitLevel 열거형이다.

// SyncCommitLevel — src/include/access/xact.h
typedef enum
{
SYNCHRONOUS_COMMIT_OFF, /* asynchronous commit */
SYNCHRONOUS_COMMIT_LOCAL_FLUSH, /* wait for local flush only */
SYNCHRONOUS_COMMIT_REMOTE_WRITE,/* wait for local flush and remote write */
SYNCHRONOUS_COMMIT_REMOTE_FLUSH,/* wait for local and remote flush */
SYNCHRONOUS_COMMIT_REMOTE_APPLY,/* wait for local and remote flush and remote apply */
} SyncCommitLevel;
/* Define the default setting for synchronous_commit */
#define SYNCHRONOUS_COMMIT_ON SYNCHRONOUS_COMMIT_REMOTE_FLUSH

친숙한 설정 이름들은 각각 offOFF(로컬 플러시도 기다리지 않음 — 비동기 커밋), localLOCAL_FLUSH(로컬 fsync만, 원격 대기 없음), remote_writeREMOTE_WRITE, onREMOTE_FLUSH(on의 기본값), remote_applyREMOTE_APPLY에 대응한다. 마지막 세 개만 원격 대기를 수반한다. offlocal은 동기 복제 큐에 전혀 진입하지 않는다.

GUC 할당 훅이 사용자 가시적 수준을 큐를 인덱싱하는 내부 대기 모드로 변환한다.

// assign_synchronous_commit — src/backend/replication/syncrep.c
void
assign_synchronous_commit(int newval, void *extra)
{
switch (newval)
{
case SYNCHRONOUS_COMMIT_REMOTE_WRITE:
SyncRepWaitMode = SYNC_REP_WAIT_WRITE; /* 0 */
break;
case SYNCHRONOUS_COMMIT_REMOTE_FLUSH:
SyncRepWaitMode = SYNC_REP_WAIT_FLUSH; /* 1 */
break;
case SYNCHRONOUS_COMMIT_REMOTE_APPLY:
SyncRepWaitMode = SYNC_REP_WAIT_APPLY; /* 2 */
break;
default:
SyncRepWaitMode = SYNC_REP_NO_WAIT; /* -1: off / local */
break;
}
}

synchronous_standby_names는 “어떤 스탠바이의, 몇 개의 확인인가?”라는 직교 질문에 답한다. 값은 소형 문법(syncrep_gram.y)이다.

// productions — src/backend/replication/syncrep_gram.y
standby_list -> create_syncrep_config("1", list, SYNC_REP_PRIORITY)
NUM '(' standby_list ')' -> create_syncrep_config(NUM, list, SYNC_REP_PRIORITY)
ANY NUM '(' standby_list ')' -> create_syncrep_config(NUM, list, SYNC_REP_QUORUM)
FIRST NUM '(' standby_list ')'-> create_syncrep_config(NUM, list, SYNC_REP_PRIORITY)

s1, s2, s3FIRST 1 (s1, s2, s3)(9.6 시대 단일 스탠바이 동작과의 하위 호환)을 의미하고, ANY 2 (s1, s2, s3)은 셋 중 임의의 둘의 정족수이며, FIRST 2 (s1, s2, s3)는 연결된 가장 높은 우선순위 두 스탠바이를 기다린다. 파싱 결과는 플랫(flat)하고 malloc 가능한 SyncRepConfigData다.

// SyncRepConfigData — src/include/replication/syncrep.h
typedef struct SyncRepConfigData
{
int config_size; /* total size of this struct, in bytes */
int num_sync; /* number of sync standbys that we need to wait for */
uint8 syncrep_method; /* SYNC_REP_PRIORITY or SYNC_REP_QUORUM */
int nmembers; /* number of members in the following list */
char member_names[FLEXIBLE_ARRAY_MEMBER]; /* nmembers NUL-terminated names */
} SyncRepConfigData;

check_synchronous_standby_names가 GUC 검사 훅에서 파서를 실행해 파싱된 구조체를 GUC의 “extra”로 보관하고, assign_synchronous_standby_names가 이를 전역 SyncRepConfig에 게시한다. 결과가 플랫 malloc 메모리이므로 메모리 컨텍스트 없이 GUC extra 데이터로 살 수 있다.

커밋 백엔드의 관점: SyncRepWaitForLSN

섹션 제목: “커밋 백엔드의 관점: SyncRepWaitForLSN”

진입점은 커밋당 한 번 호출된다. xact.cRecordTransactionCommit에서, 트랜잭션이 실제로 WAL을 기록하고 XID를 할당받은 경우에만 호출된다.

// RecordTransactionCommit (excerpt) — src/backend/access/transam/xact.c
if (wrote_xlog && markXidCommitted)
SyncRepWaitForLSN(XactLastRecEnd, true);

XactLastRecEnd는 이 트랜잭션의 커밋 레코드 끝 LSN — 스탠바이들이 도달해야 하는 정확한 위치다. commit = true 인수는 이 LSN이 커밋 레코드임을 대기에 알린다. remote_apply의 경우에 의미가 있다(커밋 레코드만 적용 피드백을 생성하므로, 비커밋 LSN은 플러시 수준으로 캡핑된다).

SyncRepWaitForLSN은 모든 커밋에서 실행되므로 동기 복제가 꺼져 있을 때 빠른 경로를 제공하도록 설계됐다. 빠른 경로는 잠금 없이 SyncRepRequested()sync_standbys_status 플래그를 확인한다.

// SyncRepWaitForLSN fast path — src/backend/replication/syncrep.c
if (!SyncRepRequested() ||
((((volatile WalSndCtlData *) WalSndCtl)->sync_standbys_status) &
(SYNC_STANDBY_INIT | SYNC_STANDBY_DEFINED)) == SYNC_STANDBY_INIT)
return;
/* Cap the level for anything other than commit to remote flush only. */
if (commit)
mode = SyncRepWaitMode;
else
mode = Min(SyncRepWaitMode, SYNC_REP_WAIT_FLUSH);

SyncRepRequested()(max_wal_senders > 0 && synchronous_commit > SYNCHRONOUS_COMMIT_LOCAL_FLUSH) 매크로다. 수준이 off 또는 local이면 할 일이 없다. 두 번째 절은 “체크포인터가 상태 데이터를 초기화했지만 동기 스탠바이가 정의되지 않음” 경우다. SYNC_STANDBY_INIT는 설정됐지만 SYNC_STANDBY_DEFINED는 해제된 상태이면 잠금 없이 건너뛸 수 있다.

빠른 경로가 종료하지 않으면, 백엔드는 SyncRepLock을 독점적으로 취득하고 잠금 하에 재확인(SyncRepUpdateSyncStandbysDefined의 경쟁 조건 해소)한 뒤 자신을 큐에 삽입한다.

// SyncRepWaitForLSN enqueue — src/backend/replication/syncrep.c
MyProc->waitLSN = lsn;
MyProc->syncRepState = SYNC_REP_WAITING;
SyncRepQueueInsert(mode);
Assert(SyncRepQueueIsOrderedByLSN(mode));
LWLockRelease(SyncRepLock);

잠금을 해제한 뒤 래치 루프에서 잠든다. 자신의 procLatch에서만 깨어난다. 백엔드 하나의 상태 기계는 NOT_WAITING → WAITING → WAIT_COMPLETE → NOT_WAITING이다.

// SyncRepWaitForLSN wait loop (condensed) — src/backend/replication/syncrep.c
for (;;)
{
ResetLatch(MyLatch);
if (MyProc->syncRepState == SYNC_REP_WAIT_COMPLETE)
break;
if (ProcDiePending) { /* WARNING: committed locally, maybe not replicated */
whereToSendOutput = DestNone; SyncRepCancelWait(); break; }
if (QueryCancelPending) { QueryCancelPending = false; /* WARNING ... */
SyncRepCancelWait(); break; }
rc = WaitLatch(MyLatch, WL_LATCH_SET | WL_POSTMASTER_DEATH, -1, WAIT_EVENT_SYNC_REP);
if (rc & WL_POSTMASTER_DEATH) {
ProcDiePending = true; whereToSendOutput = DestNone; SyncRepCancelWait(); break;
}
}

취소 시 코드가 트랜잭션을 절대 중단하지 않는다는 점이 핵심이다. 트랜잭션은 이미 로컬에 커밋됐다. 코드가 하는 최대한은 WARNING“The transaction has already committed locally, but might not have been replicated to the standby.” — 을 내보내고 출력 전송을 중단하는 것이다. 정상 완료(SYNC_REP_WAIT_COMPLETE, walsender가 설정) 시 루프를 빠져나와 pg_read_barrier()를 발행하고 syncRepState/waitLSN을 재설정한다.

flowchart TB
  A["RecordTransactionCommit<br/>XLogFlush 로컬 WAL"] --> B["SyncRepWaitForLSN(XactLastRecEnd, true)"]
  B --> C{SyncRepRequested<br/>and standbys defined?}
  C -- no --> R["즉시 반환<br/>(비동기 / 동기 스탠바이 없음)"]
  C -- yes --> D["SyncRepLock 획득<br/>waitLSN 설정, syncRepState=WAITING"]
  D --> E["SyncRepQueueInsert(mode)<br/>LSN 오름차순 삽입"]
  E --> F["SyncRepLock 해제"]
  F --> G["래치 대기 루프"]
  G --> H{syncRepState ==<br/>WAIT_COMPLETE?}
  H -- yes --> I["read barrier<br/>상태 재설정, 클라이언트 반환"]
  H -- no --> J{ProcDie / QueryCancel /<br/>PostmasterDeath?}
  J -- yes --> K["WARNING: 로컬 커밋됨<br/>SyncRepCancelWait, break"]
  J -- no --> L["WaitLatch on procLatch"]
  L --> G

Figure 1 — SyncRepWaitForLSN을 통한 커밋 백엔드 경로. 대기가 시작되기 전에 트랜잭션은 이미 로컬에서 내구성이 있다. 완료(walsender가 깨움)와 로컬 커밋을 보존하는 중단, 두 가지 종료만 있다. (syncrep.cSyncRepWaitForLSNRecordTransactionCommit의 호출 지점에서.)

대기 큐: 공유 메모리의 세 LSN 순서 리스트

섹션 제목: “대기 큐: 공유 메모리의 세 LSN 순서 리스트”

대기자들은 walsender들이 사용하는 공유 메모리 제어 블록인 WalSndCtl에 있다. 동기 복제 부분은 세 개의 이중 연결 리스트 헤드(대기 모드당 하나)와 세 개의 “여기까지 해제됨” 커서다.

// WalSndCtlData (sync-rep fields) — src/include/replication/walsender_private.h
typedef struct
{
dlist_head SyncRepQueue[NUM_SYNC_REP_WAIT_MODE]; /* one queue per request type */
XLogRecPtr lsn[NUM_SYNC_REP_WAIT_MODE]; /* head-of-queue release cursor */
bits8 sync_standbys_status; /* SYNC_STANDBY_INIT | _DEFINED */
/* ... condition variables, walsnds[] flexible array ... */
} WalSndCtlData;

각 백엔드의 링크 노드와 대기 LSN은 자신의 PGPROC에 있다.

// PGPROC (sync-rep fields) — src/include/storage/proc.h
XLogRecPtr waitLSN; /* waiting for this LSN or higher */
int syncRepState; /* wait state for sync rep */
dlist_node syncRepLinks; /* list link if process is in syncrep queue */

SyncRepQueueInsert는 각 큐를 waitLSN 오름차순으로 정렬 유지한다. 대부분의 커밋은 LSN 순서로 도착하므로(나중 커밋은 더 큰 XactLastRecEnd를 가짐) 일반적인 경우 꼬리에 추가된다. 따라서 함수는 꼬리에서 역방향으로 스캔해 삽입 지점을 찾는다.

// SyncRepQueueInsert — src/backend/replication/syncrep.c
queue = &WalSndCtl->SyncRepQueue[mode];
dlist_reverse_foreach(iter, queue)
{
PGPROC *proc = dlist_container(PGPROC, syncRepLinks, iter.cur);
/* Stop at the element we should insert after, to keep queue LSN-ordered. */
if (proc->waitLSN < MyProc->waitLSN)
{
dlist_insert_after(&proc->syncRepLinks, &MyProc->syncRepLinks);
return;
}
}
/* list was empty, or we belong at the head */
dlist_push_head(queue, &MyProc->syncRepLinks);

정렬 불변식이 깨우기를 O(깨어난 수)로 만든다. 깨우는 쪽은 헤드에서 탐색을 시작해 waitLSN이 해제된 위치를 초과하는 첫 번째 대기자에서 멈춘다. 단언(assertion) 하에서 SyncRepQueueIsOrderedByLSN은 모든 삽입과 깨우기에서 불변식을(그리고 두 대기자가 동일 LSN을 공유하지 않음을) 검증한다.

깨우는 쪽은 walsender들이다. walsender의 ProcessStandbyReplyMessage(postgres-wal-sender-receiver.md 참조)가 스탠바이의 보고된 write/flush/apply 위치를 갱신하면 비캐스케이딩 스탠바이 경로에서 SyncRepReleaseWaiters를 호출한다. 이 함수는 이 walsender의 스탠바이가 동기 스탠바이 중 하나인지 결정하고, 모든 동기 스탠바이에 걸쳐 동기화된 위치를 재계산하고, 해제 커서를 전진시키고, 충족된 대기자들을 깨운다.

이 walsender가 잠재적 동기 스탠바이가 아니거나, 아직 스트리밍 중이 아니거나, 유효한 플러시 위치가 없으면 즉시 종료한다.

// SyncRepReleaseWaiters guard — src/backend/replication/syncrep.c
if (MyWalSnd->sync_standby_priority == 0 ||
(MyWalSnd->state != WALSNDSTATE_STREAMING &&
MyWalSnd->state != WALSNDSTATE_STOPPING) ||
XLogRecPtrIsInvalid(MyWalSnd->flush))
{
announce_next_takeover = true;
return;
}

그렇지 않으면 SyncRepLock을 독점적으로 취득하고, 동기화된 위치를 계산하고, 이 walsender가 실제로 동기 스탠바이이고 충분한 수가 있는 경우에만 각 모드별 커서를 전진시키고 해당 모드 큐를 깨운다.

// SyncRepReleaseWaiters release loop — src/backend/replication/syncrep.c
got_recptr = SyncRepGetSyncRecPtr(&writePtr, &flushPtr, &applyPtr, &am_sync);
/* ... if (!got_recptr || !am_sync) release lock and leave ... */
if (walsndctl->lsn[SYNC_REP_WAIT_WRITE] < writePtr)
{
walsndctl->lsn[SYNC_REP_WAIT_WRITE] = writePtr;
numwrite = SyncRepWakeQueue(false, SYNC_REP_WAIT_WRITE);
}
if (walsndctl->lsn[SYNC_REP_WAIT_FLUSH] < flushPtr)
{
walsndctl->lsn[SYNC_REP_WAIT_FLUSH] = flushPtr;
numflush = SyncRepWakeQueue(false, SYNC_REP_WAIT_FLUSH);
}
if (walsndctl->lsn[SYNC_REP_WAIT_APPLY] < applyPtr)
{
walsndctl->lsn[SYNC_REP_WAIT_APPLY] = applyPtr;
numapply = SyncRepWakeQueue(false, SYNC_REP_WAIT_APPLY);
}
LWLockRelease(SyncRepLock);

커서는 단조롭게 전진한다. < 가드가 늦거나 순서가 뒤바뀐 응답이 해제 지점을 되감는 것을 막는다. 세 모드는 각자의 큐에서 독립적으로 해제된다.

SyncRepWakeQueue가 실제 깨우기다. 모드의 큐를 헤드에서 탐색해 waitLSN에 도달한 각 대기자를 큐에서 분리하고, 상태를 SYNC_REP_WAIT_COMPLETE로 설정하고, 래치를 설정한다.

// SyncRepWakeQueue — src/backend/replication/syncrep.c
dlist_foreach_modify(iter, &WalSndCtl->SyncRepQueue[mode])
{
PGPROC *proc = dlist_container(PGPROC, syncRepLinks, iter.cur);
/* Queue is ordered by LSN: stop at first unsatisfied waiter. */
if (!all && walsndctl->lsn[mode] < proc->waitLSN)
return numprocs;
dlist_delete_thoroughly(&proc->syncRepLinks);
pg_write_barrier(); /* publish detach before state */
proc->syncRepState = SYNC_REP_WAIT_COMPLETE;
SetLatch(&(proc->procLatch));
numprocs++;
}

pg_write_barrier()SyncRepWaitForLSNpg_read_barrier()와 쌍을 이룬다. 대기자는 잠금 없이 syncRepState를 읽으므로, 깨우는 쪽은 상태가 WAIT_COMPLETE로 바뀌기 전에 큐 분리를 가시화해야 한다. 이렇게 해야 깨어난 백엔드가 자신이 큐에서 떠났음을 볼 수 있다.

동기화된 위치 선택: 우선순위 대 정족수

섹션 제목: “동기화된 위치 선택: 우선순위 대 정족수”

SyncRepGetSyncRecPtr에서 FIRST/ANY 정책이 구체화된다. 먼저 후보 스탠바이를 스냅샷하고, 이 walsender가 그 중에 있는지 확인하고, 최소 num_sync개의 후보가 있는지 검증한다.

// SyncRepGetSyncRecPtr (excerpt) — src/backend/replication/syncrep.c
num_standbys = SyncRepGetCandidateStandbys(&sync_standbys);
for (i = 0; i < num_standbys; i++)
if (sync_standbys[i].is_me) { *am_sync = true; break; }
if (!(*am_sync) || num_standbys < SyncRepConfig->num_sync)
{
pfree(sync_standbys);
return false; /* not enough sync standbys yet — don't release */
}
if (SyncRepConfig->syncrep_method == SYNC_REP_PRIORITY)
SyncRepGetOldestSyncRecPtr(writePtr, flushPtr, applyPtr,
sync_standbys, num_standbys);
else
SyncRepGetNthLatestSyncRecPtr(writePtr, flushPtr, applyPtr,
sync_standbys, num_standbys,
SyncRepConfig->num_sync);

우선순위(FIRST k)의 경우, SyncRepGetCandidateStandbys가 이미 후보 목록을 우선순위 상위 num_sync개로 잘랐다. 보장된 위치는 그들 중 가장 작은(최솟값) LSN이다. k개 모두 적어도 그 지점에 있기 때문이다.

// SyncRepGetOldestSyncRecPtr (excerpt) — src/backend/replication/syncrep.c
for (i = 0; i < num_standbys; i++)
{
if (XLogRecPtrIsInvalid(*flushPtr) || *flushPtr > sync_standbys[i].flush)
*flushPtr = sync_standbys[i].flush;
/* ... same for write and apply ... */
}

정족수(ANY k)의 경우, 후보 중 k개면 충분하므로 보장된 위치는 k번째로 큰 LSN이다. 내림차순 정렬 후 인덱스 nth - 1을 취한다.

// SyncRepGetNthLatestSyncRecPtr (excerpt) — src/backend/replication/syncrep.c
for (i = 0; i < num_standbys; i++) { write_array[i] = sync_standbys[i].write; /* ... */ }
qsort(write_array, num_standbys, sizeof(XLogRecPtr), cmp_lsn); /* descending */
qsort(flush_array, num_standbys, sizeof(XLogRecPtr), cmp_lsn);
qsort(apply_array, num_standbys, sizeof(XLogRecPtr), cmp_lsn);
*writePtr = write_array[nth - 1];
*flushPtr = flush_array[nth - 1];
*applyPtr = apply_array[nth - 1];

cmp_lsnpg_cmp_u64(lsn2, lsn1)로 내림차순 정렬한다. 따라서 [nth-1]이 k번째로 높은 값이 되며, 이는 적어도 k개 스탠바이가 도달한 LSN이다.

flowchart TB
  A["walsender: 스탠바이 응답<br/>ProcessStandbyReplyMessage"] --> B["SyncRepReleaseWaiters"]
  B --> C{이 walsender가 동기<br/>후보이고, 스트리밍 중,<br/>유효한 flush?}
  C -- no --> Z["반환 (announce_next_takeover)"]
  C -- yes --> D["SyncRepGetCandidateStandbys<br/>스핀락 하에 WalSnd[] 스냅샷"]
  D --> E{am_sync이고<br/>num_standbys >= num_sync?}
  E -- no --> Z
  E -- yes --> F{syncrep_method?}
  F -- PRIORITY (FIRST) --> G["SyncRepGetOldestSyncRecPtr<br/>우선순위 상위-k 중 최솟값 LSN"]
  F -- QUORUM (ANY) --> H["SyncRepGetNthLatestSyncRecPtr<br/>k번째 최댓값 LSN (qsort 내림차순)"]
  G --> I["WalSndCtl->lsn[mode] 전진<br/>(더 크면)"]
  H --> I
  I --> J["SyncRepWakeQueue(false, mode)<br/>lsn[mode]까지 대기자 깨움"]
  J --> K["syncRepState=WAIT_COMPLETE 설정<br/>각 깨어난 proc에 SetLatch"]

Figure 2 — 스탠바이 응답이 대기자를 해제하는 방식. walsender는 synchronous_standby_names의 정책(FIRST: 상위-k의 최솟값, ANY: k번째 최댓값)으로 동기화된 위치를 계산하고, 모드별 해제 커서를 전진시키고, waitLSN이 충족된 모든 큐 백엔드를 깨운다. (syncrep.cSyncRepReleaseWaiters / SyncRepGetSyncRecPtr / SyncRepWakeQueue에서.)

후보 스냅샷과 walsender별 우선순위

섹션 제목: “후보 스냅샷과 walsender별 우선순위”

SyncRepGetCandidateStandbysWalSndCtl->walsnds[]를 탐색하면서, 각 활성 walsender의 보고 위치와 sync_standby_priority를 해당 walsender의 스핀락 하에 사설 SyncRepStandbyData 배열로 복사한다. walsender가 후보가 되려면 PID가 있고, STREAMING 또는 STOPPING 상태이고, 우선순위가 0이 아니고, 유효한 플러시 위치를 가져야 한다. 우선순위 모드에서 num_sync보다 많은 후보가 있으면 배열을 우선순위 순으로 정렬하고 num_sync로 자른다.

// SyncRepGetCandidateStandbys (excerpt) — src/backend/replication/syncrep.c
if (SyncRepConfig->syncrep_method == SYNC_REP_PRIORITY &&
n > SyncRepConfig->num_sync)
{
qsort(*standbys, n, sizeof(SyncRepStandbyData), standby_priority_comparator);
n = SyncRepConfig->num_sync; /* keep only the highest-priority ones */
}

각 walsender는 시작 시와 SIGHUP 이후 SyncRepInitConfigSyncRepGetStandbyPriority 호출로 자신의 우선순위를 한 번 계산한다. 스탠바이의 application_name을 파싱된 member_names 목록(또는 * 와일드카드)과 대조한다. FIRST에서는 목록의 위치가 우선순위이고, ANY에서는 모든 구성원의 우선순위가 1이다. 캐스케이딩 walsender는 항상 우선순위 0을 받는다(동기 캐스케이드 복제는 지원되지 않음).

// SyncRepGetStandbyPriority (excerpt) — src/backend/replication/syncrep.c
if (am_cascading_walsender)
return 0;
if (!SyncStandbysDefined() || SyncRepConfig == NULL)
return 0;
standby_name = SyncRepConfig->member_names;
for (priority = 1; priority <= SyncRepConfig->nmembers; priority++)
{
if (pg_strcasecmp(standby_name, application_name) == 0 ||
strcmp(standby_name, "*") == 0) { found = true; break; }
standby_name += strlen(standby_name) + 1;
}
if (!found) return 0;
return (SyncRepConfig->syncrep_method == SYNC_REP_PRIORITY) ? priority : 1;

synchronous_standby_names는 런타임에 빈 값으로 설정될 수 있다(SIGHUP). 단순한 구현이라면 아무도 깨워주지 않아 백엔드가 큐에서 영원히 막혀 있을 것이다. 체크포인터가 sync_standbys_status 플래그를 소유하고 SyncRepUpdateSyncStandbysDefined에서 이를 조정한다. GUC가 빈 값으로 전환되면 모든 모드에서 모든 큐를 깨우고(SyncRepWakeQueue(true, i) 반복 호출) SYNC_STANDBY_DEFINED를 해제한다. 플래그는 큐 진입도 막으므로, 설정을 아직 다시 로드하지 않은 백엔드는 플러시 후 큐에 진입하지 못한다.

// SyncRepUpdateSyncStandbysDefined (excerpt) — src/backend/replication/syncrep.c
if (!sync_standbys_defined)
{
for (i = 0; i < NUM_SYNC_REP_WAIT_MODE; i++)
SyncRepWakeQueue(true, i); /* all=true: drain the queue unconditionally */
}
WalSndCtl->sync_standbys_status = SYNC_STANDBY_INIT |
(sync_standbys_defined ? SYNC_STANDBY_DEFINED : 0);

SyncRepWaitForLSN이 큐에 진입하기 전에 잠금 하에서 SYNC_STANDBY_DEFINED를 재확인하는 이유가 여기에 있다. 플래그와 큐는 SyncRepLock 하에 원자적으로 조작되므로, 방금 비워진 큐에 백엔드가 진입하는 시간 창이 없다.

기호를 역할별로 그룹화했다. 파일은 /data/hgryoo/references/postgres/ 아래에 있다.

설정: 두 GUC (xact.h, syncrep.h, syncrep_gram.y, syncrep.c)

섹션 제목: “설정: 두 GUC (xact.h, syncrep.h, syncrep_gram.y, syncrep.c)”
  • SyncCommitLevel (열거형) — SYNCHRONOUS_COMMIT_OFF_REMOTE_APPLY; SYNCHRONOUS_COMMIT_ON_REMOTE_FLUSH의 별칭.
  • synchronous_commit (GUC int) — 현재 수준.
  • SyncRepWaitMode (파일-정적 int) — 수준에서 파생된 내부 모드: SYNC_REP_NO_WAIT (-1), SYNC_REP_WAIT_WRITE/FLUSH/APPLY (0/1/2).
  • NUM_SYNC_REP_WAIT_MODE — 상수 3; 큐와 커서의 차원.
  • SyncRepRequested() (매크로) — max_wal_senders > 0 && synchronous_commit > SYNCHRONOUS_COMMIT_LOCAL_FLUSH.
  • assign_synchronous_commit — GUC 할당 훅; 수준 → SyncRepWaitMode 매핑.
  • SyncRepConfigData (구조체) — 파싱된 synchronous_standby_names: num_sync, syncrep_method, nmembers, member_names[].
  • SYNC_REP_PRIORITY / SYNC_REP_QUORUMsyncrep_method 값.
  • SyncStandbysDefined() (매크로) — SyncRepStandbyNames가 비어 있지 않음.
  • check_synchronous_standby_names / assign_synchronous_standby_names — GUC 검사/할당 훅; syncrep_yyparse로 파싱, SyncRepConfig 게시.

커밋 백엔드 (syncrep.c, xact.c, proc.h)

섹션 제목: “커밋 백엔드 (syncrep.c, xact.c, proc.h)”
  • SyncRepWaitForLSN — 커밋 경로 진입; 빠른 경로 종료, 큐 삽입, 래치 대기 루프, 취소 처리.
  • RecordTransactionCommit — 호출 지점: 로컬 XLogFlush 이후 SyncRepWaitForLSN(XactLastRecEnd, true).
  • MyProc->waitLSN / ->syncRepState / ->syncRepLinksPGPROC의 백엔드별 대기 레코드.
  • SYNC_REP_NOT_WAITING / SYNC_REP_WAITING / SYNC_REP_WAIT_COMPLETEsyncRepState 값.
  • SyncRepQueueInsertMyProc를 모드 큐에 삽입, waitLSN으로 정렬(꼬리에서 스캔).
  • SyncRepCancelWait — 중단 시 잠금 하에 큐에서 분리.
  • SyncRepCleanupAtProcExit — 백엔드 종료 시 분리(잠금-없는 사전 확인).

공유 메모리 대기 구조 (walsender_private.h)

섹션 제목: “공유 메모리 대기 구조 (walsender_private.h)”
  • WalSndCtlData.SyncRepQueue[NUM_SYNC_REP_WAIT_MODE] — 세 개의 LSN 순서 큐.
  • WalSndCtlData.lsn[NUM_SYNC_REP_WAIT_MODE] — 모드별 “여기까지 해제됨” 커서.
  • WalSndCtlData.sync_standbys_statusSYNC_STANDBY_INIT / SYNC_STANDBY_DEFINED 비트.
  • SyncRepQueueIsOrderedByLSN (단언 전용) — 정렬 불변식 검증.
  • SyncRepReleaseWaitersProcessStandbyReplyMessage에서 호출; 커서를 전진시키고 큐를 깨움.
  • SyncRepGetSyncRecPtr — 동기화된 write/flush/apply 계산; syncrep_method로 분기; am_sync 설정.
  • SyncRepGetOldestSyncRecPtr — 우선순위: 후보들의 최솟값 LSN.
  • SyncRepGetNthLatestSyncRecPtr — 정족수: k번째 최댓값 LSN (qsort 내림차순).
  • cmp_lsn — 내림차순 XLogRecPtr 비교자.
  • SyncRepGetCandidateStandbysWalSnd[] 스냅샷; 활성/스트리밍/우선순위/유효-flush 필터; 우선순위 모드에서 num_sync로 자름.
  • standby_priority_comparator — 우선순위로 정렬, walsnd_index로 동점 처리.
  • SyncRepWakeQueue — 큐를 헤드에서 탐색, 충족된 대기자 깨움, WAIT_COMPLETE 설정.
  • SyncRepInitConfig / SyncRepGetStandbyPriority — 시작 / SIGHUP 시 walsender별 우선순위 계산.
  • WalSnd->sync_standby_priority — 이 walsender의 우선순위 (0 = 동기 후보 아님).
  • SyncRepStandbyData (구조체) — 후보의 write/flush/apply/priority/is_me 사설 복사본.
  • SyncRepUpdateSyncStandbysDefinedsync_standbys_status 유지; GUC가 비어지면 큐 비움.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
기호파일
SyncCommitLevel (열거형)src/include/access/xact.h68
SYNCHRONOUS_COMMIT_ON (매크로)src/include/access/xact.h80
SyncRepRequested (매크로)src/include/replication/syncrep.h18
SYNC_REP_WAIT_WRITE (매크로)src/include/replication/syncrep.h23
NUM_SYNC_REP_WAIT_MODE (매크로)src/include/replication/syncrep.h27
SYNC_REP_NOT_WAITING (매크로)src/include/replication/syncrep.h30
SYNC_REP_PRIORITY (매크로)src/include/replication/syncrep.h35
SyncRepStandbyData (구조체)src/include/replication/syncrep.h42
SyncRepConfigData (구조체)src/include/replication/syncrep.h63
WalSndCtlData.SyncRepQueuesrc/include/replication/walsender_private.h90
WalSndCtlData.lsnsrc/include/replication/walsender_private.h96
WalSndCtlData.sync_standbys_statussrc/include/replication/walsender_private.h103
SYNC_STANDBY_INIT (매크로)src/include/replication/walsender_private.h125
SYNC_STANDBY_DEFINED (매크로)src/include/replication/walsender_private.h132
PGPROC.waitLSNsrc/include/storage/proc.h267
PGPROC.syncRepStatesrc/include/storage/proc.h268
PGPROC.syncRepLinkssrc/include/storage/proc.h269
SyncRepWaitForLSNsrc/backend/replication/syncrep.c148
SyncRepQueueInsertsrc/backend/replication/syncrep.c372
SyncRepCancelWaitsrc/backend/replication/syncrep.c406
SyncRepCleanupAtProcExitsrc/backend/replication/syncrep.c416
SyncRepInitConfigsrc/backend/replication/syncrep.c445
SyncRepReleaseWaiterssrc/backend/replication/syncrep.c474
SyncRepGetSyncRecPtrsrc/backend/replication/syncrep.c586
SyncRepGetOldestSyncRecPtrsrc/backend/replication/syncrep.c660
SyncRepGetNthLatestSyncRecPtrsrc/backend/replication/syncrep.c693
cmp_lsnsrc/backend/replication/syncrep.c738
SyncRepGetCandidateStandbyssrc/backend/replication/syncrep.c754
standby_priority_comparatorsrc/backend/replication/syncrep.c833
SyncRepGetStandbyPrioritysrc/backend/replication/syncrep.c860
SyncRepWakeQueuesrc/backend/replication/syncrep.c907
SyncRepUpdateSyncStandbysDefinedsrc/backend/replication/syncrep.c964
SyncRepQueueIsOrderedByLSNsrc/backend/replication/syncrep.c1024
check_synchronous_standby_namessrc/backend/replication/syncrep.c1058
assign_synchronous_standby_namessrc/backend/replication/syncrep.c1118
assign_synchronous_commitsrc/backend/replication/syncrep.c1124
SyncRepWaitForLSN 호출 지점src/backend/access/transam/xact.c1557

아래 모든 주장은 2026-06-05에 커밋 273fe94REL_18_STABLE에서 재확인했다. 기호 행 번호는 위의 위치 힌트 표에 있다.

  • 모든 동기 복제 로직은 주 서버에서 실행된다. 스탠바이는 자신이 동기 복제 대상임을 알지 못한다. syncrep.c 파일 헤더에서 확인. “이 모듈의 모든 코드는 주 서버에서 실행된다. … 대기/해제에 관한 모든 로직을 주 서버에 격리한다. 스탠바이는 주 서버 트랜잭션의 내구성 요구 사항을 완전히 알지 못한다.” 스탠바이의 walreceiver는 어느 주 서버의 synchronous_standby_names에 이름이 있는지와 관계없이 동일한 write/flush/apply 피드백을 보낸다.

  • 대기는 로컬 WAL 플러시 이후에 삽입된다. RecordTransactionCommit의 호출 지점(xact.c)에서 확인. XactLastRecEnd의 로컬 XLogFlush와 CLOG 상태 갱신이 SyncRepWaitForLSN(XactLastRecEnd, true) 호출보다 앞에 온다. 주 서버가 대기 중에 크래시하더라도 트랜잭션은 클라이언트가 확인을 받지 못했더라도 로컬에서 복구 가능하다.

  • synchronous_commit은 다섯 수준을 갖는다. onremote_flush다. SyncCommitLevel(xact.h)에서 확인. OFF, LOCAL_FLUSH, REMOTE_WRITE, REMOTE_FLUSH, REMOTE_APPLY. #define SYNCHRONOUS_COMMIT_ON SYNCHRONOUS_COMMIT_REMOTE_FLUSH. assign_synchronous_commit은 세 REMOTE_* 수준만 실제 대기 모드에 매핑한다. offlocalSYNC_REP_NO_WAIT (-1)을 산출한다.

  • 대기 모드와 큐는 정확히 세 개다. NUM_SYNC_REP_WAIT_MODE == 3(syncrep.h) 확인. WalSndCtlDatadlist_head SyncRepQueue[NUM_SYNC_REP_WAIT_MODE]XLogRecPtr lsn[NUM_SYNC_REP_WAIT_MODE]를 가진다(walsender_private.h). 세 모드는 SyncRepReleaseWaiters에서 독립적으로 해제된다.

  • SyncRepWaitForLSNSyncRepRequested()sync_standbys_status를 기반으로 잠금-없는 빠른 경로를 가진다. !SyncRepRequested()이거나 상태 플래그가 정확히 SYNC_STANDBY_INIT(초기화됐지만 동기 스탠바이 미정의)이면 즉시 반환한다. 두 단락-회로 모두 발동하지 않으면 SyncRepLock을 독점적으로 취득하고 SYNC_STANDBY_DEFINED를 재확인한다.

  • 대기 큐는 오름차순 waitLSN으로 엄격히 정렬된다. SyncRepQueueInsert(꼬리에서 역방향 스캔, 더 작은 waitLSN을 가진 첫 번째 요소 뒤에 삽입)에서 확인되며, SyncRepQueueIsOrderedByLSN으로 모든 삽입/깨우기에서 단언된다. 이 불변식이 SyncRepWakeQueue를 첫 번째 충족되지 않은 대기자에서 멈추게 한다.

  • FIRST k(우선순위)는 상위-k 후보의 최솟값 LSN을 사용한다. ANY k(정족수)는 k번째 최댓값 LSN을 사용한다. SyncRepGetSyncRecPtrSyncRepConfig->syncrep_method로 분기해 SYNC_REP_PRIORITY에는 SyncRepGetOldestSyncRecPtr(최솟값), SYNC_REP_QUORUM에는 SyncRepGetNthLatestSyncRecPtr(cmp_lsn으로 내림차순 qsort 후 인덱스 nth - 1)을 호출한다. 우선순위 모드에서 SyncRepGetCandidateStandbys는 이미 후보 집합을 num_sync개의 최고 우선순위 구성원으로 잘랐다.

  • 후보 walsender는 활성, 스트리밍/정지 중, 0이 아닌 동기 우선순위, 유효한 flush LSN을 가져야 한다. SyncRepGetCandidateStandbys에서 확인. pid == 0, WALSNDSTATE_STREAMING/WALSNDSTATE_STOPPING 이외의 상태, sync_standby_priority == 0, XLogRecPtrIsInvalid(flush) walsender는 건너뛴다. 캐스케이딩 walsender는 SyncRepGetStandbyPriority에서 우선순위 0을 받는다.

  • 해제 커서는 단조롭게 전진한다. SyncRepReleaseWaiters에서 확인. 각 walsndctl->lsn[mode]는 새로 계산된 위치가 엄격히 더 클 때만(< 가드) 갱신된다.

  • 취소는 트랜잭션을 절대 중단하지 않는다. 경고를 내보내고 진행한다. SyncRepWaitForLSN 대기 루프에서 확인. ProcDiePending 시 “canceling the wait for synchronous replication and terminating connection due to administrator command” 경고를 올리고 SyncRepCancelWait를 호출한다. 어떤 경로도 롤백하지 않는다.

  • synchronous_standby_names가 런타임에 지워지면 모든 큐가 무조건 비워진다. SyncRepUpdateSyncStandbysDefined에서 확인. !sync_standbys_defined이면 모든 모드에 SyncRepWakeQueue(true, i)를 호출한다(all = true 인수가 LSN 검사를 우회해 모든 대기자를 깨움). 그런 다음 SYNC_STANDBY_DEFINEDsync_standbys_status에서 해제한다.

  • SyncRepWakeQueuepg_write_barrier()(dlist_delete_thoroughly 이후, SYNC_REP_WAIT_COMPLETE 설정 전)와 SyncRepWaitForLSNpg_read_barrier()(SYNC_REP_WAIT_COMPLETE 관찰 이후)의 쌍은 깨어난 백엔드가 SyncRepLock 없이 syncRepState를 읽으면서도 자신이 큐에서 떠난 것을 볼 수 있게 하는 잠금-없는 핸드오프다.

  • SyncRepQueueInsert를 꼬리에서 역방향으로 스캔하는 “대부분의 커밋이 꼬리에 추가된다” 근거는 역방향 스캔 코드와 일치하는 성능 관찰이며, 별도로 측정된 주장이 아니다.

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

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”
  • 세미 동기 복제 (DDIA ch. 5). PostgreSQL의 FIRST 1 (...)은 Kleppmann의 세미 동기 구성과 정확히 일치한다. 팔로워 하나는 동기로, 나머지는 비동기로, 동기 팔로워가 정지하면 다른 것이 그 자리를 차지한다(SyncRepReleaseWaitersannounce_next_takeover가 더 높은 우선순위 스탠바이가 동기 슬롯을 재탈환하는 접합부다). DDIA의 “단일 동기 팔로워” 장애 모드를 PostgreSQL의 우선순위-인계 로직에 매핑하는 정리된 노트가 가용성 이야기를 선명히 할 것이다.

  • 정족수 복제 대 Dynamo 방식 R + W > N. ANY k (s1..sN)은 N개 후보 스탠바이에 대한 크기 k의 쓰기 정족수지만, PostgreSQL은 읽기 측을 “주 서버에서 읽기”로 고정해 조정 가능한 읽기 정족수를 제공하지 않는다. SyncRepGetNthLatestSyncRecPtr의 “k번째 최댓값 LSN” 규칙을 Dynamo의 느슨한-정족수 + 힌트된-핸드오프 모델과 비교하면 PostgreSQL이 단일 권위적 주 서버를 위해 무엇을 포기하는지(비지도자 쓰기 없음, 읽기-복구 없음)가 드러난다.

  • 합의 없음: PostgreSQL 동기 복제는 Raft/Paxos가 아니다. 커밋 백엔드가 k개의 확인을 기다리지만, 리더 선출도, 로그-매칭 안전 속성도, 코어의 자동 페일오버도 없다. 주 서버는 단순히 블로킹한다. 주 서버 크래시 시 클러스터는 스탠바이를 승격할 외부 중재자(Patroni, repmgr, pg_auto_failover)가 필요하다. Raft(커밋 인덱스가 복제된 합의 자체이고, 리더십이 프로토콜의 일부인)와의 대조가 “synchronous_commit은 내구성을 보장하지 가용성을 보장하지 않는다”를 설명하는 가장 깔끔한 방법이다.

  • CAP 포지셔닝. 동기 복제는 파티션 하에서 A를 C와 교환한다. 요구된 스탠바이에 연결할 수 없으면 커밋 백엔드는 무한정 블로킹된다(쿼리-취소 / 종료 / 포스트마스터-사망 종료만 있음). 이것이 CAP 삼각형의 CP 모서리다. “왜 synchronous_replication_timeout이 없는가”에 대한 노트 — 그리고 타임아웃이 내구성을 조용히 다운그레이드할 것이라는 명시적 설계 결정 — 가 여기에 속한다.

  • ARIES와 커밋-레코드 LSN. 전체 메커니즘은 ARIES 방식 WAL 불변식(postgres-xlog-wal.md)에 기반한다. 커밋은 XactLastRecEnd에서 끝나는 단일 WAL 레코드를 생성하고, “복제됨”은 “스탠바이의 보고된 LSN ≥ XactLastRecEnd”로 축약된다. 동기 복제는 새로운 로그 의미론을 추가하지 않는다. 기존 리두 스트림이 원격-내구성을 가진다고 알려질 때까지 클라이언트 확인을 지연시킬 뿐이다.

  • 적용 수준 대기와 스탠바이의 읽기-자신-쓰기. remote_apply는 스탠바이로 라우팅된 쿼리가 커밋과 인과적으로 일치하도록 만드는 유일한 수준이다. 대가는 스탠바이 재실행(세 피드백 지점 중 가장 느림)을 기다리는 것이다. 명시적 세션 수준 “읽기-자신-쓰기” 토큰(LSN 핸드오프를 애플리케이션에)을 노출하는 시스템과 비교하면 PostgreSQL이 멈추는 지점이 드러난다. 클러스터 전체 보장을 제공하지만 코어에 세션별 인과 토큰은 없다.

인-트리 소스 파일 (REL_18_STABLE, 커밋 273fe94)

섹션 제목: “인-트리 소스 파일 (REL_18_STABLE, 커밋 273fe94)”
  • src/backend/replication/syncrep.c — 전체 모듈: SyncRepWaitForLSN(커밋 경로 대기, 빠른 경로, 래치 루프, 취소), SyncRepQueueInsert / SyncRepWakeQueue / SyncRepQueueIsOrderedByLSN(LSN 순서 큐), SyncRepReleaseWaiters / SyncRepGetSyncRecPtr / SyncRepGetOldestSyncRecPtr / SyncRepGetNthLatestSyncRecPtr / SyncRepGetCandidateStandbys(해제 측과 FIRST/ANY 정책), SyncRepGetStandbyPriority / SyncRepInitConfig(walsender별 우선순위), SyncRepUpdateSyncStandbysDefined(체크포인터 조정), assign_synchronous_commit / check_synchronous_standby_names / assign_synchronous_standby_names(GUC 훅).
  • src/backend/replication/syncrep_gram.ysyncrep_scanner.lsynchronous_standby_names 문법. SYNC_REP_PRIORITY(FIRST/나열) 또는 SYNC_REP_QUORUM(ANY)를 가진 SyncRepConfigData 생성.
  • src/include/replication/syncrep.hSyncRepRequested / SyncStandbysDefined 매크로, SYNC_REP_WAIT_*NUM_SYNC_REP_WAIT_MODE 상수, SYNC_REP_NOT_WAITING/WAITING/WAIT_COMPLETE 상태, SyncRepStandbyData, SyncRepConfigData.
  • src/include/replication/walsender_private.hWalSndCtlData(SyncRepQueue[], lsn[], sync_standbys_status 필드)와 SYNC_STANDBY_INIT / SYNC_STANDBY_DEFINED 플래그 비트.
  • src/include/storage/proc.hPGPROC.waitLSN / syncRepState / syncRepLinks, 백엔드별 대기 레코드.
  • src/include/access/xact.hSyncCommitLevel 열거형과 SYNCHRONOUS_COMMIT_ON 별칭.
  • src/backend/access/transam/xact.cRecordTransactionCommit, SyncRepWaitForLSN의 유일한 인-코어 호출 지점.
  • Designing Data-Intensive Applications (Kleppmann 2017), ch. 5 “Replication” — 동기 대 비동기 팔로워, FIRST 1 (...)이 구현하는 세미 동기 중간 지점.
  • DeCandia et al. (2007), “Dynamo.” dbms-papers/dynamo.mdANY k의 비교 지점으로서 정족수 복제(R + W > N).
  • Ongaro & Ousterhout (2014), “Raft”; Lamport, “Paxos.” dbms-papers/raft.md, dbms-papers/paxos.md — 합의 복제, 동기 복제가 내구성이지 가용성이 아님을 설명하는 대조점.
  • Gilbert & Lynch (2002), CAP. dbms-papers/cap.md — 타임아웃 없는 동기 대기의 CP 포지셔닝.
  • Mohan et al. (1992), “ARIES.” dbms-papers/aries.md — 대기가 기반하는 WAL / 커밋-레코드-LSN 토대.
  • Database Internals (Petrov 2019), 복제와 합의 장 — 리더 기반 로그 복제의 일반적 프레이밍.

형제 문서 (교차 참조 — 메커니즘은 거기에 있으며 여기서 중복되지 않음)

섹션 제목: “형제 문서 (교차 참조 — 메커니즘은 거기에 있으며 여기서 중복되지 않음)”
  • postgres-wal-sender-receiver.md — 스트리밍 전송과 ProcessStandbyReplyMessage. SyncRepReleaseWaiters를 호출한다. write/flush/apply 피드백 위치는 거기서 유래한다. 이 문서는 그것을 소비할 뿐이다.
  • postgres-xact.mdRecordTransactionCommit과 로컬 플러시 이후 SyncRepWaitForLSN을 호출하는 커밋 경로.
  • postgres-xlog-wal.mdXactLastRecEnd, XLogFlush, 전체 대기가 기반하는 LSN-바이트-오프셋 모델.
  • postgres-replication-slots.md — 슬롯 기반 스탠바이 추적. 동기 스탠바이와 직교하지만 자주 함께 배포된다.
  • postgres-overview-replication-ha.md — 복제/HA 서브시스템 중 동기 복제가 위치하는 축 수준 개요.