20. 온라인 샤드 마이그레이션의 읽기/쓰기 컷오버 경합 (쓰기 유실·stale 읽기) (C#)
난이도 최상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 온라인 샤드 마이그레이션의 읽기/쓰기 컷오버 경합 (쓰기 유실·stale 읽기) (C#)
난이도: 최상
요약
이관(Migrate)이 진행되는 동안 라우팅이 잘못돼, Copying 상태에서 **읽기는 new(아직 미완성)**로, 쓰기는 old로 흩어진다. 게다가 이관은 old 에서 스냅샷을 한 번 떠서 new 에 넣는데, 스냅샷 이후 old 로 들어온 쓰기는 new 로 복사되지 않아 컷오버 후 영구 유실된다. 상태 맵(_state)도 동기화 없이 여러 스레드가 읽고 쓴다.
문제점
(A) 이관 중 단일 샤드 쓰기 — 스냅샷 이후 쓰기 유실(lost write)
- 증상: 이관된 키에서 컷오버 직전에 한 갱신이 사라진다(구매·재화·진행도 롤백처럼 보임).
- 재현 조건:
Migrate가state=Copying후snapshot = _old.Get(key)로 값 V0 을 읽는다.- 그 직후 유저 쓰기
Write(key, V1)이 들어온다. 상태가Done이 아니므로_old.Put(key, V1). Migrate가_new.Put(key, V0)후state=Done.- 이후 읽기는
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)의 원자성과, 그 상태를 읽는 데이터 경로의 동기화.
해설 · C++
해설 — 온라인 샤드 마이그레이션의 읽기/쓰기 컷오버 경합 (쓰기 유실·stale 읽기) (C++)
난이도: 최상
요약
migrate 가 Copying 으로 바꾸는 순간부터 read 는 아직 채워지지 않은 new_ 에서 읽고, write 는 여전히 old_ 에만 쓴다. migrate 는 old_ 에서 한 번 뜬 스냅샷을 new_ 에 넣을 뿐, 스냅샷 이후 old_ 로 들어온 쓰기를 따라잡지 못해 컷오버 후 영구 유실된다. 또한 state_(unordered_map)를 라우터 스레드와 이관 스레드가 동기화 없이 접근해 데이터 레이스(UB) 가 발생한다.
문제점
(A) 이관 중 단일 샤드 쓰기 — 스냅샷 이후 lost write
- 증상: 컷오버 직전 한 갱신이 사라진다.
- 재현 조건:
migrate가snapshot = old_.get(key)(=V0) 를 뜬 직후, 유저write(key, V1)이 상태Copying에서old_.put(key, V1)로 감. 이어migrate가new_.put(key, V0)+Done. 이후 읽기는 new 의 V0 만 봄 → V1 유실. - 근본 원인: 이관 창 동안 dual-write 가 없고, 스냅샷 복사가 복사 시점 이후 변경을 추적하지 못한다.
(B) 이관 시작 즉시 new 읽기 — 미완성 소스(stale/missing)
- 증상:
Copying중 읽기가 아직 비어있는new_를 조회. - 재현 조건:
state=Copying직후new_.put완료 전에read→new_.get은 없음/이전 값. - 근본 원인: 컷오버가 원자적이지 않은데 복사 시작과 동시에 읽기 소스를 new 로 바꿨다. new 는 컷오버 전까지 권위 소스가 아니다.
(공통) state_ 동시 접근 데이터 레이스
unordered_map state_를 여러 스레드가 락 없이find/operator[]→ UB(크래시·손상).
수정안
컷오버 전까지 old 를 권위 읽기 소스로 유지, 이관 중 쓰기는 양쪽(dual-write), 상태 접근은 뮤텍스로 보호하고 컷오버를 원자화한다.
void write(const std::string& key, const std::string& value) {
MigrationState s = stateOf(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);
}
}
std::string read(const std::string& key) {
// 컷오버(Done) 전까지 old가 권위 소스
return stateOf(key) == MigrationState::Done ? new_.get(key) : old_.get(key);
}
void migrate(const std::string& key) {
setState(key, MigrationState::Copying); // 이 순간부터 쓰기는 양쪽으로
std::string snapshot = old_.get(key);
new_.put(key, snapshot); // 이후 old로 온 쓰기는 dual-write로 new에도 들어감
setState(key, MigrationState::Done); // 원자적 컷오버
}
private:
std::mutex stateMtx_;
MigrationState stateOf(const std::string& key) {
std::lock_guard<std::mutex> g(stateMtx_);
auto it = state_.find(key);
return it == state_.end() ? MigrationState::None : it->second;
}
void setState(const std::string& key, MigrationState s) {
std::lock_guard<std::mutex> g(stateMtx_);
state_[key] = s;
}
- 읽기: 컷오버 전까지 old, 쓰기: 이관 중 양쪽. dual-write 로 스냅샷 이후 변경도 new 에 반영돼 유실이 없다.
- 스냅샷
put(V0)이 dual-write 의put(V1)을 덮는 순서 경합은, 복사+컷오버를 키 단위 락으로 보호하거나 변경 로그 재생 후 짧은 freeze 에서 최종 컷오버하는 2단계로 없앤다. 버전 태그를 붙여 낮은 버전이 높은 버전을 못 덮게 하는 것이 견고하다.
더 나은 설계
- 2단계 컷오버(bulk + catch-up + freeze): 대량 복사 → 변경 로그(CDC) 재생 → 짧은 쓰기 정지에서 잔여 적용 → 원자적 라우팅 전환. 다운타임 최소화.
- 에포크/버전 펜싱: 각 값에 버전, 복사·dual-write 모두 max-version-wins 로 병합해 stale 덮어쓰기 방지.
- shared_mutex 로 라우팅 상태 보호: 읽기 다수/쓰기 소수인 상태 조회에
std::shared_mutex로 경합 완화(단 컷오버 순간은 배타). - 트레이드오프: dual-write 는 이관 창 동안 쓰기 2배·부분 실패 처리 필요. 2단계 컷오버는 복잡하나 확실. 상태 락은 필수지만 핫 경로 오버헤드 → 샤딩/읽기락으로 완화.
면접 포인트
- 온라인 마이그레이션 3대 불변식: 컷오버 전까지 기존 소스가 권위, 이관 창 동안 dual-write, 컷오버는 원자적.
- 스냅샷 복사가 왜 부족한가(복사 후 변경 미추적) → CDC/변경 로그 재생의 필요성.
- C++ 특유: 라우팅 상태 맵을 여러 스레드가 만지면 표준 컨테이너 규칙상 외부 동기화 필수 — 논리 결함 위에 데이터 레이스(UB)가 겹친다.