Arquitetura real · Pleno
Discord: de Go para Rust (picos de GC)
Arquitetura real em produção. Pontos centrais: pausas de GC.
Resposta curta
Arquitetura real em produção. Pontos centrais: pausas de GC. Este caso vem de um relato de engenharia da Discord sobre o serviço Read States. A função dele é acompanhar quais canais e mensagens cada pessoa já leu. Ele é acessado quando alguém se conecta, quando uma mensagem é enviada e quando uma mensagem é lida, ou seja, fica no caminho…
01 · CONTEXTO
O serviço que sabe o que você já leu
Este caso vem de um relato de engenharia da Discord sobre o serviço Read States. A função dele é acompanhar quais canais e mensagens cada pessoa já leu. Ele é acessado quando alguém se conecta, quando uma mensagem é enviada e quando uma mensagem é lida, ou seja, fica no caminho crítico do produto.
O volume de dados é grande: há bilhões de Read States, um por usuário e por canal. Cada um carrega contadores que precisam ser atualizados de forma atômica e que costumam voltar a zero, como o número de menções que você tem em um canal.
Na versão em Go, o serviço era rápido na maior parte do tempo, mas a cada poucos minutos aparecia um grande pico de latência que prejudicava a experiência. A investigação atribuiu os picos ao modelo de memória e ao coletor de lixo (GC) do Go. A portagem inicial para Rust foi concluída em maio de 2019.
02 · ARQUITETURA
Cache LRU na memória, Cassandra atrás
Cada servidor de Read States mantém um cache LRU (Least Recently Used) de Read States, para conseguir atualizações atômicas de contadores com rapidez. Segundo o relato, cada cache tem milhões de usuários, dezenas de milhões de Read States e centenas de milhares de atualizações por segundo.
Para persistência, o cache é sustentado por um cluster Cassandra. Os Read States de uma chave despejada são gravados no banco, e toda atualização também agenda uma gravação para 30 segundos depois; segundo o relato, há dezenas de milhares de gravações no banco por segundo.
Os clientes acessam o Read States ao conectar, ao enviar uma mensagem e ao ler uma mensagem.
O servidor mantém os Read States e seus contadores num cache LRU em memória, para atualizar contadores de forma atômica e rápida.
Quando uma chave é despejada do cache, os Read States dela são gravados no cluster Cassandra.
Além do despejo, cada atualização agenda uma gravação no banco para 30 segundos depois.
03 · O PROBLEMA
Picos periódicos e o dilema do tamanho do cache
Nos gráficos do horário de pico, a latência e o uso de CPU do serviço em Go tinham picos aproximadamente a cada 2 minutos. Os gráficos são da versão 1.9.2 do Go; as versões 1.8, 1.9 e 1.10 também foram testadas, sem melhoria.
Em Go, a memória de uma chave despejada do cache não é liberada na hora: o coletor de lixo roda de tempos em tempos para achar a memória sem referências e liberá-la, e esse trabalho pode deixar o programa mais lento. O código já era eficiente e fazia poucas alocações, então a equipe não estava gerando muito lixo.
Ao estudar o código-fonte do Go, a equipe viu que ele força uma coleta de lixo quando nenhuma rodou em 2 minutos, independentemente do crescimento do heap. Ajustar o GC percent, porém, não mudou nada, porque o serviço não alocava memória rápido o bastante para forçar coletas mais frequentes.
No horário de pico, o serviço em Go mostra picos de latência e de CPU aproximadamente a cada 2 minutos.
O coletor de lixo precisa varrer o cache LRU inteiro para saber se a memória está livre de referências, e é isso que torna os picos tão grandes.
Os picos eram grandes porque o coletor precisava varrer o cache LRU inteiro para saber se a memória estava mesmo livre de referências. Com o cache menor, a coleta gerava picos menores, e a equipe passou a ter vários caches LRU particionados por servidor. A contrapartida foi uma latência maior no 99º percentil, pois é menos provável que o Read State do usuário esteja no cache e, quando não está, é preciso uma leitura no banco.
A equipe passou a ter vários caches LRU particionados por servidor, e com caches menores o coletor de lixo gerava picos menores.
Em contrapartida, com caches menores é menos provável que o Read State esteja na memória, então mais pedidos vão ao banco e a latência do 99º percentil sobe.
04 · A MUDANÇA
Sem coletor de lixo: liberação imediata
O Rust não tem coleta de lixo. Ele acompanha quem pode ler e escrever na memória e aplica as regras de memória em tempo de compilação. Na versão em Rust do Read States, quando o Read State de um usuário é despejado do cache LRU, a memória é liberada na hora, sem esperar por um coletor.
A escolha desse serviço teve motivos práticos: ele era pequeno e autocontido, e a equipe queria criar os frameworks e as bibliotecas para construir novos serviços inteiramente em Rust. Havia um obstáculo, porque na época o Rust estável não tinha uma boa solução para Rust assíncrono. A equipe se comprometeu a usar o Rust nightly até o async ser totalmente suportado no estável.
05 · RESULTADOS
Teste de carga, canário e cache maior
A reescrita começou como uma tradução direta e depois foi enxugada. No teste de carga, a latência da versão em Rust ficou tão boa quanto a do Go e sem picos de latência. Mesmo com otimização básica, o Rust superou a versão Go ajustada à mão, e depois de perfilar e otimizar, a versão em Rust ficou melhor que a de Go em latência, CPU e memória.
As otimizações incluíram trocar o HashMap por um BTreeMap no cache LRU para usar menos memória, trocar a biblioteca de métricas por uma que usa a concorrência moderna do Rust e reduzir o número de cópias de memória. O lançamento foi tranquilo por causa do teste de carga.
No Rust, a memória de um Read State despejado do cache é liberada imediatamente, sem esperar um coletor.
O serviço foi colocado primeiro em um único nó canário.
No canário apareceram alguns casos de borda que faltavam, e a equipe os corrigiu.
Logo depois, a versão em Rust foi liberada para toda a frota.
Em Go, um cache maior alongava as coletas de lixo. Depois de alguns dias rodando com sucesso em Rust, e sem esse problema, a equipe aumentou a capacidade do cache LRU para 8 milhões de Read States.
Com o cache maior, o tempo médio passou a ser medido em microssegundos e o máximo de menções em milissegundos. Atualizar o tokio, o runtime assíncrono usado pela equipe, também deu ganho de CPU sem esforço.
| Coleta de lixo | Cache e latência | |
|---|---|---|
| Go com cache LRU menor | Picos menores | Latência maior no 99º percentil, com mais leituras no banco |
| Rust, antes do cache maior | Sem picos de latência no teste de carga | Latência, CPU e memória melhores que no Go |
| Rust com cache LRU maior | Sem coletor de lixo para esperar pela memória despejada | Capacidade aumentada para 8 milhões de Read States |
Perguntas frequentes
O que é Discord: de Go para Rust (picos de GC)?
Arquitetura real em produção. Pontos centrais: pausas de GC.
O que o design de Discord: de Go para Rust (picos de GC) cobre?
Ele está organizado nestas seções: O serviço que sabe o que você já leu, Cache LRU na memória, Cassandra atrás, Picos periódicos e o dilema do tamanho do cache, Sem coletor de lixo: liberação imediata e Teste de carga, canário e cache maior.
Quanto tempo leva Discord: de Go para Rust (picos de GC) e qual é o nível?
Leva cerca de 15 minutos para ler e tem nível Pleno.
Agora projete você mesmo
Rode este caso como uma entrevista na escola de System Design: você toma as decisões, o tutor as questiona e você vê a nota do seu desenho.