Elixir como sistema de fanout
- Sempre que algo acontece no Discord, como uma mensagem ser enviada ou alguém entrar em um canal de voz, é necessário atualizar a UI nos clientes de todos os usuários online que estão no mesmo servidor (também chamado de "guild")
- Eles usam um processo Elixir por guild como ponto central de roteamento de tudo o que acontece naquele servidor, e outro processo ("sessão") para o cliente de cada usuário conectado
- O processo da guild rastreia as sessões dos usuários que são membros daquela guild e é responsável por distribuir trabalho para essas sessões
- Quando uma sessão recebe uma atualização, ela a entrega ao cliente por meio da conexão WebSocket
- Algumas ações se aplicam a todos no servidor, enquanto outras exigem verificação de permissões; por isso, é preciso conhecer os papéis do usuário, além das informações de cargos e canais daquele servidor
- O volume de atividade de uma guild é proporcional ao número de pessoas naquele servidor, e o trabalho necessário para fazer fanout de uma única mensagem também é proporcional ao número de usuários online naquele servidor
- Ou seja, a carga de trabalho necessária para atender um servidor do Discord cresce na quarta potência do tamanho do servidor
- Se 1.000 pessoas estiverem online em um servidor e todas disserem "eu gosto de geleia" uma vez, isso significa processar 1 milhão de notificações
- Se forem 10.000 pessoas, isso gera 100 milhões de notificações; com 100 mil, seria preciso entregar 10 bilhões de notificações
- Além do problema geral de throughput, algumas operações ficam mais lentas à medida que o servidor cresce
- Para que o servidor pareça responsivo — por exemplo, quando uma mensagem enviada precisa ser vista imediatamente pelos outros, ou quando alguém entra em um canal de voz e deve poder participar na hora — quase todas as operações precisam ser processadas rapidamente
- Se operações custosas levarem vários segundos, a experiência do usuário se degrada
- Apesar desses problemas, como foi possível dar suporte ao servidor do Midjourney, que tem mais de 10 milhões de membros e mais de 1 milhão online o tempo todo?
- Primeiro, era importante entender o desempenho do sistema
- Depois de obter os dados, eles buscaram oportunidades para melhorar tanto o throughput quanto a responsividade
Entendendo o desempenho do sistema
- Wall time analysis:
- Stack tracing com
Process.info(pid, :current_stacktrace)
- Medição do loop de processamento de eventos para registrar, por tipo de mensagem, quantas foram recebidas e o tempo máximo/mínimo/médio/total gasto para processá-las
- Operações que representavam menos de 1% do tempo total eram ignoradas, a menos que estivessem explodindo de forma extrema
- Isso permitiu excluir operações baratas e destacar as mais custosas
- Process Heap Memory Analysis
- Também era importante entender como a memória estava sendo usada
- Em vez de analisar cada elemento individualmente, eles escreveram uma biblioteca auxiliar que faz amostragem de maps e listas grandes (não structs) para gerar uma estimativa de uso de memória
- Essa biblioteca ajudou não só a entender o desempenho do GC, mas também a encontrar campos que valia a pena focar na otimização e outros que no fim não eram relevantes
- Depois de descobrir onde o processo da guild gastava tempo, foi possível traçar estratégias para evitar que ele ficasse 100% ocupado
- Em alguns casos, bastou reescrever implementações ineficientes de forma mais eficiente
- Mas esse caminho só levava até certo ponto. Era necessária uma mudança mais fundamental
Sessões passivas - evitando trabalho desnecessário
- Uma das melhores formas de aliviar gargalos de throughput é reduzir a quantidade de trabalho
- Uma maneira de fazer isso é considerar os requisitos da aplicação cliente
- Na topologia original, todos os usuários recebiam todas as ações visíveis em todas as guilds das quais faziam parte
- Mas alguns usuários pertencem a várias guilds e talvez nem cliquem para ver o que está acontecendo em algumas delas
- E se nada fosse enviado até o usuário clicar? Não seria necessário verificar permissões para cada mensagem individualmente, e consequentemente também haveria muito menos dados enviados ao cliente
- Eles chamaram isso de conexão "Passive" e a mantiveram em uma lista separada das conexões "Active", que precisam receber todos os dados
- Como resultado, cerca de 90% das conexões usuário-guild em servidores grandes eram passivas, reduzindo em 90% o custo do trabalho de fanout
- Isso trouxe um bom alívio, mas conforme a comunidade continuou crescendo, naturalmente isso sozinho não bastou
(Quando a carga de trabalho cai 10x, é possível obter um ganho de cerca de 3x no tamanho máximo da comunidade)
Relay - dividindo o fanout entre várias máquinas
- Uma técnica padrão para escalar além do limite de throughput de um único core é dividir o trabalho entre várias threads (ou, em termos do Elixir, processos)
- Com base nessa ideia, eles construíram um sistema chamado "relay" entre a guild e as sessões dos usuários
- Em vez de processar todo o trabalho de atendimento das sessões em um único processo, ele foi dividido entre vários relays, permitindo que uma única guild usasse mais recursos para atender comunidades grandes
- Algumas operações ainda precisavam continuar no processo principal da guild, mas isso permitiu lidar com comunidades com centenas de milhares de membros
- Para implementar isso, foi necessário identificar quais operações importantes deveriam ser feitas no relay, quais deveriam ficar na guild e quais poderiam ser feitas em ambos os sistemas
- Depois de entender o que era necessário, eles começaram um trabalho de refatoração para extrair a lógica compartilhável entre os sistemas
- Por exemplo, boa parte da lógica de como fazer fanout foi refatorada para uma biblioteca usada tanto pela guild quanto pelos relays
- Parte da lógica que não podia ser compartilhada exigiu outras soluções; o gerenciamento de estado de voz, por exemplo, foi implementado basicamente fazendo o relay encaminhar todas as mensagens para a guild com mudanças mínimas
- Uma decisão de design interessante na primeira versão dos relays foi incluir a lista completa de membros no estado de cada relay
- Do ponto de vista da simplicidade, isso foi uma boa decisão, porque toda a informação necessária sobre membros estava disponível
- Mas na escala do Midjourney, com milhões de membros, esse desenho começou a fazer cada vez menos sentido
- Não só havia dezenas de cópias das informações de dezenas de milhões de membros armazenadas em RAM, como também criar um novo relay exigia serializar todos os dados dos membros e enviá-los para o novo relay, causando atrasos de dezenas de segundos na guild
- Para resolver isso, eles adicionaram lógica para identificar quais membros o relay realmente precisava para funcionar — e isso era apenas uma fração minúscula do conjunto total de membros
Mantendo a responsividade do servidor
- Além de permanecer dentro dos limites de throughput, também era necessário manter a responsividade do servidor
- Aqui também foi útil observar os dados de timing
- Em vez da duração total, foi mais eficaz focar em operações com alta duração por chamada
- Processos worker + ETS
- Uma das maiores causas de falta de resposta eram operações executadas na guild que precisavam iterar por todos os membros
- Isso acontece com pouca frequência, mas acontece. Por exemplo, quando alguém dá ping em todos, é preciso saber quem no servidor pode ver aquela mensagem
- Mas essas verificações podem levar vários segundos. Como lidar com isso?
- O ideal seria executar essa lógica enquanto a guild continua processando outras tarefas, mas processos Elixir não compartilham memória muito bem. Então era necessária outra solução
- Uma das ferramentas de Erlang/Elixir para armazenar dados em memória compartilhável entre processos é o ETS
- Trata-se de um banco de dados em memória que oferece recursos para acesso seguro por vários processos Elixir
- É menos eficiente do que acessar dados no heap do processo, mas ainda é muito rápido. Além disso, tem a vantagem de reduzir o tamanho do heap do processo, diminuindo a latência de garbage collection
- Eles decidiram criar uma estrutura híbrida para manter a lista de membros:
- armazenar a lista de membros no ETS para que outros processos também possam lê-la, mas manter no heap do processo também as mudanças recentes (inserções, atualizações e remoções)
- como a maioria dos membros não é atualizada o tempo todo, o conjunto de mudanças recentes representa uma parte muito pequena do conjunto total de membros
- Agora era possível criar processos worker usando os membros no ETS e passar o identificador da tabela ETS para que eles trabalhassem quando houvesse operações de alto custo
- Os workers podiam lidar com a parte custosa enquanto a guild continuava cuidando de outras tarefas. Há também uma forma simples de fazer isso (com snippet de código no original)
- Um exemplo de uso dessa abordagem é quando o processo de uma guild precisa ser transferido de uma máquina para outra (geralmente por manutenção ou deploy)
- Nesse processo, cria-se um novo processo para atender a guild na nova máquina, copia-se o estado do processo antigo para o novo, reconectam-se todas as sessões ligadas ao novo processo da guild e depois processa-se o backlog acumulado durante essa operação
- Com workers, é possível transferir a maior parte dos membros — o que pode significar vários GB de dados — enquanto o processo antigo da guild continua trabalhando, reduzindo os minutos de atraso que antes ocorriam a cada handoff
- Manifold offload
- Outra ideia para melhorar a responsividade e superar os limites de throughput foi estender o manifold para usar um processo separado de "sender" para fazer o fanout para os nós receptores, em vez de o processo da guild fazer o fanout diretamente
- Isso não só reduziria a carga de trabalho do processo da guild, como também o protegeria do backpressure do BEAM caso uma das conexões de rede entre a guild e os relays ficasse temporariamente congestionada (BEAM é a máquina virtual onde o código Elixir roda)
- Em teoria, parecia algo fácil de resolver, mas infelizmente, ao testar esse recurso (chamado de manifold offload), descobriram que o desempenho na prática piorava bastante
- Como isso era possível? Em teoria, a carga de trabalho diminuía, então por que o processo ficava mais ocupado?
- Ao investigar mais a fundo, descobriram que a maior parte do trabalho adicional estava relacionada ao garbage collection
- Foi aí que a função
erlang.trace apareceu como salvadora
- Com ela, foi possível coletar dados sempre que o processo da guild executava garbage collection, obtendo insights não só sobre a frequência com que isso acontecia, mas também sobre o que o estava provocando
- Com base nessas informações de tracing e olhando o código de garbage collection do BEAM, eles descobriram que, quando o manifold offload estava ativado, a condição que disparava garbage collections principais (completas) era o virtual binary heap
- O virtual binary heap é um recurso projetado para permitir que o processo libere memória usada por strings não armazenadas dentro do heap do processo, mesmo quando ele não precisaria fazer garbage collection
- Infelizmente, no padrão de uso deles isso significava disparar garbage collection repetidamente para recuperar algumas centenas de KB de memória, ao custo de copiar heaps de vários GB — claramente um trade-off que não valia a pena
- Felizmente, no BEAM esse comportamento pode ser ajustado com a process flag
min_bin_vheap_size
- Ao aumentar esse valor para alguns megabytes, o comportamento patológico de garbage collection desapareceu, e foi possível ativar o manifold offload com um grande ganho de desempenho
9 comentários
Elixir mandando ver
As sessões passivas, tecnicamente, não são nada de mais, mas parecem uma boa ideia.
Com certeza dá para reduzir a carga de forma significativa.
Imagino que não só o Discord, mas outros lugares também já tenham implementado algo assim; fico curioso sobre quais seriam as diferenças entre os serviços.
Muito incrível mesmo
Pelo visto, o destino final do famoso streaming SSR do Next.js atualmente também é o framework Phoenix, do Elixir. Em vários aspectos, o Elixir parece estar na linha de frente das linguagens de programação modernas.
Viva o Elixir
Há alguns anos, adotei Elixir em um serviço em tempo real tomando como referência o blog técnico do Discord, e tenho muitas boas lembranças porque lançamos o serviço com uma velocidade de desenvolvimento e uma segurança muito satisfatórias, tanto para mim quanto para o executivo responsável.
Tomara que Elixir fique mais popular.
Hoje em dia, não parece mais que Naver, Kakao e Line estejam tão nesse nível; na verdade, parece que startups pequenas e médias é que são quase um monopólio do Spring. Como a maioria desses gestores de startup é especialista em Spring, não tem muito como evitar.
Toda ineficiência pode ser resolvida com dinheiro e escala. Afinal, a empresa não entende muito bem mesmo.