본문으로 바로가기

Discord — 수조 개 메시지 저장 (Cassandra→ScyllaDB)

채널×10일 버킷 파티션의 정상 쓰기·읽기 경로에서 시작해, Puzzles & Dragons 톰스톤 사건과 JVM GC 연쇄 지연, 그리고 ScyllaDB + Rust data services(coalescing·해시 라우팅)로의 초당 320만 건·9일 무중단 이전까지 22단계로 따라간다.

1 / 222015년 MongoDB 단일 replica set이 메시지 1억 건에서 RAM을 초과하고 지연이 예측 불가능해지자, Discord는 Cassandra로 이전합니다.읽기:쓰기가 약 50:50인 랜덤 액세스 워크로드라 선형 확장과 자동 페일오버가 필요했습니다.
Cassandra — cassandra-messages (12→177노드)ScyllaDB — GC 없음 · shard-per-core (72노드)클라이언트Discord 앱Discord API메시지 전송·조회일관 해시 라우팅channel_id 기준Rust Data Servicesrequest coalescingRust 마이그레이터최대 320만 건/초Cassandra 노드 A핫 파티션 보유cassandra-messagesCassandra 노드 B복제본Cassandra 노드 C복제본ScyllaDB 노드 Ashard-per-coreScyllaDB 노드 Bshard-per-coreLocal NVMeRAID0 — 읽기 담당Persistent DiskRAID1 미러 — 쓰기
단계 목록 · 키보드 ←/→ 이동, 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(내구성)를 한 볼륨으로 — 읽기는 로컬, 쓰기는 미러.
Discord — 수조 개 메시지 저장 (Cassandra→ScyllaDB) | KIE