상품 정보를 고치면 연동된 채널에 반영하는 구조로 수정 시점에 ProductUpdated 메시지를 SQS 표준 큐에 넣고 워커가 꺼내 채널 API 를 부르고 있었다. 가격을 올렸는데 채널에는 그 전 가격이 올라가 있었다.
Table of contents
Open Table of contents
원인 — 값을 통째로 담은 메시지
메시지가 발행 시점의 상품 상태를 통째로 담고 있었고 워커는 그 값을 그대로 채널에 보냈다. 큐가 밀리지 않을 때는 순서대로 처리되므로 이 구조의 문제가 안 드러난다.
문제는 워커가 여러 대일 때였다. 꺼내는 쪽이 병렬이라 먼저 꺼낸 것의 채널 응답이 느리면 나중 것이 먼저 도착하고 꺼내는 순서와 도착하는 순서가 달라진다. AWS 공식 문서대로 FIFO 큐에서 같은 MessageGroupId 의 메시지는 한 번에 하나씩 엄격한 순서로 처리되므로 상품별로 그룹을 주면 이것 자체는 막을 수 있다.
판단 기준 — 가리키는 것만 담기
두 가지 방법이 있었는데 큐를 FIFO 로 바꾸고 MessageGroupId 에 product_id 를 주는 것과 표준 큐를 두고 메시지에서 상태 전문을 빼는 것이다. 뒤쪽을 골랐다. 이유는 AWS 공식 문서대로 FIFO 큐는 파티션당 API 동작마다 초당 300 트랜잭션 상한이 있고 표준 큐를 FIFO 로 바꾸려면 큐를 새로 만들고 발행자와 소비자를 다 고쳐야 해서다. 대신 메시지 수만큼 조회가 하나씩 따라붙는 것을 감수했다. 메시지에는 product_id 와 version 과 changed_fields 만 남겼고 워커는 메시지를 꺼낸 뒤에 products 를 다시 읽어 현재 값을 가져온다.
늦게 처리하더라도 데이터베이스에는 최신 값이 들어 있으므로 최신 값을 보내게 된다. 같은 값을 두 번 보내게 되지만 결과가 같으므로 낡은 값이 남지 않는다.
다시 읽은 행으로 하는 비교
같은 것을 두 번 보내는 것도 줄이려고 channel_sync_versions.last_sent_version 을 기록하고 그보다 낮거나 같으면 건너뛰게 했다. 버전 값으로는 products.version 이 갱신될 때마다 올라가는 카운터를 썼다.
여기서 중요한 것이 비교 대상이다. 메시지에 실린 버전이 아니라 다시 읽은 행의 버전으로 비교해야 하는데 메시지 쪽 값은 발행 시점 것이라 같은 문제를 그대로 안고 있기 때문이다.
주의 — 채널이 순서를 안 지킬 때
이렇게 해도 채널 호출 두 건이 뒤집히면 낡은 값이 남을 수 있으므로 채널이 조건부 갱신을 지원하는지도 확인했다. 한 곳은 요청에 updated_at 을 받아 더 오래된 것을 무시했고 다른 한 곳에는 그런 장치가 없었다.
그런 장치가 없는 쪽은 같은 상품에 대해 동시에 두 건이 안 나가도록 Redis SETNX 로 잠갔다. 같은 상품은 한 번에 하나만 나가고 다른 상품은 그대로 병렬로 처리된다.
다시 읽을 것과 스냅숏이 맞는 것
전부 다시 읽으면 안전한 대신 메시지 수만큼 조회가 따라붙는다. 그래서 대상을 두 종류로 갈라서 다르게 다뤘다.
가격과 재고와 상태처럼 덮어써도 되는 것은 다시 읽게 했는데 마지막 값만 맞으면 되기 때문이다. 주문 접수나 정산 확정처럼 한 번만 일어나는 것은 메시지에 담긴 값을 쓰는데 다시 읽으면 그 사이에 취소된 것이 접수되지 않은 것으로 보여 사실 자체가 바뀌기 때문이다. 이 사고는 큐가 밀릴 때만 나므로 published_at 과 processed_at 의 차이를 재서 지연이 커지는 구간을 볼 수 있게 했다.
정리
- 큐에 값을 통째로 담으면 꺼낼 때 이미 다른 값일 수 있다
- 표준 큐는 꺼내는 순서와 도착 순서가 다르고 FIFO 는 처리량 상한이 있다
- 덮어써도 되는 것은
product_id만 담고 꺼낸 뒤 다시 읽는다 - 버전 비교는 다시 읽은 행의 값으로 한다
- 채널이 순서를 안 지키면 같은 대상에 하나만 나가게 잠근다
- 한 번만 일어나는 일은 발행 시점 스냅숏이 맞다
- 그것을 다시 읽으면 사실이 바뀐다
- 큐 지연을 재 두면 이 사고가 나는 구간이 보인다