4. 지역 간 캐시 무효화 전파의 순서·유실·재채움 경합
난이도 최상해설 — 지역 간 캐시 무효화 전파의 순서·유실·재채움 경합
난이도: 최상
요약
무효화가 커밋 전에(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.
면접 포인트
- 캐시 무효화는 커밋 후에, 버전과 함께, TTL 안전망과 함께 — 세 가지가 빠지면 stale 이 부활하거나 영구화된다.
- 재채움(read-fill)은 무효화와 경합하므로 SetIfNewer(버전 비교) 로 옛 값이 새 값을 못 덮게 한다.
- 복제 지연(read-your-writes)은 무효화로 해결되지 않는다 — 버전 게이트/마스터 읽기로 별도 처리. best-effort pub/sub 은 순서 있는 신뢰 로그로 보강.
해설 — 지역 간 캐시 무효화 전파의 순서·유실·재채움 경합
난이도: 최상
요약
무효화가 커밋 전에(A) 발행되고, 캐시 항목에 버전이 없으며, 읽기 채움(F)이 무효화(G)와 경합한다. 따라서 (1) 원격 지역이 (A) 수신 → 캐시 비움 → 재읽기(E) → 아직 커밋/복제 안 된 옛 값을 다시 캐싱(early invalidation), (2) best-effort 라 메시지 유실 시 영구 stale, (3) (E)→(F) 사이에 (G)가 끼면 무효화 직후 옛 값이 (F)로 다시 들어가는 stale 부활이 생긴다. 버전이 없어 자가 치유가 불가능하다.
문제점
1. (순서) 커밋 전 무효화 — (A)(B)
- 원격이 (A) 수신 후 재읽기하면 커밋/복제 전 옛 값을 다시 캐싱. 무효화는 새 값이 읽힐 수 있게 된 뒤 보내야 한다.
2. (복제 지연) 복제본 읽기 — (E)
- 무효화를 커밋 후로 옮겨도 원격 복제본이 옛 값이면 재읽기가 stale(복제 지연). 무효화만으론 복제 지연을 못 넘는다.
3. (메시지 유실) → 영구 stale
- 메시지 유실 + TTL 부재 → 그 지역은 무한정 옛 값 제공.
4. (재채움 경합 / 버전 부재) — (E)(F)(G)
- 리더가 (E)에서 옛 값을 읽고 (G)가 캐시를 비운 뒤 리더의 (F)가 옛 값을 써 넣음 → 무효화 무위(stale 부활). 항목에 버전이 없어 "최신 채움인가"를 판단 못 함.
5. (순서 역전) 다중 갱신 무효화가 뒤바뀌어 도착 → (4)와 결합 시 어느 값이 남을지 비결정적.
수정안
커밋 후 무효화 + 버전 동반 + 버전 비교 채움(setIfNewer) + TTL 안전망 + 순서 있는 신뢰 로그.
struct Entry { Profile value; int64_t version; std::chrono::steady_clock::time_point expireAt; };
void RegionCache::updateProfile(int64_t playerId, const Profile& p) {
int64_t version = db_.updateProfileReturningVersion(playerId, p); // (B') 커밋 + 새 버전
{ std::lock_guard<std::mutex> lk(mtx_); cache_.erase(playerId); }
bus_.publish("profile.invalidate", Inv{playerId, version}); // (A') 커밋 '후' + 버전
}
void RegionCache::onInvalidate(const Inv& m) {
std::lock_guard<std::mutex> lk(mtx_);
auto it = cache_.find(m.playerId);
if (it != cache_.end() && it->second.version < m.version) // 더 옛 것만 제거
cache_.erase(it);
}
Profile RegionCache::getProfile(int64_t playerId) {
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = cache_.find(playerId);
if (it != cache_.end() && clock::now() < it->second.expireAt)
return it->second.value;
}
auto [fromDb, ver] = db_.getProfileWithVersion(playerId); // 값+버전
std::lock_guard<std::mutex> lk(mtx_);
auto it = cache_.find(playerId);
if (it == cache_.end() || it->second.version < ver) // setIfNewer: 옛 값이 새 값을 못 덮음
cache_[playerId] = Entry{ fromDb, ver, clock::now() + std::chrono::seconds(30) };
return fromDb;
}
- 버전 비교로 (4)(5)의 재채움 경합·순서 역전 해결(옛 버전은 절대 새 버전을 덮지 못함).
- 무효화를 커밋 후로 옮겨 early invalidation 제거. 복제 지연이 우려되면 변경 직후 읽기는 마스터에서 읽거나, 무효화 버전 이상으로 복제될 때까지 캐싱 보류.
- TTL 안전망으로 메시지 유실에도 결국 자가 치유. 전파는 best-effort 대신 키별 순서 보장 로그(Kafka 등).
더 나은 설계
- 단조 버전(LSN/버전 컬럼)을 캐시의 모든 결정 기준으로(무효화/채움/교체 = 버전 비교).
- 원격엔 무효화만(새 값 푸시 금지) — 다음 읽기가 권위 소스에서 최신을 가져오게.
- 강한 일관성이 필요하면 캐시 우회(권위 읽기), 그 외엔 TTL + 버전 무효화로 eventual.
면접 포인트
- 무효화는 커밋 후 + 버전 동반 + TTL 안전망 — 셋 중 하나라도 빠지면 stale 부활/영구화.
- read-fill 은 무효화와 경합 → setIfNewer(버전 비교) 로 옛 값이 새 값을 못 덮게.
- 복제 지연은 무효화로 안 풀린다(버전 게이트/마스터 읽기). best-effort pub/sub 은 순서 있는 신뢰 로그로 보강.