Como escalar: aplicativos de chat
Olá!
Recentemente rodei a seguinte enquete no Linkedin:
E cá estamos, vamos tentar construir a arquitetura de um aplicativo de troca de mensagens?
Lembrando que o intuito é abordar conceitos de arquitetura mais amplos e detalhes de implementação como qual linguagem de programação utilizar ou qual mecanismo de SGBD usar serão considerados somente em um segundo momento. Além disso, tarefas comuns a outros tipos de sistemas como autenticação, autorização, logs e similares também não serão abordados.
Eu já escrevi outro artigo dessa natureza voltado para sistemas financeiros aqui se você tiver curiosidade.
Vamos começar do jeito mais simples possível e nos certificando que entendemos qual o domínio do problema e qual sua complexidade essencial envolvida, certo?
Um aplicativo de chat simples basicamente envolve duas coisas: pelo menos dois usuários trocando mensagens e algum intermediário recebendo as mensagens de um usuário e repassando para o outro. Não estou considerando interações P2P para esse problema, pelos seguintes motivos inerentes a esse modelo: alta latência (o que pode se tornar um problema em grupos de mensagens, por exemplo), além de adicionar uma complexidade considerável para suportar features simples como percepção de mensagens instantâneas, upload de imagens e áudio e demais funcionalidades que você provavelmente conhece e usa nos apps de Chat por aí.
Então vamos começar pelo simples: mensagens síncronas. Se o mínimo que eu preciso são dois usuários trocando mensagens via um servidor, vamos definir ao menos o modelo mínimo de uma mensagem para que isso aconteça:
Eu resumi bastante a quantidade de campos para fins de brevidade do texto, mas vale detalhar um pouco não ficar tão abstrato assim. Imagine que esse objeto message é um json contendo os seguintes objetos:
Content: Nesse objeto vai o conteúdo da mensagem, que pode ser áudio, texto ou vídeo, bem como tamanho e demais identificadores do tipo da mensagem.
Metadata: Nesse objeto vai todos os demais campos auxiliares sobre a mensagem, como remetente, destinatário (ou um grupo destinatário), timestamp e afins.
Utilizando esse objeto, temos o seguinte fluxo síncrono mínimo para se realizar essa troca de mensagens:
Antes de irmos para o próximo passo, vamos pensar um pouco sobre quais as limitações desse desenho.
Nessa arquitetura, podemos ter problemas com: latência na entrega de mensagens (caso o servidor esteja sobrecarregado), não há nenhum tipo de suporte a uma escala maior de máquinas, não há persistência e de certa forma, não há suporte a envio de conteúdos além de texto (já explico o porquê). Para saber para qual usuário enviar e receber mensagens, teríamos que usar aqui websocket (pois ele permite manter a conexão ativa entre o usuário e o servidor e é um protocolo bi-direcional, ao contrário do http).
Com esse modelo em produção, você teria um app que seria lerdo na entrega de mensagens em horários de pico e que não possui suporte a mensagens offline (pois depende de ambos os usuários estarem online ao mesmo tempo que o servidor está, dado que não existe armazenamento de mensagens devido a ausência de banco de dados). Além desses problemas, um servidor somente suporta uma quantidade limitada de usuários, então, em outras palavras: um app inútil para o caso de uso que estamos tentando atender. Que tal tentarmos melhorar isso?
Vamos tentar suportar agora mais features adicionando dois componentes: um load balancer (LB) e um mecanismo de banco de dados (DB):
Com essa alteração, agora permitimos uma escala muito maior em se tratando de servidores, bastando apensar adicionar máquinas ao server pool que o LB se encarrega de distribuir o tráfego de acordo. Além disso, todas as instâncias escrevem no banco de dados, permitindo que existam logs das mensagens enviadas (portanto, não há perda de mensagens em caso de usuários offline no momento do envio), além de adicionar suporte ao envio de conteúdo que não seja somente texto, pois eu posso enviar o áudio, vídeo ou imagens para o servidor e isso será armazenado corretamente no banco de dados e recuperado depois quando necessário, certo?
É um erro muito comum querer armazenar outros formatos que não sejam dados em formato texto no banco de dados. Imagine a modelagem de um tabela que armazena o conteúdo de uma mensagem, só que em formato de áudio, imagem ou vídeo. Mesmo convertendo esses arquivos para algum tipo de binário (que pode ser representado em texto), a variância do tamanho desses campos pode causar sérios problemas de performance no seu banco de dados (além do custo exorbitante de armazenamento).
Hoje em dia, um dos grandes impactos no custo da infraestrutura está no banco de dados. A título de curiosidade, fiz uma simulação na AWS (que tem fama de ser uma cloud provider barata), com um Amazon EC2 (que é uma VM para hospedar sua aplicação) e um DB gerenciado (Amazon RDS), tudo nas opções padrão e o resultado foi o abaixo:
É possível encontrar opções mais baratas sem dúvida, mas, a dica para DB relacionais na nuvem é a tecnologia TCCPC (trate com carinho, pois é carinho) e — sim — eu acabei de inventar isso.
Com isso dito, como podemos resolver o problema do armazenamento de conteúdo não textual? A resposta é mais simples (e antiga) do que parece: file systems.
Ao usar algum tipo de Cloud Storage, como um Amazon S3 ou Azure Blob Storage — que nada mais são do que um disco em algum computador com uma camada de software na frente para gerenciar as leituras e escritas desses arquivos, te cobrando um valor por isso no processo — , a solução do nosso chat fica mais segura e fácil de gerenciar, pois basta armazenar em tabelas o caminho para esses arquivos no DB e os arquivos em si no Cloud Storage que fica tudo mais fácil e barato. Mais detalhes sobre essa técnica aqui.
Com a adição dessa técnica, temos:
Porque usamos uma CDN ao invés de um banco de dados em memória (como o Redis, por exemplo) aqui para efetuar cache dos arquivos? Explico.
O Redis suporta um estrutura de tupla em formato chave/valor, no qual o valor pode ser dos seguintes tipos (e a chave geralmente é uma string):
Ao meu ver, nenhum desses tipos combinam muito bem com o armazenamento de arquivos, portanto, um cache que devolve o arquivo completo via web de forma geograficamente distribuída acaba tendo um desempenho melhor para o usuário final do nosso app de chat nesse caso. É por isso que a CDN entra na frente do object storage no nosso desenho, como mecanismo primário de obtenção dos arquivos, sendo que a ida ao Object Storage só acontece em caso de cache-miss.
Em termos de custo, fiz simulações de ambos os serviços (cerca de 1MM de leituras/mês na CDN e o mesmo armazenamento de 20GB em ambos) e ficou até que barato:
A mensagem aqui é: é muito mais barato (e performático) armazenar arquivos nessa estrutura que usar um DB comum para tal.
Para suportar Push Notifications, basta adicionar uma camada de software dedicada ao desenho, ficando parecido com isso:
Agora temos um desenho simplificado que suporta quase todas as features do WhatsApp, exceto criptografia E2EE.
Mas ainda dá para ficar melhor se melhorarmos o desenho e explicitar melhor as responsabilidades dos componentes e as responsabilidades deles — e adicionando E2EE no processo — nos deixando com essa versão final dos componentes:
Para entender melhor o todo, vamos começar pelo Fluxo de Envio de Mensagens:
O usuário A no seu app tenta enviar uma mensagem para o usuário B (ou um grupo de usuários), porém, antes da mensagem sair de seu dispositivo, o Cryptography Service é componente responsável por criptografar o conteúdo da mensagem antes da mensagem ser enviada ao Back-End. Lembrando: o servidor não precisa ler o conteúdo da mensagem, somente os metadados da mesma para realizar suas atividades, portanto, não tem problema os dados trafegarem criptografados.
O app chama a API de envio (Send Message API), que recebe requisições roteadas pelo Load Balancer para distribuir a carga, permitindo que o Back-End escale adicionando máquinas, conforme mencionado anteriormente.
Para processar a mensagem, o componente Message Service grava os metadados da mensagem e o conteúdo criptografado no Message Log DB (tanto faz se o banco é relacional ou NoSQL nesse caso), para que consigamos armazenar as mensagens em caso do usuário destino estar Offline. Nessa etapa, o serviço escreve no Message Cache para agilizar futuras leituras de mensagens também. Nesse caso, pode ser um Redis para armazenar as mensagens de texto, pois é adequado a esse tipo de dado.
Após esse processo, o Multimedia Service detecta se a mensagem é do tipo arquivo (ou seja, áudio, imagem ou video), caso seja, ocorre uma transação que faz três coisas: escreve no Multimedia Control DB como localizar os arquivos no Object Storage, escreve os arquivos no Object Storage e o envio para a CDN, para que o cache funcione na leitura.
Após o processamento bem sucedido da mensagem, o Notification Service é acionado para enviar uma notificação para o usuário destino (ou um grupo, tanto faz), informando que há mensagens novas, concluindo o Fluxo de Envio.
No Fluxo de Leitura/Recebimento, temos:
O App de cada usuário de um grupo (ou um usuário individual), chama a Read Message API mantendo uma conexão via websocket ativa, que pode fazer duas coisas: entregar os arquivos (caso seja mensagem de mídia) via CDN já nessa etapa, poupando processamento. Caso ocorra cache-miss, a API pergunta para o Multimedia Service onde estão os arquivos, que pergunta ao Multimedia Control DB onde estão os arquivos. Com a resposta, o endereços dos arquivos no Object Storage são retornados para API e consequentemente para o App.
O outro fluxo que pode acontecer é que, para mensagens comuns, uma consulta ao Message Service que pergunta para o Message Cache é o suficiente para trazer todas as mensagens frequentemente consultadas (em caso de um grupo com muitos usuários ou algum caso de uso similar). Caso ocorra cache-miss, uma consulta ao Message Log DB é efetuada, retornando as mensagens de texto diretamente do banco de dados.
Ao obter o retorno dessas mensagens (que estão criptografadas), o Cryptography Service faz o processo de descriptografia no app do usuário, finalizando o fluxo e exibindo as mensagens para o usuário (ou na conversa em grupo, de novo, tanto faz).
Isso conclui o desenho técnico de um app de chat com as mesmas features do WhatsApp, de uma forma que não dependa de uma linguagem ou tecnologia específica. E temos mais um detalhe também: os componentes Services podem ser microservices com banco de dados próprios ou até mesmo módulos dentro de um monolito que usa o mesmo banco de dados, com tabelas diferentes fazendo as funções explicitadas no desenho.
Obviamente se você criar os services com uma linguagem de alta eficiência e tolerante a falhas, o ecossistema tende a ser mais eficiente e robusto também. Acredito que seja por isso que serviços de chat de grande escala usam Elixir ou Erlang, dado sua capacidade de suportar milhões de requisições por instância. O Discord, por exemplo, usa Elixir. Já o WhatsApp, Erlang. Já o Snapchat, por exemplo, parece usar Java.
E você? O que achou do texto? Espero que tenha sido útil para você!
Até!
Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.