Dragonflydb Point-in-Time Snapshotting Design


특정 시점 스냅샷 디자인 (Dragonfly Point-in-Time Snapshotting Design)

이 문서는 Dragonfly의 포크를 사용하지 않는(forkless) 특정 시점 스냅샷 생성 절차의 내부 구조를 설명합니다. Dragonfly 백업 설정에 대한 가이드는 백업 문서를 참조하십시오.

⬜️ Dragonfly Point-in-Time Snapshotting Design 원문(영어)

📦 Redis 호환 RDB 스냅샷 (Redis-compatible RDB snapshot)

이 스냅샷은 단일 파일이나 네트워크 소켓으로 직렬화됩니다. 이 구성은 Redis 호환 백업 스냅샷을 생성하는 데 사용됩니다.

이 알고리즘은 Dragonfly의 '공유 무공유(Shared-nothing)' 아키텍처를 활용하며, 각 샤드 스레드(shard-thread)가 오직 자신의 데이터만 직렬화하도록 보장합니다. 아래는 흐름에 대한 전반적인 설명입니다.



1. RdbSave 클래스가 단일 블로킹 채널(빨간색)을 인스턴스화합니다. 이 채널의 목적은 모든 샤드로부터 오는 모든 블롭(blob)을 모으는 것입니다.

2. 추가로, 각 Dragonfly 샤드에 스레드 로컬 스냅샷 인스턴스를 생성합니다. (스냅샷이라는 단어가 모호함을 유발하므로 코드베이스에서 `SnapshotShard` 같은 다른 이름으로 변경할 예정입니다.)

3. 각 `SnapshotShard`는 자체 `RdbSerializer`를 인스턴스화합니다. 이는 Redis 포맷 사양에 따라 각 K/V(키/값) 엔트리를 바이너리 표현으로 직렬화하는 데 사용됩니다. `SnapshotShard`들은 동일한 Dash 버킷(Dash bucket)에서 나온 여러 블롭을 단일 블롭으로 결합합니다. 이들은 항상 버킷 단위(bucket granularity)로 블롭 데이터를 전송합니다. 즉, 버킷을 부분적으로만 커버하는 블롭은 절대 채널로 보내지 않습니다. 이는 스냅샷 격리(Snapshot Isolation)를 보장하기 위해 필요합니다.

4. `RdbSerializer`는 바이너리 데이터를 출력하기 위해 `io::Sink`를 사용합니다. `SnapshotShard` 인스턴스는 여기에 `std::string` 객체를 래핑한 메모리 전용 싱크인 `StringFile`을 전달합니다. `StringFile` 인스턴스가 커지면 위의 규칙을 준수하는 한 채널로 플러시(flush)됩니다.

5. `RdbSave`는 또한 채널에서 모든 블롭을 뽑아내는 파이버(`SaveBody`)를 생성합니다. 블롭은 정해지지 않은 순서로 올 수 있지만, 각 블롭 자체는 독립적(self-sufficient)임이 보장됩니다.

6. Dragonfly는 I/O 처리량을 향상시키기 위해 직접 I/O(Direct I/O)를 사용합니다. 이를 위해서는 OS 페이지 단위로 올바르게 정렬된 메모리 버퍼(aligned memory buffers)가 필요합니다. 불행하게도 RDB 채널에서 오는 블롭들은 크기가 제각각이며 OS 페이지 크기로 정렬되어 있지 않습니다. 따라서 Dragonfly는 RDB 채널의 모든 데이터를 `AlignedBuffer` 변환 과정을 거치게 합니다. 이 클래스의 목적은 들어오는 데이터를 올바르게 정렬된 버퍼로 복사하는 것입니다. 데이터가 충분히 쌓이면 출력 파일로 플러시합니다.

요약하자면, 이 구성은 단일 싱크(Sink)를 채택하여 데이터베이스 전체를 나타내는 하나의 파일 또는 하나의 데이터 스트림을 생성합니다.

💡 해설
• 핵심 개념 (Forkless vs Fork): Redis는 스냅샷을 찍을 때 `fork()`를 호출하여 자식 프로세스를 만들고, OS의 Copy-on-Write(CoW) 매커니즘에 의존해 시점 격리를 수행합니다. 하지만 이는 메모리를 최대 2배까지 쓸 수 있는 치명적인 단점이 있습니다. Dragonfly는 `fork()` 없이, 멀티스레드가 각자 맡은 데이터(샤드)를 직접 직렬화하는 방식을 취합니다.
• Dash 버킷 단위 전송: Dragonfly의 메인 자료구조인 DashTable의 '버킷' 단위로 데이터를 묶어서 채널에 보냅니다. 버킷을 쪼개지 않고 통째로 다루어야 동시 쓰기가 발생하더라도 데이터의 일관성(격리 수준)을 유지하기 쉽기 때문입니다.
• Direct I/O와 `AlignedBuffer`: 성능 극대화를 위해 OS 캐시를 거치지 않는 Direct I/O를 사용하는데, 하드웨어 제약상 메모리 주소가 특정 바이트(예: 4KB) 단위로 정렬되어 있어야 합니다. 파이프라인에서 무작위 크기로 들어오는 직렬화 데이터를 `AlignedBuffer`가 이쁘게 정렬하여 디스크에 고속으로 써 내려가는 구조입니다.

