8. 분산 전역 한도 카운터의 초과 판매(local count + 지연 동기화) (C#)
난이도 최상내 리뷰 · 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) × (주기 동안 한 노드가 팔 수 있는 양)으로 더 커진다. 한도 근처에서 "거의 모든 노드가 마지막 좌석을 동시에 판다."
- 재현(수치): 한도 1000, 현재 전역 990, 노드 5개. 마지막 동기화 직후 모든 노드의
- [노드 내 비원자 증가] (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.
해설 · C++
해설 — 분산 전역 한도 카운터의 초과 판매(local count + 지연 동기화) (C++)
난이도: 최상
요약
각 노드가 "마지막으로 본 전역 스냅샷 + 자기 로컬 판매분"으로 한도를 판정한다(A). 스냅샷은 주기마다 갱신되는 stale 값이라 다른 노드의 같은-주기 판매가 안 보인다. N개 노드가 동시에 한도 직전까지 팔면 합계가 한도를 크게 초과한다 — 한정 아이템 초과 판매.
문제점
- [stale 스냅샷 기반 판정: 분산 lost update] (A) —
globalSnapshot_ + localSold_ < limit_의globalSnapshot_은 마지막sync()시점 값. 동기화 주기 동안 다른 노드 판매가 미반영.- 재현(수치): 한도 1000, 전역 990, 노드 5개. 마지막 동기화 직후 모든 노드
globalSnapshot_=990, localSold_=0. 각 노드가 10건씩 팔 수 있다고 판단 → 5×10 = 50건 초과 → 전역 1040. 트래픽이 많으면 주기당 초과량 = (노드 수−1) × (주기 동안 한 노드 판매량).
- 재현(수치): 한도 1000, 전역 990, 노드 5개. 마지막 동기화 직후 모든 노드
- [노드 내 비원자/데이터 레이스] (B) —
++localSold_와globalSnapshot_읽기는 여러 워커 스레드에서 동시 호출 시 보호되지 않은 read-modify-write다(노드 내부에서도 경합). C++에선 비원자 공유 변수 동시 접근 자체가 데이터 레이스(UB). - 근본원인: 전역 불변식(합계 ≤ 한도)을 강한 일관성이 없는 로컬 캐시로 판정. 카운터를 "각자 세고 나중에 합치는" 방식은 본질적으로 상한을 보장할 수 없다.
수정안
"좌석 선점"은 중앙 저장소에서 원자적으로. 처리량을 위해 매 건 왕복을 피하려면 배치 예약(lease) 으로 정확성을 유지하며 왕복을 줄인다.
// (1) 정확한 기본형: 중앙 원자 증가 후 한도 비교, 초과 시 롤백
bool trySell() {
int64_t n = store_.incrBy(key_, 1); // 원자적으로 한 좌석 선점
if (n <= limit_) return true;
store_.incrBy(key_, -1); // 초과면 즉시 반납
return false;
}
// (2) 처리량 최적화: 배치 예약 — 핫패스는 로컬, 선점은 중앙 원자 INCR
bool trySell() {
if (used_ < reserved_) { ++used_; return true; } // 로컬 소진(왕복 없음)
const int64_t batch = 50;
int64_t end = store_.incrBy(key_, batch); // [start, end) 원자 선점
int64_t start = end - batch;
int64_t granted = std::max<int64_t>(0, std::min(batch, limit_ - start));
if (granted < batch) store_.incrBy(key_, -(batch - granted)); // 잉여 반납
if (granted <= 0) return false;
reserved_ = granted; used_ = 1; return true;
}
(중앙이 Redis면 Lua 스크립트로 "검사+INCR"을 한 번에 원자화하면 롤백조차 불필요하다.)
- (1)은 매 건 중앙 원자 연산 → 정확하지만 왕복 비용. (2)는 batch 단위로만 중앙을 치고 그 안은 로컬 → 정확성 유지(선점은 원자 INCR이 권위 보장)하면서 왕복을 1/batch로. batch↑면 처리량↑·미사용 잉여↑.
- 노드 내 다중 스레드면
used_/reserved_접근도std::mutex나std::atomic으로 보호한다(원래 (B) 결함의 노드 내 경합 해소).
더 나은 설계 / 운영 고려
- 크래시 시 예약 누수(under-sell): 배치 예약 노드가 미사용분을 들고 죽으면 그만큼 못 판다. 좌석에 리스/TTL을 붙여 만료 회수하거나 종료 훅에서 반납.
- 멱등성: 구매 재시도가 좌석을 두 번 까먹지 않도록 요청 id 멱등키.
- 정적 파티셔닝: 한도를 노드 수로 미리 분배하면 왕복 0이지만, 트래픽 불균형 시 전역적으로 덜 팔린다(효율↓). 동적 재배분으로 보완.
- 트레이드오프: 강한 일관성(매 건 중앙) ↔ 처리량(로컬). 권위는 항상 중앙 원자 연산에 두고, 로컬은 "이미 확보한 좌석"만 소비해야 한다.
면접 포인트
- "왜 로컬 카운트+주기 동기화로는 상한을 못 지키나?" — 합계는 단조 증가, 스냅샷은 과거값. 동기화 지연 동안 모든 노드가 같은 여유분을 중복 사용 → 초과량은 노드 수에 비례.
- 정확성+처리량: 선점은 중앙 원자 연산, 왕복은 batch 예약으로 절감. 추가로 크래시 시 회수(리스/TTL)·멱등성까지 갖춰야 production-ready.