Discord — 수조 개 메시지 저장 (Cassandra→ScyllaDB)
채널×10일 버킷 파티션의 정상 쓰기·읽기 경로에서 시작해, Puzzles & Dragons 톰스톤 사건과 JVM GC 연쇄 지연, 그리고 ScyllaDB + Rust data services(coalescing·해시 라우팅)로의 초당 320만 건·9일 무중단 이전까지 22단계로 따라간다.
단계 목록 · 키보드 ←/→ 이동, Space 재생
출처: Discord — How Discord Stores Billions of Messages · Discord — How Discord Stores Trillions of Messages · The Register — Discord's super-disk on GCP
Discord의 메시지 저장은 세 겹 방어다 — 파티션 설계(채널×10일 버킷, ≤100MB)가 거대 파티션을 막고, GC 없는 엔진(ScyllaDB)이 지연 스파이크를 막고, Rust 중간층의 request coalescing이 핫 파티션 폭주를 DB에 닿기 전에 접는다.
왜 헷갈리는가
'DB를 갈아탔으니 문제가 풀렸겠지'가 흔한 오해다. ScyllaDB로 바꾸면 JVM GC 스파이크는 사라지지만, 수십만 명이 같은 채널을 동시에 읽는 핫 파티션 자체는 어떤 엔진에서도 핫하다. 그래서 Discord는 DB 앞에 Rust data services를 세워 동일 행 동시 요청을 질의 1회로 합치고(request coalescing), channel_id 일관 해시 라우팅으로 같은 채널 요청을 같은 인스턴스에 모아 그 합치기를 극대화했다. 엔진 교체와 중간층 설계는 서로 다른 문제를 푼다.
파티션 설계 — 왜 (channel_id, bucket)이고 왜 10일인가
기본 키는 ((channel_id, bucket), message_id)다. 파티션 키가 채널 + 시간 버킷의 복합 키라서 같은 채널의 최근 메시지가 물리적으로 한 파티션에 모이고, 클러스터링 키인 Snowflake ID(시간순 정렬되는 64비트)가 파티션 안을 시간순으로 정렬한다. 채팅 화면의 '최근 메시지 역순 조회'가 곧 파티션 내 순차 읽기가 된다.
버킷이 약 10일인 이유는 거꾸로 읽어야 한다 — 파티션이 100MB를 넘으면 GC·compaction 압박이 커지므로, 평균 채널 트래픽 기준 100MB 아래로 들어가는 시간 폭을 역산한 것이 10일이다. 크기 한도가 원인이고 10일은 결과다.
무엇이 무너졌나 — 톰스톤, 그리고 GC의 연쇄
Cassandra는 삭제를 즉시 지우지 않고 톰스톤(삭제 표식)으로 남긴다. 복제본 사이에서 '삭제됐다'는 사실이 유실되지 않게 하기 위한 장치인데, 읽기는 그 표식들을 전부 스캔해야 한다. Puzzles & Dragons Subreddit 서버에서는 대량 삭제 후 메시지 1건에 톰스톤 수백만 개가 남았고, 그 채널을 여는 것만으로 JVM 힙이 차올라 GC가 폭주했다.
핫 파티션을 쥔 노드가 느려지면 그 노드가 담당하는 다른 채널까지 밀리고, 지연이 클러스터 전체로 번진다(cascading latency). 12노드에서 177노드까지 키우는 동안 stop-the-world GC로 인한 수동 재부팅과 compaction 밀림 관리('gossip dance')가 일상이 됐다. gc_grace를 10일에서 2일로 줄이고 빈 버킷을 건너뛰는 임시 대응은 증상만 눌렀다.
- 톰스톤은 버그가 아니라 분산 삭제의 안전장치 — 대신 읽기가 값을 치른다
- 동시 수정+삭제는 upsert 특성 때문에 기본 키만 남은 유령 행(author_id null)을 만들었다
- 파티션 하나의 문제 → 노드의 문제 → 클러스터의 문제로 번지는 전형적 연쇄
해법의 구조 — 엔진, 중간층, 디스크, 그리고 이전 자체
ScyllaDB는 Cassandra 호환이지만 C++라 JVM GC가 없고, shard-per-core 아키텍처로 코어마다 워크로드를 격리해 락 경합과 컨텍스트 스위칭을 없앤다. 그 앞의 Rust data services는 같은 행을 향한 동시 요청을 질의 1회로 접고(coalescing), channel_id 일관 해시 라우팅이 그 접기를 극대화한다. 스토리지는 Local NVMe RAID0과 Persistent Disk RAID1을 미러링한 super-disk — 쓰기는 양쪽, 읽기는 로컬 SSD.
이전은 Rust로 새로 짠 마이그레이터가 초당 최대 320만 건을 옮겨 9일 만에 끝냈다(Spark 기반 도구 예상치 3개월). 마지막 0.0001%가 미압축 톰스톤 구간에서 정체됐지만 해당 파티션 compaction 후 통과 — 수조 건을 다운타임 0으로 옮겼다. 결과는 177노드(약 4TB/대)→72노드(9TB/대), 과거 조회 p99 40–125ms→15ms, 삽입 p99 5–70ms→안정적 5ms.
기억할 것
- 파티션 키 (channel_id, bucket)의 10일 버킷은 '파티션 ≤ 100MB' 한도에서 역산된 값이다.
- 톰스톤은 분산 삭제의 안전장치지만 읽기가 전부 스캔한다 — 메시지 1건에 톰스톤 수백만이면 그 채널이 클러스터를 무너뜨린다.
- 엔진 교체(ScyllaDB, GC 없음·shard-per-core)와 중간층(coalescing + 해시 라우팅)은 서로 다른 문제를 푼다 — 전자는 GC 스파이크, 후자는 핫 파티션 폭주.
- 무중단 이전의 비결은 이중 기록 + 뒤에서 따라잡는 전용 마이그레이터 — Rust로 초당 320만 건, 9일, 다운타임 0.
- super-disk = Local NVMe(속도) + Persistent Disk(내구성)를 한 볼륨으로 — 읽기는 로컬, 쓰기는 미러.