Vamos conferir como funciona esse sistema?
O Discord continua a crescer mais rápido do que o esperado, assim como o conteúdo gerado pelos usuários. Com mais usuários, vêm mais mensagens de chat. Em janeiro de 2017, eles anunciaram 120 milhões de mensagens por dia. Desde o início, decidiram armazenar todo o histórico de chat para que os usuários pudessem acessar seus dados a qualquer momento, em qualquer dispositivo.
Isso representa uma grande quantidade de dados em constante aumento em termos de velocidade e tamanho, que também deve permanecer disponível. Como eles fazem isso? Com o Cassandra!
O Que Eles Estão Fazendo?
Possivelmente um dos melhores bancos de dados para iteração rápida é o MongoDB. Tudo no Discord era armazenado em um único conjunto de réplicas do MongoDB, e isso foi intencional. Além disso, eles planejaram tudo para facilitar a migração para um novo banco de dados (eles sabiam que não iam usar o particionamento do MongoDB porque é complicado e não é conhecido por sua estabilidade).
As mensagens eram armazenadas em uma coleção do MongoDB com um índice composto em channel_id e created_at. Por volta de novembro de 2015, eles já tinham 100 milhões de mensagens armazenadas, e foi nesse momento que começaram a enfrentar os problemas esperados.
Os dados e o índice não cabiam mais na RAM, e as latências começaram a ficar imprevisíveis.
Escolhendo o Banco de Dados Certo
Antes de escolher um novo banco de dados, eles precisavam entender os padrões de leitura/escrita e por que estavam tendo problemas com a solução atual.
As leituras eram extremamente aleatórias, e a proporção de leitura/escrita era de aproximadamente 50/50. Servidores Discord com foco em bate-papo de voz enviavam quase nenhuma mensagem. Isso significa que eles enviavam uma ou duas mensagens a cada poucos dias, e, em um ano, esses servidores provavelmente não chegariam a 1.000 mensagens.
Servidores Discord de texto privado enviavam um número considerável de mensagens, facilmente chegando a entre 100 mil e 1 milhão de mensagens por ano.
Servidores Discord públicos e grandes enviavam muitas mensagens. Eles tinham milhares de membros enviando milhares de mensagens por dia, chegando facilmente a milhões de mensagens por ano. Quase sempre solicitavam mensagens enviadas na última hora e as solicitavam com frequência, o que mantinha os dados geralmente na memória do disco.
Sabiam que, no ano seguinte, adicionariam ainda mais maneiras para os usuários fazerem leituras aleatórias: visualização e busca de mensagens fixadas, e busca em texto completo. Tudo isso significava mais leituras aleatórias.
Em seguida, eles definiram seus requisitos:
- Escalabilidade linear – eles não queriam reconsiderar a solução posteriormente ou redistribuir manualmente os dados.
- Failover automático – eles gostam de dormir à noite e querem que o Discord se recupere o máximo possível por conta própria.
- Manutenção mínima – deveria funcionar após a configuração inicial. Eles só deveriam precisar adicionar mais nós à medida que os dados crescem.
- Comprovado – eles gostam de experimentar novas tecnologias, mas não algo muito novo.
- Desempenho previsível – eles recebem alertas quando o tempo de resposta do API atinge o percentil 95 acima de 80ms. Eles também não querem armazenar mensagens em Redis ou Memcached.
- Não um repositório de blobs – escrever milhares de mensagens por segundo não seria ótimo se eles tivessem que desserializar constantemente blobs e acrescentar a eles.
- Código aberto – eles acreditam em controlar o próprio destino e não querem depender de uma empresa de terceiros.
Então, finalmente, o Cassandra foi o único banco de dados que atendeu a todos os seus requisitos. Eles podem adicionar nós para dimensioná-lo e ele pode tolerar a perda de nós sem impacto no aplicativo. Os dados relacionados são armazenados de forma contígua no disco, proporcionando poucas buscas e distribuição fácil no cluster.
Modelagem de Dados
A melhor maneira de descrever o Cassandra para um iniciante é que ele é um armazenamento de chave-valor composto. As duas chaves compõem a chave primária. A primeira chave é a chave de partição e é usada para determinar em qual nó os dados estão e onde eles são encontrados no disco. A partição contém várias linhas e uma
linha dentro de uma partição é identificada pela segunda chave, que é a chave de ordenação. A chave de ordenação atua como uma chave primária dentro da partição e como as linhas são ordenadas.
Lembre-se de que as mensagens foram indexadas no MongoDB usando channel_id e created_at? channel_id se tornou a chave de partição, já que todas as consultas operam em um canal, mas created_at não era uma ótima chave de ordenação, pois duas mensagens podem ter a mesma hora de criação. Felizmente, todos os IDs no Discord são realmente Snowflakes (ordenáveis cronologicamente), então eles conseguiram usá-los. A chave primária se tornou (channel_id, message_id), onde message_id é um Snowflake.
Aqui está um esquema simplificado para a tabela de mensagens deles (isso exclui cerca de 10 colunas):
CREATE TABLE messages (
channel_id bigint,
message_id bigint,
author_id bigint,
content text,
PRIMARY KEY (channel_id, message_id)
)
Embora o Cassandra tenha esquemas, eles são baratos de serem alterados e não impõem nenhum impacto temporário no desempenho. Quando começaram a importar mensagens existentes para o Cassandra, começaram a ver avisos nos logs informando que as partições tinham mais de 100MB de tamanho. O que estava acontecendo? O Cassandra anuncia que pode suportar partições de 2GB!
Aparentemente, apenas porque pode ser feito, não significa que deve ser. Partições grandes exercem muita pressão de coleta de lixo no Cassandra durante a compactação, expansão do cluster e mais. Ficou claro que eles precisavam de alguma forma limitar o tamanho das partições, porque um único canal do Discord pode existir por anos e continuar crescendo perpetuamente.
Decidiram agrupar suas mensagens por tempo. Analisaram os maiores canais do Discord e determinaram que, armazenando cerca de 10 dias de mensagens em um grupo, poderiam ficar confortavelmente abaixo de 100MB. Os grupos precisavam ser deriváveis a partir do message_id ou de um carimbo de data/hora. As chaves de partição do Cassandra podem ser compostas, então a nova chave primária deles se tornou ((channel_id, bucket), message_id).
CREATE TABLE messages (
channel_id bigint,
bucket int,
message_id bigint,
author_id bigint,
content text,
PRIMARY KEY ((channel_id, bucket), message_id)
)
WITH CLUSTERING ORDER BY (message_id DESC);
Para consultar mensagens recentes no canal, eles geram um intervalo de grupos a partir do momento atual até o channel_id (que também é um Snowflake e deve ser mais antigo que a primeira mensagem). Em seguida, consultam sequencialmente as partições até obterem mensagens suficientes.
A desvantagem desse método é que servidores Discord raramente ativos terão que consultar vários grupos para coletar mensagens ao longo do tempo. Na prática, isso se mostrou bem porque para servidores Discord ativos, mensagens suficientes geralmente são encontradas na primeira partição e são a maioria.
Dark Launch
Introduzir um novo sistema em produção é sempre assustador, então é uma boa ideia tentar testá-lo sem impactar os usuários. Eles configuraram o código para ler/escrever para MongoDB e Cassandra.
Imediatamente após o lançamento, começaram a receber erros em seu rastreador de bugs informando que author_id estava nulo. Como isso era possível? É um campo obrigatório!
Consistência Eventual – Então, como isso os afetou? Exemplo de condição de corrida de edição/exclusão
No cenário em que um usuário edita uma mensagem ao mesmo tempo que outro usuário exclui a mesma mensagem, eles acabam com uma linha que está faltando todos os dados, exceto a chave primária e o texto, já que todas as gravações no Cassandra são atualizações. Havia duas soluções possíveis para lidar com esse problema:
- Escrever toda a mensagem novamente ao editá-la. Isso tinha a possibilidade de ressuscitar mensagens que foram excluídas e criar mais chances de conflito com gravações concorrentes em outras colunas.
- Descobrir que a mensagem está corrompida e excluí-la do banco de dados.
Eles optaram pela segunda opção, que fizeram escolhendo uma coluna que era obrigatória (neste caso, author_id) e excluindo a mensagem se ela estivesse nula.
Enquanto resolviam esse problema, perceberam que estavam sendo muito ineficientes em suas gravações. Como o Cassandra é eventualmente consistente, não pode simplesmente excluir dados imediatamente. Ele precisa replicar as exclusões para outros nós, mesmo que outros nós estejam temporariamente indisponíveis. O Cassandra faz isso tratando as exclusões como uma forma de gravação chamada “lápide”. Na leitura, ele simplesmente pula as lápides que encontra. As lápides têm uma vida útil configurável (10 dias por padrão) e são permanentemente excluídas durante a compactação quando esse tempo expira.
Excluir uma coluna e gravar nulo em uma coluna é exatamente a mesma coisa. Ambos geram uma lápide. Como todas as gravações no Cassandra são atualizações, isso significa que você está gerando uma lápide mesmo ao escrever nulo pela primeira vez. Na prática, todo o esquema de mensagens deles contém 16 colunas, mas a mensagem média tem apenas 4 valores definidos. Eles estavam gravando 12 lápides no Cassandra na maioria das vezes sem motivo.
A solução para isso foi simples: gravar apenas valores não nulos no Cassandra.
Desempenho
Sabe-se que o Cassandra tem gravações mais rápidas do que leituras, e eles observaram exatamente isso. As gravações eram de submilissegundos e as leituras estavam abaixo de 5 milissegundos. Eles observaram isso independentemente do tipo de dado que estava sendo acessado, e o desempenho permaneceu consistente durante uma semana de testes.
Tudo correu tranquilamente, então eles o lançaram como seu banco de dados principal e eliminaram gradualmente o MongoDB em uma semana. Continuou funcionando perfeitamente… por cerca de 6 meses, até que um dia o Cassandra ficou sem resposta.
Eles perceberam que o Cassandra estava rodando constantemente a coleta de lixo de 10 segundos “stop-the-world”, mas não tinham ideia do motivo. Começaram a investigar e encontraram um canal do Discord que demorava 20 segundos para carregar. O servidor público Discord do Subreddit Puzzles & Dragons era o culpado.
Como era público, eles se juntaram para dar uma olhada. Para a surpresa deles, o canal tinha apenas 1 mensagem. Foi nesse momento que ficou claro que eles tinham excluído milhões de mensagens usando sua API, deixando apenas 1 mensagem no canal.
Você pode se lembrar de como o Cassandra lida com exclusões usando lápides (mencionado na Consistência Eventual). Quando um usuário carregava esse canal, mesmo que houvesse apenas 1 mensagem, o Cassandra tinha que escanear efetivamente milhões de lápides de mensagens (gerando lixo mais rápido do que a JVM podia coletá-lo).
Eles resolveram isso fazendo o seguinte:
- Reduziram a vida útil das lápides de 10 dias para 2 dias, porque estavam rodando reparos no Cassandra (um processo de anti-entropia) todas as noites em seu cluster de mensagens.
- Mudaram seu código de consulta para rastrear grupos vazios e evitá-los no futuro para um canal. Isso significava que, se um usuário causasse essa consulta novamente, o Cassandra no máximo estaria escaneando no grupo mais recente.
Em um ano, o Discord passou de mais de 100 milhões de mensagens totais para mais de 120 milhões de mensagens por dia, com desempenho e estabilidade consistentes.
Fonte: https://discord.com/blog/how-discord-stores-billions-of-messages
Sou um profissional na área de Tecnologia da informação, especializado em monitoramento de ambientes, Sysadmin e na cultura DevOps. Possuo certificações de Segurança, AWS e Zabbix.