포스트

Logical Replication의 전송 메커니즘: 개념 정리와 #6047 패치 리뷰

publisher의 DELETE가 subscriber에서 어느 행을 지우는지부터 REPLICA IDENTITY와 wire 프로토콜, pubviaroot에서 정합성이 깨지는 #6047 사례까지 코드 경로를 따라 정리합니다.

Logical Replication의 전송 메커니즘: 개념 정리와 #6047 패치 리뷰

#6047 관련 내용은 현재 진행 중인 CommitFest 논의를 정리한 것이며 확정된 결론이 아닙니다.

Logical replication 은 한 PostgreSQL 서버(publisher)에서 일어난 테이블 변경을 다른 PostgreSQL 서버(subscriber)에 똑같이 재생해 주는 기능입니다. 디스크를 통째로 복사하는 물리 복제와 달리, INSERT·UPDATE·DELETE 같은 행 단위 변경만 골라 논리적으로 전달합니다.

그런데 쓰다 보면 의문이 하나 생깁니다. publisher 에서 DELETE FROM p WHERE a = 2 한 줄이 실행됐을 때, subscriber 는 자기 테이블에서 어느 행을 지워야 할지 어떻게 알까요. 두 서버는 완전히 별개라서 같은 데이터라도 행이 저장된 물리적 위치가 서로 다릅니다. 결국 publisher 가 “지운 행이 무엇이었는지” 를 값으로 알려줘야 하는데, 그 값을 무엇으로 정할지가 바로 REPLICA IDENTITY 입니다.