📦 Dragonfly 스냅샷 - 향후 (Dragonfly Snapshot - TBD)

복제(Replication)에 필요합니다. `SnapshotShard`당 하나씩, 여러 개의 파일을 생성합니다. 중앙 싱크(Central Sink)가 필요하지 않습니다. 각 `SnapshotShard`는 버킷 수준의 단위를 보장하기 위해 여전히 `StringFile`과 함께 `RdbSerializer`를 사용합니다. Direct I/O를 원한다면 여전히 `AlignedBuffer`가 필요합니다. N개의 샤드를 가진 Dragonfly 프로세스의 경우, N개의 파일이 생성됩니다. 파일 수준의 일관성을 제공하기 위해 추가적인 메타데이터 파일이 필요할 수 있지만, 현재 우리의 유스케이스는 네트워크 기반 복제이므로 일단 N개의 파일만 생성된다고 가정할 수 있습니다.

이것이 어떻게 사용될까요? 복제본(Replica/Slave)은 마스터와 핸드셰이크를 수행하여 마스터의 샤드가 몇 개인지 알아냅니다. 그런 다음 N개의 소켓을 열고 각 소켓이 샤드 데이터를 당겨옵니다(Pull). 먼저 스냅샷 데이터를 당겨와서 K개의 복제본 샤드에 엔트리를 분산시켜 재생(Replay)합니다. 모든 스냅샷 데이터가 재생된 후에는 변경 로그(Changelog)를 재생하는 안정 상태 복제(Stable State Replication)로 계속 진행되며, 이는 본 문서의 범위를 벗어납니다.

💡 해설
• 멀티 파일 백업 및 네트워크 복제 최적화: 앞선 'Redis 호환' 방식은 단일 파일로 뭉쳐야 하므로 중앙 채널(`RdbSave`)이 일종의 병목(Bottleneck)이 될 수 있습니다. 반면, 이 'Dragonfly 전용 스냅샷'은 중앙 싱크를 거치지 않고 N개의 샤드가 각각 N개의 파일(또는 네트워크 소켓)로 데이터를 바로 쏴버립니다.

• 병렬 복제: 데이터를 받는 복제본(Replica) 서버도 마스터의 샤드 개수만큼 동시에 소켓을 열어 병렬로 데이터를 다운로드하므로, 대용량 데이터 복제 속도가 Redis에 비해 수배 이상 빨라집니다.

📦 완화된 특정 시점 - 향후 (Relaxed point-in-time - TBD)

Dragonfly가 디스크에 스냅샷 파일을 저장할 때, 모든 프로세스 샤드에 걸쳐 가상 컷(Virtual Cut)을 적용하여 스냅샷 격리를 유지합니다. 스냅샷 생성에는 시간이 걸릴 수 있으며, 이 기간 동안 Dragonfly는 많은 쓰기 요청을 처리할 수 있습니다. 이러한 변경 사항(Mutations)은 스냅샷이 시작된 시점까지의 데이터만 캡처하기 때문에 스냅샷의 일부가 되지 않습니다. 이는 백업에 완벽한 방식이며, 이를 보수적 스냅샷(Conservative Snapshotting)이라고 부릅니다.

그러나 복제를 위해 스냅샷을 생성할 때는, 스냅샷 생성이 끝나는 시점까지의 모든 데이터를 포함하는 스냅샷을 생성하고 싶어집니다. 이를 완화된 스냅샷(Relaxed Snapshotting)이라고 부릅니다. 완화된 스냅샷을 사용하는 이유는 스냅샷 생성 중에 발생하는 모든 변경 사항의 로그(Changelog)를 따로 보관하는 것을 피하기 위함입니다.

스냅샷 단계(전체 동기화/Full-sync)는 많은 시간이 소요될 수 있으며, 이는 시스템에 큰 메모리 압박을 줍니다. 전체 동기화 단계 동안 변경 로그를 따로 유지하는 것은 압박을 더 가중시킬 뿐입니다. 우리는 변경 사항을 따로 저장하지 않고 복제 소켓으로 즉시 밀어 넣음으로써 완화된 스냅샷을 달성합니다. 물론, 스냅샷이 언제 끝나고 안정 상태 복제가 시작되는지 알기 위해 특정 시점의 일관성(Point-in-Time Consistency)은 여전히 필요합니다.

(부연 설명 - 이론적으로 파일 스냅샷에 대해서도 동일한 완화된 세맨틱을 지원할 수 있지만, 스냅샷 크기가 커질 수 있으므로 굳이 필요하지는 않습니다.)

💡 해설
• 보수적(Conservative) vs 완화된(Relaxed)의 차이: * 보수적 방식 (백업용): 스냅샷 시작 버튼을 누른 딱 그 순간(T1)의 데이터만 저장합니다. 스냅샷을 찍는 동안 유입된 쓰기 명령은 백업 파일에 안 들어갑니다.

