← 문제로

20. 온라인 샤드 마이그레이션의 읽기/쓰기 컷오버 경합 (쓰기 유실·stale 읽기) (C#)

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

해설 — 온라인 샤드 마이그레이션의 읽기/쓰기 컷오버 경합 (쓰기 유실·stale 읽기) (C#)

난이도: 최상

요약

이관(Migrate)이 진행되는 동안 라우팅이 잘못돼, Copying 상태에서 **읽기는 new(아직 미완성)**로, 쓰기는 old로 흩어진다. 게다가 이관은 old 에서 스냅샷을 한 번 떠서 new 에 넣는데, 스냅샷 이후 old 로 들어온 쓰기는 new 로 복사되지 않아 컷오버 후 영구 유실된다. 상태 맵(_state)도 동기화 없이 여러 스레드가 읽고 쓴다.

문제점

(A) 이관 중 단일 샤드 쓰기 — 스냅샷 이후 쓰기 유실(lost write)

  • 증상: 이관된 키에서 컷오버 직전에 한 갱신이 사라진다(구매·재화·진행도 롤백처럼 보임).
  • 재현 조건:
    1. Migratestate=Copyingsnapshot = _old.Get(key) 로 값 V0 을 읽는다.
    2. 그 직후 유저 쓰기 Write(key, V1) 이 들어온다. 상태가 Done 이 아니므로 _old.Put(key, V1).
    3. Migrate_new.Put(key, V0)state=Done.
    4. 이후 읽기는 new 에서 V0 을 본다 — V1 은 old 에만 있고 아무도 안 봄 → 유실.
  • 근본 원인: 이관 창(window) 동안 양쪽 쓰기(dual-write) 가 없다. 스냅샷 방식 복사는 "복사 시점 이후의 변경"을 따라잡지 못한다(변경 로그/재복사 부재).

(B) 이관 시작 즉시 new 에서 읽기 — 미완성 소스 읽기(stale/missing)

  • 증상: Copying 중 읽기가 아직 채워지지 않은 new 를 조회해 빈 값/이전 값을 반환.
  • 재현 조건: state=Copying 로 바뀐 직후, _new.Put 이 끝나기 전에 Read(key)_new.Get(key) 는 없음/stale.
  • 근본 원인: 컷오버가 원자적이지 않은데도 복사 시작과 동시에 읽기 소스를 new 로 전환했다. new 는 컷오버(복사 완료 + 검증) 전까지 권위 소스가 아니다.

(공통) 상태 맵 비동기화

  • _state 를 라우터 스레드(읽기/쓰기)와 이관 스레드가 락 없이 접근 → Dictionary 동시 수정으로 예외/손상 가능.

수정안

이관 창 동안 old 를 권위 읽기 소스로 유지하고 쓰기는 양쪽(dual-write), 컷오버는 원자적으로 한 번에 전환한다. 스냅샷 이후 변경분은 dual-write 로 자동 반영된다.

public void Write(string key, string value)
{
    var s = GetState(key);
    if (s == MigrationState.Done)      { _new.Put(key, value); }
    else if (s == MigrationState.Copying) { _old.Put(key, value); _new.Put(key, value); } // dual-write
    else                               { _old.Put(key, value); }
}

public string Read(string key)
{
    var s = GetState(key);
    // 컷오버(Done) 전까지 old 가 권위 소스
    return s == MigrationState.Done ? _new.Get(key) : _old.Get(key);
}

public void Migrate(string key)
{
    SetState(key, MigrationState.Copying);   // 이 순간부터 쓰기는 양쪽으로
    string snapshot = _old.Get(key);
    _new.Put(key, snapshot);                 // 이후 old로 온 쓰기는 dual-write로 new에도 반영됨
    // (복사 중 발생한 dual-write와의 순서 경합을 막으려면 컷오버는 키 락 아래 원자적으로)
    SetState(key, MigrationState.Done);       // 원자적 컷오버: 이제 new가 권위
}

private readonly object _lock = new();
private MigrationState GetState(string key){ lock(_lock){ _state.TryGetValue(key,out var s); return s; } }
private void SetState(string key, MigrationState s){ lock(_lock){ _state[key]=s; } }
  • 읽기: 컷오버 전까지 old, 쓰기: 이관 중 양쪽. dual-write 덕분에 스냅샷 이후 변경도 new 에 들어가 유실이 없다.
  • 여전히 남는 미세 경합(스냅샷 put 과 dual-write put 의 순서: V0 이 V1 을 덮어쓸 수 있음)은 키 단위 락으로 복사+컷오버를 보호하거나, 스냅샷 대신 변경 로그 재생(catch-up) 후 짧은 쓰기 정지(freeze)에서 최종 컷오버하는 2단계로 해결한다.

더 나은 설계

  • 2단계 컷오버(bulk copy + catch-up + freeze): 대량 복사 → 그동안의 변경 로그 재생 → 짧게 키 쓰기를 멈추고 잔여 로그 적용 → 원자적 라우팅 전환. 다운타임을 밀리초로 최소화.
  • 버전/에포크 펜싱: 각 쓰기에 버전을 달아 stale 복사가 최신을 덮지 않게 한다(스냅샷 V0 이 V1 을 못 덮음).
  • 읽기 검증(read-repair): 컷오버 직후 잠시 old·new 이중 읽기로 불일치 감지.
  • 트레이드오프: dual-write 는 이관 창 동안 쓰기 지연·비용 2배, 그리고 한쪽 실패 처리(부분 성공)를 정의해야 한다. 2단계 컷오버는 복잡하지만 유실·stale 을 확실히 없앤다.

면접 포인트

  • 온라인 리샤딩/마이그레이션의 핵심 불변식: 컷오버 전까지 기존 소스가 권위, 이관 창 동안 dual-write, 컷오버는 원자적. 세 축 중 하나만 빠져도 유실/stale.
  • "스냅샷 복사"의 한계 — 복사 시점 이후 변경을 못 따라감. 변경 로그 재생/CDC 가 필요한 이유.
  • 라우팅 상태 전환(None→Copying→Done)의 원자성과, 그 상태를 읽는 데이터 경로의 동기화.