(KO) PostgreSQL 논리적 디코딩 — Reorder Buffer, Snapshot Builder, 출력 플러그인
목차:
- 이론적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 접근 방식
- 소스 워크스루
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 전선
- 출처
이론적 배경
섹션 제목: “이론적 배경”모든 내구성 관계형 엔진은 **선행 기록 로그(write-ahead log)**를 쓴다. 데이터 페이지가 디스크에 도달하기 전에 변경을 재현할 리두(redo) 정보를 안정 저장소에 먼저 기록한다. 이 로그의 1차 목적은 크래시 복구와 물리적 복제다. 로그 레코드는 스토리지 엔진의 어휘로 표현된다. “이 관계 파일의 이 블록의 이 바이트 범위가 이 값으로 바뀌었다”는 식이다. 스키마의 어휘, 즉 “accounts 테이블의 어느 행에서 balance가 90으로 갱신되었다”는 말은 없다. 복구는 레코드를 같은 블록 레이아웃으로 재생하면 그만이고, 그것이 UPDATE 문에 해당한다는 사실은 알 필요가 없다.
**변경 데이터 캡처(CDC, Change Data Capture)**는 그 논리적 의미를 복원하는 기법이다. Kleppmann의 Designing Data-Intensive Applications(11장 “스트림 처리”)는 CDC를 데이터베이스를 이벤트 스트림의 원천으로 전환하는 문제로 규정한다. “CDC는 데이터베이스에 기록된 모든 데이터 변경을 관찰하고, 그것을 다른 시스템에 복제할 수 있는 형태로 추출하는 과정이다”(§“Change Data Capture”). 그가 제시하는 표준 구현 전략은 복제 로그 파싱이다. 내구성을 위해 이미 쓰고 있는 WAL을 재활용하는 것이며, 모든 테이블에 트리거를 다는 방식이 아니다. 이 방식의 장단점도 명시한다. 로그 기반 CDC는 “비동기”이고 소스 데이터베이스를 느리게 하지 않는다. 다만 “데이터베이스 내부 스토리지 포맷 파싱”이 필요해 소비자가 엔진의 온디스크 인코딩에 결합된다(§“Change Data Capture”, “Implementing change data capture”).
“복제 로그 파싱”에서 세 가지 이론적 의무가 파생된다. 이 문서가 다루는 세 모듈이 바로 그것이다.
-
물리 인코딩 디코딩. 로그는 블록과 바이트 범위로 말한다. 리두 레코드를 “이 관계에 이 튜플이 삽입되었다”로 역매핑하는 무언가가 필요하다. 이 과정은 일반적으로 손실이 있다. WAL은 원래 논리 스트림으로 설계되지 않았으므로, 엔진은 추가 정보를 기록하거나 일부 연산은 디코딩 불가로 받아들여야 한다.
-
트랜잭션 재조합과 커밋 순서 부여. WAL은 물리적 인터리빙이다. 여러 동시 트랜잭션의 레코드가 백엔드들이 플러시한 순서로 섞여 기록되고, 하나의 논리 트랜잭션 레코드가 여러 WAL 위치와 서브트랜잭션/세이브포인트에 걸쳐 분산될 수 있다. Kleppmann은 유용한 변경 스트림이 행 단위로 “기록된 순서와 같아야 한다”고 말하지만, 소비자 대부분이 원하는 것은 트랜잭션 원자성을 보장한 커밋 시각 순 전달이다. 다운스트림 레플리카는 트랜잭션 커밋 후에, 그리고 커밋 순서대로 모든 변경을 한꺼번에 받아야 한다. 레코드가 로그에 기록된 순서로 받아서는 안 된다. 따라서 디코더는 트랜잭션별 변경 스트림을 버퍼링하고, 커밋 레코드 도달 시에만 방출하며, 어보트 레코드가 오면 버퍼를 폐기해야 한다.
-
시간 일관성 있는 카탈로그로 튜플 해석. 원시 튜플 바이트를 이름과 타입이 있는 컬럼으로 변환하려면 변경이 기록된 당시의 테이블 정의가 필요하다. 현재 카탈로그 기준으로 해석하면 안 된다. WAL 레코드 기록 이후 컬럼이 추가되거나 타입이 변경되었다면, 현재 카탈로그로 레코드를 디코딩하면 쓰레기가 나온다. 가장 미묘한 의무다. 디코더는 디코딩 중인 정확한 로그 위치를 기준으로 재구성된 시스템 카탈로그의 역사적 뷰가 필요하다. 그 카탈로그 행들이 이후에 갱신되거나 배큠되었을 수도 있다.
첫 번째 의무는 파싱 문제다. 두 번째는 정렬된 스트림들의 k-way 병합과 동형인 버퍼링 및 순서 부여 문제다. 세 번째는 카탈로그 접근에 특화된 MVCC 가시성 문제다(동반 문서 postgres-mvcc-snapshots.md 참고). PostgreSQL은 세 책임을 각각 decode.c, reorderbuffer.c, snapbuild.c라 명명하고, 출력 플러그인 API(logical.c)로 소비자에게 연결한다. 이 문서의 나머지 부분은 위 이론을 기준으로 네 파일을 읽는다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”로그 기반 CDC는 이제 엔진 전반의 표준 기능이 되었고, 설계 공간은 인식 가능한 형태로 수렴했다. 세 가지 반복되는 선택이 지배적이다.
논리 정보의 출처. 엔진은 (a) 기존 물리 로그를 디코딩 가능할 만큼 자기 기술적으로 만들거나, (b) 물리 로그와 나란히 별도의 논리 로그를 쓰거나, (c) 로그 바깥에서 트리거나 감사 테이블로 변경을 캡처하는 방식을 선택할 수 있다. MySQL의 ROW 포맷 binlog는 (b)에 해당한다. binlog는 행 이미지의 논리 로그로서 InnoDB 리두 로그와 별개다. Oracle GoldenGate와 LogMiner는 (a)를 택해 물리 리두/언두 스트림을 마이닝한다. 트리거 기반 캡처(c)는 이식성이 있지만 모든 쓰기에 동기 비용을 부과한다. Kleppmann이 “종종 취약하고 성능 오버헤드가 크다”고 경고한 방식이다. PostgreSQL은 (a)의 하이브리드를 선택했다. 하나의 WAL이 내구성과 논리 디코딩을 모두 담당하되, wal_level = logical일 때만 물리 복구에 불필요한 추가 정보(복제 아이덴티티용 old 튜플 이미지, 카탈로그 변경용 명령 ID 레코드)를 기록한다.
트랜잭션 재조합 방법. 모든 실제 로그는 동시 트랜잭션을 인터리빙하므로, 디코더는 xid를 키로 변경을 누적하고 커밋 시 방출하는 버퍼링 레이어가 필요하다. 공통 과제는 세 가지다. (1) 메모리 — 단일 트랜잭션이 RAM보다 커 디스크 스필이나 스트리밍이 필요할 수 있다. (2) 서브트랜잭션 — 세이브포인트는 자식 트랜잭션 ID를 만들고, 이를 LSN 순서로 부모에 합쳐야 한다. (3) 어보트 처리 — 롤백된 트랜잭션의 버퍼링된 변경은 소비자에게 전달되지 않고 폐기되어야 한다. 병합 자체는 외부 정렬의 고전인 교체 선택 / k-way 병합이다. 각 (서브)트랜잭션은 로그 위치 기준으로 이미 정렬된 스트림을 제공하고, 다음 레코드 위치를 키로 하는 우선순위 큐(이진 힙)가 전역적으로 LSN 정렬된 단일 스트림을 생성한다.
스키마 복원 방법. 엔진들이 가장 많이 갈리는 지점이다. MySQL은 각 행 이벤트 앞에 컬럼 레이아웃을 담은 TABLE_MAP 이벤트를 삽입해 문제를 회피한다. 스트림이 자기 기술적이라 소비자가 라이브 카탈로그를 조회할 필요가 없다. Oracle LogMiner는 리두에서 마이닝하거나 스냅샷된 딕셔너리를 사용해 DDL을 재구성한다. PostgreSQL은 가장 MVCC 본래적인 경로를 택한다. 엔진 자체의 시간 여행 메커니즘을 재활용해 히스토릭 스냅샷을 구축한다. 이 스냅샷 객체는 일반 카탈로그 스캔 코드(syscache, relcache, 힙 가시성)가 라이브 MVCC 스냅샷 대신 받아들이므로, 디코딩 중 카탈로그 조회가 투명하게 “해당 WAL 위치 당시”의 카탈로그를 본다. 따라서 디코더는 RelationIdGetRelation을 평범하게 호출하면 무료로 히스토릭 테이블 정의를 얻는다. 그 대가는 WAL에서 히스토릭 스냅샷을 구축해야 한다는 것이다. 그 일이 스냅샷 빌더의 몫이다.
출력 경계. 모든 디코더는 소비자가 무엇을 보는지를 결정해야 한다. 모놀리식 설계는 단일 와이어 포맷을 고정하고, 플러그인 설계는 콜백 인터페이스를 노출해 포맷 결정을 로더블 모듈로 위임한다. PostgreSQL은 플러그인 방식을 택했다. 코어가 재생 중 호출하는 소수의 콜백(begin, change, commit, 선택적 truncate/message/스트리밍/2단계 변형)을 제공하고, 와이어 포맷 결정은 플러그인에 맡긴다(내장 논리 복제에는 pgoutput, 맞춤형 소비자에는 test_decoding, wal2json 등).
PostgreSQL의 접근 방식
섹션 제목: “PostgreSQL의 접근 방식”PostgreSQL 논리 디코딩 파이프라인은 WAL 리더가 구동하는 4단계 조립 라인이다. walsender(또는 SQL SRF pg_logical_slot_get_changes)가 다음 WAL 레코드를 반복 읽어 파이프라인 최상단인 LogicalDecodingProcessRecord에 전달한다.
flowchart TD
WAL["WAL stream<br/>XLogReadRecord()"] --> LDPR["LogicalDecodingProcessRecord<br/>(decode.c)"]
LDPR -->|"GetRmgr(rmid).rm_decode"| DISP{"per-rmgr<br/>decode handler"}
DISP -->|"RM_HEAP_ID"| HEAP["heap_decode<br/>DecodeInsert/Update/Delete"]
DISP -->|"RM_HEAP2_ID"| HEAP2["heap2_decode<br/>MultiInsert / NEW_CID"]
DISP -->|"RM_XACT_ID"| XACT["xact_decode<br/>DecodeCommit/Abort/Prepare"]
DISP -->|"RM_STANDBY_ID"| STBY["standby_decode<br/>RUNNING_XACTS"]
HEAP -->|"ReorderBufferQueueChange"| RB["Reorder Buffer<br/>(reorderbuffer.c)"]
HEAP2 -->|"SnapBuildProcessNewCid"| SB["Snapshot Builder<br/>(snapbuild.c)"]
STBY -->|"SnapBuildProcessRunningXacts"| SB
XACT -->|"SnapBuildCommitTxn"| SB
XACT -->|"ReorderBufferCommit"| RB
SB -.->|"historic catalog snapshot"| RB
RB -->|"replay in commit order"| CB["Output-plugin callbacks<br/>begin / change / commit<br/>(logical.c wrappers)"]
CB --> PLUGIN["Output plugin<br/>(pgoutput, test_decoding, …)"]
1단계 — rmgr 디스패치 (decode.c). 모든 WAL 레코드는 *리소스 매니저 ID(rmid)*를 가진다. LogicalDecodingProcessRecord는 해당 rmgr의 선택적 rm_decode 콜백을 조회해 호출한다. 논리 디코딩과 무관한 리소스 매니저(대부분이 해당)는 이 콜백을 NULL로 남겨두고, 레코드의 xid만 등록된다. 파일 헤더에 명시된 핵심 불변 조건이 있다. 모든 레코드의 xid는 디코딩 가능한 변경이 없어도 반드시 리오더 버퍼를 통과해야 한다. 리오더 버퍼가 어떤 트랜잭션이 존재하는지 추적하는 역할을 맡기 때문이다.
// LogicalDecodingProcessRecord — src/backend/replication/logical/decode.crmgr = GetRmgr(XLogRecGetRmid(record));if (rmgr.rm_decode != NULL) rmgr.rm_decode(ctx, &buf);else{ /* just deal with xid, and done */ ReorderBufferProcessXid(ctx->reorder, XLogRecGetXid(record), buf.origptr);}per-rmgr 핸들러들(heap_decode, heap2_decode, xact_decode, standby_decode, xlog_decode, logicalmsg_decode)은 공통 형태를 공유한다. xid를 등록하고, 아직 일관성 있는 스냅샷이 없으면 조기 반환한 다음, 레코드의 info 비트로 switch한다. 힙 INSERT는 ReorderBufferChange가 된다.
// DecodeInsert — src/backend/replication/logical/decode.cchange = ReorderBufferAllocChange(ctx->reorder);if (!(xlrec->flags & XLH_INSERT_IS_SPECULATIVE)) change->action = REORDER_BUFFER_CHANGE_INSERT;else change->action = REORDER_BUFFER_CHANGE_INTERNAL_SPEC_INSERT;change->origin_id = XLogRecGetOrigin(r);memcpy(&change->data.tp.rlocator, &target_locator, sizeof(RelFileLocator));tupledata = XLogRecGetBlockData(r, 0, &datalen);tuplelen = datalen - SizeOfHeapHeader;change->data.tp.newtuple = ReorderBufferAllocTupleBuf(ctx->reorder, tuplelen);DecodeXLogTuple(tupledata, datalen, change->data.tp.newtuple);ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, change, xlrec->flags & XLH_INSERT_ON_TOAST_RELATION);변경이 큐에 삽입되기 전에 두 가지 필터가 적용된다. 레코드의 대상 관계가 이 슬롯의 데이터베이스에 속해야 하고(target_locator.dbOid != ctx->slot->data.database → return), 출력 플러그인의 선택적 오리진 필터(FilterByOrigin)가 조회된다. 다른 노드에서 재생된 변경은 복제 루프를 피하기 위해 건너뛸 수 있다.
2단계 — 트랜잭션 재조합 (reorderbuffer.c). 리오더 버퍼는 시스템의 핵심이다. xid를 키로 하는 ReorderBufferTXN 해시 테이블을 유지하고(“동일 xid” 단순 케이스에는 단일 항목 룩업 캐시 선행), 각 TXN은 LSN 순서로 정렬된 ReorderBufferChange의 이중 연결 리스트를 소유한다. 서브트랜잭션은 부모를 알기 전까지 별도로 추적된다(ReorderBufferAssignChild가 자식 xid를 최상위 부모에 연결하며, rmgr 스위치 이전에 decode.c에서 호출된다). 변경은 목적 맞춤 메모리 컨텍스트에 보관된다. 고정 크기 변경 및 TXN 구조체에는 SlabContext, 가변 길이 튜플 데이터에는 GenerationContext다. 전체 메모리는 logical_decoding_work_mem을 기준으로 계측된다. 한도 초과 시 가장 큰 트랜잭션을 퇴거시킨다. 스트리밍이 활성화되고 해당 트랜잭션이 스트리밍 가능하면 플러그인으로 스트리밍하고, 그렇지 않으면 per-xid 디스크 파일로 스필한다.
// ReorderBufferCheckMemoryLimit — src/backend/replication/logical/reorderbuffer.cwhile (rb->size >= logical_decoding_work_mem * (Size) 1024 || ...){ if (ReorderBufferCanStartStreaming(rb) && (txn = ReorderBufferLargestStreamableTopTXN(rb)) != NULL) ReorderBufferStreamTXN(rb, txn); /* stream in-progress txn */ else { txn = ReorderBufferLargestTXN(rb); /* pick biggest (sub)txn */ ReorderBufferSerializeTXN(rb, txn);/* spill to disk */ }}최상위 트랜잭션의 커밋 레코드가 디코딩되면, xact_decode → DecodeCommit → ReorderBufferCommit 경로로 재생이 트리거된다. 재생은 트랜잭션과 서브트랜잭션들의 LSN 정렬 변경 리스트 전체를 이진 힙 하나로 병합해 전역 순서를 만든다. 힙의 키는 각 스트림의 현재 변경 LSN이다. 앞서 이론에서 설명한 k-way 병합이다.
flowchart LR T0["top-txn changes<br/>(LSN-sorted list)"] --> H[["binary heap<br/>keyed on current LSN<br/>ReorderBufferIterCompare"]] S1["subtxn A changes<br/>(LSN-sorted)"] --> H S2["subtxn B changes<br/>(LSN-sorted)"] --> H H -->|"ReorderBufferIterTXNNext<br/>pops smallest LSN"| OUT["single commit-ordered<br/>change stream"] OUT --> APPLY["ReorderBufferProcessTXN<br/>begin → change* → commit"] APPLY -->|"per change"| CBW["change_cb_wrapper<br/>(logical.c)"]
ReorderBufferProcessTXN은 히스토릭 스냅샷을 설정하고 내부 트랜잭션을 열어(syscache/relcache 조회가 동작하고 데이터베이스 쓰기가 차단되도록) begin 콜백을 실행한다. 이후 변경을 순회하며 change/truncate 콜백을 호출하고 마지막으로 commit을 실행한다. 어보트(DecodeAbort → ReorderBufferAbort)와 크로스 데이터베이스 커밋(ReorderBufferForget)은 change 콜백 없이 버퍼링된 변경을 폐기한다.
3단계 — 히스토릭 카탈로그 스냅샷 (snapbuild.c). 리오더 버퍼가 튜플 컬럼을 디코딩하려면 변경 LSN 당시의 카탈로그가 필요하다. 스냅샷 빌더는 WAL만으로 SNAPSHOT_HISTORIC_MVCC 스냅샷을 구성한다. 핵심 경제성은 이것이다. 대부분의 트랜잭션은 카탈로그를 건드리지 않으므로, [xmin, xmax) 범위에 속하는 커밋된 카탈로그 수정 xid만 추적하고 나머지는 어보트/진행 중으로 처리한다. 어떤 xid가 카탈로그를 수정했는지는 XLOG_HEAP2_NEW_CID 레코드로 알 수 있다. heapam은 wal_level = logical이고 카탈로그 행이 변경될 때만 이 레코드를 기록한다. 같은 레코드로 튜플 내부 필드가 디코딩 중에 제공할 수 없는 cmin/cmax 명령 ID도 복원한다. 빌더는 즉시 준비 상태에 들어갈 수 없다. 모든 진행 중 트랜잭션이 종료되었음을 확인할 만큼 충분한 WAL을 관찰해야 하므로, 아래 워크스루에서 자세히 다루는 4단계 상태 기계를 거쳐 START에서 CONSISTENT로 나아간다. 데이터 디코딩을 시작하지 않으려면 SnapBuildCurrentState(builder) >= SNAPBUILD_FULL_SNAPSHOT이어야 한다.
// heap_decode — src/backend/replication/logical/decode.cif (SnapBuildCurrentState(builder) < SNAPBUILD_FULL_SNAPSHOT) return;switch (info){ case XLOG_HEAP_INSERT: if (SnapBuildProcessChange(builder, xid, buf->origptr) && !ctx->fast_forward) DecodeInsert(ctx, buf); break; ...}4단계 — 출력 플러그인 (logical.c). logical.c는 복제 슬롯에 지정된 플러그인을 로드하고 세 가지 필수 콜백이 등록되었는지 검증하며, 플러그인이 walsender로 바이트를 밀어내기 위해 사용하는 OutputPluginPrepareWrite / OutputPluginWrite 쌍을 제공한다. 모든 콜백은 래퍼 함수를 경유한다. 래퍼는 오류 컨텍스트 항목을 푸시해(플러그인 오류 시 슬롯, 플러그인 이름, LSN이 보고되도록) ctx->accept_writes를 토글함으로써 플러그인이 begin/change/commit 내부에서만 출력을 낼 수 있도록 한다. 컨텍스트 시작 시 리오더 버퍼의 함수 포인터(rb->begin, rb->apply_change, rb->commit, …)가 이 래퍼들로 설정된다. 이 연결 덕분에 2단계 재생이 플러그인에 도달한다.
소스 워크스루
섹션 제목: “소스 워크스루”이 절은 네 파일을 콜 플로 순서로 추적한다. 안정적인 심벌 이름을 기준점으로 삼는다. 줄 번호는 끝의 위치 힌트 테이블에만 있다.
4.1 — rmgr 디스패치와 레코드 파싱 (decode.c)
섹션 제목: “4.1 — rmgr 디스패치와 레코드 파싱 (decode.c)”진입점 LogicalDecodingProcessRecord는 WAL 레코드마다 한 번 호출된다. 레코드의 시작/끝 LSN을 로컬 XLogRecordBuffer에 저장한 뒤, 디스패치 이전에 무조건적으로 레코드가 top xid를 가지면 서브트랜잭션을 최상위 부모에 할당한다.
// LogicalDecodingProcessRecord — src/backend/replication/logical/decode.ctxid = XLogRecGetTopXid(record);if (TransactionIdIsValid(txid)) ReorderBufferAssignChild(ctx->reorder, txid, XLogRecGetXid(record), buf.origptr);rmgr = GetRmgr(XLogRecGetRmid(record));if (rmgr.rm_decode != NULL) rmgr.rm_decode(ctx, &buf);rm_decode 슬롯은 리소스 매니저 테이블에서 채워진다. RM_XLOG_ID, RM_XACT_ID, RM_STANDBY_ID, RM_HEAP2_ID, RM_HEAP_ID, RM_LOGICALMSG_ID만 이를 제공한다. 핸들러는 두 역할로 나뉜다.
-
스냅샷 공급 핸들러.
standby_decode는XLOG_RUNNING_XACTS를SnapBuildProcessRunningXacts로 전달해(상태 기계와 슬롯의catalog_xmin구동)ReorderBufferAbortOld로 레코드의oldestRunningXid보다 오래된 트랜잭션을 잊는다.xlog_decode는 셧다운/엔드오브리커버리 체크포인트를SnapBuildSerializationPoint로 인식하고 스탠바이의wal_level >= logical가드를 강제한다.heap2_decode의XLOG_HEAP2_NEW_CID케이스는SnapBuildProcessNewCid를 호출해 카탈로그 명령 ID를 기록한다. -
변경 생성 핸들러.
heap_decode와heap2_decode의 데이터 케이스는 모든 변경을SnapBuildProcessChange(해당 트랜잭션이 디코딩 가능한지 확인하고 기본 스냅샷을 지연 할당)와!ctx->fast_forward로 게이팅한 뒤,DecodeInsert/DecodeUpdate/DecodeDelete/DecodeTruncate/DecodeMultiInsert/DecodeSpecConfirm파서를 호출한다. 각 파서는ReorderBufferChange를 만들고,data.tp.rlocator와 신규/이전HeapTuple을DecodeXLogTuple로 채우고,ReorderBufferQueueChange로 큐에 넣는다.
DecodeXLogTuple은 WAL 튜플 인코딩의 저수준 역변환이다. 비정렬 온디스크 이미지를 정렬된 HeapTupleHeader로 복사하고 t_infomask, t_infomask2, t_hoff를 복원한다. t_tableOid는 의도적으로 InvalidOid로, t_self는 유효하지 않게 남긴다. “트랜잭션 재조합 이후에만 알아낼 수 있다”는 이유에서다.
트랜잭션 경계 레코드는 xact_decode를 통한다. SNAPBUILD_FULL_SNAPSHOT 전에는 동작을 거부하고, 이후 xact op-mask로 분기한다. XLOG_XACT_COMMIT[_PREPARED] → DecodeCommit, XLOG_XACT_ABORT[_PREPARED] → DecodeAbort, XLOG_XACT_PREPARE → DecodePrepare, XLOG_XACT_INVALIDATIONS는 커밋 시 실행할 캐시 무효화를 누적한다.
// xact_decode (XLOG_XACT_INVALIDATIONS) — decode.cif (TransactionIdIsValid(xid)){ if (!ctx->fast_forward) ReorderBufferAddInvalidations(reorder, xid, buf->origptr, invals->nmsgs, invals->msgs); ReorderBufferXidSetCatalogChanges(ctx->reorder, xid, buf->origptr);}DecodeCommit은 스냅샷 빌딩과 재조합이 만나는 지점이다. 먼저 스냅샷 빌더에 커밋을 알리고(SnapBuildCommitTxn), 트랜잭션이 처리 대상인지 확인한 뒤(DecodeTXNNeedSkip — 잘못된 데이터베이스, 필터된 오리진, 시작 LSN 이전, fast-forward), 대상이라면 ReorderBufferCommit(또는 2단계의 경우 ReorderBufferFinishPrepared)을 호출한다.
// DecodeCommit — src/backend/replication/logical/decode.cSnapBuildCommitTxn(ctx->snapshot_builder, buf->origptr, xid, parsed->nsubxacts, parsed->subxacts, parsed->xinfo);if (DecodeTXNNeedSkip(ctx, buf, parsed->dbId, origin_id)){ for (i = 0; i < parsed->nsubxacts; i++) ReorderBufferForget(ctx->reorder, parsed->subxacts[i], buf->origptr); ReorderBufferForget(ctx->reorder, xid, buf->origptr); return;}for (i = 0; i < parsed->nsubxacts; i++) ReorderBufferCommitChild(ctx->reorder, xid, parsed->subxacts[i], buf->origptr, buf->endptr);if (two_phase) ReorderBufferFinishPrepared(ctx->reorder, xid, ...);else ReorderBufferCommit(ctx->reorder, xid, buf->origptr, buf->endptr, commit_time, origin_id, origin_lsn);4.2 — 리오더 버퍼 (reorderbuffer.c)
섹션 제목: “4.2 — 리오더 버퍼 (reorderbuffer.c)”ReorderBufferTXNByXid가 기본 조회 프리미티브다. 단일 항목 캐시(by_txn_last_xid / by_txn_last_txn)를 먼저 조회하고, 이후 by_txn 해시 테이블을 조회하며 필요 시 새 ReorderBufferTXN을 생성한다. 최상위 트랜잭션이라면 toplevel_by_lsn에 푸시해 SnapBuildDistributeSnapshotAndInval이 나중에 진행 중 트랜잭션을 LSN 순서로 순회할 수 있게 한다. 큐에 삽입되는 모든 변경이 이를 거친다.
// ReorderBufferQueueChange — src/backend/replication/logical/reorderbuffer.ctxn = ReorderBufferTXNByXid(rb, xid, true, NULL, lsn, true);if (rbtxn_is_aborted(txn)){ ReorderBufferFreeChange(rb, change, false); /* known-aborted: drop */ return;}change->lsn = lsn;change->txn = txn;dlist_push_tail(&txn->changes, &change->node);txn->nentries++;txn->nentries_mem++;ReorderBufferChangeMemoryUpdate(rb, change, NULL, true, ReorderBufferChangeSize(change));ReorderBufferProcessPartialChange(rb, txn, change, toast_insert);ReorderBufferCheckMemoryLimit(rb); /* may spill or stream */두 불변 조건을 짚을 만하다. 첫째, 메모리 계측은 증분이다. ReorderBufferChangeMemoryUpdate가 per-txn size와 버퍼 전체 rb->size를 동시에 조정하고, 크기 기준 최대 힙도 관리해 ReorderBufferLargestTXN이 O(1)로 찾아진다. 둘째, 앞의 어보트 체크는 스트리밍 중 CLOG 확인으로 이미 어보트된 것으로 밝혀진 트랜잭션이 변경을 더 이상 누적하지 않음을 보장한다.
ReorderBufferTXN 구조체가 재조합의 단위다. 필드들은 decode.c와 snapbuild.c가 증분으로 공급하는 데 필요한 것과 재생이 순서대로 소비하는 데 필요한 것을 모두 담는다.
// ReorderBufferTXN — src/include/replication/reorderbuffer.htypedef struct ReorderBufferTXN{ bits32 txn_flags; /* RBTXN_IS_PREPARED, _HAS_CATALOG_CHANGES, … */ TransactionId xid; /* top-level or sub xid */ TransactionId toplevel_xid; /* known once linked */ XLogRecPtr first_lsn; /* first data record for this xid */ XLogRecPtr final_lsn; /* commit/prepare/abort record LSN */ XLogRecPtr end_lsn; /* end of commit record + 1 */ struct ReorderBufferTXN *toptxn; /* NULL for top-level */ XLogRecPtr restart_decoding_lsn; /* where to resume to recover this txn */ Snapshot base_snapshot; /* historic snapshot attached at first change */ uint64 nentries; /* total changes (excl. subxacts) */ uint64 nentries_mem; /* of which still in memory (rest spilled) */ dlist_head changes; /* LSN-ordered change list */ dlist_head subtxns; /* child transactions */ ...};nentries와 nentries_mem의 분리는 ReorderBufferIterTXNNext가 스트림을 디스크에서 다시 읽어야 하는지 판단하는 데 쓰인다. base_snapshot은 SnapBuildProcessChange가 첫 번째 디코딩 가능한 변경 시 지연 할당하는 per-transaction 히스토릭 스냅샷이다. restart_decoding_lsn은 진행 중 모든 트랜잭션에 걸쳐 집계되면 슬롯의 restart_lsn이 된다. 재시작 후 디코딩이 안전하게 재개할 수 있는 지점이다(postgres-replication-slots.md 참고).
재생은 병합이다. ReorderBufferCommit이 TXN을 찾아 ReorderBufferReplay → ReorderBufferProcessTXN에 위임한다.
- ctid→(cmin,cmax) 해시를 구축하고(
ReorderBufferBuildTupleCidHash) 히스토릭 스냅샷을 설치한다(SetupHistoricSnapshot). - 내부 (서브)트랜잭션을 열어 카탈로그 접근이 동작하고 데이터베이스 쓰기가 금지되게 한다.
begin/begin_prepare를 실행한다.- 이진 힙 병합으로 순회하며
change->action에 따라 분기한다. commit을 실행한다.
병합은 ReorderBufferIterTXNInit이 설정한다. 변경이 있는 (서브)트랜잭션마다 힙 항목 하나씩 할당하고 각 항목을 해당 스트림의 선두 변경으로 초기화한다.
// ReorderBufferIterTXNInit — reorderbuffer.cstate->heap = binaryheap_allocate(state->nr_txns, ReorderBufferIterCompare, state);...if (txn->nentries > 0){ if (rbtxn_is_serialized(txn)) /* spilled? restore first batch */ { ReorderBufferSerializeTXN(rb, txn); ReorderBufferRestoreChanges(rb, txn, &state->entries[off].file, &state->entries[off].segno); } cur_change = dlist_head_element(ReorderBufferChange, node, &txn->changes); state->entries[off].lsn = cur_change->lsn; state->entries[off].change = cur_change; state->entries[off].txn = txn; binaryheap_add_unordered(state->heap, Int32GetDatum(off++));}ReorderBufferIterTXNNext는 LSN이 가장 작은 항목을 팝하고, 해당 스트림을 앞으로 이동시킨 뒤(스트림이 스필되었으면 ReorderBufferRestoreChanges로 다음 온디스크 배치 로드), binaryheap_replace_first로 재삽입한다.
// ReorderBufferIterTXNNext — reorderbuffer.cif (state->heap->bh_size == 0) return NULL;off = DatumGetInt32(binaryheap_first(state->heap));entry = &state->entries[off];change = entry->change;if (dlist_has_next(&entry->txn->changes, &entry->change->node)){ ReorderBufferChange *next_change = /* next in this stream */ ...; state->entries[off].lsn = next_change->lsn; state->entries[off].change = next_change; binaryheap_replace_first(state->heap, Int32GetDatum(off)); return change;}스필 경로인 ReorderBufferSerializeTXN은 메모리 내 변경을 pg_replslot/<slot>/ 아래 per-segment 파일에 쓰고 rb->spillCount를 증가시킨다. 복원 경로는 max_changes_in_memory 크기 배치로 읽어온다. 스트리밍(ReorderBufferStreamTXN)은 소비자가 streaming = on을 선택한 경우, 진행 중 트랜잭션을 스트리밍 콜백으로 곧바로 재생한다.
TOAST 재조합. 리오더 버퍼 내부에 두 번째 재조합 문제가 있다. 행에 아웃오브라인(TOASTed) 컬럼이 있으면 WAL은 TOAST 청크를 TOAST 관계에 대한 별도 힙 INSERT로 기록한다. 메인 테이블 행 레코드 바로 앞에 기록된다. 디코딩 중 이 청크 INSERT들은 TOAST 관계의 평범한 INSERT 변경으로 도착하지만, 소비자는 청크가 아니라 재조합된 데이텀을 받아야 한다. reorderbuffer.c는 per-txn ReorderBufferToastEnt 해시로 이를 해결한다. ReorderBufferToastAppendChunk가 디코딩되는 청크를 TOAST 값 ID 키로 누적하고, 메인 행 변경 처리 시 ReorderBufferToastReplace가 change 콜백 호출 전에 재조합된 값을 튜플에 대입한다. 이후 ReorderBufferToastReset이 해시를 초기화한다. DecodeInsert/DecodeMultiInsert가 각 변경에 clear_toast_afterwards 플래그를 전달하는 이유가 바로 이것이다. 멀티 INSERT 배치의 마지막 행을 표시해 대기 중인 청크를 참조할 수 있는 마지막 행 이후에만 TOAST 상태가 초기화되도록 한다. 파일 헤더는 “단일 최상위 트랜잭션 내에서 행의 TOAST 청크와 행 데이터 사이에 다른 데이터 레코드는 없다”고 명시한다. 이 불변 조건이 단일 패스 재조합을 올바르게 만든다.
4.3 — 스냅샷 빌더 (snapbuild.c)
섹션 제목: “4.3 — 스냅샷 빌더 (snapbuild.c)”빌더는 SnapBuild->state 위의 작은 상태 기계다. snapbuild.h에 정의된 상태는 SNAPBUILD_START = -1, SNAPBUILD_BUILDING_SNAPSHOT = 0, SNAPBUILD_FULL_SNAPSHOT = 1, SNAPBUILD_CONSISTENT = 2다. 전이는 전적으로 xl_running_xacts 레코드가 SnapBuildProcessRunningXacts → SnapBuildFindSnapshot에 도달함으로써 구동된다.
flowchart TD START["SNAPBUILD_START<br/>(no snapshot)"] -->|"running_xacts with<br/>zero running xacts"| CONS["SNAPBUILD_CONSISTENT<br/>can decode everything after"] START -->|"running_xacts with<br/>running xacts: record nextXid"| BUILD["SNAPBUILD_BUILDING_SNAPSHOT"] BUILD -->|"all xacts running at #1<br/>have finished"| FULL["SNAPBUILD_FULL_SNAPSHOT<br/>txns starting now are decodable"] FULL -->|"all xacts running at #2<br/>have finished"| CONS START -.->|"on-disk serialized<br/>snapshot found"| CONS
SnapBuildFindSnapshot은 코드 주석에 명시된 스냅샷 도달 세 가지 경로를 인코딩한다. (a) running_xacts 레코드에 실행 중 트랜잭션이 없으면(running->oldestRunningXid == running->nextXid) 바로 CONSISTENT로 점프. (b) 디스크에서 직렬화된 스냅샷 복원. (c) 각 단계 경계에서 next_phase_at = running->nextXid를 기록하며 증분 구축.
// SnapBuildFindSnapshot (case a) — src/backend/replication/logical/snapbuild.cif (running->oldestRunningXid == running->nextXid){ builder->xmin = running->nextXid; /* < are finished */ builder->xmax = running->nextXid; /* >= are running */ builder->state = SNAPBUILD_CONSISTENT; builder->next_phase_at = InvalidTransactionId; ereport(LOG, (errmsg("logical decoding found consistent point at %X/%X", LSN_FORMAT_ARGS(lsn)), errdetail("There are no running transactions."))); return false;}두 콜백이 카탈로그 수정 xid 집합을 관리한다. SnapBuildProcessNewCid는 XLOG_HEAP2_NEW_CID마다 실행된다. 해당 트랜잭션을 카탈로그 변경으로 표시하고(ReorderBufferXidSetCatalogChanges), ctid→(cmin,cmax) 매핑을 리오더 버퍼에 공급하며(ReorderBufferAddNewTupleCids), 트랜잭션의 명령 ID를 진행시킨다(ReorderBufferAddNewCommandId). SnapBuildCommitTxn은 DecodeCommit에서 호출된다. 막 커밋된 트랜잭션을 커밋된 카탈로그 변경자로 기억해야 하는지, 그리고 진행 중 트랜잭션들에 새 스냅샷을 배포해야 하는지 결정한다.
// SnapBuildCommitTxn — src/backend/replication/logical/snapbuild.cfor (nxact = 0; nxact < nsubxacts; nxact++){ TransactionId subxid = subxacts[nxact]; if (SnapBuildXidHasCatalogChanges(builder, subxid, xinfo)) { sub_needs_timetravel = true; needs_snapshot = true; SnapBuildAddCommittedTxn(builder, subxid); /* record as committed */ if (NormalTransactionIdFollows(subxid, xmax)) xmax = subxid; } else if (needs_timetravel) SnapBuildAddCommittedTxn(builder, subxid);}if (SnapBuildXidHasCatalogChanges(builder, xid, xinfo)){ needs_snapshot = true; needs_timetravel = true; SnapBuildAddCommittedTxn(builder, xid);}스냅샷 객체 자체는 SnapBuildBuildSnapshot이 구체화한다. SnapshotData를 재활용하되 배열을 재해석한다. xip에는 일반 MVCC 스냅샷의 진행 중 xid 대신 정렬된(bsearch 가능한) 커밋된 카탈로그 수정 xid를 담는다. subxip는 스냅샷이 카탈로그 수정 트랜잭션 컨텍스트로 복사될 때만 채워진다. 결과 스냅샷은 SNAPSHOT_HISTORIC_MVCC로 태깅되어 전용 가시성 루틴(HeapTupleSatisfiesHistoricMVCC, heapam_visibility.c)이 처리한다. SnapBuildProcessChange가 스냅샷을 트랜잭션에 바인딩한다. 기본 스냅샷이 없는 트랜잭션에 디코딩 가능한 변경이 도착하면, 현재 스냅샷을 빌드(또는 재사용)해 ReorderBufferSetBaseSnapshot으로 연결한다.
4.4 — 출력 플러그인 API (logical.c)
섹션 제목: “4.4 — 출력 플러그인 API (logical.c)”CreateInitDecodingContext / CreateDecodingContext가 StartupDecodingContext를 호출한다. StartupDecodingContext는 LogicalDecodingContext, ReorderBuffer, SnapBuild를 할당하고, 슬롯에 지정된 플러그인을 LoadOutputPlugin으로 로드해 필수 콜백을 검증한다.
// LoadOutputPlugin — src/backend/replication/logical/logical.cplugin_init = (LogicalOutputPluginInit) load_external_function(plugin, "_PG_output_plugin_init", false, NULL);plugin_init(callbacks); /* plugin fills the OutputPluginCallbacks struct */if (callbacks->begin_cb == NULL) elog(ERROR, "output plugins have to register a begin callback");if (callbacks->change_cb == NULL) elog(ERROR, "output plugins have to register a change callback");if (callbacks->commit_cb == NULL) elog(ERROR, "output plugins have to register a commit callback");리오더 버퍼는 플러그인 콜백을 직접 호출하지 않는다. rb에 설치된 *_cb_wrapper 함수들을 거친다. 각 래퍼는 ErrorContextCallback을 푸시해(실패 시 슬롯/플러그인/LSN 어노테이션) ctx->accept_writes와 ctx->write_xid/write_location을 설정한 다음 실제 콜백을 호출한다.
// change_cb_wrapper — src/backend/replication/logical/logical.cLogicalDecodingContext *ctx = cache->private_data;...errcallback.callback = output_plugin_error_callback;error_context_stack = &errcallback;ctx->accept_writes = true;ctx->write_xid = txn->xid;ctx->write_location = change->lsn;ctx->end_xact = false;ctx->callbacks.change_cb(ctx, txn, relation, change);error_context_stack = errcallback.previous;콜백 내부에서 플러그인은 OutputPluginPrepareWrite / OutputPluginWrite로 바이트를 기록한다. 이 함수들은 컨텍스트의 prepare_write / write 함수 포인터(walsender가 COPY 프로토콜 라이터로 설정)로 라우팅된다. accept_writes는 플러그인이 begin/change/commit 콜백 밖에서 출력을 내지 못하도록 막는다.
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심벌 | 파일 | 줄 |
|---|---|---|
LogicalDecodingProcessRecord | src/backend/replication/logical/decode.c | 88 |
xlog_decode | src/backend/replication/logical/decode.c | 129 |
xact_decode | src/backend/replication/logical/decode.c | 201 |
standby_decode | src/backend/replication/logical/decode.c | 359 |
heap2_decode | src/backend/replication/logical/decode.c | 405 |
heap_decode | src/backend/replication/logical/decode.c | 469 |
logicalmsg_decode | src/backend/replication/logical/decode.c | 586 |
DecodeCommit | src/backend/replication/logical/decode.c | 667 |
DecodeInsert | src/backend/replication/logical/decode.c | 894 |
DecodeXLogTuple | src/backend/replication/logical/decode.c | 1252 |
DecodeTXNNeedSkip | src/backend/replication/logical/decode.c | 1296 |
logical_decoding_work_mem (GUC) | src/backend/replication/logical/reorderbuffer.c | 225 |
ReorderBufferAllocChange | src/backend/replication/logical/reorderbuffer.c | 506 |
ReorderBufferTXNByXid | src/backend/replication/logical/reorderbuffer.c | 652 |
ReorderBufferQueueChange | src/backend/replication/logical/reorderbuffer.c | 809 |
ReorderBufferAssignChild | src/backend/replication/logical/reorderbuffer.c | 1098 |
ReorderBufferIterCompare | src/backend/replication/logical/reorderbuffer.c | 1260 |
ReorderBufferIterTXNInit | src/backend/replication/logical/reorderbuffer.c | 1283 |
ReorderBufferIterTXNNext | src/backend/replication/logical/reorderbuffer.c | 1411 |
ReorderBufferProcessTXN | src/backend/replication/logical/reorderbuffer.c | 2210 |
ReorderBufferCommit | src/backend/replication/logical/reorderbuffer.c | 2874 |
ReorderBufferProcessXid | src/backend/replication/logical/reorderbuffer.c | 3279 |
ReorderBufferLargestTXN | src/backend/replication/logical/reorderbuffer.c | 3792 |
ReorderBufferCheckMemoryLimit | src/backend/replication/logical/reorderbuffer.c | 3883 |
ReorderBufferSerializeTXN | src/backend/replication/logical/reorderbuffer.c | 3963 |
ReorderBufferStreamTXN | src/backend/replication/logical/reorderbuffer.c | 4308 |
ReorderBufferRestoreChanges | src/backend/replication/logical/reorderbuffer.c | 4510 |
SnapBuildCurrentState | src/backend/replication/logical/snapbuild.c | 277 |
SnapBuildXactNeedsSkip | src/backend/replication/logical/snapbuild.c | 304 |
SnapBuildBuildSnapshot | src/backend/replication/logical/snapbuild.c | 360 |
SnapBuildGetOrBuildSnapshot | src/backend/replication/logical/snapbuild.c | 579 |
SnapBuildProcessChange | src/backend/replication/logical/snapbuild.c | 639 |
SnapBuildProcessNewCid | src/backend/replication/logical/snapbuild.c | 689 |
SnapBuildAddCommittedTxn | src/backend/replication/logical/snapbuild.c | 829 |
SnapBuildCommitTxn | src/backend/replication/logical/snapbuild.c | 940 |
SnapBuildProcessRunningXacts | src/backend/replication/logical/snapbuild.c | 1136 |
SnapBuildFindSnapshot | src/backend/replication/logical/snapbuild.c | 1238 |
SnapBuildSerialize | src/backend/replication/logical/snapbuild.c | 1497 |
StartupDecodingContext | src/backend/replication/logical/logical.c | 152 |
CreateInitDecodingContext | src/backend/replication/logical/logical.c | 332 |
CreateDecodingContext | src/backend/replication/logical/logical.c | 500 |
OutputPluginPrepareWrite | src/backend/replication/logical/logical.c | 694 |
OutputPluginWrite | src/backend/replication/logical/logical.c | 707 |
LoadOutputPlugin | src/backend/replication/logical/logical.c | 735 |
startup_cb_wrapper | src/backend/replication/logical/logical.c | 776 |
begin_cb_wrapper | src/backend/replication/logical/logical.c | 837 |
commit_cb_wrapper | src/backend/replication/logical/logical.c | 868 |
change_cb_wrapper | src/backend/replication/logical/logical.c | 1088 |
OutputPluginCallbacks (struct) | src/include/replication/output_plugin.h | 216 |
SnapBuild->state enum | src/include/replication/snapbuild.h | 22 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”이 문서의 모든 주장은 커밋 273fe94의 REL_18_STABLE 워킹 트리를 기준으로 확인했다.
- rmgr 디스패치.
LogicalDecodingProcessRecord는 non-NULL일 때rmgr.rm_decode를 호출하고, 그렇지 않으면ReorderBufferProcessXid만 호출한다.decode.c에서 확인. 여섯 논리 rmgr 핸들러(xlog_decode,xact_decode,standby_decode,heap2_decode,heap_decode,logicalmsg_decode) 모두 인용된 시그니처로 존재한다. - 스냅샷 게이팅.
heap_decode와heap2_decode모두SnapBuildCurrentState(builder) < SNAPBUILD_FULL_SNAPSHOT시 조기 반환하고, 각 데이터 변경을SnapBuildProcessChange(...) && !ctx->fast_forward로 게이팅한다. 그대로 확인. - 변경 액션 열거형.
REORDER_BUFFER_CHANGE_INSERT,_UPDATE,_DELETE,_MESSAGE,_TRUNCATE, 내부 투기적 변형들이reorderbuffer.h의enum ReorderBufferChangeType멤버다. 확인. - 메모리 한도 / 퇴거.
ReorderBufferCheckMemoryLimit가rb->size >= logical_decoding_work_mem * 1024동안 루프를 돌며, 가장 크고 스트리밍 가능한 최상위 트랜잭션에ReorderBufferStreamTXN을 우선 적용하고 가장 큰 (서브)트랜잭션에ReorderBufferSerializeTXN으로 폴백한다. 확인. - k-way 병합. 이진 힙이 비교자
ReorderBufferIterCompare로ReorderBufferIterTXNInit에서 할당되고,ReorderBufferIterTXNNext가binaryheap_first로 팝해binaryheap_replace_first로 재삽입한다. 확인. - 상태 기계. 네 상태와
-1/0/1/2값이snapbuild.h에 정의되어 있다. 실행 중 xact 없을 때의START → CONSISTENT숏컷과next_phase_at = running->nextXid단계 경계 기록이SnapBuildFindSnapshot에 있다. 확인. - 히스토릭 스냅샷 재해석.
SnapBuildBuildSnapshot이snapshot->snapshot_type = SNAPSHOT_HISTORIC_MVCC를 설정하고builder->committed.xip로snapshot->xip를 채우며 bsearch를 위해 qsort한다. 확인. - 출력 플러그인 계약.
LoadOutputPlugin이_PG_output_plugin_init을 요구하고 non-NULLbegin_cb,change_cb,commit_cb를 강제한다.*_cb_wrapper함수들이 오류 컨텍스트를 설치하고accept_writes를 토글한다. 확인. - REL_18 정합성. PG-19 전용 심벌 없음.
XLOG2rmgr이나B_DATACHECKSUMSWORKER_*참조 없음. 2단계 디코딩 경로(DecodePrepare,ReorderBufferFinishPrepared)와 스트리밍 경로(ReorderBufferStreamTXN)가 기술된 대로 존재한다.
PostgreSQL 너머 — 비교 설계와 연구 전선
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 전선”세 모듈이 더 큰 시스템에서 차지하는 위치. 논리 디코딩은 CDC의 생산 절반일 뿐이다. 디코딩된 스트림은 스트리밍 복제 프로토콜로 walsender(postgres-wal-sender-receiver.md)가 소비자에게 전달하고, 소비자는 논리 복제 슬롯이 restart_lsn과 catalog_xmin을 고정해 스냅샷 빌더가 필요로 하는 WAL과 카탈로그 행이 제거되지 않도록 유지한다(postgres-replication-slots.md). 내장 소비자는 pgoutput 플러그인으로 변경을 인코딩한다(postgres-pgoutput.md). 스냅샷 빌더의 catalog_xmin 피드백이 디코딩을 vacuum에 다시 결합하는 메커니즘이다. 느리거나 방치된 논리 슬롯이 클러스터 전체의 카탈로그 배큠을 막는다. 이 설계의 표준 운영 위험이다.
MySQL 행 기반 binlog와 비교. MySQL binlog는 전용 논리 로그이므로 snapbuild.c에 해당하는 것이 없다. 각 행 이벤트 앞에 TABLE_MAP 이벤트가 컬럼 레이아웃을 싣기 때문에 스트림이 자기 기술적이고 디코딩이 카탈로그에 대한 상태를 갖지 않는다. 그 대가는 두 번째 로그(binlog + InnoDB 리두)와 둘 사이의 내구성/순서 조율(binlog와 스토리지 엔진 사이의 악명 높은 2단계 커밋)이다. PostgreSQL의 단일 로그 설계는 그 조율을 없애는 대신 스냅샷 빌더의 복잡성과 catalog_xmin 보존 압력을 지불한다. PostgreSQL에는 binlog의 STATEMENT 포맷에 해당하는 것도 없다. 논리 디코딩은 근본적으로 행 레벨이다. 마이닝하는 WAL 레코드 자체가 행 레벨이기 때문이다.
Oracle GoldenGate / LogMiner와 비교. Oracle은 리두와 언두 스트림을 마이닝해 변경과 스키마 딕셔너리 뷰를 모두 재구성한다. 딕셔너리는 스냅샷으로 찍거나 리두에서 마이닝할 수 있다. PostgreSQL과의 아키텍처 유사성이 강하다. 둘 다 물리 로그를 역엔지니어링한다. 다만 Oracle의 성숙한 제품은 충돌 감지, 이기종 대상, DDL 복제를 추가로 제공한다. PostgreSQL은 의도적으로 플러그인 경계에서 멈추고 생태계(pgoutput 위의 Debezium, wal2json 등)가 그 레이어를 구축하도록 한다.
연구와 발전 전선.
-
진행 중 트랜잭션 스트리밍 (PG 14+,
ReorderBufferStreamTXN경로)은 커밋 시각 디코딩의 핵심 지연 약점을 공략한다. 긴 트랜잭션을 완전히 버퍼링하지 않고도 일부를 소비자에게 전달할 수 있다. 열린 트레이드오프가 있다. 소비자는 이제 나중에 어보트될 수 있는 투기적 변경을 처리해야 한다.ReorderBufferProcessTXN의SetupCheckXidLive/ 동시 어보트 처리 기계가 트랜잭션 스트리밍 중 어보트를 감지하기 위해 존재한다. -
퇴거 정책.
reorderbuffer.c헤더 주석은 현재 “가장 큰 트랜잭션 퇴거” 정책이 “매우 단순”하고, 나이(LSN) 인식 정책이 세대 할당자와 함께 메모리를 더 효과적으로 확보할 수 있다고 솔직하게 언급한다. 현재는logical_decoding_work_mem이 유일한 조정 수단이다. -
페일오버 슬롯과 슬롯 동기화 (
slotsync.c, PG 17+)는 논리 슬롯이 물리 페일오버에서 살아남을 수 있도록restart_lsn/catalog_xmin을 스탠바이에 동기화한다. 논리 복제가 퍼블리셔의 HA 페일오버에서 살아남지 못한다는 오랜 불만에 대한 답이다. -
스탠바이에서의 디코딩 (PG 16+)은 물리 스탠바이가 논리 슬롯을 호스팅할 수 있게 한다.
xlog_decode의XLOG_PARAMETER_CHANGE케이스에서wal_level >= logical on the primary체크가 가드한다. 디코딩 부하를 프라이머리에서 이동시키는 방향이다. 스트리밍 시스템 문헌(Kleppmann 11장, “하나의 로그에서 여러 소비자 파생”)이 자연스러운 최종 상태로 보는 방향이기도 하다. 하나의 내구성 있는 로그, 독립적으로 속도가 다른 여러 파생 소비자. -
DDL 복제. 눈에 띄는 공백이 남는다. 논리 디코딩은 DML과
TRUNCATE를 복제하지만 임의의 DDL은 복제하지 않는다. 히스토릭 스냅샷 기계는 스키마 변경을 기준으로 디코딩하기 위해 존재하지만, 스키마 변경 자체가 논리 이벤트로 방출되지는 않는다.XLOG_XACT_INVALIDATIONS처리를 보면 인프라가 카탈로그 변동을 인식하고 있음이 분명하다. 다만 플러그인에 노출하지 않을 뿐이다. 이 공백을 닫는 것(이벤트 트리거가 논리 메시지로 전달하거나, 네이티브 DDL 디코딩)은 반복되는 로드맵 항목이다.
- 소스 트리 — PostgreSQL
REL_18_STABLE, 커밋 273fe94 (/data/hgryoo/references/postgres):src/backend/replication/logical/decode.c— rmgr 디스패치와 레코드 파서.src/backend/replication/logical/reorderbuffer.c— 트랜잭션 재조합, 메모리 관리, 스필/스트림, k-way 병합 재생.src/backend/replication/logical/snapbuild.c— 히스토릭 카탈로그 스냅샷 구축과 상태 기계.src/backend/replication/logical/logical.c— 디코딩 컨텍스트, 출력 플러그인 로딩, 콜백 래퍼.src/include/replication/reorderbuffer.h,src/include/replication/snapbuild.h,src/include/replication/output_plugin.h— 구조체/열거형/콜백 정의.
- 교재 근거 — Martin Kleppmann, Designing Data-Intensive Applications, 11장 “스트림 처리”, §“Change Data Capture” (로그 기반 CDC, 복제 로그 파싱, 단일 내구성 로그에서 소비자 파생).
- 동반 문서 (교차 참조, 중복 없음) —
postgres-wal-sender-receiver.md(디코딩된 스트림을 운반하는 전송 계층),postgres-replication-slots.md(restart_lsn/catalog_xmin보존),postgres-pgoutput.md(내장 출력 플러그인 와이어 포맷),postgres-mvcc-snapshots.md(히스토릭 스냅샷이 특화하는 MVCC 가시성 모델),postgres-xlog-wal.md(디코딩 대상인 물리 WAL 레코드),postgres-xact.md(커밋/어보트/준비 레코드 구조).