Architettura di dati e sistemi, dalle fondamenta Lezione 32 / 80

Caso reale: il viaggio di Discord da MongoDB a Cassandra a ScyllaDB

Come lo storage dei messaggi di Discord è passato da MongoDB a Cassandra a ScyllaDB nell'arco di dieci anni, cosa è costata ciascuna migrazione, e quali sono le lezioni per tutti gli altri.

Questa è la prima lezione del corso che è, dall’inizio alla fine, un singolo case study. La ragione per cui rompo il formato è che le astrazioni del Modulo 4 (sharding, replication, hot key, fan-out) sono più facili da interiorizzare quando le vedi sviluppate in un sistema di produzione reale nell’arco di dieci anni. Il viaggio dello storage dei messaggi di Discord è l’esempio pubblico più pulito che conosca. L’azienda ha scritto in dettaglio di ogni transizione sul proprio engineering blog, e le tre ere del sistema mappano direttamente sulle lezioni di questo modulo.

Discord è una piattaforma di chat. Entro il 2023 aveva centinaia di milioni di utenti mensili e un message store che conteneva migliaia di miliardi di righe. Il sistema è stato riscritto su tre database diversi dal 2015 in poi: prima MongoDB, poi Cassandra, poi ScyllaDB. Ogni migrazione è stata guidata dall’aver toccato un soffitto operativo sul database precedente. Il data model, dopo la seconda migrazione, è rimasto lo stesso.

Il workload, definito con precisione

Prima delle ere: cosa sta chiedendo Discord al database?

La query dominante è “dammi gli ultimi N messaggi in questo canale, opzionalmente prima del timestamp T, per la paginazione mentre l’utente scorre indietro nella cronologia”. Quasi ogni read nel sistema è questa query. Alcune read includono anche filtri: messaggi di un utente specifico, messaggi con una reaction specifica, messaggi che corrispondono a una ricerca. La write dominante è “inserisci un nuovo messaggio in questo canale”. Edit e delete sono enormemente meno comuni delle insert.

I dati hanno due chiavi naturali. I messaggi sono confinati ai canali: il #general di un server, una DM privata tra due utenti, una DM di gruppo. Dentro un canale, i messaggi sono ordinati per tempo. L’unità naturale di località è “tutti i messaggi in un canale, in ordine temporale”. L’unità naturale di distribuzione è il canale.

Il traffico è fortemente sbilanciato. Pochi canali sono giganteschi (i canali announcement dei server popolari, le DM virali durante eventi mondiali) e la maggior parte dei canali è minuscola (una DM tra due utenti che ha cinquanta messaggi totali). Qualunque storage layer per questo workload deve gestire entrambi gli estremi senza cedere su nessuno dei due.

Questo è l’enunciato del problema. Ora le ere.

2015 fino al 2016: MongoDB

Discord è stato lanciato nel 2015 con MongoDB come message store, per la pragmatica ragione da startup: MongoDB era facile da operare a piccola scala, il document model schema-less si adattava alla forma sciolta di un messaggio di chat, e il team non sapeva ancora esattamente quali query gli sarebbero servite. MongoDB ha permesso loro di spedire.

La rottura è arrivata quando il working set ha smesso di stare in RAM. La performance in lettura di MongoDB è eccellente quando gli indici e i dati caldi stanno in memoria. Quando il dataset cresce oltre il soffitto della RAM, ogni read che manca la cache va su disco, e la coda di latenza esplode. Entro il 2017 Discord era a circa cento milioni di messaggi, e la latenza in lettura sulla cronologia chat (la query dominante) stava diventando abbastanza brutta da farla notare agli utenti.

Il blog post del 2017 vale la pena di essere letto per intero perché è onesto su cosa esattamente è andato storto. Il pattern di IO di “messaggi recenti in questo canale” veniva servito da un index lookup seguito da read randomiche contro i message document su disco, e le read randomiche erano il problema. Cachare l’indice era facile; cachare tutti i messaggi non lo era. Un secondo problema: il modello replica-set di MongoDB aveva spigoli operativi vivi alla scala a cui stavano operando. La decisione di migrare è stata guidata in parti uguali dal problema di latenza e dalla fatica operativa.

2016: il passaggio a Cassandra

L’obiettivo della migrazione è stato Cassandra, e il data model è stato ridisegnato da zero attorno alla query dominante. Questa è la parte della storia da cui è più facile imparare, perché l’esercizio di data modelling è esattamente quello che la lezione 20 ha descritto.

