62 pontos por xguru 2023-11-09 | 9 comentários | Compartilhar no WhatsApp

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

 
roxie 2023-11-18

Elixir mandando ver

 
arfwene 2023-11-10

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.

 
mhj5730 2023-11-10

Muito incrível mesmo

 
abhidhamma 2023-11-09

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.

 
papillon 2023-11-09

Viva o Elixir

 
n1ghtc4t 2023-11-09

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.

 
kotlinc 2023-11-09

Tomara que Elixir fique mais popular.

 
[Este comentário foi ocultado.]
 
damtet 2023-11-10

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.