Arquitetura real · Pleno
Discord: Elixir com 5 milhões de usuários simultâneos
Arquitetura real em produção. Pontos centrais: WebSocket e presença e consistent hashing.
Resposta curta
Arquitetura real em produção. Pontos centrais: WebSocket e presença e consistent hashing. Segundo o próprio Discord, quase tudo o que o produto faz se resume a publicação e assinatura (pub/sub). O usuário abre uma conexão WebSocket e ganha um processo de sessão (um GenServer).
01 · CONTEXTO
Um produto de tempo real feito de pub/sub
Segundo o próprio Discord, quase tudo o que o produto faz se resume a publicação e assinatura (pub/sub). O usuário abre uma conexão WebSocket e ganha um processo de sessão (um GenServer). Esse processo conversa com nós Erlang remotos que hospedam os processos de guild, o nome interno de um servidor do Discord.
Tudo o que é publicado numa guild é distribuído a toda sessão conectada a ela. Quando um usuário entra, a guild também publica a presença dele para as demais sessões conectadas.
O cliente abre uma conexão WebSocket com o gateway, e a conexão ganha um processo de sessão.
A sessão conversa com os nós remotos que hospedam os processos de guild.
Quando algo é publicado numa guild, o evento é distribuído a cada sessão conectada a ela.
Quando um usuário entra, a guild publica a presença dele para as outras sessões conectadas.
A API do produto era servida por Python, separada do gateway em tempo real.
Em 2015 o Discord escolheu Elixir e Python como linguagens principais. O Elixir ficou com o gateway WebSocket, responsável por retransmitir mensagens e replicar em tempo real, e o Python com a API. Segundo o estudo de caso, a API Python era um monólito e a pilha Elixir reunia cerca de vinte serviços.
02 · FANOUT
Quando uma guild grande não consegue publicar
O desenho funcionou enquanto o Discord atendia grupos de até 25 pessoas. Depois surgiram servidores com até 30 mil usuários simultâneos. No pico, os processos de guild deixaram de acompanhar suas filas de mensagens, e foi preciso desligar manualmente funcionalidades que geravam mensagens para aliviar a carga.
O time fez benchmark dos caminhos quentes dos processos de guild e achou um culpado óbvio: enviar mensagens entre processos Erlang custava mais do que o esperado. Uma única chamada a send/2 levava de 30 a 70 microssegundos de tempo real, porque o Erlang descalonava o processo chamador, e publicar um evento de uma guild grande no pico podia levar de 900 ms a 2,1 s. Como processos Erlang são efetivamente single-thread, a forma de paralelizar é particioná-los.
A primeira ideia foi criar um processo por publicação, mas ela foi descartada: cada publicação poderia ser agendada em momento diferente, e os clientes dependem da linearizabilidade dos eventos.
Enviar mensagens entre processos Erlang custava mais do que o esperado: uma única chamada a send/2 levava de 30 a 70 microssegundos de tempo real.
Numa guild grande, publicar um evento no pico podia levar de 900 ms a 2,1 s.
O Manifold recebe os destinatários e os agrupa pelo nó remoto onde cada um vive.
Em vez de um envio por destinatário, o emissor chama send/2 no máximo uma vez por nó remoto, rumo ao partitioner daquele nó.
No nó remoto, o partitioner aplica hash consistente aos PIDs, agrupa por número de núcleos e envia a workers filhos.
Os workers entregam as mensagens aos processos reais, e a linearizabilidade garantida por send/2 se mantém.
03 · HASH CONSISTENTE
O anel que diz onde cada guild vive
O Discord é um sistema distribuído obtido por hash consistente, o que exige uma estrutura em anel para descobrir o nó responsável por uma entidade. O problema apareceu em rajadas de reconexão: o processo dono do anel não dava conta dos pedidos e o sistema inteiro sobrecarregava.
Os números do caminho quente, segundo o post: um usuário médio está em 5 guilds, uma VM Erlang de sessões pode ter até 500 mil sessões ativas, e ao conectar cada sessão consulta o nó remoto de cada guild de interesse. Um request/reply entre processos custa cerca de 12 microssegundos e, se o servidor de sessões caísse e reiniciasse, só as consultas ao anel levariam cerca de 30 segundos.
Ao conectar, a sessão consulta o anel para descobrir o nó remoto de cada guild de seu interesse.
Em rajadas de reconexão, o processo dono do anel não acompanhava os pedidos e o sistema todo sobrecarregava.
Mover o anel para ETS melhorou o desempenho, mas copiar a estrutura grande para dentro e fora do ETS era a maior parte do custo.
Com a técnica do mochiglobal, adotada na versão chamada FastGlobal, os dados viram uma constante numa heap compartilhada somente leitura, lida sem cópia.
| Abordagem | Custo por consulta | Tempo total das consultas |
|---|---|---|
| Processo dono do anel | cerca de 12 microssegundos por request/reply | cerca de 30 segundos num restart |
| Anel copiado em ETS | cerca de 7 microssegundos por leitura | 17,5 segundos |
| Módulo constante (mochiglobal/FastGlobal) | 0,3 microssegundo | 750 ms |
04 · CONCORRÊNCIA LIMITADA
Acelerar o caminho moveu o gargalo
Depois de acelerar a busca do nó, os processos de registro de guilds nos nós de guild começaram a acumular fila. A lentidão da busca funcionava antes como contrapressão que os protegia; o problema passou a ser que quase 5 milhões de processos de sessão disputavam dez processos de registro, um em cada nó de guild.
O pior era o ciclo: a chamada da sessão estourava o timeout e deixava a requisição na fila do registro. A sessão repetia o pedido após um backoff, e os pedidos se acumulavam até um estado irrecuperável. As filas de mensagens das sessões inflavam até a VM Erlang inteira ficar sem memória, com quedas em cascata.
Quase 5 milhões de sessões disputavam dez processos de registro de guilds, um em cada nó de guild.
A chamada estourava o timeout e a requisição ficava na fila do registro; o retry com backoff acumulava pedidos até um estado irrecuperável.
As filas de mensagens das sessões inflavam até estourar a memória da VM Erlang, com quedas em cascata.
A solução foi um semáforo: um contador atômico em ETS de pedidos pendentes, para a sessão desistir cedo quando o número está alto.
Quando o serviço de presença caiu por um problema não relacionado, as sessões não oscilaram.
O Discord descartou um circuit breaker porque não queria que uma rajada de timeouts levasse a um estado temporário em que nenhuma tentativa é feita. Em outras linguagens, um contador atômico de pedidos pendentes permitiria desistir cedo, o que na prática é um semáforo. No Erlang, a peça fundamental foi o :ets.update_counter/4, que faz incremento condicional atômico sobre um número guardado numa chave ETS.
05 · LIÇÕES E LIMITES
O que levar para o seu desenho
O estudo de caso do Elixir acrescenta peças do ecossistema: o Discord usa Cowboy para conexões WebSocket e GenStage para back-pressure e load-shedding, e monta uma malha parcial entre serviços Elixir, com etcd para descoberta de serviços e configuração compartilhada. Segundo o estudo de caso, a equipe de infraestrutura de chat é formada por cinco engenheiros.
Perguntas frequentes
O que é Discord: Elixir com 5 milhões de usuários simultâneos?
Arquitetura real em produção. Pontos centrais: WebSocket e presença e consistent hashing.
O que o design de Discord: Elixir com 5 milhões de usuários simultâneos cobre?
Ele está organizado nestas seções: Um produto de tempo real feito de pub/sub, Quando uma guild grande não consegue publicar, O anel que diz onde cada guild vive, Acelerar o caminho moveu o gargalo e O que levar para o seu desenho.
Quanto tempo leva Discord: Elixir com 5 milhões de usuários simultâneos 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.