• 완화된(Relaxed) 방식(복제용): 스냅샷 시작(T1)부터 끝나는 순간(T2) 사이에 발생한 변경 사항까지 전부 스냅샷 스트림에 포함시켜 버립니다.

• 메모리 절약 기법: 복제 도중에 대량의 쓰기가 들어오면, Redis의 경우 이를 메모리 버퍼(Replication Buffer)에 다 쌓아두었다가 스냅샷 전송이 끝나면 한 번에 보냅니다. 이는 메모리 부족(OOM)을 유발하는 주원인입니다. Dragonfly는 변경 사항을 메모리에 쌓아두지 않고, 현재 가고 있는 스냅샷 데이터 스트림 뒤에 실시간으로 실어서 보내 버리는 방식을 고안한 것입니다.

📦 보수적 및 완화된 스냅샷 변형 (Conservative and relaxed snapshotting variations)

두 알고리즘 모두 메인 딕셔너리를 반복적으로 순회하며 데이터를 직렬화하는 스캔 프로세스(파이버)를 유지합니다. 프로세스를 시작하기 전에 `SnapshotShard`는 해당 샤드의 변경 에포크(Change Epoch)를 캡처합니다 (이 에포크는 쓰기 요청이 있을 때마다 증가합니다).


단순하게 생각해서, 샤드의 각 엔트리가 자체 버전 카운터(Version Counter)를 유지한다고 가정할 수 있습니다. 에포크 번호를 캡처함으로써 우리는 하나의 '컷(Cut)'을 수립합니다: `version <= SnapshotShard.epoch`인 모든 엔트리는 아직 직렬화되지 않았으며, 동시 쓰기에 의해 수정되지 않은 상태입니다.

DashTable 순회 알고리즘은 수렴과 커버리지("최대 한 번, At-most-once")를 보장하지만, 각 엔트리가 '정확히 한 번' 방문되는 것을 보장하지는 않습니다. 따라서 엔트리 버전을 두 가지 용도로 사용합니다:
1. 동일한 엔트리가 여러 번 직렬화되는 것을 방지하기 위해, 그리고
2. 동시 쓰기로 인해 변경되어야 하는 엔트리를 올바르게 직렬화하기 위해.

직렬화 파이버 (Serialization Fiber):
스냅샷 단계 중 동시 쓰기를 허용하기 위해, 테이블의 각 엔트리가 변경될 때마다 트리거되는 훅(Hook)을 설정합니다:

온라이트 훅: 보수적 버전 (OnWriteHook: Conservative)
이 훅은 데이터를 변경하기 전에 엔트리의 이전 값을 싱크로 밀어 넣음으로써 보수적 변형에 대한 특정 시점 세맨틱을 유지합니다.

그러나 완화된 특정 시점 방식의 경우 이전 값을 저장할 필요가 없습니다. 따라서 다음과 같이 할 수 있습니다:

온라이트 훅: 완화된 버전 (OnWriteHook: Relaxed)

변경 데이터는 나머지 콘텐츠와 함께 전송되며, 이를 위해서는 기존 RDB 포맷을 확장하여 `hset`, `append` 등과 같은 차분 연산(Differential Operations)을 지원해야 합니다. 직렬화 파이버 루프는 이 변형에서도 동일합니다.

💡 해설
• 가상 컷(Virtual Cut)과 에포크(Epoch) 매커니즘: `fork()` 없이 완벽한 시점 격리를 하기 위해 Dragonfly는 데이터마다 '버전(`version`)'을 붙입니다. 스냅샷 시작 시점의 기준선(`cut.epoch`)을 세워두고 테이블을 쭉 훑어 나갑니다.

• OnWriteHook - 보수적(Conservative) 작동 원리: 스냅샷 스캔 파이버가 아직 방문하지 않은 데이터인데 사용자가 쓰기(수정) 요청을 보냈다면, 원래 갖고 있던 과거 데이터(`entry`)를 가로채서 얼른 백업 채널로 먼저 던져버립니다. 그 후 데이터를 새 값으로 바꿉니다. 이렇게 하면 스냅샷 파일에는 온전히 '시작 시점'의 데이터만 남게 됩니다.

• OnWriteHook - 완화된(Relaxed) 작동 원리: 이 방식에서는 과거 데이터가 중요하지 않고 최신 상태가 중요합니다. 스캔 파이버가 아직 안 온 데이터라면 그냥 새 데이터(`new_entry`)를 바로 던지면 됩니다. 만약 이미 스캔이 지나간 데이터가 또 수정되었다면, 수정된 차분(`IncrementalDiff`)을 스냅샷 스트림 뒤에 덧붙여서 보냅니다. 받는 쪽(Replica)에서는 이를 받아 원래 데이터 뒤에 적용하게 됩니다. 이를 위해 Dragonfly는 표준 Redis RDB 포맷에 없는 '차분 전송 포맷'까지 새로 정의하겠다는 고도의 설계안을 담고 있습니다.


Email 답글이 올라오면 이메일로 알려드리겠습니다.