Escalando uma aplicação para 100M+ jobs e dezenas de milhares de requisições por minuto com Laravel
Mateus Guimarães
Posted on July 24, 2022
Nessa post eu vou mostrar como escalamos uma aplicação usando um stack que muita gente rolaria os olhos: Laravel, Redis e MySQL. Só.
Em 2019 eu entrei num projeto que era um gerenciador de campanhas SMS.
Basicamente, grandes marcas pagavam um valor mensal + um markup por mensagem enviada.
Eles faziam upload das suas próprias listas e depois segmentavam usando as campanhas.
Nessa época, a plataforma enviava, por dia, entre 1 e 2M de mensagens. Uma coisa importante é que enviar uma mensagem envolvia algumas outras coisas: pra cada uma, nós recebíamos um webhook do provedor informando o “delivery status” daquela mensagem. Cada resposta à SMS também voltava como um webhook, e internamente nós tínhamos que associar aquela resposta à uma mensagem enviada pela plataforma.
Tinha um fluxo grande de dados em principalmente duas partes: a tabela que guardava os contatos de cada lista (tinha empresa fazendo upload de lista com 50M+ de registros) e as mensagens enviadas.
O stack, nessa época, era Laravel, Vue e MongoDB. A arquitetura era bem cagada, e as coleções eram mais ou menos assim:
Eu não faço a menor ideia do que porque havia 3 coleções pra representar as mensagens e tenho certeza que isso daria um piripaque no @zanfranceschi, mas, basicamente, quando alguém pausava uma campanha, todos os registros pendentes eram copiados pra coleção de registros pausados, e vice-versa.
Quando uma mensagem era enviada, ela era deletada da coleção de mensagens pendentes.
Vale lembrar que quando alguém respondia uma mensagem, às vezes isso fazia o sistema enviar outra mensagem, então uma única mensagem podia gerar até 4 registros (mensagem enviada, webhook recebido, resposta, e uma outra mensagem enviada).
Não precisa pensar muito pra imaginar que isso não ia escalar. Rapidamente tivemos alguns problemas bem grandes:
- O upload de listas quase nunca funcionava direito. Eram listas enormes e os registros eram empurrados pra uma fila que usava o banco de dados como driver e processava em chunks — era muito comum dar consecutivos timeouts até o job ser descartado.
- A criação de um contato era um pouco complexa e intensiva — tínhamos que gerar dados de localização (cidade, estado, timezone, etc) a partir do número, e existiam algumas tabelas constantemente atualizadas que nos davam essa informação. Pra cada contato, eram executadas 3 queries (baseadas em partes do número de telefone).
- Quando as campanhas começavam a rodar, era comum o site cair por causa da enxurrada de requisições. Como era tudo síncrono, cada requisição demorava a ser respondida e aí o php-fpm começava a chorar.
- O Mongo funcionava muito bem até ter alguns milhões de registros em cada coleção. Esse esquema de copiar dados de uma coleção pra outra, obviamente, não ajudava — às vezes simplesmente não conseguiam pausar uma campanha.
- Quem processava as mensagens pendentes era um programa em Go. Não tinha UI, nada – ele só ficava lendo do banco e enviando. Isso tornava adicionar novos drivers de envio bem problemático, já que o código do “sender” precisava ser mexido.
Existia, claramente, um grande problema de arquitetura aqui. Não sei porque escolheram Mongo, não sei porque fizeram o esquema das coleções, mas o ponto é que não tava rolando. Contrataram um especialista pra tentar escalar o banco, mas mesmo assim não dava pra passar muito dos ~5-6M diários, e todo dia tinha que rolar uma limpeza de dados.
Nesse momento foi decidido que eu ia fazer um MVP, basicamente de uma v2, que desse pra escalar. O único ponto é que precisava escalar — não precisava de muitas features, nada — isso vinha depois.
Bom, a minha experiência com microserviços era muito pequena, e eu não queria arriscar usar nada que eu não dominasse bem.
Resolvi, então, usar Laravel, Redis e MySQL. Só.
O banco ficou mais ou menos assim:
Eu deixei alguns detalhes importantes de fora pra não deixar essa thread mais gigante do que já está — mas existiam alguns outros requisitos:
- Durante todos os envios, tínhamos que verificar se o número já tinha recebido uma mensagem daquela empresa nas últimas 24 horas, que não fosse resposta.
- Em qualquer resposta, precisávamos verificar se a mensagem continha alguma “stop word”, que eram customizadas, portanto envolviam acesso ao banco, e bloquear o número de receber qualquer mensagem daquela empresa.
- Em qualquer resposta também precisávamos verificar se continha alguma “reply keyword” pra enviar mensagens na sequência. Também envolvia acesso ao banco.
- Toda campanha estava associada a uma “Account” que continha diversos números para serem usados. Existia um cálculo durante runtime pra determinar quantas mensagens essa conta podia enviar por MINUTO sem queimar o número nem ser rate limited pelo provedor.
Pra resolver tudo isso eu usei Redis e filas extensivamente.
Pra gerenciar as filas, usei o próprio Laravel Horizon.
A nova aplicação ficou assim:
- Todas as mensagens ficavam numa tabela “outbounds”, com uma FK para a campanha e uma coluna datetime “sent_at” que era nullable. Era assim que determinávamos o que já tinha sido enviado ou não.
- As campanhas tinham uma coluna de status (pending, cancelled, paused, running, completed). Pending era quando as mensagens ainda estavam sendo geradas.
- Nada era processado sincronamente — tudo ia para a fila. Desde webhooks, ao envio dos nossos próprios web hooks, tudo era enfileirado.
- Quando uma lista era importada, ela era processada na fila em batches de 10000 registros. Isso permitia que os jobs fossem executados rapidamente.
- Quando uma campanha era criada, as mensagens também eram geradas em batches de 10000 — quando o último batch era gerado, o status da campanha era alterado para “paused”.
- Lembra do processo intensivo de pegar os dados geográficos de um contato? Como o número era desmembrado em 3 partes, muitas vezes o mesmo registro era usado diversas vezes. Tudo foi pro Redis — só buscávamos uma vez e depois ficava em memória pra evitar queries no banco.
- O processamento de mensagens continuou complexo, mas era mais fácil de manusear: o processo era feito por “Accounts” ao invés de campanhas, pois precisávamos respeitar o número máximo de envios por minuto. Existia um job que rodava de minuto em minuto, que pegava todas as Accounts que estavam sendo usadas por campanhas naquele momento e passava cada uma para um job que as processava. Esse job calculava quantas mensagens poderiam ser enviadas naquele minuto, buscava outbounds pendentes entre todas as campanhas usando aquela “Account”, e em seguida os despachava de forma que eles fossem enviados uniformemente dentro do intervalo de um minuto (ao invés da fila processar tudo o mais rápido possível). Cada envio de mensagem era um único job.
Lembra das stop e reply keywords? Tudo passou a ficar em cache, também.
O Laravel Horizon orquestrava algumas filas – uma pra importar os CSVs de listas, outra para gerar contatos, outra pra despachar os jobs das accounts, outras para enviar outbounds, outra pra processar webhooks, etc.
A parte de infra ficou mais ou menos assim:
Eu não lembro o tamanho de cada servidor agora, só que o Redis tinha uma cacetada de RAM, mas sempre deixávamos MUITA margem caso alguém resolvesse enviar mais mensagens do nada.
Com esse stack essa aplicação escalou, tranquilamente, para mais de 100 milhões de jobs e 15M de mensagens enviadas dentro de um período de 12 horas.
Passou bem rápido de 1 bilhão de contatos e de mensagens sem nenhuma dor de cabeça. A conta mensal desceu de >USD 11000 pra menos de USD 900.
Não tinha autoscaling, clusters, k8s, nada, pelo simples fato de que eu não manjava disso — e se eu tivesse ido por esse caminho, talvez teria demorado muito mais pra escrever essa aplicação. Usei o básico que eu sabia e funcionou muito bem.
Sobre o MySQL, tive bastante cuidado com os índices e evitava fazer quaisquer queries desnecessárias. Usei Redis onde dava e sempre com TTLs generosos.
A parte da API hoje em dia seria muito mais fácil com Swoole e Laravel Octane, mas naquela época não existia. Só precisei mexer um pouco no php-fpm até achar valores que deixassem uma margem legal.
Uma outra parte que facilitou muito foi como eu escrevi os drivers de envio na aplicação nova — ficou muito fácil de adicionar novos drivers e escrever os testes. Não mencionei isso, mas a aplicação anterior só tinha alguns testes críticos que eu escrevi. A nova ser bem testada deixou tudo muito mais fácil 😁.
Pra ler mais do que eu escrevo, pode me seguir no twitter: @mateusjatenee e também no YouTube.
Posted on July 24, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.