fbpx

Como reduzimos em 98% o tempo de resposta de uma aplicação

Recentemente, comentei no meu instagram a respeito de algumas melhorias realizadas em uma das aplicações que meu time mantém.

Falei sobre a redução do tempo de resposta de um de nossos principais sistemas. É uma aplicação responsável pela atualização de milhares de terminais de pagamento em todo Brasil.

Como o sistema funciona?

Todo mundo que tem algum dispositivo conectado à internet já deve ter se deparado com a seguinte mensagem: “Há uma nova atualização do sistema disponível para você. Deseja instalar agora?”.

Qualquer sistema precisa de atualização. Se um programa não muda, não evolui, está fadado à extinção. Por isso, as empresas investem tanto em melhoria e inovação de seus produtos.

É um grande desafio lidar com atualização remota de sistemas, principalmente os que rodam em dispositivos com recursos de hardware limitados, tais como caixas automáticos, checkouts de pagamento, vending machines, totens de check-in de viagem, etc. Não é tão simples como atualizar um site hospedado em servidor web, que você tem total controle e ótimas condições de hardware e comunicação.

Em sistemas embarcados, como costumam ser chamados os programas criados exclusivamente para executar em dispositivos com finalidade muito específica, é preciso lidar com alguns cenários de falha, que podem ir desde conexão com internet até a descarga de bateria no meio da instalação de um programa.

A abordagem mais comum para um dispositivo se atualizar é programar o próprio aparelho para verificar, de tempo em tempo, através da internet, se há uma nova versão de software disponível. Quando sim, o dispositivo inicia o processo de atualização do sistema.

O processo de atualização de um terminal é dividido em duas etapas: download do pacote e instalação. Falaremos da primeira etapa, visto que a segunda está fora da fronteira desse tipo de serviço, pois é comum que cada dispositivo implemente de maneiras distintas.

Um pacote de atualização pode ser grande, alguns chegam a passar de 100mb. Por isso, todos os terminais são programados para efetuar o download dos pacotes em partes, e depois unificar as partes. Só após o término deste processo, o dispositivo pode iniciar a instalação.

Um outro detalhe, é que um terminal nunca interrompe completamente tudo que está fazendo para se dedicar apenas à atualização. Ele só começa a baixar o pacote, se o dispositivo estiver ocioso, ou então na inicialização.

Existem algumas limitações que inviabilizam a execução do processo de atualização de uma só vez. Imagine você, se no meio de uma operação de pagamento, o terminal resolve interromper a transação para iniciar o download de um arquivo de 50mb, enquanto o cliente espera, impacientemente, com os filhos, sedentos pelo novo brinquedo que ele prometeu comprar na próxima loja?

Por essas e outras, é comum um terminal ser programado para interromper o download quando precisa executar uma tarefa importante; quando fica ocioso, retorna ao processo de transferência de onde parou.

A Causa

Quando uma aplicação é desenvolvida, nem sempre é possível prever qual será a sua demanda. O negócio pode dar muito certo e crescer exponencialmente, pegando de surpresa sistemas que não estão preparados para suportar a alta demanda repentina.

Foi o que aconteceu conosco. Com o aumento do número de clientes, o serviço passou a responder em tempo nada satisfatório. O TPS, indicador que mede o número de transações por segundo, estava abaixo do ideal, e quanto mais a base de clientes crescia, mais tempo levava para os terminais atualizarem.

Isso fez acender um alerta: se a aplicação continuasse performando mal, em pouco tempo ficaria inviável liberar uma nova atualização para os clientes. Diante disso, precisamos agir rápido e o primeiro passo foi investigar as possíveis causas do problema.

O Diagnóstico

Durante a investigação, identificamos três pontos principais que poderiam estar afetando o serviço.

O primeiro deles foi no fluxo que lia os dados da sessão de download do terminal do banco de dados. A leitura da sessão de download é necessária, pois contém dados importantes do pacote que o terminal pretende baixar.

O segundo ponto, sem dúvida o mais crítico, foi na rotina de armazenamento do estado da sessão. Para cada requisição que a aplicação recebe de um terminal, solicitando os bytes do pacote de atualização, o serviço persistia o conteúdo desta requisição. É uma rotina necessária para acompanharmos o progresso de atualização dos terminais no parque.