La query è “messaggi in questo canale, ordinati per tempo”. Il data model Cassandra che trasforma questo in un’operazione veloce è:

  • Partition key: channel_id (tecnicamente un composto di channel_id e un time bucket, per limitare la dimensione della partition). Tutti i messaggi in un canale vivono nella stessa partition.
  • Clustering key: timestamp del messaggio (o message ID, che incorpora un timestamp). I messaggi in una partition sono memorizzati su disco in ordine temporale.

Con questo layout, “messaggi recenti nel canale X” è un singolo partition lookup seguito da una read contigua delle righe più recenti. Cassandra fa questo tipo di read molto velocemente, perché la partition è su un singolo nodo, le righe sono contigue su disco, e l’access pattern combacia esattamente con lo storage LSM-tree. Niente IO randomico, niente join, niente fan-out: uno shard, read sequenziale, fatto.

La partition key composta con un time bucket affronta il problema “alcuni canali sono enormi”. Senza bucketing, la partition di un singolo canale crescerebbe senza limite, causando alla fine i problemi che la lezione 28 ha descritto (hot partition, compaction lente, repair dolorosa). Con il bucketing (per esempio, partition key = (channel_id, year_month) o simili), ogni partition contiene al massimo un mese di messaggi di un canale, il che mantiene le partition limitate indipendentemente dalla dimensione del canale. Il prezzo è che “dammi gli ultimi N messaggi” potrebbe coprire due bucket vicino al confine di un mese, cosa che l’applicazione gestisce interrogando il bucket corrente e ripiegando sul bucket precedente se necessario.

La migrazione in sé è durata mesi. Il blog di Discord descrive una fase di dual-write in cui i nuovi messaggi venivano scritti sia su MongoDB sia su Cassandra simultaneamente, mentre un job di backfill copiava i messaggi storici da Mongo dentro Cassandra. Una volta che il backfill aveva recuperato, le read sono state spostate su Cassandra, e dopo una finestra di verifica MongoDB è stato dismesso. Questo è il playbook standard per le migrazioni di database, ed è quello giusto. Qualcosa di più aggressivo (un singolo big-bang cutover) sarebbe stato un disastro alla scala a cui stavano operando.

Per i sei anni successivi, Cassandra è stato il message store. Il cluster è cresciuto da dodici nodi iniziali a centosettantasette nodi entro il 2022, contenente migliaia di miliardi di messaggi. Il data model non è cambiato. Aggiungere capacità significava aggiungere nodi, cosa che Cassandra gestiva, con dolore operativo di cui Discord ha scritto in dettaglio.

Dal 2022 al 2023: il passaggio a ScyllaDB

Il blog post del 2022 sulla migrazione a ScyllaDB è il più recente e il più interessante dei due. Il titolo del post è il costo: Discord è migrato da centosettantasette nodi Cassandra a settantadue nodi ScyllaDB, con miglior latenza e costo inferiore.

Le ragioni della migrazione non erano problemi di data model. Il data model funzionava. Le ragioni erano operative, e si allineano esattamente con le cose che la lezione 20 ha detto su Cassandra.

Overhead della JVM e variabilità di latenza. Cassandra è un’applicazione JVM. Le pause di garbage collection causano picchi periodici di latenza che si manifestano nelle code p99 e p99.9. Alla scala di Discord, anche una piccola frazione di read lente è un sacco di read lente in termini assoluti. Il team aveva speso uno sforzo significativo nel tunare il GC, e la coda di latenza era ancora problematica.

Il dolore della compaction. Il processo di compaction di Cassandra fonde i file ordinati su disco (SSTable) in cui le write si accumulano. La compaction è necessaria per mantenere le read veloci e per recuperare spazio dalle righe cancellate, ma consuma IO e CPU sugli stessi nodi che stanno servendo le read. Alla scala di Discord, la compaction era una fonte continua di lavoro operativo: tunare le strategie di compaction, schedulare le compaction, gestire i backlog di compaction, monitorare i picchi di latenza indotti dalla compaction.

Fatica operativa. La repair (il processo che riconcilia repliche divergenti) era dolorosa a centosettantasette nodi. Aggiungere nodi era doloroso. Rimuovere nodi era doloroso. Il team era diventato molto bravo a far girare Cassandra, e farla girare era ancora una frazione sostanziale del loro tempo.

