FoundationDB의 내부 동작 방식과 핵심 설계 선택 및 장애 복구 과정을 상세하게 설명합니다. 이 글에서는 FDB 논문에서 주요 논점과 실제 시스템 구조, 트랜잭션 처리 방식, 장애 복구 절차까지 구체적으로 다룹니다.
FoundationDB는 매우 인상적인 데이터베이스입니다. 논문은 SIGMOD’21에서 최고의 산업 논문상을 수상했습니다. 이 글에서는 FDB가 어떻게 동작하는지, 그리고 몇 가지 흥미로운 설계 선택에 대해 자세히 설명합니다. 논문은 아이디어가 매우 밀집되어 있고, 일부 세부사항이나 올바름 증명이 다루어지지 않은 부분도 있습니다. 필요하다면 직접 증명을 추가했습니다.
샤딩되지 않은(non-sharded), strict serializable(엄격 직렬화), 장애 허용, 키-값(key-value) 저장소로 포인트 작성·읽기와 범위 읽기를 지원합니다. SQL이 아니라 키-값 API를 제공합니다. 또한 샤딩되지 않아 전체 키 공간이 단 하나의 논리 샤드에 위치합니다. 엄격 직렬화 보장하는 키-값 저장소가 있으면, 그 위에 SQL 엔진이나 2차 인덱스를 얼마든지 얹을 수 있습니다. 엄격 직렬화(필요시 완화 가능)된 키-값 저장소는 분산 데이터베이스의 "기반(Foundation)"을 구성합니다. 이 위에 원하는 대로 (거의) 무엇이든 안전하게 구축할 수 있습니다. 이는 탁월한 설계 선택입니다.
논문 도입부에서 정말 놀라운 주장을 몇 가지 합니다.
이 엄청난 주장들은 뒤에서 다시 다룹니다. 만약 이들이 흥미롭지 않다면, 이 글과 논문은 당신에게 맞지 않을 수 있습니다.
FDB는 마이크로서비스 아키텍처(논문에서는 decoupling이라 부름)를 극한까지 끌고갑니다. 각 핵심 기능이 완전히 분리된 서비스(상태유무와 무관)로 분리되어, 다양한 역할명이 많이 나옵니다.
Control Plane에서 가장 중요한 서비스는 _Coordinator_입니다. 전체 FDB 배포/구성과 관련된 작은 메타데이터(현재 epoch 등, 재구성/복구시마다 갱신)를 저장합니다. _ClusterController_는 모든 서버의 헬스(아마도 하트비트로 추정)를 감시합니다.
FDB의 Data Plane은 Transaction System, Log System, Storage System으로 나뉩니다. Log와 Storage는 익숙한 분산 로그+샤드 스토리지 구조입니다. Transaction System, 특히 _Sequencer_가 가장 중요한 역할입니다.
[read-version, commit-version]
기간 내 읽은 값이 변경됐는지(충돌)를 검사합니다.기본적으로 낙관적 동시성 제어(OCC)이며 구조가 매우 직관적입니다. 충돌이 발생(읽은 값이 바뀜)하면 어차피 트랜잭션은 중단 또는 재시도되므로, 사실 MVCC 조차 필수는 아닙니다. FDB의 MVCC는 스냅샷 리드/읽기 전용 트랜잭션 지원 용입니다.
(수정: 논문 저자 Bhaskar Muppana가 이 LinkedIn 답글로 오류를 지적해주셔서 바로잡았습니다. 감사합니다!)
Proxy가 필요한 이유는, 전통 RDB에서 흔한 "트랜잭션 내 미커밋 쓰기(local buffer)를 읽는" 패턴을 제공하기 위함입니다. 프록시 서버는 미커밋 쓰기를 로컬로 버퍼링하여, 같은 키 조회시 저장소와 병합해 읽어들입니다. (클라이언트, 즉 Proxy가 아님, 는 추가로 언커밋된 쓰기를 캐싱해 동일 트랜잭션 내 read-uncommitted-writes를 지원함) 이런 read-repair는 단순 k/v 모델일 때만 현실적으로 가능합니다. 조금 더 복잡한(예: 그래프 모델 등) 경우엔 난이도가 급상승합니다. 캐시는 클라이언트단에서 하므로, 일부 쿼리는 트랜잭션 시스템을 건너뛰고 직접 읽을 수도 있습니다.
Proxy는 클라이언트와 트랜잭션 시스템 사이에서 준-상태유지(client) 역할입니다. 예: KCV를 기억해 복구시 활용, 다수 고객 요청을 배치로 뭉쳐 Sequencer qps를 낮추는 일 등입니다.
_Resolver_는 범위별로 샤딩돼 병렬 충돌검사가 가능합니다. 따라서 각 트랜잭션마다 모든 관련 Resolver의 응답을 기다려야 합니다. 그러면, Resolver는 최근 커밋된 키/버전을 어디서 받는가? 사실 별도 동기화를 하지 않습니다. 로그와 Resolver 간 분산 트랜잭션이 필요해지기 때문입니다. 대신 Resolver는 "최근 시도(write-attempt)와 버전"만 자체 보관합니다. 이 때문에 롤백된 트랜잭션의 키가 최근에 기록된 것으로 오검출될 수 있습니다(즉 false positive), 논문에서 이 오탐지를 관리 가능한 수준이라 주장합니다. 그 이유는:
커밋 레이스는 어떻게 처리할까? 예: 한 Resolver가 (Vr, Vc)
트랜잭션을 먼저 승인, 그리고 나서 Vr < Vc2 < Vc
인 새 트랜잭션이 들어오는 경우. 이 경우 로그 서버가 LSN순 정렬로 Vc2를 Vc보다 먼저 받아 일관성을 보장합니다. (모든 LogServer 메시지는 LSN순 정렬, 2.4.2 참조)
Resolver는 충돌 검출에 록이 필요 없습니다. 그래서 FDB가 "lock-free"라 주장한 것입니다. 그러나 진정한 의미의 "락 프리"는 아니라고 봅니다. 엄격 직렬성을 실현하려면 시스템 어디선가 록이 필요합니다(예: Sequencer 내부나, 커널의 패킷 큐 동기화, 심지어 하드웨어 레벨의 atomic 연산도 원자성 보장을 위해 락 활용). 락 없이 순서 보장은 불가합니다.
_Sequencer_나 Resolver 장애시 어떻게 되는지 곧 다룹니다.
FDB의 로그 프로토콜은 표면적으로 매우 단순합니다. 다수 LogServer와, 독립적인 다수 StorageServer(복제/토폴로지 분리)가 있으며, FDB 특유의 분리 설계의 또 하나의 예시입니다. 더 자세히 들여다보면 핵심적인 차별점이 있습니다.
FDB엔 여러 LogServer가 있습니다. FDB는 비샤딩(단일 논리 샤드) DB 추상화를 제공합니다. 일반적으로 분산 로그(예: Raft)에서 각 로그는 논리 샤드와 1:1 대응되는 반면, FDB는 셋업이 다릅니다. 그 비밀은 바로 _시퀀싱_이 로그와 완전히 분리(separate sequencing and logging) 되어 있기 때문입니다. 즉, 물리적으로 오직 하나의 _Sequencer_만이 전역 순서를 부여, 나머지 다수 LogServer는 저장(쓰기)의 역할만 담당합니다. 이 뛰어난 설계 덕분에 FDB는 로그 여러 개를 두고, 로그는 순서 관리가 아닌 "데이터 저장" 역만 맡을 수 있습니다. 시퀀서 역할은 반드시 "단일"이어야 하므로 여기서 Throughput/레이턴시 향상이 가능합니다. (반면 Raft는 시퀀싱과 로깅을 결합해 성능상 한계를 내포함)
LSN
, prevLSN
, KCV
포함, KCV < LSN
) 메시지에는 필요한 샤드(LogServer)에만 데이터가 포함(성능 최적화 목적).Proxy가 로그 "모두"에 메시지 브로드캐스트하는 이유는? 모든 로그에서 메시지 순서적용에 공백이 없어야 하고, 복구시 반드시 필요하기 때문입니다. 데이터 없이 헤더만 보내는 것도 최적화 이유입니다.
시퀀싱과 로깅 분리로 인해, LogServer에 커밋 자체가 분산 트랜잭션처럼 보이지만, FDB는 Calvin 스타일의 결정적(deterministic) 처리를 따릅니다. Proxy는 각 로그에 데이터를 "무조건적으로" 씁니다. 장애나 타임아웃이 발생시 재시도하면 되며, 2PC가 불필요합니다. 이 구조 덕분에 Proxy가 모든 로그에 브로드캐스트 해도 무방합니다.
이때, LogServer 장애(크래시 등) 시만 실제 롤백/복구가 필요해집니다. 이에 대한 대응은 아래에서 다룹니다.
위 그림: 로그 5개, 스토리지 노드 10개, 시퀀서 1개(그림엔 없음). #StorageNode > #Logs > #Sequencer
순. 시퀀서가 매우 가볍기 때문(디스크쓰기 없음). 하나의 로그는 여러 StorageNode를 커버(로그는 prank easily/연속쓰기라 저렴함 등).
이제 진짜 재밌는 파트입니다. Sequencer, Resolver, _LogServer_가 장애날 경우 어떻게 될까요?
Paxos 등과 달리, FDB의 복구 과정엔 _다운타임_이 존재합니다. 과도기 동안 전체 FDB 배포/키공간 모두가 불가능해집니다. 한 샤드 수준의 단발성 불가용성은 실무적으로 용인될 수 있지만, FDB는 하나의 논리 샤드 구조라 전체가 멈춥니다.
P50 FDB 복구 다운타임은 3.08초입니다. 이 기간엔 누구도 FDB에 기록할 수 없습니다. 논문 주장: 클라이언트는 여전히 스토리지 노드에서 읽기가 가능.
복구 중, 읽기-쓰기 트랜잭션은 잠시 차단되며 타임아웃 후 재시도됨. 단순 읽기는 StorageServer가 서비스하므로 영향을 받지 않음.
이 진술은 완벽히 정확하진 않습니다. 스냅샷 읽기는 무관하지만, _Sequencer_가 다운이면 읽기 전용 트랜잭션도 지원 불가. 다만, FDB가 읽기 비중이 매우 높으므로 실제로는 대부분 스냅샷 리드가 주를 이룬다고 볼 수 있습니다. 영향이 작다는 주장 역시 합리적입니다.
시스템이 다운타임을 허용하면 리더-팔로워 구조에서 리더 1, 팔로워 10명, 총 11복제라면 이론상 10실패 시까지 무중단 운영이 가능합니다.
Proxy는 대부분 스테이트리스(단순 KCV 유지)해서 복구가 쉽고, Resolver는 이전 epoch 데이터 소거가 안전합니다. (동일 트랜잭션이 epoch 간 span 불가, 매 복구 시 완전히 새 Proxy, Resolver 배치) 결국 로그와 Sequencer가 까다롭습니다. 새 Sequencer 기동에는 이전 로그의 마지막 커밋 LSN 위치만 알면 충분합니다.
LogServer가 _m_개, 복제 k라 할 때, 알고리즘은 다음과 같습니다:
DV
(Durable Version, 최대 기록된 LSN), KCV
(Proxy가 알려준 마지막 커밋 버전)를 유지. 모두 프록시->로그 메시지에 담아 전달.max(KCVs)
까지 트랜잭션이 커밋됨을 알 수 있습니다. 이것을 PEV
(이전 epoch의 마지막 버전)이라 칭함. 신규 Sequencer는 PEV+1
부터 epoch 시작.아주 간단해 보이나, 논문엔 RV
(Recovery Version, min(DVs)) 개념과 [PEV+1, RV]
구간의 로그 복사 및 이후 데이터 폐기에 대한 언급이 있습니다. 그 이유는:
[PEV+1, RV]
의 데이터에 대해 (복제 factor 복구 목적) 보존논문엔 복구 과정의 올바름 증명이 없으므로, 여기서 직접 설명합니다.
로그에 도달한 메시지는 이미 충돌검사를 통과했으니 영구저장해도 안전합니다. 특히 데이터가 이미 다른 클라이언트에 읽혔을 수도 있으므로, 되도록 안전하게 더 많은 과거 로그를 보존해야 합니다. PEV 이전 모든 트랜잭션은 커밋됐음이 보장되며, 이제 로그의 끝(tail)을 찾아야 합니다. 즉:
아래 그림 참조:
RV 이후 데이터 폐기, 이전 데이터 보존이 안전하려면 다음 조건이 필요:
#1, #2에 대해 모순법 증명:
결론: RV를 tail로 간주하고, PEV~RV 구간 메시지는 보수적으로 커밋으로 처리(이미 충돌검사 통과, 불확실하더라도 안전성 우선). 새 epoch는 PEV+1부터 시작, 기존 로그의 [PEV+1, RV] 구간 데이터 재반영.
각 구성요소는 완전히 분리되어 각자 별도 복제전략을 가집니다.
FDB는 최고의 k/v 저장소라고 생각하지만, 도입부의 대담한 주장 몇 개는 과장이 있다고 생각됩니다.
FDB는 지역 배포용 최고의 k/v 저장소라 생각합니다. 견고함은 시뮬레이션 프레임워크 기반이 크고, 설계 선택이 매우 뛰어납니다. 논문의 분량 제약 탓에 주요 세부(및 증명)가 생략됐지만, FoundationDB 팀에 축하를 보냅니다.