4. 읽기-통과 캐시의 캐시 스탬피드(thundering herd)
난이도 상해설 — 읽기-통과 캐시의 캐시 스탬피드(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 보호.
면접 포인트
- 캐시 스탬피드는 single-flight(요청 합치기) 로 잡는다 — 한 키 재계산은 하나, 나머지는 결과 공유.
- TTL 지터 + stale-while-revalidate 로 동시 만료와 지연 스파이크를 함께 완화.
- 분산에선 노드별이 아니라 분산 락/리더가 재계산, 음수 캐싱으로 관통 방어.
해설 — 읽기-통과 캐시의 캐시 스탬피드(thundering herd)
난이도: 상
요약
미스 시 재계산을 한 번으로 합치는 single-flight 가 없다. 읽기 락(A)에서 미스를 본 뒤 락을 풀고 (B) computeRanking 을 호출하는데, TTL 만료 순간 다수 워커가 동시에 미스를 보고 각자 (B)를 실행한다 → 동일 집계가 DB에 수천 번 몰린다(스탬피드). 쓰기 락(C)은 캐시 갱신만 직렬화할 뿐, 그 사이 이미 시작된 중복 재계산은 막지 못한다. 고정 30초 TTL은 키들의 동시 만료를 부른다.
문제점
1. (스탬피드) single-flight 부재 — (A)(B)(C)
- 증상: 만료 직후 같은 키로 수천 개의 동시
computeRanking. - 근본원인: 읽기 락과 쓰기 락 사이에 "재계산은 한 번"을 보장하는 장치가 없다. (C)는 결과 저장만 직렬화.
2. (동시 만료) 고정 TTL — (C)
- 지터 없음 → 같은 시각 채워진 키가 같은 시각 만료 → 주기적 동시 미스.
3. (지연) 동기 재계산
- 만료 후 첫 요청들이 비싼 (B)를 그대로 기다린다(옛 값 제공으로 완화 가능).
수정안
키별 in-flight future 로 첫 요청만 계산하고 나머지는 그 future 를 기다리게 한다(요청 합치기). 계산자만 promise 를 쥐고 나머지는 shared_future 만 기다린다.
// 멤버: std::unordered_map<std::string, std::shared_future<Ranking>> inflight_;
// (이 예시는 cache_ 보호에 shared_mutex 대신 std::mutex 를 쓴 형태)
Ranking RankingCache::get(const std::string& key) {
using clock = std::chrono::steady_clock;
{
std::unique_lock<std::mutex> rl(mtx_);
auto it = cache_.find(key);
if (it != cache_.end() && clock::now() < it->second.expireAt)
return it->second.value; // 신선하면 즉시 반환
}
std::shared_future<Ranking> fut;
std::shared_ptr<std::promise<Ranking>> myProm; // 내가 계산자면 set_value 할 promise
{
std::unique_lock<std::mutex> wl(mtx_);
auto it = cache_.find(key); // (재확인) 락 잡는 사이 누가 채웠을 수 있음
if (it != cache_.end() && clock::now() < it->second.expireAt)
return it->second.value;
auto inflightIt = inflight_.find(key);
if (inflightIt != inflight_.end()) {
fut = inflightIt->second; // 이미 누가 계산 중 → 그 결과 공유
} else {
myProm = std::make_shared<std::promise<Ranking>>();
fut = myProm->get_future().share();
inflight_[key] = fut; // "이 키 재계산 중" 등록
}
}
if (myProm) { // 내가 유일 계산자
Ranking data = db_.computeRanking(key); // 핫 키당 한 번만
auto ttl = std::chrono::seconds(30 + (std::rand() % 10)); // 지터
{
std::unique_lock<std::mutex> wl(mtx_);
cache_[key] = Entry{ data, clock::now() + ttl };
inflight_.erase(key);
}
myProm->set_value(data); // 대기자들 깨우기
return data;
}
return fut.get(); // 다른 요청은 결과만 공유
}
- 핵심은 inflight_ 맵으로 "이 키 재계산 중"을 공유해, 동시 미스가 하나의 future 를 기다리게 하는 것(요청 합치기).
- 분산이면 노드별 single-flight 위에 분산 락(Redis SET NX + 펜싱; concurrency/problem12)으로 클러스터 전체에서 한 노드만 재계산.
- stale-while-revalidate: soft/hard 만료를 둬 soft 만료면 옛 값 즉시 반환 + 백그라운드 1회 갱신 → 지연 스파이크 제거.
더 나은 설계
- 핫 키 refresh-ahead(만료 전 선제 갱신)로 미스 제거, TTL 지터로 동시 만료 분산.
- 재계산 동시성 상한(throttle)으로 DB 보호, 큰 비용 값은 그레이스풀 폴백.
면접 포인트
- 스탬피드는 single-flight(in-flight future/요청 합치기) 로 잡는다 — 읽기/쓰기 락만으론 중복 재계산을 못 막는다.
- TTL 지터 + stale-while-revalidate 로 동시 만료·지연 스파이크 완화.
- 분산에선 노드별이 아니라 분산 락/리더가 재계산해야 클러스터 전체 스탬피드를 막는다.