← 문제로

8. 분산 전역 한도 카운터의 초과 판매(local count + 지연 동기화) (C#)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 분산 전역 한도 카운터의 초과 판매(local count + 지연 동기화) (C#)

난이도: 최상

요약

각 노드가 "마지막으로 본 전역 스냅샷 + 자기 로컬 판매분"으로 한도를 판정한다(A). 그런데 스냅샷은 동기화 주기마다 갱신되는 stale 값이고, 다른 노드들이 같은 주기 동안 판 수는 반영되지 않는다. 그래서 N개 노드가 동시에 한도 직전까지 팔면 합계가 한도를 크게 초과한다 — 한정 아이템 초과 판매(복제·인플레와 같은 등급의 사고).

문제점

  • [stale 스냅샷 기반 판정: 분산 lost update] (A)_globalSnapshot + _localSold < _limit_globalSnapshot은 마지막 SyncAsync 시점의 값이다. 동기화 주기(예: 1초) 동안 다른 노드들의 판매는 보이지 않는다.
    • 재현(수치): 한도 1000, 현재 전역 990, 노드 5개. 마지막 동기화 직후 모든 노드의 _globalSnapshot=990, _localSold=0. 각 노드가 다음 동기화 전까지 자기 판단으로 (1000-990)=10건씩 팔 수 있다고 본다 → 5노드 × 10건 = 50건 추가 판매 → 전역 1040(40 초과). 트래픽이 많으면 주기당 초과량 = (노드 수 − 1) × (주기 동안 한 노드가 팔 수 있는 양)으로 더 커진다. 한도 근처에서 "거의 모든 노드가 마지막 좌석을 동시에 판다."
  • [노드 내 비원자 증가] (B)_localSold++는 read-modify-write라 같은 노드의 여러 워커가 동시에 TrySell하면 자체로도 경합한다(_globalSnapshot 읽기와 ++도 비원자 묶음).
  • 근본원인: 전역 불변식(합계 ≤ 한도)을 강한 일관성이 없는 로컬 캐시로 판정. 카운터를 "각자 세고 나중에 합치는" 방식은 본질적으로 한도를 보장할 수 없다(합계는 단조 증가하므로 늦게 합칠수록 초과).

수정안

"좌석을 차지하는" 결정은 단일 권위(중앙 저장소)에서 원자적으로 내려야 한다. 처리량을 위해 매 건 왕복을 피하려면 배치 예약(lease) 으로 정확성을 깨지 않고 왕복을 줄인다.

// (1) 정확한 기본형: 중앙 원자 증가 후 한도 비교, 초과 시 롤백
public async Task<bool> TrySellAsync()
{
    long n = await _store.IncrByAsync(Key, 1);   // 원자적으로 한 좌석 선점
    if (n <= _limit) return true;
    await _store.IncrByAsync(Key, -1);           // 초과면 즉시 반납
    return false;
}
-- (1') 더 깔끔: Lua로 검사+증가를 원자 1회 (롤백 불필요)
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
if cur + 1 > tonumber(ARGV[1]) then return 0 end
redis.call('INCR', KEYS[1]); return 1
// (2) 처리량 최적화: 배치 예약(lease) — 핫패스는 로컬, 정확성은 중앙 원자 INCR이 보장
private long _reserved;     // 중앙에서 확보한 좌석 수
private long _used;         // 그중 사용한 수

public async Task<bool> TrySellAsync()
{
    if (_used < _reserved) { _used++; return true; }       // 로컬 소진(왕복 없음)
    const long batch = 50;
    long start = await _store.IncrByAsync(Key, batch);     // 원자로 batch 선점
    long granted = Math.Min(batch, Math.Max(0, _limit - (start - batch)));
    if (granted < batch)
        await _store.IncrByAsync(Key, -(batch - granted)); // 한도 넘은 잉여 반납
    if (granted <= 0) return false;
    _reserved = granted; _used = 1; return true;
}
  • (1)은 매 건 중앙 원자 연산 → 정확하지만 왕복 비용. (2)는 batch 단위로만 중앙을 치고 그 안에서는 로컬 → 정확성 유지(선점은 원자 INCR로 권위 보장)하면서 왕복을 1/batch로 줄인다. batch가 클수록 처리량↑·반납 잉여(노드 종료 시 미사용 좌석)↑.

더 나은 설계 / 운영 고려

  • 노드 크래시 시 예약 누수(under-sell): 배치 예약 방식은 노드가 미사용 좌석을 들고 죽으면 그만큼 영영 못 판다. 좌석에 TTL/리스를 붙여 만료 시 회수하거나, 종료 훅에서 미사용분을 반납한다.
  • 멱등성: 같은 구매 요청의 재시도가 좌석을 두 번 까먹지 않도록 요청 id 멱등키로 보호.
  • 한도 분할(정적 파티셔닝): 한도를 노드 수로 미리 나눠주면 중앙 왕복이 0이 되지만, 트래픽 불균형 시 한 노드가 자기 몫을 다 못 팔아 전역적으로 덜 팔린다(공정성 vs 효율). 동적 재배분으로 보완.
  • 트레이드오프 요약: 강한 일관성(매 건 중앙) ↔ 처리량(로컬). 본 결함은 "로컬로 판정하되 권위를 로컬에 둔" 것이 잘못이다. 권위는 항상 중앙 원자 연산에, 로컬은 "이미 확보한 좌석"만 소비.

면접 포인트

  • "왜 로컬 카운트+주기 동기화로는 상한을 못 지키나?" — 합계는 단조 증가하고 스냅샷은 과거값이라, 동기화 지연 동안 모든 노드가 같은 여유분을 중복 사용한다. 초과량은 노드 수에 비례.
  • 정확성과 처리량 동시 달성: 선점은 중앙 원자 연산(INCR/Lua)으로, 왕복은 batch 예약으로 줄인다. 추가로 크래시 시 예약 회수(리스/TTL)와 멱등성을 설계해야 진짜 production-ready.