그래서 INSERT·UPDATE·DELETE 가 각각 어떤 정보를 싣고 subscriber 까지 가는지, 그 경로를 처음부터 끝까지 따라가 봤습니다. 앞부분은 공식 문서로 확인되는 개념과 프로토콜을, 뒷부분은 그 경로를 따라가다 만난 실제 CommitFest 패치 한 건(#6047)을 리뷰로 남겨 둡니다.

설명에는 작은 예시 테이블 하나를 계속 쓰겠습니다. 컬럼이 둘인 테이블 p(a, b) 이고, a 가 행을 식별하는 키, b 는 일반 값이라고 하겠습니다. 지금 (a=2, b=20) 행 하나가 들어 있다고 보면 됩니다.

사전 지식: Replica Identity

먼저 배경 하나가 필요합니다. PostgreSQL 은 테이블에 변경을 반영하기 전에, 그 변경 내용을 WAL(Write-Ahead Log)에 먼저 적습니다. 원래는 장애가 났을 때 복구하기 위한 장치인데, logical replication 은 바로 이 WAL 을 읽어 subscriber 로 흘려보냅니다. 그래서 “무엇이 복제되는가” 는 곧 “무엇이 WAL 에 적히는가” 와 같은 질문이 됩니다.

INSERT 는 간단합니다. 새로 생긴 행을 통째로 적어 그대로 보내면 됩니다. 까다로운 쪽은 UPDATE 와 DELETE 입니다. subscriber 가 “어느 행을” 고치거나 지울지 찾으려면, 그 행을 알아볼 값이 WAL 에 함께 적혀 있어야 합니다. 이때 함께 남기는 변경 전 행 정보를 old tuple 이라 부르고, 여기에 무엇을 담을지 정하는 규칙이 REPLICA IDENTITY 입니다.

설정값은 네 가지이고, 각각 old tuple 을 WAL 에 얼마나 남기는지가 다릅니다.

모드WAL에 기록되는 old tuple비고
DEFAULTPK 컬럼만기본값
USING INDEX idx해당 unique index 컬럼만NOT NULL unique index 필요
FULL행 전체WAL 증가 + subscriber 탐색 비용
NOTHING없음UPDATE/DELETE 발행 불가

표에서 한 가지 짚어둘 점이 있습니다. unique key(UK)가 따로 있어도 기본 설정인 DEFAULT 는 그것을 자동으로 쓰지 않습니다. DEFAULT 가 식별자로 보는 것은 primary key 뿐이고, UK 를 쓰려면 USING INDEX 로 그 인덱스를 직접 지정해야 합니다. 이때 지정하는 인덱스는 unique 이고, partial(부분 인덱스)이 아니며, deferrable 이 아니고, 컬럼이 모두 NOT NULL 이어야 합니다. 아예 마땅한 키가 없으면 FULL 로 행 전체를 통째로 식별자처럼 쓸 수 있습니다.

예시 테이블 pa 에 unique index 를 두고 USING INDEX 로 지정했다고 하겠습니다. 그러면 old tuple 에는 키인 a 값만 담기고 b 는 빠집니다. 이 “키만 담긴 old tuple” 이 뒤에서 계속 등장합니다.

전체 그림: 네 단계의 파이프라인

publisher 가 변경 하나를 subscriber 로 보내기까지, publisher 내부에서 크게 네 단계를 거칩니다.

① 실행heap_insert/update/delete + replica identity 추출
② WAL 기록변경을 WAL 레코드로 디스크에 저장
③ decodeWAL을 트랜잭션 단위로 재조립
④ pgoutputwire 프로토콜로 직렬화해 전송

① 실행 시점에 replica identity 가 old tuple 의 정보량을 고정합니다. 뒤따르는 단계는 그 정보를 운반할 뿐입니다.

각 단계를 풀어 보면 이렇습니다. ① 실행 단계에서 PostgreSQL 은 행을 실제로 바꾸면서, 동시에 “이 행을 나중에 어떻게 식별할지”(즉 replica identity 에 따른 old tuple)를 함께 뽑아 둡니다. ② 그 변경을 WAL 레코드로 디스크에 적습니다. ③ 나중에 별도 프로세스가 이 WAL 을 읽어 트랜잭션 단위로 재조립합니다. 이 과정을 logical decoding 이라 부릅니다. ④ 마지막으로 pgoutput 이라는 출력 플러그인이 그 변경을 subscriber 가 알아들을 수 있는 네트워크 메시지(wire 프로토콜)로 바꿔 보냅니다. 여기까지 ①~④ 는 모두 publisher 쪽에서 일어나는 일이고, 받은 메시지를 자기 테이블에 적용하는 일만 subscriber 쪽에서 합니다. 그 일을 맡는 것이 뒤에서 만날 apply worker 입니다.

여기서 가장 중요한 사실은, old tuple 에 무엇을 담을지가 ① 실행 시점에 이미 정해져 박제된다는 점입니다. 이후 단계는 그 내용을 운반만 할 뿐, 한 번 WAL 에 남기지 않은 정보는 뒤에서 아무리 애써도 되살릴 수 없습니다.

무엇보다 저는 정보량이 결정되는 ① 단계와, 그것이 실제 바이트로 나가는 ④ 단계에 집중해 보았습니다. ②(WAL 기록)와 ③(재조립)은 내용을 그대로 옮길 뿐 정보량을 바꾸지 않기 때문입니다.

INSERT: 가장 단순한 경우

INSERT 부터 보겠습니다. subscriber 입장에서 INSERT 는 그냥 새 행 하나를 추가하는 일이라, “어느 행인지 찾는” 과정이 없습니다. 그래서 old tuple 이라는 개념 자체가 필요 없습니다. publisher 는 새 행을 통째로 담아 보내고, 그게 전부입니다.

프로토콜로 보면 Insert 메시지는 'N' 이라는 바이트로 시작합니다. 이 'N' 은 “뒤따라오는 데이터가 새 튜플이다” 라는 표시이고, replica identity 설정은 아예 참조하지 않습니다.

세 연산이 각각 무엇을 보내고 subscriber 가 무엇을 하는지, 한눈에 비교되도록 아래 그림에 정리해 봤습니다. 지금은 INSERT 행만 이해되면 충분하고, UPDATE·DELETE 행은 다음 절들에서 하나씩 풀어 가겠습니다.

INSERT
'N' new tuple 만. replica identity 무관
그대로 삽입 (찾을 행 없음)
UPDATE
new tuple('N')은 항상 보냄. old tuple 은 FULL이면 'O', 키 변경이면 'K', 아니면 없음
대상 행을 찾아 갱신 (old 있으면 그 키로, 없으면 new 안의 키로)
DELETE
old tuple 만. FULL이면 'O', 아니면 'K'
대상 행을 찾아 삭제

왼쪽은 publisher 가 보내는 내용, 오른쪽은 subscriber 가 하는 일입니다. old tuple 에 무엇을 담을지는 replica identity 가, 그 행을 어떻게 찾을지는 subscriber 의 인덱스 유무가 정합니다.

old tuple은 어떻게 만들어지나

이제 UPDATE·DELETE 의 핵심인 old tuple 이 실제로 어떻게 만들어지는지 보겠습니다. PostgreSQL 내부에서 이 일을 하는 함수가 ExtractReplicaIdentity() 입니다. 이 함수는 테이블의 replica identity 설정을 보고 old tuple 을 만드는데, FULL 이면 행 전체를 그대로 쓰고, 인덱스 기반(DEFAULT·USING INDEX)이면 키 컬럼만 채운 새 튜플을 만듭니다.

여기서 흔히 오해하는 지점이 있습니다. 인덱스 기반일 때 키가 아닌 컬럼(non-key)이 old tuple 에서 빠지는 게 아니라, 자리는 그대로 두고 값만 NULL 로 채워진다는 점입니다. 예시로 보면, (a=2, b=20) 행을 USING INDEX (a) 로 지우면 old tuple 은 (a=2, b=NULL) 이 됩니다. b 가 사라진 게 아니라 NULL 로 바뀐 것입니다. 사소해 보이지만, 이 차이가 뒤에서 silent divergence 의 씨앗이 됩니다.

UPDATE: old tuple은 조건부로 실린다

UPDATE 는 조금 미묘합니다. 바뀐 뒤의 값(new tuple)은 언제나 보내지만, 바뀌기 전 값(old tuple)은 항상 보내지는 않습니다.

공식 프로토콜 문서는 Update 메시지의 old tuple 부분을 세 갈래로 규정합니다. 'K' 조각은 업데이트가 replica identity 키 컬럼을 바꿨을 때만 붙고, 'O' 조각은 테이블의 replica identity 가 FULL 일 때만 붙습니다. 그리고 한 메시지가 'K''O' 를 동시에 가질 수는 없습니다. 둘 다 없을 수는 있습니다.

말로 풀면 세 경우입니다.

  • replica identity 가 FULL 이면 'O'(행 전체)가 실립니다.
  • FULL 이 아닌데 키 컬럼을 바꾸는 UPDATE 이면 'K'(키)가 실립니다. 예를 들어 a 값을 2 에서 3 으로 바꾸는 경우입니다.
  • 키는 그대로 두고 다른 값만 바꾸는 UPDATE 이면 old tuple 이 아예 없습니다. new tuple 만 가고, subscriber 는 그 안에 든 키 값으로 대상 행을 찾습니다.

세 번째 경우가 성립하는 이유는 ① 실행 단계로 거슬러 올라갑니다. PostgreSQL 은 키 컬럼이 실제로 바뀐 경우에만 old tuple 을 WAL 에 남깁니다. 키가 그대로면 new tuple 안에 이미 같은 키가 들어 있으니, old tuple 을 따로 남길 필요가 없는 것입니다.

DELETE: 식별 정보만 보낸다

DELETE 는 UPDATE 와 반대입니다. 새 값이 없으니 new tuple 은 없고, 지울 행을 가리키는 old tuple 만 보냅니다. DELETE 는 지울 행을 늘 식별해야 하므로 old tuple 이 빠지는 경우가 없습니다. replica identity 가 FULL 이면 'O'(행 전체)가, 인덱스 기반(DEFAULT·USING INDEX)이면 'K'(키)가 실리며, 둘이 함께 오지는 않습니다. 그래서 평범한 PK 테이블의 DELETE 도 DEFAULT 라서 'K' 로 나갑니다. UPDATE 에서는 키를 바꿀 때만 'K' 가 등장했지만, DELETE 는 식별이 필수라 인덱스 기반이면 항상 'K' 라는 점이 다릅니다.

두 태그의 의미를 분명히 해두면 이렇습니다.

  • 'K': 뒤따르는 튜플이 키(인덱스 또는 PK 컬럼)만 담고 있다는 표시.
  • 'O': 뒤따르는 튜플이 행 전체라는 표시. FULL 일 때만 붙습니다.

이 구분은 외부 도구에게 특히 중요합니다. Debezium 같은 CDC(Change Data Capture) 도구는 이 복제 스트림을 그대로 받아 읽는데, 'O' 를 보면 “변경 전 행 전체가 왔구나” 하고 믿고 동작합니다. 이 믿음이 깨질 수 있다는 점이 뒤에서 다룰 #6047 의 출발점입니다.

wire format: 행이 바이트로 바뀌는 방식

여기서 한 단계 더 내려가 보겠습니다. publisher 가 튜플을 네트워크로 보내려면, 메모리 속 행을 바이트 나열로 바꿔야 합니다. 이 변환을 직렬화(serialization)라 하고, 그 결과로 한 튜플이 TupleData 라는 메시지 조각이 됩니다.

TupleData 안에서 각 컬럼은 자기 값 앞에 1바이트짜리 상태 마커를 답니다. 이 마커가 “이 컬럼이 지금 어떤 상태인지” 를 알려 줍니다. 종류는 네 가지입니다.

마커의미
'n'이 컬럼은 NULL
'u'변경되지 않은 TOAST 값 (실제 값은 전송하지 않음)
't'텍스트 포맷 데이터
'b'바이너리 포맷 데이터

'u'(변경되지 않은 TOAST 값)는 지금 다루는 문제와 무관하니 신경 쓰지 않아도 됩니다.

직렬화 루프는 컬럼을 절대 건너뛰지 않습니다. 값이 없는 컬럼이라도 그 자리를 비우는 게 아니라, “이 컬럼은 NULL” 이라는 'n' 마커를 반드시 박아 넣습니다. 앞에서 ExtractReplicaIdentity() 가 non-key 컬럼을 NULL 로 채웠다고 했는데, 그 NULL 이 여기서 그대로 'n' 으로 나가는 것입니다. 왜 그냥 비우지 않고 NULL 로 채울까요. wire 포맷에는 “이 컬럼은 안 보냈다” 를 뜻하는 마커가 따로 없기 때문입니다. 컬럼에 붙일 수 있는 마커는 위 네 가지뿐이라, 값이 없는 컬럼을 표현할 길이 NULL('n') 밖에 없습니다. 그래서 subscriber 는 “안 보낸 컬럼” 과 “진짜 NULL 인 컬럼” 을 받은 바이트만으로는 구분할 수 없습니다.

예시 테이블 p 에서 (a=2, b=20) 행을 USING INDEX (a) 기준으로 지우면, wire 에는 이렇게 나갑니다. 맨 앞 D 는 Delete 메시지를 뜻하고, 그 뒤로 앞서 본 태그(K)와 컬럼별 마커(t, n)가 이어집니다.

1
2
3
4
D ... K  t "2"  n
      │  │      └─ 두 번째 컬럼 b: NULL ('n')
      │  └─ 첫 컬럼 a: 텍스트 "2"
      └─ 태그 'K' (인덱스 기반 replica identity)

'K' 태그는 “키 기반으로 식별한다” 고 정직하게 선언하고, 실제 내용도 b'n'(NULL)으로 옵니다. 선언과 내용이 서로 맞습니다.

subscriber: 받은 메시지로 행을 찾는 법

이제 받는 쪽입니다. subscriber 에는 복제 변경을 자기 테이블에 실제로 적용하는 apply worker 라는 프로세스가 있습니다. DELETE 나 UPDATE 를 적용하려면 먼저 로컬 테이블에서 대상 행을 찾아야 하는데, 이 찾는 방법이 크게 두 갈래로 갈립니다.

첫째는 인덱스 경로입니다. 로컬 테이블에 PK 나 replica identity 인덱스가 있으면, 받은 키 값으로 그 인덱스를 타고 곧장 행을 찾습니다. 이때는 키 컬럼만 비교하고 나머지는 보지 않습니다. 그래서 old tuple 의 b 가 NULL 이든 20 이든 상관없이 a=2 하나로 정확히 찾아냅니다.

둘째는 순차 경로입니다. subscriber 테이블은 publisher 와 독립적으로 만들어지기 때문에, publisher 의 키 인덱스가 subscriber 에도 있으리란 보장이 없습니다. 쓸 만한 인덱스가 없으면 테이블을 처음부터 끝까지 훑으면서, 받은 튜플과 각 행을 tuples_equal() 로 통째로 비교합니다. 문제는 이 비교가 모든 컬럼을 대조하고, NULL 을 “모르는 값” 이 아니라 “진짜 NULL” 로 취급한다는 점입니다. 한쪽만 NULL 이어도 곧바로 “다른 행” 으로 판정합니다.

여기서 함정이 터집니다. old tuple 이 (a=2, b=NULL) 로 왔는데 로컬 실제 행은 (a=2, b=20) 입니다. 인덱스 경로라면 a=2 로 찾고 끝이지만, 순차 경로는 b 까지 비교하다가 NULL 과 20 이 다르다며 “이런 행 없음” 이라고 결론 내립니다.

그리고 못 찾았을 때가 진짜 문제입니다. 에러가 나지 않습니다. apply worker 는 delete_missing 이라는 충돌을 로그로만 남기고 조용히 넘어갑니다.

1
2
LOG:    conflict detected on relation "public.p": conflict=delete_missing
DETAIL: Could not find the row to be deleted: replica identity full (2, null).

로그에 full 이라고 찍혔다고 해서 publisher 가 FULL 설정이라는 뜻은 아닙니다. 이 문구는 subscriber 가 키 인덱스를 못 찾았을 때, 찾으려던 값을 통째로 로그에 적으면서 붙는 표현입니다. 그러니 이 문구가 보인다는 것 자체가 “subscriber 가 순차 경로로 빠졌다” 는 신호이기도 합니다.

결과적으로 publisher 에서는 행이 지워졌는데 subscriber 에는 그대로 남습니다. 두 데이터베이스가 에러 한 번 없이 조용히 어긋나는 silent divergence 입니다.

분기를 정하는 것은 태그가 아니다

여기서 직관과 어긋나는 사실이 하나 있습니다. subscriber 가 인덱스 경로와 순차 경로 중 무엇을 쓸지 정할 때, 방금 받은 'O''K' 태그는 보지 않는다는 점입니다. apply worker 는 그 태그를 “뒤에 old tuple 이 따라오는가” 를 판단하는 데만 쓰고, 읽고 나면 버립니다.

그럼 경로는 무엇으로 정해질까요. 오직 두 가지입니다. 로컬 테이블에 쓸 만한 인덱스가 있는지, 그리고 Relation 메시지로 미리 받아 둔 원격 테이블의 replica identity 선언이 무엇인지입니다. Relation 메시지는 데이터에 앞서 publisher 가 보내는 “테이블 명세서” 로, 컬럼 구성과 replica identity 설정(어느 컬럼이 키인지 포함)을 담습니다. 이 사실은 공식 문서로도 확인됩니다.

정리하면, “키만 보내기” 는 그 자체로 나쁜 방식이 아닙니다. subscriber 에 키 인덱스만 있으면 인덱스 경로가 키로 정확히 찾으니 완벽하게 동작합니다. 문제는 키 인덱스가 없어 순차 경로로 빠질 때만 생깁니다. 실무에서 “왜 어떤 subscriber 는 멀쩡하고 어떤 것은 데이터가 어긋나는가” 가 바로 이 차이에서 갈립니다.


리뷰: 파티션 테이블에서 어긋나는 지점 (#6047)

위 메커니즘을 따라가다 실제 CommitFest 패치 한 건을 만났고, 리뷰로 남겨 둡니다. 진행 중인 논의라 결론을 단정하기보다, 어떤 구조에서 문제가 드러나는지까지만 적어 봤습니다.

지금까지는 평범한 테이블을 가정했습니다. 파티션 테이블에서는 결이 한 겹 더 복잡해집니다. 파티션 테이블은 부모(root) 하나 아래에 실제 데이터가 든 자식 파티션(leaf)들이 달린 구조입니다. 대상은 여기에 publish_via_partition_root = true 를 켠 경우입니다. 이 옵션을 켜면 leaf 에서 일어난 변경을 root 이름으로 발행해서, subscriber 쪽 테이블 구조가 publisher 와 달라도 복제되게 해 줍니다. 이때 pgoutput 은 메시지를 만들 때 기준 테이블을 leaf 가 아니라 root 로 바꿉니다.

여기서 핵심 전제가 하나 있습니다. replica identity 는 파티션마다 따로 설정됩니다. 부모에 한 번 걸어 둔다고 기존 자식들에게 자동으로 내려가지 않습니다. ALTER TABLE ... REPLICA IDENTITY 가 자식으로 재귀하지 않기 때문입니다. 그래서 root 는 FULL 인데 어떤 leaf 는 USING INDEX 인, 서로 다른 설정이 한 트리 안에 섞이는 상황이 자연스럽게 생깁니다.

바로 이 섞임에서 세 가지 결정이 서로 다른 테이블을 기준으로 삼게 됩니다. 그 어긋남과 subscriber 에서의 결과를 아래 그림에 정리해 봤습니다. 그림의 c2 는 자식 파티션 하나, attrmap 은 부모와 자식의 컬럼 배치를 맞춰 주는 변환이라고 보면 됩니다.

publisher (pgoutput)
leaf c2 에서 DELETEWAL old = (a=2), b 없음
targetrel = ancestor(root)pgoutput_change, attrmap 변환
태그 = root 기준 'O'(root 가 FULL 이기 때문)
튜플 내용 = leaf 기준b 는 'n'(NULL)
D 'O' (2, NULL)
→ subscriber
subscriber (apply worker)
R 메시지: replident = rootpublisher 가 FULL 로 선언
로컬에 PK/RI 인덱스 없음FindReplTupleInLocalRel
seq scan + tuples_equalNULL ≠ 20 → 못 찾음
delete_missing 로그만삭제 스킵, 에러 없음

태그와 R 메시지는 root 기준, 튜플 내용만 leaf 기준입니다. (함수명만 표기, 라인 번호는 버전마다 달라 생략합니다.)

문제를 한 줄씩 떼어 보면 이렇습니다.

  • old tuple 의 실제 내용은 leaf 기준입니다. 변경은 leaf 에서 일어났고, leaf 의 replica identity(USING INDEX)에 따라 키만 담겨 b 는 NULL 입니다.
  • 'O''K' 태그는 root 기준입니다. root 가 FULL 이라 'O'(행 전체)를 찍습니다.
  • Relation 메시지의 replica identity 선언도 root 기준이라 FULL 로 나갑니다.

즉 메시지는 “이건 행 전체('O')” 라고 선언해 놓고, 실제로는 b 가 NULL 인 반쪽짜리 튜플을 담습니다. 프로토콜이 정한 'O' 의 의미와 내용이 어긋나는 것입니다. 그리고 subscriber 에 키 인덱스가 없으면 순차 경로로 빠지는데, 순차 경로는 태그와 무관하게 모든 컬럼을 비교하므로 (2, NULL)(2, 20) 이 달라 행을 못 찾고 silent divergence 가 납니다. 반대로 키 인덱스가 있으면 인덱스 경로가 정상 삭제하므로, 이 발산은 무인덱스 subscriber 에서만 나타납니다.

흥미로운 점은, 'O''K' 로 바로잡는 것만으로는 이 발산이 사라지지 않는다는 것입니다. 앞에서 봤듯 subscriber 는 태그를 경로 선택에 쓰지 않기 때문입니다. 태그를 고치면 태그를 믿는 외부 CDC 도구는 보호되지만, PostgreSQL 자체 복제의 동작은 그대로입니다. 진짜로 정합성을 맞추려면 태그·Relation 선언·실제 내용 세 가지를 모두 leaf 기준으로 정렬하거나, 처음부터 root 와 leaf 의 replica identity 가 어긋나는 설정을 막아야 합니다. 어느 쪽이 옳은지는 아직 커뮤니티에서 정해지지 않았고, 추후 지속적으로 리뷰에 참여하며 저또한 견문과 지식을 넓혀나갈 예정입니다.

정리

INSERT·UPDATE·DELETE 가 subscriber 로 가는 길을 한 줄씩 요약하면 이렇습니다.

  • INSERT 는 “어느 행” 이라는 질문이 없으니 새 행만 보냅니다. replica identity 와 무관합니다.
  • UPDATE 는 새 값을 항상 보내고, old tuple 은 FULL 이면 'O', 키가 바뀌면 'K', 키가 그대로면 보내지 않습니다.
  • DELETE 는 식별용 old tuple 만 보냅니다. 'O''K' 냐는 replica identity 가 정합니다.

이 모든 걸 관통하는 원리는 하나입니다. 무엇을 보낼지는 publisher 의 변경 시점에 replica identity 로 고정되고, subscriber 는 그것을 받아 로컬에 인덱스가 있으면 키로, 없으면 전 컬럼 비교로 행을 찾습니다. “키만 보내기” 는 양쪽 선언과 실제 내용이 맞고 subscriber 에 키 인덱스가 있는 한 충분하고 효율적입니다.

문제는 그 일치가 깨질 때입니다. 적어도 순차 경로에서는 PostgreSQL 이 에러 없이 조용히 실패합니다. #6047 은 바로 그 일치가 깨지는 한 가지 경우입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.