← 문제로

4. 지역 간 캐시 무효화 전파의 순서·유실·재채움 경합

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

해설 — 지역 간 캐시 무효화 전파의 순서·유실·재채움 경합

난이도: 최상

요약

무효화가 커밋 전에(A) 발행되고, 캐시 항목에 버전이 없으며, 읽기 경로의 캐시 채움(F)이 무효화(G)와 경합한다. 그래서 (1) 다른 지역이 (A)를 받아 캐시를 비우고 곧장 재읽기하면 아직 커밋 안 된(또는 복제 안 된) 옛 값을 다시 캐싱한다(early invalidation). (2) best-effort pub/sub 이라 메시지가 유실되면 그 지역은 영원히 stale. (3) 메시지 순서가 뒤바뀌거나 읽기의 (E)→(F) 사이에 (G)가 끼면, 무효화가 먼저 처리됐는데 그 직후 옛 값이 (F)로 다시 들어가는 stale 부활이 일어난다. 버전이 없으니 어느 쪽도 스스로 고쳐지지 않는다.

문제점

1. (순서) 커밋 전 무효화 — (A)(B)

  • 증상: 원격 지역이 (A) 수신 → 캐시 비움 → 재읽기(E) → 아직 커밋/복제 안 된 옛 값 → 다시 캐싱.
  • 근본원인: 무효화는 새 값이 읽힐 수 있게 된 뒤(커밋·복제 후) 보내야 하는데 먼저 보냈다.

2. (read-your-writes / 복제 지연) 복제본 읽기 — (E)

  • 무효화를 커밋 후로 옮겨도, 원격 지역의 복제본이 아직 옛 값이면 재읽기가 stale 을 가져온다(복제 지연). 무효화만으로는 복제 지연을 못 넘는다(이 저장소 persistence/problem1 와 연계).

3. (메시지 유실) at-most-once → 영구 stale

  • pub/sub 메시지가 유실되면 그 지역은 무효화를 못 받아 만료 장치(TTL)도 없으니 무한정 옛 값을 제공.

4. (재채움 경합 / 버전 부재) — (E)(F)(G)

  • 리더가 (E)에서 옛 값을 읽은 뒤, (G) 무효화가 캐시를 비우고, 그 다음 리더의 (F)가 옛 값을 써 넣는다 → 무효화가 무위로 돌아가는 stale 부활. 항목에 버전이 없어 "이 채움이 최신인가"를 판단할 수 없다.

5. (순서 역전) 다중 갱신

  • 두 번의 갱신 무효화가 뒤바뀌어 도착해도 그냥 erase 라 문제 없어 보이지만, (4)의 재채움 경합과 결합하면 어느 값이 남을지 비결정적.

수정안

(가) 무효화는 커밋 후, (나) 캐시 항목에 버전을 달아 재채움을 compare-and-set 으로, (다) TTL 을 안전망으로, (라) 순서 있는 신뢰 로그로 전파한다.

// 캐시 항목: 값 + 버전(단조 증가, 예: 커밋 시점의 row version / LSN)
record Entry(Profile Value, long Version);

public async Task UpdateProfileAsync(long playerId, Profile p)
{
    long version = await _db.UpdateProfileReturningVersionAsync(playerId, p); // (B') 커밋 + 새 버전
    _local.Remove(playerId);                                                  // 내 로컬 제거
    _bus.Publish("profile.invalidate", new Inv(playerId, version));           // (A') 커밋 '후' 발행 + 버전
}

private void OnInvalidate(Inv m)
{
    // 내 캐시 버전이 무효화 버전보다 낮을 때만 제거(순서 역전에 안전)
    if (_local.TryGetVersion(m.PlayerId, out var v) && v < m.Version)
        _local.Remove(m.PlayerId);
}

public async Task<Profile> GetProfileAsync(long playerId)
{
    if (_local.TryGet(playerId, out var e)) return e.Value;

    var (fromDb, ver) = await _db.GetProfileWithVersionAsync(playerId);       // 값+버전 함께
    // compare-and-set: 그 사이 더 새 버전이 들어왔으면 옛 값으로 덮어쓰지 않음
    _local.SetIfNewer(playerId, new Entry(fromDb, ver));                      // (F') stale 부활 방지
    return fromDb;
}
  • 버전 동반 무효화 + SetIfNewer 로 (4)(5)의 재채움 경합·순서 역전을 해결(옛 값은 절대 새 값을 못 덮음).
  • 무효화를 커밋 후로 옮겨 (1) early invalidation 제거. 복제 지연(2)이 우려되면 변경 직후 읽기는 마스터에서 읽거나(read-your-writes), 무효화 메시지에 버전을 실어 복제본이 그 버전 이상이 될 때까지 캐싱 보류.
  • TTL 안전망: 무효화가 유실돼도 짧은 TTL 로 결국 자가 치유(eventual consistency).
  • 전파는 best-effort pub/sub 대신 키별 순서가 보장되는 로그(Kafka 등) 로 — 유실/순서 역전 자체를 줄인다.

더 나은 설계

  • 단조 버전(LSN/타임스탬프 버전) 을 모든 캐시 결정의 기준으로: 무효화·채움·교체 모두 버전 비교로.
  • delete(무효화) 우선, 쓰지 않기: 원격엔 새 값을 푸시하지 말고 무효화만(쓰기 푸시는 순서·복제와 더 꼬인다). 다음 읽기가 권위 소스에서 최신을 가져오게.
  • 강한 일관성이 필요한 데이터는 캐시 대신 권위 읽기, 그 외에는 TTL + 버전 무효화로 eventual.

면접 포인트

  1. 캐시 무효화는 커밋 후에, 버전과 함께, TTL 안전망과 함께 — 세 가지가 빠지면 stale 이 부활하거나 영구화된다.
  2. 재채움(read-fill)은 무효화와 경합하므로 SetIfNewer(버전 비교) 로 옛 값이 새 값을 못 덮게 한다.
  3. 복제 지연(read-your-writes)은 무효화로 해결되지 않는다 — 버전 게이트/마스터 읽기로 별도 처리. best-effort pub/sub 은 순서 있는 신뢰 로그로 보강.