← 문제로

10. Cache-Aside 읽기 재채움 vs 쓰기 무효화 경합(stale 부활) (C#)

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

해설 — Cache-Aside 읽기 재채움 vs 쓰기 무효화 경합(stale 부활) (C#)

난이도: 최상

요약

"DB 갱신 → 캐시 삭제"(무효화)와 "캐시 미스 → DB 읽기 → 캐시 채움"이 인터리브되면, 느린 읽기 스레드가 갱신 전(옛) DB 값을 캐시에 되써 무효화를 무효화한다. 그 결과 DB 는 새 값인데 캐시엔 옛 값이 TTL 만료까지 부활한다. "삭제로 무효화"(update 대신 delete)만으로는 이 경합이 사라지지 않는다는 점이 핵심 — 버전/CAS 또는 지연 이중 삭제가 필요하다.

문제점

  • [읽기 재채움 vs 무효화 경합 → stale 부활] (A)(B) _cache.Set(playerId, v, Ttl)_cache.Delete(playerId)
    • 인터리빙:
      1. 읽기 R: 캐시 미스 → _db.Read옛 값 v0 을 얻음(아직 캐시에 안 씀).
      2. 쓰기 W: _db.Write(v1) (DB=v1) → _cache.Delete (캐시 비어 있어 무의미, 또는 R 이 아직 안 씀).
      3. 읽기 R: _cache.Set(playerId, v0)캐시=v0(옛 값). DB=v1 인데 캐시=v0.
    • 증상: 이후 모든 GetProfile 이 TTL(10분) 동안 옛 프로필을 반환. 랭킹/과금/권한 값이면 심각(잘못된 판단·환불 분쟁).
    • 근본원인: 캐시 채움이 무조건 덮어쓰기라, 그 사이 값이 바뀌었는지(버전) 확인하지 않는다. 무효화는 "그 순간의 항목"만 지우지 캐시를 채우려는 진행 중 읽기를 막지 못한다.
  • [삭제 vs 갱신은 이 경합의 해법이 아님]
    • "무효화 시 캐시에 새 값을 쓰지 말고 지워라"는 (다른 종류의 dual-write 불일치엔 유효하나) 이 부활 경합은 못 막는다. R 이 삭제 이후에 옛 값을 쓰기 때문.
  • [쓰기 순서·원자성] (B) DB 갱신과 캐시 무효화가 별도 단계(dual write) — 사이에 크래시하면 캐시가 옛 값으로 남는다(무효화 유실). distributed_systems/problem4(무효화 전파)·persistence_data/problem6(아웃박스)와 같은 계열.

수정안

A. 버전/CAS 기반 캐시 채움 (근본적)

DB 행에 단조 증가 version(또는 갱신 타임스탬프)을 두고, 읽기가 읽은 시점의 version 과 함께 캐시에 CAS 로 쓴다. 더 큰 version 이 이미 있으면 덮지 않는다.

public string GetProfile(string playerId)
{
    if (_cache.TryGet(playerId, out var cached)) return Deserialize(cached).Value;

    var (v, ver) = _db.ReadWithVersion(playerId);      // 값 + version
    // 캐시에 {value, version} 저장하되, 기존 version 이 더 크면 덮지 않음(Lua/CAS)
    _cache.SetIfNewer(playerId, v, ver, Ttl);          // (A) 조건부 채움
    return v;
}
public void UpdateProfile(string playerId, string val)
{
    long ver = _db.WriteReturningVersion(playerId, val);   // DB 갱신 + version++
    _cache.SetIfNewer(playerId, val, ver, Ttl);            // 무효화 대신 최신 버전으로 갱신
    // (또는) _cache.DeleteIfOlder(playerId, ver);
}
  • 읽기가 옛 version(v0) 으로 쓰려 해도 캐시엔 이미 더 큰 version(v1) 이 있어 거부 → 부활 차단.

B. 지연 이중 삭제(delayed double delete) — 버전 못 넣을 때

public void UpdateProfile(string playerId, string val)
{
    _cache.Delete(playerId);          // 1차 삭제(진행 중 읽기 최소화)
    _db.Write(playerId, val);
    _cache.Delete(playerId);          // 2차 삭제(즉시)
    ScheduleAfter(500 /*ms*/, () => _cache.Delete(playerId));  // 3차: 늦은 재채움 제거
}
  • 갱신 도중 시작된 느린 읽기가 옛 값을 되쓸 창(수십~수백 ms)을 지연 삭제로 덮어 정리. 완벽하진 않지만 부활 지속시간을 창 밖으로 축소.

C. 그 밖의 보강

  • 짧은 TTL + 지터: 부활이 나도 오래 안 가게(그리고 스탬피드 방지, persistence_data/problem4).
  • single-flight: 같은 키 미스를 하나의 DB 조회로 합쳐 재채움 경합 자체를 축소.
  • write-through / 무효화 큐: 쓰기 경로가 캐시를 최신 버전으로 원자 갱신하고, 무효화는 아웃박스로 신뢰성 전파(persistence_data/problem6).

더 나은 설계

  • 버전이 정답에 가깝다: Cache-Aside 의 이 경합은 "덮어쓰기에 버전 조건"을 넣는 게 정공법. Facebook memcache 의 lease, Redis 의 SET+Lua CAS 가 같은 아이디어.
  • 정합성 요구에 맞게: 강한 정합이 필요하면 캐시를 쓰지 않거나 write-through+버전; 게임 프로필처럼 수렴만 되면 되는 값은 짧은 TTL+지연 이중 삭제로 실용 타협.
  • 관측: 캐시-DB 불일치 감지용 샘플 검증(주기적으로 캐시 값 vs DB 대조)으로 부활을 조기 발견.

면접 포인트

  • Cache-Aside 의 고전적 경합: 읽기 재채움이 무효화를 되돌린다 — 왜 "delete 로 무효화"만으론 못 막는지.
  • 버전/CAS 캐시 쓰기가 근본 해법인 이유, 지연 이중 삭제의 원리와 한계(창 축소일 뿐 완전 제거 아님).
  • TTL·single-flight·write-through 의 역할 구분(persistence_data/problem1,4, distributed_systems/problem4 와의 연결).