SlateDB는 오브젝트 스토리지를 내구성의 기반으로 사용하는 오픈 소스 임베디드 키-값 데이터베이스로, 온라인 시스템을 위한 오브젝트 네이티브 LSM을 제공합니다.
“디스크리스” 시스템, 즉 내구성을 오브젝트 스토리지에 위임하는 시스템은 데이터베이스 시스템의 미래입니다:
이러한 특성 덕분에 오브젝트 스토리지는 오프라인 워크로드의 표준이 되었고, 최근의 성공 사례들(예: Turbopuffer, Warpstream, Quickwit 등)은 온라인 시스템에서도 그 잠재력을 보여주고 있습니다.
온라인 시스템에서 오브젝트 스토리지 채택을 가속하기 위해, 우리는 지난 몇 년 동안 SlateDB를 구축해 왔습니다. SlateDB는 임베디드 키-값 인터페이스를 갖춘 OSS 오브젝트 네이티브 LSM 구현체입니다.
오늘까지 공식적으로 “발표”된 적은 없었지만, SlateDB는 이미 Dropbox, ZeroFS, HelixDB, Opendata 등에서 프로덕션에 사용되고 있습니다.
블로그를 읽는 것보다 직접 만져보는 쪽을 선호한다면, SlateDB는 지금 바로 사용 가능하며 Rust, Go, Java, Node, Python 바인딩을 제공합니다.
use slatedb::Db;// 오브젝트 스토리지 버킷을 백엔드로 사용하는 SlateDB 인스턴스를 연다let slate = Db::open("/dir", object_store).await?;// SlateDB를 키 값 저장소로 사용한다slate.put(b"key", b"value").await?slate.get(b"key").await?;
수많은 장점에도 불구하고, 오브젝트 스토리지에는 온라인 워크로드 채택을 가로막아 온 세 가지 특성이 있습니다:
GET 및 PUT 요청은 각각 개별 과금되며, 읽기는 약 $0.40/백만 회, 쓰기는 약 $5/백만 회입니다적당한 수준의 10K ops/sec를 읽기와 쓰기에 반반 나눠 S3를 직접 키-값 저장소로 사용하는 순진한 시스템은 월 $70K의 비용이 들고 성능도 좋지 않을 것입니다.
이를 해결하기 위해, 오브젝트 네이티브 시스템은 쓰기를 배치 처리하고 읽기를 캐시합니다.
쓰기의 경우, 가능한 트레이드오프는 지연 시간, 비용, 내구성 사이에 있습니다. 쓰기가 내구적이어야 한다면, 지연 시간을 낮추기 위해 더 자주 PUT 요청을 보내거나, 더 적은 빈도의 요청으로 여러 쓰기 구간을 배치해 비용을 절감할 수 있습니다. 데이터 손실 위험을 감수할 수 있다면, 쓰기를 즉시 승인하면서도 많은 요청을 하나의 PUT으로 묶을 수 있습니다.
╭─────────────────────────────────────────╮ ╭─────────────────────────────────────────╮│ ◎ ○ ○ ░░░░░░░ Pick Two (Writes) ░░░░░░░░│ │ ◎ ○ ○ ░░░░░░░░ Pick Two (Reads) ░░░░░░░░│├─────────────────────────────────────────┤ ├─────────────────────────────────────────┤│ │ │ ││ │ │ ││ ┌─────────────────────────────────┐ │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ││ │● LATENCY │ │ │ ○ LATENCY ││ └─────────────────────────────────┘ │ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ││ ┌─────────────────────────────────┐ │ │ ┌─────────────────────────────────┐ ││ │● DURABILITY │ │ │ │● CONSISTENCY │ ││ └─────────────────────────────────┘ │ │ └─────────────────────────────────┘ ││ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ │ ┌─────────────────────────────────┐ ││ ○ COST │ │ │● COST │ ││ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │ └─────────────────────────────────┘ ││ │ │ ││ │ │ ││ │ │ │└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
오브젝트 스토리지에서 쓰기와 읽기를 지배하는 둘 중 둘 선택 트레이드오프입니다.
여러 복제본에 걸친 읽기의 경우, 이 식은 지연 시간, 비용, 일관성 사이의 교환 관계가 되며 결국 캐시를 어떻게 처리하느냐로 귀결됩니다. 낮은 지연 시간과 일관된 리더가 필요하다면, 머신 간에 쓰기를 적극적으로 복제하고 캐시를 무효화하기 위해 비용을 지불해야 합니다(GET 요청을 자주 보내거나 네트워크 호출을 통해서). 최종적 일관성을 받아들일 수 있다면, 일정한 폴링 주기까지 오래된 캐시에서 데이터를 제공하고 그 시점에 오브젝트 스토리지에서 GET을 배치 처리하여 비용을 줄일 수 있습니다.
오브젝트 스토리지를 기반으로 하는 모든 온라인 시스템은 이러한 “오브젝트 물리 법칙”의 지배를 받습니다.
오브젝트 물리 법칙 두 가지는 로그와 정렬 배열이라는 두 데이터 구조를 유지하는 메커니즘과 잘 맞아떨어집니다.
지연 시간, 내구성, 비용 사이의 트레이드오프는 로그에 쉽게 투영되며, 여기서 조절 레버는 로그의 꼬리 부분을 얼마나 자주 오브젝트 스토리지로 플러시하느냐입니다. 로그의 문제는 특정 데이터를 찾기 위해 전체 로그를 스캔해야 한다는 점이며, 이것이 로그가 “write ahead” 용도로만 사용되는 이유입니다.
반대쪽 끝에는 정렬 배열이 있습니다. 정렬 배열은 로그의 데이터를 정렬 배열로 얼마나 자주 병합할지 결정함으로써 읽기 지연 시간, 일관성, 비용 사이를 조절할 수 있게 해줍니다. 오브젝트 스토리지에서는 모든 오브젝트가 불변이고 업데이트 시 전체를 다시 써야 한다는 제약 때문에, 데이터를 자주 병합하는 비용이 더 커집니다.
한 데이터 구조로 쓰기를 모델링하고 다른 데이터 구조로 읽기를 모델링하려면, 둘을 조정하는 어떤 방법이 필요합니다. 놀랍지 않게도, 이런 깨달음에 처음 도달한 데이터베이스 엔지니어는 우리가 아니며, 바로 이 주제에 대해 수십 년간의 연구가 축적되어 있습니다. 그 결과물이 LSM 트리라는 데이터 구조이며, 우리는 이를 SlateDB의 기반으로 사용합니다.
╭──────────────────────────────────────────────────────────────╮│ ◎ ○ ○ ░░░░░░░░░░░ Bytewise Data Structures ░░░░░░░░░░░░░░░░░░│├──────────────────────────────────────────────────────────────┤│ ││ ││ cheap writes cheap reads ││ slow reads slow writes ││ ◀──────○───────────────────────────────────────○─────▶ ││ │ │ ││ ┌────◎───┐ ┌────────┐ ┌────────◎──────┐ ││ │ logs │◀───────┤LSM Tree├───────▶│ sorted arrays │ ││ └────────┘ └────────┘ └───────────────┘ ││ ││ │└──────────────────────────────────────────────────────────────┘
LSM 트리는 변경 사항을 불변 오브젝트로 배치 처리하기 때문에 오브젝트 스토리지에 특히 잘 맞습니다. 단일 정렬 배열 안의 데이터 블록을 계속 다시 쓰는 대신, SST라고 불리는 정렬 파일을 만들고 이를 트리 구조로 조직하며, 이 구조는 compaction이라 불리는 백그라운드 프로세스로 유지할 수 있습니다.
요약하면, LSM 트리는 오브젝트 스토리지의 제약과 잘 맞습니다:
| Limitation | LSM 트리의 특성 |
|---|---|
| 오브젝트는 불변이어야 함 | SST는 불변이며 배치 방식으로 서로 병합되어 새로운 SST가 됨 |
| PUT 요청은 비쌈 | PUT은 로그에서 배치 처리되고 compaction은 대량 데이터에 대해 백그라운드에서 수행됨 |
| GET 지연 시간은 높음 | 워크로드 요구사항에 맞게 읽기 및 쓰기 증폭을 조정할 수 있는 노브가 제공됨 |
LSM 트리에 대해 더 읽고 싶다면, SlateDB의 커미터 중 한 명이 쓴 이 글을 읽어볼 수 있습니다.
이전 세대의 데이터베이스 시스템은 Facebook의 RocksDB, Mongo의 WiredTiger, Cockroach의 PebbleDB 같은 키-값 엔진 위에 구축되었습니다. 이러한 기반은 10년 넘게 우리에게 큰 도움을 주었지만, 오브젝트 스토리지의 제약을 우회하도록 키-값 스토리지를 처음부터 다시 설계하는 일이 다음 세대로 나아가기 위해 필요합니다.
SlateDB를 만들기 시작했을 때, 우리는 먼저 RocksDB의 스토리지 API를 S3 위에 구현하면 원하는 것을 얻을 수 있는지 시도해 보았습니다. 결과는 아니었습니다. 로컬 디스크에 의존하는 시스템은 오브젝트 스토어 물리 법칙의 제약을 같은 방식으로 받지 않습니다. 로컬 디스크에는 요청당 비용이 없고, 변경 가능 단위는 훨씬 더 작으며, 각 작업의 지연 시간도 훨씬 낮습니다.
“물리”적 제약 외에도, 파일 시스템 API는 오브젝트 스토리지 API와 1:1로 대응되지 않으며 어떤 번역 계층도 손실을 동반합니다. 특히 RocksDB에서 이것이 드러나는 몇 가지 예가 있습니다:
IF-MATCH API에 기반한 자체 쓰기 프로토콜을 구현합니다.오브젝트 스토리지에 잘 대응하는 API 매핑이 없다는 점을 넘어, 상태가 로컬에 있다고 가정하면 아키텍처 선택지가 제한됩니다. 스토리지를 분리하면 아키텍처를 분해해 리더, 라이터, compactor를 각각 다른 머신에서 실행할 수 있으며, 이렇게 배포를 분리하면 단일 RocksDB 노드가 감당할 수 있는 수준을 훨씬 넘어 유연하게 확장할 수 있습니다.
여기까지 따라오셨다면, SlateDB가 RocksDB의 자연스럽고 오브젝트 네이티브한 후속작이라는 점은 놀랍지 않을 것입니다. SlateDB는 Apache 2.0 라이선스의 임베디드 키-값 데이터베이스로, 오브젝트 스토어 네이티브 LSM 트리로 구축되었습니다. async Rust로 작성되었고 주요 여러 언어 바인딩을 제공하며, 트랜잭션 워크로드, 멀티 리더 배포, 그리고 체크포인트와 포크 같은 여러 새로운 기능을 지원합니다.
┌Server────────────────┐ ┌object storage────────┐ ┌Read Replica──────────┐│ ╔═════════════╗ │ │██████████████████████│▒ │ ╔═════════════╗ ││ ║ SlateDB ║───┼──────▶│██████████████████████│───────┼───▶║ SlateDB ║ ││ ╚══════════╦══╝ │ │██████████████████████│▒ │ ╚═══════════╦═╝ ││ ║ │ └──────────────────────┘▒ │ ║ ││┌disk cache────▼─────┐│ ▒▒▒▒▒▒▒▒▒▒▒▒│▒▒▒▒▒▒▒▒▒▒▒ │┌disk cache─────▼────┐│││████████████████████││ ▼ ││████████████████████│││└────────────────────┘│ ┌────────────────────────┐ │└────────────────────┘│└──────────────────────┘ │slate/ │ └──────────────────────┘ │├── manifest/ │ ││ ├── 000005.manifest │ ││ └── 000006.manifest │ │├── wal/ │ ││ └── 00012.sst │ │└── compacted/ │ │ ├── 01J53Z.sst │ │ ├── CCPENT.sst │ │ └── 543X3B.sst │ └────────────────────────┘
오브젝트 스토리지 위에서 동작하는 SlateDB의 단일 라이터, 멀티 리더 아키텍처입니다.
이제 풀어볼 내용이 많으니, 하나씩 살펴보겠습니다.
SlateDB는 임베디드 키-값 엔진입니다. 즉, HTTP 서버 없이 라이브러리 형태로 제공됩니다. 기본적인 API 표면만 제공하며 스키마나 serdes를 강제하지 않습니다:
// 데이터를 조회하는 주요 메서드async get(key_bytes) -> value_bytes;async scan(bytes_start..bytes_end) -> [bytes];// 데이터를 수정하는 주요 메서드async put(key_bytes, value_bytes);async delete(key_bytes);async merge(key_bytes, value_bytes);
아래 그래프들은 SlateDB에서 기대할 수 있는 읽기/쓰기 처리량의 감을 잡을 수 있도록, RocksDB 벤치마크와 유사한 워크로드를 SlateDB에 대해 실행한 결과입니다. 이 테스트는 50M 키(20바이트 키, 400바이트 값), AWS m5d.2xlarge, S3 위의 SlateDB, 그리고 6GiB 메모리 블록 캐시(RocksDB 벤치마크의 CACHE_SIZE와 일치)로 수행했습니다. 세 가지 워크로드는 (a) 지속적인 읽기 없이 무작위로 데이터를 쓰는 경우, (b) 무작위 키 분포로 데이터를 읽는 경우, (c) 고정된 5k ops/s로 쓰면서 무작위로 읽는 경우입니다.
전체적으로 SlateDB의 쓰기 처리량은 RocksDB와 비슷하지만, 읽기 성능은 아직 따라잡기 위해 더 할 일이 남아 있습니다. 쓰기 지연 시간은 직접 비교하기 어렵습니다. SlateDB는 데이터를 S3에 내구적으로 저장하는 반면, RocksDB 벤치마크는 단일 NVMe 환경이기 때문입니다. p50 읽기 지연 시간은 비슷하지만, p99 지연 시간이 처리량을 RocksDB보다 상당히 낮게 끌어내립니다(RocksDB는 유사한 워크로드에서 100k QPS를 훌쩍 넘습니다).
직접 실행해 보고 싶다면, 벤처는 GitHub에서 확인할 수 있습니다.
SlateDB는 내구성을 위해 오직 오브젝트 스토리지에만 의존하며 디스크를 필요로 하지 않습니다. 다만 추가 캐시 용량을 위해 디스크와 함께 배포할 것을 권장합니다. 직접적인 효과는, SlateDB 기반 시스템이 동일한 기능의 클러스터형 RocksDB 배포보다 훨씬 낮은 운영 오버헤드로 훨씬 저렴하게 실행될 수 있다는 점입니다.
순수한 스토리지 및 네트워크 비용 절감 외에도:
다음 차트는 SlateDB와 RocksDB의 비용을 비교한 것입니다. RocksDB는 유사한 내구성 보장을 위해 가용 영역 간 3배 복제를 하고, compaction과 NVMe를 위해 1.5배 여유 공간을 프로비저닝한다고 가정합니다. “small cache”와 “large cache”는 디스크에 데이터의 100% 미만만 캐시하도록 선택할 수 있음을 보여줍니다. small cache 워크로드는 데이터의 30%만을 위해 충분한 NVMe만 프로비저닝합니다.
SlateDB는 모든 데이터를 오브젝트 스토리지에 기록하므로, 지정된 라이터 외의 노드들도 그 데이터를 볼 수 있고 심지어 기능적으로는 데이터 의미를 바꾸지 않는 방식으로 수정할 수도 있습니다(compact 및 고아 데이터 정리).
이는 SlateDB가 라이터가 데이터를 쓰는 동일한 버킷을 가리키는 리더를 원하는 만큼 열 수 있음을 의미하며, 쓰기와 격리된 상태로 읽기 확장을 가능하게 합니다.
추가로, SlateDB는 활성 라이터와 별도의 머신에서 compaction을 실행할 수 있게 해주므로, 프로덕션 트래픽과 자원 경쟁 없이 compaction을 수행할 수 있습니다.
┌Server────────────────┐ ┌object storage────────┐ ┌Read Replica──────────┐│ ╔═════════════╗ │ │██████████████████████│▒ │ ╔═════════════╗ ││ ║ SlateDB ║───┼──────▶│██████████████████████│───┬───┼───▶║ SlateDB ║ ││ ╚══════════╦══╝ │ │██████████████████████│▒ │ │ ╚═══════════╦═╝ ││ ║ │ └──────────▲┬──────────┘▒ │ │ ║ ││┌disk cache────▼─────┐│ ▒▒▒▒▒▒▒▒▒▒││▒▒▒▒▒▒▒▒▒▒▒▒ │ │┌disk cache─────▼────┐│││████████████████████││ ││ │ ││████████████████████│││└────────────────────┘│ ││ │ │└────────────────────┘│└──────────────────────┘ ││ │ └──────────────────────┘ ┌Compactor─┴▼──────────┐ │ ┌Read Replica──────────┐ │ ╔═════════════╗ │ │ │ ╔═════════════╗ │ │ ║ Compactor ║ │ │ │ ║ SlateDB ║ │ │ ╚═════════════╝ │ │ │ ╚═══════════╦═╝ │ └──────────────────────┘ └──▶│ ║ │ │┌disk cache─────▼────┐│ ││████████████████████││ │└────────────────────┘│ └──────────────────────┘
SlateDB는 오브젝트 스토리지에서 메타데이터와 데이터를 분리합니다. 메타데이터 파일은 “manifest”라고 하며, 모든 데이터 파일(이들 역시 오브젝트 스토리지에 존재함)을 가리키는 포인터를 담고 있습니다. 이것은 흥미롭고 저렴한 몇 가지 연산을 가능하게 합니다. 그중 하나가 체크포인팅과 브랜칭입니다.
SlateDB는 manifest가 참조하지 않는 데이터 파일만 삭제합니다. 즉, 데이터베이스를 브랜치하는 것은 단지 더 오래된 manifest를 가비지 컬렉션 대상에서 제외 표시하는 O(1) 연산입니다. 새 데이터베이스가 충분한 데이터를 써서 오래된 데이터가 더 이상 참조되지 않게 되거나(또는 새로운 안정적 SST 집합을 만드는 major compaction이 트리거되면), 오래된 manifest를 삭제할 수 있고 이에 대응하는 데이터도 삭제할 수 있습니다.
이것은 데이터를 재처리하거나 새로운 데이터로 이어지는 여러 의사결정 트리를 탐색하고 싶은 사용 사례에 특히 잘 맞습니다.
┌manifest A──┐ ┌manifest B──┐┌Main──────────────────┐ │████████████│▒ │████████████│▒ ┌Fork──────────────────┐│ ╔═════════════╗ │ │████████████│▒ │████████████│◀─┐ │ ╔═════════════╗ ││ ║ SlateDB ║ │ │████████████│▒ └────────────┘▒ │ │ ║ SlateDB ║ ││ ╚══════════╦══╝ │ │████████████│▒ ▒▒▒▒▒▒│▒▒▒▒▒▒▒ │ │ ╚═══════════╦═╝ ││ ║ │────▶│████████████│◀──────────┘ └──│ ║ ││┌disk cache────▼─────┐│ │████████████│▒ │┌disk cache─────▼────┐│││████████████████████││ │████████████│▒ ││████████████████████│││└────────────────────┘│ │████████████│▒ │└────────────────────┘│└──────────────────────┘ └────────────┘▒ └──────────────────────┘ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
체크포인트에서 포크하면 분기점이 생길 때까지 부모와 불변 데이터를 공유합니다.
SlateDB의 불변성은 또 다른 이점도 제공합니다. manifest에 “view”를 적용하는 방식으로 데이터베이스를 키 범위별로 O(1)에 쉽게 분할할 수 있습니다. 리스케일링은 체크포인트와 비슷하게 동작하지만, 새 데이터베이스가 부모 전체를 참조하는 대신 기반 데이터에 필터를 적용합니다. 이렇게 하면 데이터를 복사하는 비용 대신 더 큰 인덱스와 bloom filter의 비용만 지불하면 되고, 시간이 지나며 compaction이 SST를 다시 압축하면서 도달 불가능한 데이터를 제거합니다.
┌key range──────────────────────────────────────────────┐│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░│├──────────────────────────┬─┬──────────────────────────┤└──────┬───────────────────┘ └───────────────────┬──────┘ │ │ │ ┌manifest A──┐ │ │ │████████████│▒ │ │ │████████████│▒ │┌manifest B──┐ │████████████│▒ ┌manifest C──┐│▓▓▓▓▓▓▓▓▓▓▓▓│▒ │████████████│▒ │░░░░░░░░░░░░│▒│▓▓▓▓▓▓▓▓▓▓▓▓│──────▶│████████████│◀──────│░░░░░░░░░░░░│▒└────────────┘▒ │████████████│▒ └────────────┘▒ ▒▒▒▒▒▒▲▒▒▒▒▒▒▒ │████████████│▒ ▒▒▒▒▒▒▲▒▒▒▒▒▒▒ ║ │████████████│▒ ║ ║ └────────────┘▒ ║╔═════════════╗ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ╔═════════════╗║ SlateDB ║ ║ SlateDB ║╚═════════════╝ ╚═════════════╝
리스케일링은 공유 데이터를 기준으로 한 manifest view를 사용해 키 범위별로 데이터베이스를 분할합니다.
SlateDB는 1.0 릴리스를 눈앞에 두고 있으며 이미 프로덕션에서 널리 사용되고 있어, 오브젝트 스토리지를 활용하는 온라인 시스템을 구축하기 위한 최선의 선택지 중 하나가 되고 있습니다.
또한 SlateDB의 기여자와 커미터들은 RocksDB 및 유사 시스템에 대한 상당한 프로덕션 경험을 갖고 있습니다. 그 결과 우리는 SlateDB를 단지 훌륭한 오브젝트 네이티브 LSM으로 만드는 데 그치지 않고, 그 전 세대 시스템의 함정들, 즉 불투명한 설정, 상당한 복잡성, 성능 만능주의를 피하려고 노력하고 있습니다. 이러한 제품 철학은 CLEAN_SLATE 문서에 설명되어 있습니다.
SlateDB의 다음 기술적 과제는 이 고유한 분리형 아키텍처를 더욱 활용할 수 있도록 기능 집합을 확장하는 것입니다. 우리가 특히 기대하는 몇 가지 아이디어는 다음과 같습니다:
그동안에는, 선호하는 언어로 SlateDB quickstart를 확인하고 오늘부터 애플리케이션에서 오브젝트 스토리지를 활용해 볼 수 있습니다.