LabHub
배우기 러닝패스 코스

マイクロサービスアーキテクチャ

Outboxパターンでイベント発行を保証する

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

이중 쓰기의 불일치를 직접 만들어 본 뒤, 아웃박스 패턴으로 유실을 없애고, 남는 중복을 소비자 멱등성으로 흡수하는 전 구간을 손으로 완성한다.

왜 중요한가

DB 저장과 이벤트 발행을 나란히 호출하는 코드는 어디에나 있고, 평소에는 잘 동작합니다. 문제는 브로커가 3초 흔들리는 그 순간에만 나타나고, 그때 만들어진 불일치는 조용히 남습니다. 주문은 있는데 재고가 안 줄어든 상태를 며칠 뒤 정산에서 발견하는 식입니다. 아웃박스는 이 문제를 "브로커를 트랜잭션에 참여시키자" 대신 "발행 의도를 DB 에 함께 적자"로 뒤집어 풉니다. 대신 새 성질이 생깁니다 — 유실은 없어지지만 중복은 생깁니다. 릴레이가 발행 직후 상태 갱신 전에 죽으면 재시작 후 같은 이벤트를 또 보냅니다. 그래서 이 실습은 중복을 버그가 아니라 설계 전제로 다루고, 소비자 쪽에서 흡수하는 것까지 한 실습 안에서 끝냅니다.

단계

  1. /root/outbox/init.py/root/outbox/app.db 를 만든다. orders(id, sku, qty)outbox(event_id, aggregate_id, seq, event_type, payload, status) 두 테이블이 있어야 하고 status 기본값은 PENDING 이다.
  2. /root/outbox/dualwrite.py 는 주문 1건을 저장한 뒤 발행에 실패한다. 실행 후 /root/outbox/dualwrite.out 첫 줄에 INCONSISTENT orders=<n> published=<m> 을 적는다. n 과 m 이 달라야 한다.
  3. /root/outbox/place_order.py 는 orders 와 outbox 에 같은 트랜잭션으로 쓴다. 3번 실행하면 orders 3행, outbox 3행이 된다.
  4. /root/outbox/relay.pystatus='PENDING' 행을 Redis 리스트 outbox.events 에 RPUSH 하고 그 행을 PUBLISHED 로 바꾼다. 두 번 실행해도 리스트 길이가 늘지 않아야 한다.
  5. 같은 aggregate_id 의 이벤트가 seq 오름차순으로 큐에 들어가야 한다. /root/outbox/order_check.outORDER OK 를 남긴다.
  6. /root/outbox/relay_crash.py 는 발행만 하고 상태를 갱신하지 않는다. 실행 후 정상 릴레이를 다시 돌리면 같은 event_id 가 큐에 2번 들어간다. /root/outbox/atleastonce.outDUPLICATE event_id=<id> count=2 를 적는다.
  7. /root/outbox/consumer.py 는 큐를 비우면서 event_id 기준으로 중복을 걸러 처리한다. 처리 결과를 Redis 해시 processed 에 남긴다. 중복이 있어도 processed 크기는 고유 이벤트 수와 같아야 한다.
  8. /root/outbox/report.txtorders=<n>, outbox=<n>, enqueued=<n>, processed_unique=<n> 네 줄을 적는다. enqueuedprocessed_unique 보다 크거나 같아야 한다.

참고

주문과 아웃박스 테이블 만들기

/root/outbox/init.py/root/outbox/app.db 를 만든다. orders(id, sku, qty)outbox(event_id, aggregate_id, seq, event_type, payload, status) 두 테이블이 있어야 하고 status 기본값은 PENDING 이다.

python3 의 sqlite3 모듈로 충분합니다. 아웃박스 행에는 이벤트 식별자, 집합체 식별자, 타입, 본문, 상태가 필요합니다.

이중 쓰기의 불일치 재현하기

/root/outbox/dualwrite.py 는 주문 1건을 저장한 뒤 발행에 실패한다. 실행 후 /root/outbox/dualwrite.out 첫 줄에 INCONSISTENT orders=<n> published=<m> 을 적는다. n 과 m 이 달라야 한다.

DB 저장은 성공시키고 브로커 발행만 실패시키면 됩니다. 두 저장소의 개수를 세어 서로 다르다는 것을 파일로 남기세요.

한 트랜잭션으로 접어 넣기

/root/outbox/place_order.py 는 orders 와 outbox 에 같은 트랜잭션으로 쓴다. 3번 실행하면 orders 3행, outbox 3행이 된다.

두 INSERT 를 같은 커넥션, 같은 커밋 안에 넣습니다. 커밋 전에 예외가 나면 둘 다 없어야 정상입니다.

릴레이로 브로커에 옮기기

/root/outbox/relay.pystatus='PENDING' 행을 Redis 리스트 outbox.events 에 RPUSH 하고 그 행을 PUBLISHED 로 바꾼다. 두 번 실행해도 리스트 길이가 늘지 않아야 한다.

PENDING 행을 읽어 Redis 리스트에 밀어 넣고 상태를 바꿉니다. 여러 번 실행해도 이미 옮긴 것을 또 옮기지 않아야 합니다.

집합체별 순서 지키기

같은 aggregate_id 의 이벤트가 seq 오름차순으로 큐에 들어가야 한다. /root/outbox/order_check.outORDER OK 를 남긴다.

같은 주문의 이벤트는 발생 순서대로 나가야 합니다. 정렬 기준을 무엇으로 잡을지 생각해 보세요 — 시각은 같을 수 있습니다.

릴레이 크래시로 중복 만들기

/root/outbox/relay_crash.py 는 발행만 하고 상태를 갱신하지 않는다. 실행 후 정상 릴레이를 다시 돌리면 같은 event_id 가 큐에 2번 들어간다. /root/outbox/atleastonce.outDUPLICATE event_id=<id> count=2 를 적는다.

발행은 했는데 상태 갱신을 못 하고 죽는 상황을 흉내 냅니다. 다시 돌리면 같은 이벤트가 두 번 큐에 들어갑니다.

소비자에 중복 제거 붙이기

/root/outbox/consumer.py 는 큐를 비우면서 event_id 기준으로 중복을 걸러 처리한다. 처리 결과를 Redis 해시 processed 에 남긴다. 중복이 있어도 processed 크기는 고유 이벤트 수와 같아야 한다.

이미 처리한 이벤트 식별자를 기억해 두면 됩니다. Redis 의 집합 자료구조나 SET NX 가 어울립니다.

전 구간 수치로 보고하기

/root/outbox/report.txtorders=<n>, outbox=<n>, enqueued=<n>, processed_unique=<n> 네 줄을 적는다. enqueuedprocessed_unique 보다 크거나 같아야 한다.

주문 수, 아웃박스 행 수, 큐에 들어간 수, 실제 처리된 고유 수를 한 파일에 모읍니다. 앞의 세 값과 마지막 값의 관계가 이 패턴의 전부입니다.