여러 Pod(replica) 환경 동시성 제어: 단일 SQL CAS 패턴
서비스를 pod replica 여러 개로 띄우면 처리량은 늘지만, 같은 데이터를 두 pod가 동시에 건드릴 수 있다. 내 경우는 job 하나를 완료(Done)로 쓰는 순간이 문제였다. 분산락까지 갈 것 없이 SQL 문 하나로 이 경합을 막았는데, 알고 보니 CAS(Compare-And-Swap) 패턴이었다.
문제: 마지막에 쓴 쪽이 이긴다
취소 요청을 받은 pod(leader가 아님)와 job을 실행하던 executor(leader pod)가 같은 job row를 동시에 Done으로 쓰려고 했다.
락이 없으니 두 pod 모두 state=running을 읽고, 둘 다 쓰기에 성공한다. 결과는 마지막에 쓴 쪽을 따르니 cancelled가 success로 뒤집히거나 그 반대가 된다. 사용자 입장에선 분명 취소했는데 성공으로 떠 있는 버그다.
해법: UPDATE에 기대 상태를 건다
해결은 단순했다. UPDATE의 WHERE에 내가 기대하는 상태를 조건으로 걸어서, 첫 번째 writer만 성공하게 만든다.
UPDATE jobSET state = ?, result = ?, finished_at = ?WHERE id = 3 AND run_seq = 3 -- 내가 알던 세대(generation) AND state = 'running' -- 아직 진행 중 AND action = 'start' -- 같은 액션성공 여부는 RowsAffected로 판단한다. 1이면 내가 첫 writer이니 그대로 완료하고, 0이면 다른 쪽이 먼저 끝냈으니 건너뛴다.
기대 상태와 비교(Compare)해서 맞을 때만 바꾸니(Swap) CAS다. DB가 UPDATE를 row 단위 락으로 원자적으로 처리하니, 애플리케이션이 따로 락을 잡을 필요가 없다.
run_seq를 같이 비교하는 이유
state = 'running'만 보면, 재시작이나 재실행으로 같은 상태가 다시 와도 구분하지 못한다. run_seq(세대 번호)는 지금 row가 내가 알던 그 실행인지를 확인해 준다. 값은 같아 보여도 사실 다른 세대인 ABA 문제를 막는 장치다.
기존 코드에도 같은 패턴이 있었다
같은 코드베이스의 다른 작업 흐름은 이미 이 패턴을 중간 checkpoint 저장(SaveStatusIfCurrent)에 쓰고 있었다. 이번에 만든 완료 처리(FinishIfCurrent)는 WHERE 조건을 그대로 가져오고, 완료 전용이라 SET 부분만 단순하게 했다.
finish(Done, Cancelled) ├─ 조건: {JobID=3, ExpectRunSeq=3, ExpectAction=start} ├─ FinishIfCurrent(cond, Done, Cancelled, now, logs) │ └─ UPDATE ... WHERE run_seq=3 AND state=running AND action=start ├─ matched=true → 정상 완료 └─ matched=false → "superseded by another actor" warn 로그, DB는 그대로분산락과 비교
예전에는 이 경합을 분산락으로 풀었다.
분산락
- 락을 잡는다.
- 상태를 확인한다.
- 쓴다.
- 락을 푼다.
단계마다 etcd를 오가고, 그 사이 어디서든 실패할 수 있다.
✓CAS 조건부 UPDATE
- SQL 한 문장으로 같은 보장을 준다. etcd 왕복이 없다.
- 멱등하다. 같은 호출을 여러 번 해도 한 번만 반영된다.
- 정상, 실패, 취소 완료를 한 메서드로 처리한다.
언제 CAS, 언제 분산락
pod 사이의 동시성 문제는 생각보다 거창한 인프라 없이 WHERE 한 줄로 풀리는 경우가 많다.