4.6 KiB
Outbox · Inbox
두 패턴이 각각 무엇을 해결하는가
| 패턴 | 해결하는 문제 | 해결하지 않는 문제 |
|---|---|---|
| Transactional Outbox | DB commit과 publish 사이의 창(窓) | 중복 |
| Inbox | 중복 delivery의 side effect | 유실 |
둘 다 필요하다. Outbox만으로는 exactly-once가 되지 않는다.
Outbox
business transaction과 같은 transaction에서 row를 쓴다. 둘 다 commit되거나 둘 다 안 된다.
BEGIN;
UPDATE orders SET status = 'PLACED' WHERE id = ?;
INSERT INTO messaging_outbox (message_id, destination, ...) VALUES (?, ?, ...);
COMMIT;
relay
leaseBatch(100, 30s) -- lease로 다중 relay 인스턴스 안전
→ publish (messageId 그대로)
→ CONFIRMED → markPublished
→ AMBIGUOUS → markAmbiguous (같은 messageId로 재시도 가능)
→ REJECTED → markFailed
핵심 규칙: ambiguous는 같은 messageId로 재시도
새 id를 발급하면 "전달됐을 수도 있는 메시지"가 "확실히 두 번째인 메시지"가 되어 downstream의 어떤 중복 제거도 복구할 수 없다. failed로 표시하면 broker가 이미 가지고 있을 수 있는 메시지를 잃는다.
message_id를 primary key로 둔 것도 같은 이유다. 어떤 코드 경로도 실수로 새 id를 붙일 수 없다.
lease
status IN ('PENDING','AMBIGUOUS','IN_FLIGHT')
AND (lease_expires_at IS NULL OR lease_expires_at <= now)
AND next_attempt_at <= now
AND attempts < maxAttempts
IN_FLIGHT가 목록에 있는 것이 핵심이다. relay가 publish 도중 죽으면 row는 IN_FLIGHT로 남는데,
이를 제외하면 그 메시지는 영원히 발행되지 않는다 — outbox가 막으려던 바로 그 실패다. 대신
lease가 만료됐을 때만 회수하므로, 살아 있는 relay가 들고 있는 row는 회수되지 않는다.
회수는 같은 message_id로 이루어지고 lease_token이 1 증가한다. 새 id를 발급하면 "전달됐을
수도 있는 메시지"가 "확실히 두 번째"가 되기 때문이다 (위의 AMBIGUOUS 논의와 같은 이유).
이 문단의 근거는 실제 PostgreSQL 컨테이너 레인이다:
OutboxPostgresIT#anExpiredLeaseBecomesClaimableAgain— 만료된 lease의 재회수OutboxPostgresIT#anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken— 같은 id, 증가한 tokenOutboxPostgresIT#aLeasedRowIsInvisibleToASecondRelayInstance— 살아 있는 lease는 회수 불가OutboxPostgresIT#aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt— fencing
partial index ix_messaging_outbox_claimable이 이 쿼리를 backlog 크기에 비례하게 유지한다.
PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다.
Inbox
reservation과 side effect가 같은 transaction이어야 한다.
transactions.inTransaction(() -> {
if (!inbox.reserve(messageId, consumerId, now)) {
return InboxOutcome.duplicate(); // 이미 처리됨
}
return InboxOutcome.processed(sideEffect.get());
});
별도 transaction으로 예약하면 Inbox가 닫으려던 바로 그 창이 다시 열린다.
복합 키
PRIMARY KEY (message_id, consumer_id).
message_id만으로 중복 제거하면 같은 event를 소비하는 두 번째 consumer가 첫 번째에 의해 억제된다. 각 consumer가 한 번씩 처리해야 한다.
retention
broker의 최대 redelivery window보다 길어야 한다. row를 먼저 지우면 늦게 도착한 redelivery가 두 번 처리된다.
Debezium CDC 대안
polling relay 대신 WAL을 읽는다. polling interval과 lease 경합이 사라지지만 인프라와 그 자체의 실패 모드가 추가된다.
wire contract는 동일하다. DebeziumOutboxEventRouter가 polling relay와 같은 reserved header를
방출하므로 consumer는 어느 쪽이 발행했는지 구분할 수 없고, 전환은 배포 결정일 뿐 계약 변경이 아니다.
Claim Check
1 MiB 초과 payload는 broker 프레임을 키우지 않고 외부 저장소로 offload한다.
ClaimCheckReference는 digest를 필수로 가진다. claim check는 메시지를 서로 다른 retention과
replication을 가진 두 시스템으로 쪼개므로, consumer는 producer가 저장한 바로 그 bytes를 받았음을
증명할 수 있어야 한다. 그렇지 않으면 잘린 객체와 정상 객체를 구분할 수 없다.
ClaimCheckIntegrityGuard는 fetch 전에 만료를, fetch 후에 크기와 digest를 검사한다.
digest 불일치는 DESERIALIZATION이 아니라 validation 실패로 분류한다.
bytes가 깨진 JSON인 게 아니라, 틀린 bytes이기 때문이다.