21. 비동기 복제 failover 시 확인된 쓰기 유실 (C#)
난이도 최상해설 — 비동기 복제 failover 시 확인된 쓰기 유실 (C#)
난이도: 최상
요약
프라이머리 로컬 커밋만으로 클라에 성공을 응답 (A) 하고 복제는 비동기로 뒤따른다.
프라이머리가 죽으면 아직 복제되지 않은(그러나 클라엔 이미 "성공"이라 답한) 쓰기가
승격된 복제본 (B) 에는 없어 확인된 쓰기가 유실된다. 게다가 옛 프라이머리가
복귀하면 그 노드에만 있는 커밋과 새 프라이머리의 이후 쓰기가 갈라져
divergence/split-brain 이 된다.
문제점
- [분류: 내구성 위반(acknowledged write loss) · 복제 지연 · split-brain]
- 증상: 결제/재화 충전이 "성공" 떴는데 장애 후 사라짐(거짓 성공). 옛 프라이머리 복귀 시 같은 계정이 노드마다 다른 잔액(데이터 분기).
- 재현:
- 클라가
WriteBalance→ 프라이머리 커밋 →(A)성공 응답. 복제ReplicateAsync는 아직 네트워크 큐에 있음. - 이 순간 프라이머리 크래시. 복제본엔 그 쓰기가 없음.
Failover()로 복제본 승격 → 그 쓰기는 영영 사라짐. 클라는 성공으로 믿음.- 옛 프라이머리 복귀 시 자기만 아는 커밋 + 새 프라이머리 이후 쓰기 → 분기.
- 클라가
- 근본 원인: 응답 시점의 내구성 경계가 잘못됐다. "로컬 커밋 = 내구성"으로 간주했지만 단일 노드 커밋은 그 노드 장애에 취약하다. 또 승격에 펜싱/에폭이 없어 옛 리더의 늦은 쓰기를 막지 못한다.
수정안
클라에 성공을 응답하기 전에 정족수(quorum) 복제를 확인한다(반동기/합의 복제). 승격에는 에폭(term) + 펜싱으로 옛 리더를 무력화한다.
public async Task<bool> WriteBalance(long playerId, long amount)
{
await _primary.CommitLocal(playerId, amount);
// 최소 정족수(예: 3노드 중 1개 이상 복제)까지 대기 후 응답
int acks = 1; // primary
var replicaAcks = await Task.WhenAll(
_replicas.Select(r => r.ReplicateAndAck(playerId, amount, _term)));
acks += replicaAcks.Count(ok => ok);
if (acks < _writeQuorum)
return false; // 내구성 미확보 → 성공으로 답하지 않음(재시도/실패)
return true;
}
public void PromoteToPrimary(long newTerm)
{
_term = newTerm; // 에폭 증가: 옛 리더의 term 은 이제 무효
// 새 리더는 커밋되지 않은/뒤처진 로그를 정족수 기준으로 조정(합의 로그 복구)
}
옛 프라이머리는 복귀 시 자신의 term 이 낮음을 확인하고 팔로워로 강등, 자기만 아는 미확정 쓰기를 폐기/재조정한다(Raft/Paxos 의 로그 매칭). 저장소가 term/fencing token 을 검사해 옛 리더의 늦은 쓰기를 거부한다.
더 나은 설계
- 합의 프로토콜(Raft) 기반 복제 로그로 "정족수 커밋 = 내구성 확정"을 정의하고, 응답을 커밋 인덱스 도달 후로 미룬다.
- 클라 프로토콜에 멱등 키를 두어 애매한 실패(응답 유실) 시 안전 재시도.
- 트레이드오프: 정족수 대기는 쓰기 지연↑(반동기). 지연에 민감한 값은 별도 경로, 재화·결제 같은 내구성 필수 값만 정족수 경로로.
면접 포인트
- "로컬 커밋"과 "확정된(durable) 커밋"의 차이, 응답 시점의 내구성 경계.
- 비동기 복제 failover 의 확인된 쓰기 유실(RPO>0)과 반동기/정족수로의 해결.
- 승격 시 에폭/펜싱/로그 매칭으로 split-brain 을 막는 원리.
해설 — 비동기 복제 failover 시 확인된 쓰기 유실 (C++)
난이도: 최상
요약
프라이머리 로컬 커밋만으로 성공을 응답 (A) 하고 복제는 비동기로 뒤따른다.
프라이머리가 죽으면 아직 복제 안 된(하지만 클라엔 "성공"으로 답한) 쓰기가 승격된
복제본 (B) 에는 없어 확인된 쓰기가 유실된다. 옛 프라이머리 복귀 시 그 노드만
아는 커밋과 새 프라이머리의 이후 쓰기가 갈라져 divergence/split-brain 이 된다.
문제점
- [분류: 내구성 위반(acknowledged write loss) · 복제 지연 · split-brain]
- 증상: 재화 충전이 "성공" 떴는데 장애 후 사라짐(거짓 성공). 복귀 후 노드마다 잔액 상이(분기).
- 재현:
WriteBalance→ 프라이머리 커밋 →(A)성공 응답.ReplicateAsync는 아직 미전파. (게다가 future 를 버려 실패해도 아무도 모름.)- 프라이머리 크래시 → 복제본엔 그 쓰기 없음.
Failover()로 복제본 승격 → 쓰기 영구 유실. 클라는 성공으로 믿음.- 옛 프라이머리 복귀 → 자기만 아는 커밋 + 새 리더 이후 쓰기 → 분기.
- 근본 원인: 응답의 내구성 경계가 단일 노드 로컬 커밋에 걸려 있고, 승격에 에폭/펜싱이 없어 옛 리더의 늦은 쓰기를 못 막는다.
수정안
성공 응답 전에 정족수 복제를 확인(반동기/합의)하고, 승격은 term + 펜싱으로.
#include <vector>
#include <cstdint>
class ReplicatedStore {
Node* primary_;
std::vector<Node*> replicas_;
uint64_t term_ = 0;
int writeQuorum_ = 2; // 예: 3노드 중 2
public:
bool WriteBalance(int64_t playerId, int64_t amount) {
primary_->CommitLocal(playerId, amount);
int acks = 1; // primary
for (Node* r : replicas_)
if (r->ReplicateAndAck(playerId, amount, term_)) // 동기 ack 수집
++acks;
if (acks < writeQuorum_)
return false; // 내구성 미확보 → 성공으로 답하지 않음(재시도/실패)
return true;
}
void PromoteToPrimary(uint64_t newTerm) {
term_ = newTerm; // 에폭 증가: 옛 리더 term 은 무효
// 정족수 기준 로그 조정(합의 복구). 저장소는 낮은 term 쓰기를 거부(펜싱).
}
};
옛 프라이머리는 복귀 시 낮은 term 을 확인하고 팔로워로 강등, 자기만 아는 미확정 쓰기를 폐기/재조정(Raft 로그 매칭). 저장소가 fencing token(term)으로 늦은 쓰기 거부.
더 나은 설계
- Raft 등 합의 복제 로그로 "정족수 커밋 = 내구성 확정"을 정의하고 응답을 커밋 인덱스 도달 후로 미룬다.
- 클라 프로토콜에 멱등 키 → 응답 유실 시 안전 재시도.
- 트레이드오프: 정족수 대기로 쓰기 지연↑. 내구성 필수 값(재화/결제)만 정족수 경로, 지연 민감 값은 별도 경로.
면접 포인트
- "로컬 커밋" vs "durable 커밋", 응답의 내구성 경계(RPO).
- 비동기 복제 failover 의 확인된 쓰기 유실과 반동기/정족수 해결.
- 승격 시 term/펜싱/로그 매칭으로 split-brain 방지. (버려진 future 로 복제 실패가 은폐되는 부수 결함도 지적.)