환불 후 복구 재시도를 위한 멱등성 신용 원장
실패한 작업은 크레딧을 환불받습니다. 그 후 재시도가 성공합니다. 이중으로 청구하지 않고 크레딧을 회수하려면 멱등성 원장과 원자적 가드가 필요합니다.
사용량 기반 시스템은 작업이 실패하면 크레딧을 환불합니다. 곤란한 경우는 동일한 작업이 나중에 재시도 시 복구되어 성공하는 때입니다. 이제 환불은 잘못된 것이 되며, 이중으로 청구하지 않으면서 크레딧을 회수해야 합니다. 해결책은 역연산 쌍으로 이루어진 추가 전용 원장이며, 여기에서 모든 환불과 모든 회수는 현재 상태에 따라 보호되는 단일 조건부 쓰기입니다. 이 게시물에서는 원장의 형태, 이중 환불과 이중 청구를 모두 차단하는 원자적 가드, 그리고 재시도 큐가 무한 루프에 빠지지 않으면서 여기에 어떻게 데이터를 공급하는지를 보여줍니다.
환불 후 복구 문제
종량제 시스템에서는 비디오 렌더링, 트랜스크립션 실행, 이미지 생성과 같은 사용자 작업에 크레딧이 소모됩니다. 일반적인 설계는 선불로 요금을 청구하고, 작업을 실행한 후, 실패 시 환불하여 사용자가 받지 못한 작업에 대해 비용을 지불하지 않도록 합니다. 문제가 시작되는 곳이 바로 그 환불입니다.
분산 작업은 깔끔하게 실패하지 않습니다. 한 워커가 30초에 타임아웃되어 작업을 실패로 표시하고 40 크레딧을 환불합니다. 하지만 작업을 수행하던 GPU는 실제로는 32초에 작업을 마쳤고, 결과는 타임아웃이 발생한 지 2초 후에 스토리지에 저장됩니다. 작업은 실패한 것이 아닙니다. 복구되었습니다. 이제 원장에는 존재하고 전달될 작업에 대한 환불이 기록됩니다.
단순한 구현은 이 문제를 더 악화시킵니다. 실패 처리와 성공 처리가 공유 상태 없이 두 개의 독립적인 코드 경로라면, 두 가지 버그 중 하나가 발생합니다. 동일한 작업에 대한 두 개의 실패 신호(타임아웃과 나중의 오류 콜백)가 각각 환불을 진행하여, 사용자는 40 크레딧 작업에 대해 80 크레딧을 받게 됩니다. 또는 환불 후 복구가 일어나고, 두 번째 복구 신호가 또다시 요금을 청구하여, 사용자는 한 작업에 대해 두 번 요금을 지불하게 됩니다. 두 경우 모두 정확성이 전부인 도메인에서 발생하는 정확성 실패입니다.
근본 원인은 대부분의 분산 시스템 버그 뒤에 있는 원인과 동일합니다. 즉, 상태를 읽고 그 다음에 상태를 쓰는 작업을 두 개의 별도 단계로 수행하며, 그 사이의 간격 동안 다른 워커가 동일한 오래된 읽기 값(stale read)에 따라 동작할 수 있다는 점입니다.
모든 변경 사항을 역연산 쌍으로 모델링하기
첫 번째 결정은 이전 원장 항목을 절대 변경하거나 삭제하지 않는 것입니다. 원장은 추가만 가능합니다. 청구되고, 환불된 후, 회수되는 작업은 세 개의 불변 행을 생성합니다.
- charge: -40
- refund: +40
- reclaim: -40
사용자의 잔액은 행들의 합이며, 덮어쓰는 가변 카운터가 아닙니다. 각 항목은 job_id, type, amount, created_at을 가지므로 모든 크레딧의 전체 내역을 감사할 수 있습니다. 지원팀이 사용자가 특정 잔액을 갖게 된 이유를 물으면, 행들을 재생합니다.
환불과 회수는 서로 역연산 관계이며, 이것이 복구를 표현 가능하게 만드는 점입니다. 회수는 환불이 먼저 발생했고 아직 회수되지 않은 경우에만 의미가 있습니다. 그 조건은 애플리케이션 코드에서 if 문으로 확인하는 것이 아닙니다. 그것은 쓰기 작업 자체에 대한 보호 장치(guard)입니다.
이중 환불을 막는 원자적 가드
이중 환불 버그는 고전적인 검사 시점-사용 시점(time-of-check-to-time-of-use) 경쟁 상태입니다. 작업자 A가 is_refunded = false를 읽고 환불하기로 결정합니다. A가 쓰기 전에, 작업자 B가 동일한 is_refunded = false를 읽고 역시 환불하기로 결정합니다. 둘 다 쓰기 작업을 합니다. 사용자는 두 번 입금받습니다.
이 간극을 없애려면 검사와 쓰기가 하나의 분리 불가능한 작업이어야 합니다. 조건부 업데이트를 지원하는 문서 저장소에서는, 필터가 검사이고 업데이트가 쓰기인 단일 UpdateOne이 바로 그것입니다. 플래그는 작업 문서에 있으며, false에서 true로 플래그를 바꾸는 작업자만이 원장 행을 추가할 수 있습니다.
// Refund only if this job has not already been refunded.
// The filter and the $set are one atomic operation, so two concurrent
// failure signals cannot both win.
now := time.Now()
res, err := jobs.UpdateOne(ctx,
bson.M{"_id": jobID, "is_refunded": false},
bson.M{"$set": bson.M{
"is_refunded": true,
"refunded_at": now,
}},
)
if err != nil {
return err
}
if res.ModifiedCount == 1 {
// We won the guard. Append the ledger row exactly once.
appendLedger(ctx, jobID, "refund", cost, now)
}
ModifiedCount가 1로 반환된 워커는 가드를 획득하고 환불을 추가합니다. 다른 모든 워커는 플래그가 설정되면 필터가 더 이상 일치하지 않으므로 ModifiedCount가 0으로 반환되는 것을 보고 아무 작업도 수행하지 않습니다. 데이터베이스가 조건부 쓰기를 직렬화해주므로, 동시성이 아무리 높아도 두 개의 환불이 생성되지 않습니다.
작업 복구 시 크레딧 회수
복구는 미러 가드를 사용합니다. 이미 환불된 작업이 나중에 성공하면 크레딧을 회수합니다. 조건은 작업이 환불되었고(is_refunded = true) 아직 회수되지 않았으며(is_reclaimed = false), 다시 한번 확인과 쓰기가 단일 작업이라는 것입니다.
// Reclaim only if the job was refunded and not yet reclaimed.
now := time.Now()
res, err := jobs.UpdateOne(ctx,
bson.M{"_id": jobID, "is_refunded": true, "is_reclaimed": false},
bson.M{"$set": bson.M{
"is_reclaimed": true,
"reclaimed_at": now,
}},
)
if err != nil {
return err
}
if res.ModifiedCount == 1 {
appendLedger(ctx, jobID, "reclaim", cost, now)
}
우아한 부분은 일반적인 성공 경로에서 일어나는 일입니다. 첫 시도에서 성공한 작업은 환불된 적이 없으므로, is_refunded는 여전히 false이고, 필터는 일치하지 않으며, ModifiedCount는 0이 되고, 회수 행은 기록되지 않습니다. 이는 정확히 맞습니다. 환불된 것이 없으므로 회수할 것도 없습니다. 성공 핸들러는 동일한 회수 호출을 무조건적으로 실행하며, 가드가 적용 여부를 결정합니다. 성공 경로는 환불 발생 여부나 중복 전송으로 인해 한 번 실행되든 다섯 번 실행되든 멱등성을 가집니다.
회수 금액은 재시도가 보고하는 어떤 값에서가 아니라, 작업에 기록된 cost에서 온다는 점에 유의하십시오. 금액을 원래 청구액에 고정하면 복구된 작업은 환불된 금액과 정확히 동일한 금액을 회수하며, 절대 다른 숫자가 되지 않습니다.
측정된 작업의 상태 전환
두 개의 불리언 플래그인 is_refunded와 is_reclaimed는 작은 상태 머신을 정의합니다. 이를 통과하는 모든 유효한 경로는 원장의 균형을 유지하며, 위의 모든 가드는 이 머신의 한 에지입니다.
| 상태 | is_refunded | is_reclaimed | 순 원장 효과 | 유효한 다음 전환 |
|---|---|---|---|---|
| 청구됨, 실행 중 | false | false | -비용 | 성공(유지) 또는 실패(환불) |
| 첫 시도 성공 | false | false | -비용 | 종료 |
| 실패, 환불됨 | true | false | 0 | 복구(회수) 또는 재시도 |
| 복구됨, 회수됨 | true | true | -비용 | 종료 |
두 종료 상태는 모두 -비용의 순 효과로 정산되며, 이는 사용자가 작업에 대해 정확히 한 번 지불했으므로 올바릅니다. 환불되었지만 회수되지 않은 상태는 0으로 정산되며, 이는 실제로 실패하고 전달되지 않은 작업에 대해 올바릅니다. 각 에지는 단 하나의 작성자만 뒤집을 수 있는 플래그에 의해 제어되므로, 전달 후 -2배 비용 또는 0에 도달하는 경로는 없습니다.
백오프를 사용한 실패한 작업의 재큐잉
환불된 작업이 항상 완전히 끝난 것은 아닙니다. 실패가 일시적인 종속성(연결이 끊긴 GPU 노드, 타임아웃된 스토리지 호출) 때문이었다면, 해당 작업은 그레이브야드로 가지 않고 재시도 큐로 돌아갑니다. RPush를 사용하여 Redis 리스트의 끝에 푸시하면 간단한 FIFO 재시도 채널이 만들어지며, 각 재큐는 증가된 시도 횟수와 not-before 타임스탬프를 전달하여 손상된 종속성이 짧은 루프에서 과도한 부하를 받지 않도록 합니다.
// Re-queue a failed job with exponential backoff, capped.
attempt := job.Attempts + 1
backoff := time.Duration(
math.Min(
float64(baseDelay)*math.Pow(2, float64(attempt)),
float64(maxDelay),
),
)
job.Attempts = attempt
job.NotBefore = time.Now().Add(backoff)
payload, _ := json.Marshal(job)
rdb.RPush(ctx, "jobs:retry", payload)
소비자는 NotBefore가 미래인 작업을 건너뛰고, 작업 없이 다시 큐에 넣으므로, 별도의 지연 큐 메커니즘 없이도 지연이 적용됩니다. maxDelay로 상한이 정해진 지수적 증가는 첫 번째 재시도는 빠르지만, 지속적으로 실패하는 종속성은 기다리고 있는 바로 그 리소스를 소모하는 바쁜 루프 대신 느린 폴링으로 물러서게 됨을 의미합니다.
데드-레터 가드는 무한 재시도를 중지합니다
백오프만으로는 절대 성공할 수 없는 작업을 중지하지 못합니다. 상한선이 없으면 영구적으로 실패하는 작업은 환불, 재대기, 실패, 환불을 영원히 순환하며, 환불 가드는 멱등성이므로 재정적 손해는 없지만 작업자를 낭비하고 큐를 혼잡하게 만듭니다. 시도 횟수 또한 상한선입니다.
// Terminal guard: past the cap, dead-letter instead of retrying.
if job.Attempts >= maxAttempts {
rdb.RPush(ctx, "jobs:dead-letter", payload)
return
}
데드-레터 목록은 자동 재시도 채널이 아니라 사람이 검사하기 위한 보류 영역입니다. 타이머에 따라 그것을 소비하는 것은 아무것도 없습니다. 그에 못지않게 중요한 것은, 동일한 is_reclaimed 및 is_refunded 플래그가 중복 전달에 대한 멱등성 키 역할을 한다는 것입니다. 재시도 큐에서 작업이 두 번 전달되면(최소 한 번 전달 보장은 이런 일이 일어날 것을 보장합니다), 두 번째 전달은 플래그가 이미 최종 상태를 반영하는 작업에 도달하게 되고, 보호된 쓰기는 아무것도 일치시키지 않습니다. 이미 처리된 작업을 재처리해도 잔액을 변경할 수 없습니다. 큐는 메시지를 몇 번이든 전달할 수 있으며, 정확성은 전달 보장이 아닌 조건부 쓰기에 있기 때문에 원장은 정확하게 유지됩니다.
트레이드오프: 원장의 복잡성 대 정확성
단순한 모델은 '실패는 곧 환불'이라는 한 줄의 논리로 끝납니다. 상태는 더 적고, 코드도 더 적으며, 추론할 것도 없습니다. 하지만 분산 시스템에서는 흔한 일인, 실패가 최종적이지 않은 순간 이 모델은 틀리게 됩니다. 느린 성공과 경쟁하는 모든 워커 타임아웃, 모든 중복 콜백, 복구되는 모든 재시도는 조용히 단순한 모델을 망가뜨리며, 조용한 결제 버그는 비용이 많이 드는 종류입니다.
원장 모델은 두 개의 플래그, 한 쌍의 역연산, 백오프가 있는 재시도 큐, 그리고 데드-레터 경로라는 비용을 치르게 합니다. 이는 실질적인 복잡성이며, 머릿속에 담아두는 것이 공짜가 아닙니다. 문제는 해당 도메인이 그럴 만한 가치가 있느냐는 것입니다. 소수의 크레딧을 잘못 계산해도 아무에게도 해가 되지 않는 무료 등급 기능의 경우, 이 모든 것을 건너뛰고 가끔 발생하는 불일치를 감수하십시오. 잔액이 돈과 유사한 형태이고 사용자가 이중 청구를 발견하면 고객 지원 티켓과 신뢰 문제로 이어지는 유료 크레딧의 경우, 추가 전용 감사 추적과 원자적 가드는 이미 환불한 작업이 되살아나는 첫 순간에 그 값어치를 합니다. 이 모든 것을 작동하게 만드는 규칙은 작습니다. 모든 원장 연산을 멱등적이게 만들고, 오직 하나의 쓰기 작업만 통과할 수 있는 원자적 가드로 모든 상태 전환을 제어하는 것입니다.
관련 글
멱등성 업서트를 위한 결정적 ULID
데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등적이어야 합니다. 콘텐츠 기반 ID는 이를 추가 비용 없이 가능하게 합니다. 여기에 그 유도 과정과 안전성을 유지하는 한 가지 불변식이 있습니다.
Firestore의 MongoDB 호환성에서 `retryWrites=false`의 함정
Firestore는 MongoDB 와이어 프로토콜을 지원하지만 재시도 가능 쓰기는 거부합니다. 드라이버의 기본값이 활성화(on)인 경우, 쓰기 작업이 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.
MongoDB 다중 문서 트랜잭션: 언제 사용하지 말아야 하는가
다중 문서 트랜잭션은 안전한 기본값처럼 보이지만, 커밋 모호성과 PSA 가용성 함정으로 인해 비용이 많이 듭니다. 이를 건너뛰어야 하는 경우는 다음과 같습니다.
Redis 키스페이스 알림 및 임대를 사용한 GPU 워커 풀의 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로, 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스(lease)와 키스페이스 알림(keyspace notification)으로 구축된 바쁨을 인지하는 스케줄러가 있습니다.
MongoDB 변경 스트림을 이용한 Cron 스케줄 핫스왑
cron을 하드코딩하면 스케줄이 변경될 때마다 재배포해야 합니다. 스케줄을 데이터베이스에 저장하고 데몬이 변경 스트림으로 수정 사항에 반응하도록 하십시오. 여기에 그 설계와 장애 처리 방법이 있습니다.
CAS와 하트비트를 사용한 단일 실행기 작업을 위한 리스 잠금
일반적인 락은 홀더가 죽었을 때 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기서는 `compare-and-swap` 획득과 하트비트를 사용하여 리스를 구축하는 방법과, 설계를 결정하는 실패 모드를 다룹니다.