A operação era bloqueante, das piores possível, pois executava um update (síncrono) em banco de dados compartilhado. Você pode imaginar o custo disso: throughput de rede, liberação de recurso de banco de dados, I/O de disco, etc. O processo dependia de alguns recursos de alta latência, muito onerosos à performance.

Por último, um problema que não estava diretamente ligado à alta latência, mas demandava um alto custo computacional.
Para cada requisição recebida, a aplicação lia um arquivo armazenado previamente em memória, particionava e calculava o checksum desta parte do arquivo, depois transferia este fragmento para o terminal.

Para quem não sabe o que é checksum, é um número de controle que representa uma cadeia de bytes. Através dele, é possível saber se ocorreu alguma perda de dados durante a transferência; que seria indício de um dado corrompido.

A rotina era executada a cada requisição, milhares de vezes ao dia, por milhares de clientes, e causava um alto consumo de CPU.

Após o diagnóstico, restou estudar uma solução que atendesse à demanda crescente de clientes, melhorasse o tempo de resposta, e não demandasse grandes mudanças arquiteturais.

Um dos maiores desafios em sistemas é lidar com a alta concorrência, ou seja, atender simultaneamente diversas solicitações, e ainda assim, garantir que o sistema não irá falhar e conseguirá entregar o dado mais atual, no momento da solicitação.

Um estudo proposto por Eric Brewer, também conhecido por Teorema CAP — de Consistência (Consistency), Disponibilidade (Availability) e Tolerância a falha (Partition-Tolerance) —, afirma que só é possível garantir dois destes atributos simultaneamente.

Ter consistência forte, significa dizer que cada leitura irá retornar o último estado (última escrita) daquilo que for consultado.
Com alta disponibilidade, o sistema garante que irá atender a solicitação sem retornar erro, independentemente da quantidade de solicitações recebidas. Um sistema tolerante a falhas, garante que, mesmo ocorrendo um número arbitrário de falhas, em qualquer dos pontos que conectam o sistema, ainda assim, o sistema estará disponível.

Agora que você entendeu o que significa cada segmento do tripé CAP, posso explicar as causas exclusivas do teorema de Brewer:

AP: O sistema sempre responderá (A) caso ocorra alguma falha na comunicação entre os nós (P), entretanto, a consistência (C) dos dados será eventual.

CA: O sistema sempre responderá (A) e os dados processados serão consistentes (C), porém, não considera a perda da comunicação entre os nós (P).

CP: O sistema executará as operações de forma consistente (C), mesmo que se perca a comunicação entre os nós (P), mas não assegura a disponibilidade (A).

Em nosso caso, estávamos priorizando a consistência forte e a alta disponibilidade (CA), o que deixava o processo menos escalável do que gostaríamos. Decidimos priorizar a alta disponibilidade e tolerância a falhas (AP), já que ter o último estado da sessão não era tão importante naquela etapa do fluxo. Na abordagem, a consistência eventual foi tolerada; passamos a priorizar a escrita e sincronizar os dados para leitura em segundo plano.

O maior gargalo da operação estava na forma como o estado da sessão de download era persistido, dando motivo suficiente para consideramos este item como primeiro da lista.

O ponto de bloqueio que ocorria na leitura dos dados da sessão do banco de dados foi o segundo item a ser priorizado. Todo mundo que já teve experiência com sistemas de alta disponibilidade sabe quanto é custoso depender de banco de dados relacional.

Por último, elencamos a etapa que lidava com os fragmentos dos pacotes. Tivemos de pensar em uma forma mais inteligente de transferir os “pedaços” do arquivo para o terminal, sem precisar percorrer uma infinidade de bytes a cada requisição e gerar o checksum de cada fragmento em tempo de execução.

Identificados os pontos, partimos para ação!

A partir daqui, vou tentar descrever a solução aplicada, sem entrar em detalhes de implementação.

1) Substituição de armazenamento do estado da sessão de forma síncrona para processamento em fila

Uma maneira de resolver o problema da latência no armazenamento do status de download do terminal foi enfileirando e tratando os “estados” em segundo plano.

A abordagem de enfileiramento de mensagens é muito usada em sistemas distribuídos e traz outros benefícios. Além da diminuição da latência, é possível implementar tratativas para recuperação de falhas e distribuição de processos.

Em nosso caso, a aplicação passou a processar cada requisição que recebe dos terminais e armazenar em uma fila, para posteriormente um outro processo tratar cada mensagem enfileirada.