ScyllaDB è la riscrittura in C++ di Cassandra. Stesso wire protocol, stesso query language, stesso data model, implementazione completamente diversa. L’architettura thread-per-core, il networking userspace e l’assenza di una JVM significano che ScyllaDB consegna un ordine di grandezza in più di throughput per nodo, con code di latenza molto più piccole e prevedibili. Per un team che fa girare un workload Cassandra esistente, ScyllaDB è la cosa più vicina a un free lunch che esista in questo spazio: stesso codice client, stesso layout dei dati, stessa forma operativa, numeri molto migliori.

La migrazione è stata, di nuovo, un esercizio ingegneristico di più mesi. Discord ha scritto un servizio custom di data migration per copiare le migliaia di miliardi di messaggi da Cassandra a ScyllaDB mentre il traffico di produzione continuava. Il servizio usava dual-write per i nuovi messaggi, un backfill in streaming per i messaggi storici, e verifica per-canale prima del cutover. Il cutover è stato incrementale: il traffico è stato spostato canale per canale, monitorato, e fatto rollback se qualcosa si comportava male. Dopo qualche mese, l’intero workload era su ScyllaDB.

I numeri dal post pubblico: centosettantasette nodi Cassandra sono diventati settantadue nodi ScyllaDB, una riduzione di circa il 60 percento. La latenza in lettura nella coda è scesa sostanzialmente. Il costo dell’infrastruttura su questo workload è stato significativamente più basso (Discord ha pubblicamente inquadrato la migrazione come una mossa principale di risparmio sui costi). Il dolore operativo associato a compaction, GC e gestione dei nodi è stato sostanzialmente ridotto, anche se non eliminato: ScyllaDB è ancora un wide-column database, e gli stessi primitivi operativi si applicano.

flowchart LR
    subgraph E1[2015-2016: MongoDB era]
      M[(MongoDB)]
      M -->|RAM ceiling, IO pattern| L1[Latency degradation]
    end
    subgraph E2[2016-2022: Cassandra era]
      C[(Cassandra, 12 to 177 nodes)]
      C -->|JVM, compaction, ops| L2[Operational ceiling]
    end
    subgraph E3[2022-2026: ScyllaDB era]
      S[(ScyllaDB, 72 nodes)]
      S --> L3[Lower cost, better tail]
    end
    L1 --> E2
    L2 --> E3

Il data model che è sopravvissuto dall’era 2 all’era 3 è lo stesso: canale come partition (con time-bucketing per il controllo della dimensione), timestamp del messaggio come clustering key. La migrazione ha cambiato l’implementazione del database sottostante, non la forma dei dati. Questo è il fatto singolo più importante sulla migrazione e la lezione più generalizzabile.

Cosa insegna il viaggio

Cinque lezioni, grossomodo nell’ordine in cui Discord le ha incontrate.

Scegli il data model attorno al tuo pattern di query dominante. La ragione per cui Cassandra ha funzionato è che il layout partition-per-canale, clustered-by-timestamp rende la query dominante una read sequenziale single-partition. Lo stesso modello è quello che ha reso possibile la migrazione a ScyllaDB senza una ri-architettura: la forma dei dati era già giusta per un wide-column store, e ScyllaDB è un wide-column store. Se lo schema Cassandra originale fosse stato sbagliato (poniamo, partition per utente) il passaggio a ScyllaDB avrebbe richiesto un cambio di data model, che è molto più difficile di uno swap di database. La lezione 20 ha detto che i wide-column database si guadagnano lo stipendio quando lo schema combacia con la query. Discord è il caso da manuale.

I wide-column database si guadagnano lo stipendio alla scala. Sono operativamente pesanti. Compaction, repair, gestione dei nodi, capacity planning: niente di tutto questo è divertente. Ma alla scala di Discord anche le alternative sono pesanti e in più non scalano. MongoDB ha toccato il soffitto a cento milioni di messaggi; Cassandra ha comprato loro sei anni e diverse migliaia di volte più dati.

La migrazione è ingegneria, non magia. Entrambe le migrazioni di Discord sono durate mesi e hanno usato lo stesso playbook: progetta prima il nuovo data model, dual-write, backfill dei dati storici mentre il traffico live continua, verifica, fai cutover incrementale, monitora, dismetti. La lezione 29 ha coperto i pattern in astratto; le migrazioni di Discord sono esattamente quel pattern alla scala industriale. Non c’è scorciatoia.

La giusta riscrittura di una vecchia tecnologia può essere trasformativa. ScyllaDB è il case study. Stesso protocollo, stesso query language, stesso data model, implementazione completamente diversa, un ordine di grandezza migliore nelle parti che contano. La migrazione è stata quasi gratuita in cambiamenti applicativi e drammatica in costi e latenza. Questo è possibile perché il wire protocol di Cassandra è documentato e stabile. I sistemi a protocollo chiuso non ottengono mai la riscrittura equivalente.

