Cassandra DB LWT 동작 방식 (+동시성 이슈 해결기)
푸시 쪽 개선을 위해서, 푸시 플랫폼에서 푸시 발송 시 특정 데이터 정보가 필요했습니다.
- 그 특정 데이터는 다른 플랫폼에 있는 데이터로 데이터 특성상 푸시 발송시 해당 플랫폼으로부터 실시간으로 데이터를 조회하는 경우, 너무 많은 요청이 해당 플랫폼으로 가게 되었습니다.
- 이러한 이유로 해당 데이터를 푸시 플랫폼으로 이벤트 파이프라인을 구성해서 사본 데이터를 저장하는 식으로 방향을 정했고, Cassandra DB의 특정 테이블에 저장해 두는 식으로 정하였습니다.
- 별도의 저장소에 사본 데이터를 저장하는 경우, 추가적인 저장소가 필요 & 푸시 발송시 추가적인 랜덤 액세스 형태의 읽기 비용이 필요해서, 발송 속도에 영향을 줄 수 있음
상황 설명 (각색)
실제 상황으로 상황을 설명하기 문제가 있을 수 있어, 전혀 다른 내용인 "주문"으로 예시를 들었습니다.
(주문 시스템을 이렇게 만들리는 없지만, 그냥 억지로 끼어넣은 예시로 봐주시길 바랍니다 ㅎㅎㅎ)
각색한 상황은 그렇습니다.
- 사용자는 주문을 취소할 수 있습니다. (주문 취소시 order 데이터를 삭제합니다)
- 사장님이 준비를 시작하면, 준비 중 상태로 변경합니다. (WAITING 상태에서 PREPARING 상태로 업데이트합니다.)
Cassandra DB에서 Update 처리 방식
갱신하는 쿼리는 다음과 같습니다. (다시 말씀드리지만 각색 상황이며, 이해를 위한 예시 테이블일 뿐입니다)
update order
set order_status = 'PREPARING' // WAITING -> PREPARING
where order_id = 'aaaa';
- Cassandra DB에서 Update 쿼리는 Upsert 형태로 동작합니다 (기존에 로우가 있던 경우 업데이트되지만 만약 Row가 없는 경우, 신규 로우가 생성됩니다)
- 그래서 기존에 로우가 있는 경우에만, Update를 하려는 경우 Select -> Update 형태로 로우가 존재하는지 체크한 후에 존재하는 경우에만 Update를 호출해야 하는데요.
동시성 이슈
- 예를 들어서, 위와 같이, 삭제 요청(delete)과 갱신 요청(select -> update) 이 거의 동시에 처리돼서, select -> delete -> update 순으로 처리되는 경우, 사용자가 취소한 주문 데이터가 되살아나는 이슈가 발생할 수 있습니다.
- 이로 인해서 손님은 주문을 취소했는데, 사장님에게는 주문 취소가 반영되지 않아서 그대로 준비하시는 문제가 발생합니다
방법 1. Cassandra DB 경량 트랜잭션 (LWT, Light Weight Transaction)
이러한 동시성 이슈를 막기 위해서, Cassandra DB의 LWT 사용하는 것을 먼저 검토해봤는데요.
Cassandra DB에서는 이러한 CAS(Compare and Set) 처리를 보장하기 위해서, 경량 트랜잭션(LWT)을 지원합니다.
update order
set order_status = 'PREPARED' // PENDING -> PREPARED
where order_id = 'aaaa';
if exists; # if not exists, if ... 등
- 예를 들어서, 해당 로우가 있는 경우에만 Update를 수행
- 참고로 update 뿐만 아니라 insert도 가능하고, 조건도 if exists 이외에 if not exists, if ... 등의 조건을 줄 수 있습니다
Cassandra DB LWT 문제점
문제점은 Cassandra DB에서의 LWT 동작 방식에 있는데요.
- CassandraDB에서는 LWT를 위해서 프로토콜을 Paxos 합의 프로토콜을 사용합니다.
- 이로 인해서 Coordinator Node와 복제본 노드 간에 총 4회의 Round-Trip을 가지는 높은 지연시간을 유발합니다.
대략적인 처리 방식은 다음과 같습니다.
(Cassandra DB는 Masterless로, 그림에서 Leader는 코디네이터로 Replica는 복제본 Quroum로 봐주세요 ㅎㅎ)
- Prepare & Promise: 주어진 파티션의 복제본들에게 제안 번호를 포함한 메시지를 보내어 준비 & 각 복제본은 받은 제안 번호가 가장 높은 번호인 경우에만 수락.
- Read & Results: 코디네이터가 복제본으로부터 약속을 받으면 각 복제본으로부터 값을 읽음.
- Propose/Accept: 코디네이터는 사용할 값을 결정하고 제안 번호와 함께 값을 복제본들에 제안 -> 각 복제본은 이미 높은 번호의 제안에 약속되어 있지 않은 경우에만 특정 번호의 제안을 수락
- Commit/Acknowledge: 모든 조건이 충족되면, 쓰기 적용.
이러한 처리 방식으로 인해서, LWT은 처리량이 높은 곳에서는 사용하는 것에 한계가 발생합니다.
(주문이라고 각색한) 실제의 이 이벤트 처리의 경우 높은 처리량을 요구하기 때문에, Cassandra DB의 LWT로 처리하기에는 한계가 존재했습니다. (일부 요청들에 대해서 timeout 발생 및 높은 Cassandra DB의 부하 유발)
cf) 카산드라 아키텍처 특성상 이런 처리를 못하는거지, 잘하는 것도 있습니다 ㅎㅎ
추가) 그래서 동시성 이슈는 어떻게 해결했냐...
방법 2. 분산 락 사용 (레디스 등)
- 유저 주문 이벤트를 처리하는 기존 로직에도 락이 추가되어야 한다.
- Select 로직부터 락이 들어가야 한다 (물론 최적화는 가능)
- 해당 이벤트의 경우 많은 처리량을 요구해서, 락을 사용하는 것을 최대한 지양하려고 하였습니다.
방법 3. 파이프라인 변경
- (현재 기존 파이프라인은 Kafka를 이용해서 이벤트를 받아서 비동기로만 처리되고 있었습니다)
처음 위와 같이 계획했던 이 파이프라인을
아래와 같이 이벤트를 받아서, 갱신 조건에 해당하는 메시지를 다시 B 토픽으로 리큐잉 하는 형태로 변경해서, 주문 데이터 처리는 모두 기존 컨슈머 로직에서 처리되고, 각 프로듀서는 동일한 메시지 키와 함께 발행합니다.
- 주문 컨슈머에서 select -> update를 통해서 주문이 존재하는 경우에만 갱신 처리..
- 주문 취소, 주문 상태 변경 등의 요청이 주문 단위로 선형적으로 처리되게 함으로써, 동시성 이슈를 해결하였습니다.
- (참고로, 일반적인 상황에서 Kafka로 키를 잡고 프로듀싱 시, 동일한 키는 동일한 파티션으로 할당되며 동일한 파티션에서는 메시지 간의 순서가 보장됩니다)