Chamamos o processo que trata a fila de consumer. Ele coleta as últimas mensagens oriundas de cada terminal e atualiza o banco de dados com o estado das sessões coletadas.

Com o enfileiramento das mensagens, reduzimos consideravelmente a latência da operação, visto que a aplicação antes era obrigada a atualizar individualmente cada status de sessão.

O processamento da fila em background foi um alívio para nós e para os DBAs. Além de aliviar a aplicação, também diminuiu o consumo de recursos causado pelas idas e vindas ao branco de dados, feitas a cada requisição. Passamos a executar a rotina de atualização das sessões em lote, persistindo vários estados de sessão em uma única transação.

2) Substituição da leitura dos dados da sessão do banco de dados por leitura em cache distribuído

A outra mudança foi no momento da criação da sessão de download. Nesta operação, um identificador da sessão criada é gerado, e enviado para o terminal. Só após receber este identificador que o terminal inicia o processo de download.

Mudamos a rotina para que toda vez que uma sessão de download fosse criada, armazenasse também os dados da sessão em cache. Assim, em vez de obter os dados da sessão do banco de dados, passamos a obtê-los do cache.

Como a aplicação é distribuída, executa em várias instância de servidor, foi necessário utilizar uma solução de cache distribuído, já que não temos a garantia de que todas as requisições irão cair no mesmo servidor em que a sessão de download foi criada.

Quando o terminal fazia a requisição para baixar o arquivo, o dado da sessão já estava em cache, eliminando a necessidade de fazer a leitura do banco de dados a cada requisição.

Para colocar em prática essa mudança, foi preciso fazer algumas refatorações para lidar com os dados em cache. Precisamos remodelar a estrutura dos dados que seriam cacheados, pois foi necessário todo esforço para reduzir o tráfego entre a aplicação e o serviço de cache, como o custo de serialização e desserialização dos dados.

3) Substituição do cálculo de checksum e fragmento do arquivo para armazenamento em cache

Como expliquei, a fragmentação do arquivo e cálculo do checksum aconteciam a cada requisição. A aplicação lia o conteúdo do arquivo (já armazenado em cache) e, com base no tamanho dos bytes que o terminal informava já ter baixado, carregávamos os próximos bytes do pacote — respeitando um limite —, em seguida o checksum da próxima cadeia de bytes era calculado e transferido para o terminal.

A manipulação dos bytes e cálculo do checksum causavam um alto uso de CPU, quando havia um número elevado de requisições. O curioso é que após a diminuição do tempo de resposta com as duas mudanças anteriores, a aplicação passou a responder mais rápido, consequentemente, processar mais requisições do que antes, aumentando ainda mais o uso de CPU.

A decisão tomada para resolver o problema foi antecipar a fragmentação dos arquivos e cálculo do checksum. Com uma simples mudança na lógica, passamos a armazenar os fragmentados e checksum dos pacotes já particionados em memória. Cada vez que o terminal solicitava o próximo fragmento do pacote, a aplicação não precisava recalcular tudo novamente, bastava procurar na memória o fragmento solicitado.

É importante destacar que a decisão de fazer entregas parciais das melhorias possibilitou a medição do impacto das mudanças. Não só os impactos, mas também a possibilidade de fazer melhorias incrementais, sem grandes impactos e difíceis de reverter. Percebidos os resultados positivos da cada iteração, avançávamos.

A entrega parcial possibilitou alguns aprendizados: permitiu rápida atuação, quando uma mudança teve um efeito colateral e precisamos revertê-la.

“O desejo de perfeição nos paralisa e se esteriliza na ideia da excelência.” — Jean Guitton

Muitas das vezes, tentando encontrar a solução ideal para um problema, algumas equipes acabam paralisando, escolhendo por um caminho mais longo, que leva a grandes mudanças arquiteturais e de infraestrutura, quando na verdade, bastaria uma perspectiva simples, utilizando as ferramentas que já tem em mãos, para prover uma solução suficiente.

No caso do sistema de download, foi um salto de melhoria; 98% de redução do tempo de resposta é muito animador. Porém, ainda que a melhoria não seja tanta, se você deixa o sistema um pouco melhor do que o encontrou, já é motivo para comemorar.

Receba meu conteúdo no Telegram

Se você deseja receber outros conteúdos direto no seu celular, entre no meu canal no Telegram.
Lá eu compartilho dicas para você dominar definitivamente a escrita do código limpo.

Hey,

o que você achou deste conteúdo? Conte nos comentários.

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *