← 문제로

4. 읽기-통과 캐시의 캐시 스탬피드(thundering herd)

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

해설 — 읽기-통과 캐시의 캐시 스탬피드(thundering herd)

난이도: 상

요약

캐시 미스 시 재계산을 직렬화(single-flight)하는 장치가 없다. 인기 키의 TTL 이 만료되면 그 순간 들어온 수천 요청이 모두 (A)에서 미스를 보고 각자 (B) ComputeRankingAsync 를 동시에 실행한다 → DB에 동일 집계 쿼리가 수천 번 몰린다(cache stampede / thundering herd). DB가 느려지면 재계산이 더 길어지고 그동안 미스가 계속 쌓여 부하가 눈덩이처럼 커진다(cascading failure). 게다가 (C)의 고정 TTL은 여러 키를 같은 시각에 동시 만료시켜 스탬피드를 키우고, 존재하지 않는 키는 매번 (B)로 빠져 캐시 관통(penetration) 이 된다.

문제점

1. (스탬피드) single-flight 부재 — (A)(B)

  • 증상: TTL 만료 직후 같은 키에 대해 수천 개의 동시 DB 재계산.
  • 재현조건: 핫 키 1개, 초당 수천 조회, TTL 만료 순간. 미스 → 모두 (B).
  • 근본원인: "한 키의 재계산은 한 번만, 나머지는 그 결과를 공유"하는 요청 합치기(coalescing)가 없다.

2. (동시 만료) 고정 TTL — (C)

  • 지터(jitter)가 없어 같은 시각에 채워진 키들이 같은 시각에 만료 → 주기적 동시 미스 폭발.

3. (캐시 관통) 음수 캐싱 부재

  • 결과가 없는 키(없는 길드)는 캐시에 안 들어가 매 요청이 (B)로. 존재하지 않는 키를 쏟아붓는 공격/버그에 무방비.

4. (지연) 만료 시 동기 재계산

  • TTL 만료 후 첫 요청들이 비싼 (B)를 동기로 기다려 응답 지연이 튄다(stale 제공으로 완화 가능).

수정안

요청 합치기(single-flight) + TTL 지터 + stale-while-revalidate + 음수 캐싱을 조합한다.

// 키별 single-flight: 같은 키의 동시 미스는 하나의 Task 를 공유한다.
private readonly ConcurrentDictionary<string, Lazy<Task<Ranking>>> _inflight = new();

public async Task<Ranking> GetAsync(string key)
{
    var cached = await _cache.GetAsync(key);
    if (cached != null) return Deserialize(cached);   // (선택) stale 이면 즉시 반환 + 백그라운드 갱신

    var lazy = _inflight.GetOrAdd(key,
        k => new Lazy<Task<Ranking>>(() => LoadAndFillAsync(k)));
    try { return await lazy.Value; }                  // 동시 미스는 모두 이 Task 를 await
    finally { _inflight.TryRemove(key, out _); }
}

private async Task<Ranking> LoadAndFillAsync(string key)
{
    var data = await _db.ComputeRankingAsync(key);    // 핫 키당 정확히 한 번만 실행
    var ttl  = Ttl + TimeSpan.FromSeconds(Random.Shared.Next(0, 10)); // 지터
    await _cache.SetAsync(key, Serialize(data), ttl);
    return data;
}
  • 분산 환경에선 노드별 single-flight 만으론 부족하니, 재계산에 분산 락(Redis SET NX + 펜싱; 이 저장소 concurrency/problem12 참고)을 걸어 클러스터 전체에서 한 노드만 재계산하고 나머지는 stale 제공 또는 짧게 대기.
  • stale-while-revalidate: 값에 softExpire/hardExpire 를 둬, soft 만료면 옛 값을 즉시 주고 백그라운드로 1회 갱신 → 지연 스파이크 제거.
  • 음수 캐싱: 없는 키는 짧은 TTL 의 "없음" 토큰으로 캐싱해 관통 방지.

더 나은 설계

  • 핫 키는 선제 갱신(refresh-ahead): 만료 전에 백그라운드로 미리 다시 계산해 미스 자체를 없앤다.
  • TTL 지터로 동시 만료 분산, 재계산 비용이 큰 값은 그레이스풀 폴백(옛 값).
  • 부하 차단: 재계산 동시성에 상한(throttle)을 둬 DB 보호.

면접 포인트

  1. 캐시 스탬피드는 single-flight(요청 합치기) 로 잡는다 — 한 키 재계산은 하나, 나머지는 결과 공유.
  2. TTL 지터 + stale-while-revalidate 로 동시 만료와 지연 스파이크를 함께 완화.
  3. 분산에선 노드별이 아니라 분산 락/리더가 재계산, 음수 캐싱으로 관통 방어.