La fatica operativa è un segnale reale. Il cluster Cassandra stava tecnicamente funzionando quando Discord è migrato via da esso. Il data model era giusto, il cluster scalava, l’applicazione serviva traffico. La migrazione è stata guidata dal peso cumulativo del dolore operativo e dalla realizzazione che una riscrittura dello stesso database in un linguaggio diverso poteva sollevare gran parte di esso. Quando un team spende una frazione sostanziale del proprio tempo nel far girare il database invece che nel costruire il prodotto, il database è troppo costoso anche se non sta fallendo. Il costo si manifesta nell’organigramma di engineering, non nella dashboard di latenza.

Cosa significa questo per i sistemi che non sono Discord

Quasi sicuramente non hai un workload di chat alla scala di Discord. Le lezioni rilevanti non sono quindi “usa Cassandra” o “migra a ScyllaDB”. Sono le meta-lezioni.

Se il tuo access pattern è “elementi recenti in uno stream, per tempo, confinati a un’entità”, il data model wide-column (entità come partition, timestamp come clustering key) è la forma giusta, indipendentemente dal fatto che lo store sottostante sia Cassandra, ScyllaDB, BigTable, DynamoDB, o anche Postgres con tabelle shardate a scale più piccole.

Se stai scegliendo un database per un nuovo prodotto, ottimizza per la query dominante e accetta che le query secondarie saranno brutte. Il design di Discord accetta che “trova un singolo messaggio per ID” sia scomodo in cambio del rendere l’operazione dominante estremamente veloce. Il trade è corretto perché l’operazione secondaria è rara.

Se stai facendo girare un database che funziona ma fa male da operare, la migrazione è un’opzione reale. È anche costosa: mesi di engineering. Migra quando il costo operativo cumulativo è genuinamente più grande del costo di migrazione, e quando il target è abbastanza maturo per scommetterci. Essere early adopter è un profilo di rischio diverso e di solito peggiore.

Se stai facendo girare una singola istanza Postgres con dieci milioni di righe: rimani lì. Il case study non dice “passa a Cassandra”. Dice “progetta il tuo layout dei dati per la tua query dominante, e scegli un database che si adatti al layout”. Per dieci milioni di righe su un workload user-centrico, quel database è quasi sicuramente Postgres.

Il Modulo 4 si chiude qui

Il Modulo 4 si è aperto con la replication, ha attraversato partitioning, strategie di sharding, hot key e rebalancing, query cross-shard, e finisce qui su un case study che lega le astrazioni a un sistema di produzione reale nell’arco di un decennio. I pattern sono gli stessi indipendentemente da quale database hai scelto nel Modulo 3, e sono la fondazione per tutto ciò su cui il Modulo 5 costruisce.

Il Modulo 5 si rivolge al processing: come i dati fluiscono attraverso un sistema una volta che sono memorizzati. Prima il batch processing (i discendenti di MapReduce, le moderne pipeline di data warehouse), poi lo stream processing (Kafka, Flink, il lato real-time), poi la convergenza dei due. Lo storage layer è il pavimento. Il processing è l’edificio.

Citazioni e letture di approfondimento

  • Discord Engineering, “How Discord Stores Trillions of Messages”, 2023, https://discord.com/blog/how-discord-stores-trillions-of-messages (consultato 2026-05-01). Il resoconto dettagliato della migrazione a ScyllaDB, inclusi i conteggi dei nodi, il servizio di data migration, il processo di cutover, e gli esiti di costo e latenza.
  • Discord Engineering, “How Discord Stores Billions of Messages”, 2017, https://discord.com/blog/how-discord-stores-billions-of-messages (consultato 2026-05-01). Il post precedente sulla migrazione MongoDB-a-Cassandra, inclusi i failure mode che hanno guidato il passaggio e il design dello schema Cassandra.
  • Documentazione di Apache Cassandra, https://cassandra.apache.org/doc/latest/ (consultato 2026-05-01). Riferimento per il data model, le strategie di compaction, e i temi operativi che il case study tocca.
  • Documentazione di ScyllaDB, https://docs.scylladb.com/ (consultato 2026-05-01). Riferimento per l’architettura e l’API Cassandra-compatibile che ha reso possibile la migrazione.
  • “Designing Data-Intensive Applications” (Martin Kleppmann, O’Reilly, 2017), capitoli 5 e 6. Il riferimento standard per replication e partitioning, con i concetti che il case study illustra.
Cerca