Neste artigo, vou tentar destacar alguns dos princípios mais comuns que você tem que levar em consideração quando quer alcançar um alto nível de desempenho enquanto constrói uma aplicação web (especificamente na parte de backend). Eu acredito que os conceitos discutidos aqui podem ser aplicados a qualquer linguagem e framework. Devido à minha experiência específica, irei citar alguns exemplos, design patterns, convenções e ferramentas que são mais utilizados no mundo do PHP.
TLDR; as regras básicas são:
- Regra 1. Evite otimização prematura
- Regra 2. Faça a quantidade mínima de trabalho para resolver o problema
- Regra 3. Adie o trabalho que você não precisa fazer imediatamente
- Regra 4. Use o cache quando puder
- Regra 5. Compreenda e evite o problema de query N+1 com bancos de dados relacionais
- Regra 6. Prepare seu aplicativo para escalabilidade horizontal quando possível
Regra 1: Evite otimização prematura
Uma das citações mais famosas do Donald Knuth diz: “otimização prematura é a raiz de todo o mal”.
Knuth notou que muitos desenvolvedores de software geralmente desperdiçam uma quantidade enorme de tempo pensando sobre o desempenho de partes não críticas do código que eles estão escrevendo. Isso muitas vezes acontece porque esses desenvolvedores não sabem naquele momento quais são as partes críticas do seu código ou aquelas que precisam ser melhor otimizadas, então eles começam a se preocupar com coisas fúteis, tais como “aspas duplas nas strings são mais lentas do que aspas simples?”.
Para evitar cair na armadilha de otimização prematura, você deve escrever a primeira versão de seu código sem se preocupar muito com o desempenho. Então você pode usar um profiler para checar seu código e ver quais são os gargalos. Dessa forma, você pode se concentrar em melhorar apenas as partes que realmente precisam de sua atenção.
Nota: Eu quero deixar claro que a citação de Knuth não significa que você não precisa se preocupar com otimização e não é uma desculpa para escrever código ruim e depois abandoná-lo. Eu só pretendia usá-la como um incentivo de como aprender a “otimizar inteligentemente”, e essa é a maneira como você deve interpretá-lo também.
Se você estiver trabalhando no mundo PHP, há muitas ferramentas que você pode facilmente adotar para checar o perfil do seu código:
- xdebug: provavelmente é o mais famoso depurador e profiler PHP, ele deve ser instalado como uma extensão PHP e é facilmente integrável à maioria das IDEs.
- xhprof: é um profiler hierárquico em nível de função para PHP. Ele vem com uma interface de navegação simples baseada em HTML e oferece alguns recursos legais de comparação (diff), para comparar o desempenho de diferentes versões do seu código.
- Symfony profiler: é um uma das melhores características do framework Symfony. Ele permite que você inspecione o tempo de execução de cada pedido, apresentando uma boa linha do tempo que lhe permite compreender facilmente que parte do seu código é a mais demorada. Ele é ativado automaticamente no modo de “desenvolvimento” e não precisa de qualquer extensão PHP para ser instalado.
- O componente Stopwatch: é a biblioteca de baixo nível usada no Symfony profiler para medir o tempo de execução de um pedaço de código PHP. Ele pode ser facilmente integrado a qualquer projeto PHP e não requer qualquer extensão.
- Blackfire.io: um profiler otimizado para PHP que oferece uma interface web muito agradável que lhe permite compreender visualmente o que o código faz e onde a CPU consome a maior parte de seu tempo.
- Tideways: uma alternativa promissora ao Blackfire, oferece uma série de ferramentas gráficas (linha do tempo, gráficos etc.) para tornar realmente fácil encontrar pontos de gargalo. É concebido para ser executado continuamente (até mesmo em produção).
Se você quiser saber mais sobre esse assunto específico, você pode ler a respeito nos seguintes artigos e documentos:
- On optimization in PHP, Anthony Ferrara
- The fallacy of premature optimization, Randall Hyde
- Premature optimization, Cunningham & Cunningham, Inc
Regra 2: Faça apenas o que você precisa fazer
Muitas vezes, o seu código faz mais coisas do que ele é obrigado a fazer para produzir o resultado esperado. Isso é especialmente verdadeiro se você estiver usando bibliotecas complexas e frameworks em seu código. Só para dar alguns exemplos, você pode carregar classes que você nunca vai usar, você pode abrir uma conexão de banco de dados ou ler um arquivo para cada pedido, mesmo quando esses recursos não são necessários para gerar a saída para um pedido específico.
Há uma série de design patterns e técnicas que podem ajudá-lo a evitar essas situações e a alcançar melhores desempenhos.
- Autoloading: é uma feature do PHP que permite carregar o arquivo que contém a definição de uma classe somente quando você está prestes a usar essa classe (instanciação, chamada de método estático, acesso a uma constante etc.). Dessa forma, você não precisa se preocupar com quais arquivos irá incluir no seu script, mas apenas de quais classes irá precisar. Autoloading fará o resto para você. Configurar o autoloading era um pouco complicado no passado, especialmente porque cada biblioteca usada tinha suas próprias convenções, mas hoje, graças aos padrões PSR-0 e PSR-4 e ferramentas como o Composer, é mamão com açúcar usar o autoloading.
- Dependency Injection: é um design pattern muito comum no mundo Java que nos últimos anos teve muita tração, mesmo no mundo PHP, graças também ao esforço de frameworks como Symfony, Zend e Laravel, que o usam e o defendem amplamente. Basicamente, ele permite injetar componentes por meio do constructor ou de um método setter. Isso tem o efeito de forçar o desenvolvedor a pensar em termos de dependências e a criar pequenos componentes isolados focados em fazer apenas uma coisa e fazer bem.
- Lazy Loading: outro design pattern importante usado para adiar a inicialização de um objeto até o ponto em que ele é necessário. É usado principalmente com objetos que lidam com recursos pesados, como conexões a banco de dados ou arquivos baseados em fontes de dados.
Regra 3: Mamãe, eu faço amanhã!
Quantas vezes você precisou enviar um e-mail para um usuário depois que ele/ela desencadeou um evento específico no seu aplicativo web (por exemplo, a senha alterada ou pedido preenchido)? Quantas vezes você precisou redimensionar uma imagem depois que o usuário fez o upload? Bem, é bastante comum fazer essas operações “pesadas” antes de enviar uma mensagem de sucesso para o usuário. Dito de outra forma, os nossos usuários esperam ver alguma mensagem em seus navegadores o mais rápido possível, e temos de garantir que qualquer tarefa adicional (não diretamente relacionada com a criação dessa mensagem) deve ser adiada.
A maneira mais comum de fazer isso é usar filas de trabalho, o que significa que você tem que armazenar a quantidade mínima de dados necessários para realizar a tarefa entregue em uma fila de algum tipo (por exemplo, um banco de dados, um corretor de mensagens) e esquecer. Você tem que voltar imediatamente para a sua tarefa principal: gerar a saída para o usuário! Haverá algum tipo de worker no local com o objetivo de ler da fila periodicamente e realizar o trabalho adiado (por exemplo, o envio do e-mail ou gerar as miniaturas de imagens).
Um sistema de fila simples pode ser feito facilmente com qualquer tipo de armazenamento de dados (muitas vezes, Redis ou MongoDB são usados) ou um corretor de mensagens como o RabbitMQ ou ActiveMQ. Há também muitas implementações já feitas para o mundo PHP:
- Resque: uma biblioteca de fila do PHP que usa Redis como armazenamento de dados.
- Laravel Queues: Laravel/Lumen é uma solução out-of-the-box para adiar os trabalhos usando filas e workers. Ele pode ser configurado para utilizar diferentes armazenamentos de dados.
- Gearman: um servidor de trabalho genérico que suporta a grande maioria de linguagens (PHP, entre outras).
- Beanstalkd: outra fila de trabalho rápida, com bibliotecas de cliente para as linguagens mais comuns (Ruby, PHP etc.)
Regra 4: Tem que armazenar tudo em cache!
Atualmente, aplicativos web são peças de código realmente bem complexas. A fim de gerar uma resposta a todos os pedidos, geralmente fazemos um monte de coisas: conectar a um ou mais bancos de dados, chamar APIs externas, ler os arquivos de configuração, computar e agregar dados, serializar os resultados em algum formato parseable (XML, JSON etc.) ou renderizar com um modelo em uma página HTML maravilhosa. Usando uma abordagem ingênua, podemos fazer isso para cada solicitação que nós temos, e os nossos servidores nunca ficam entediados ao fazer tarefas repetitivas.
Mas há uma maneira mais inteligente (e mais rápida) para fazer tarefas repetitivas, evitando calcular os mesmos resultados toda vez: colocar “no cache”!
Cache, que é pronunciado “cash” (e não “catch” ou “cashay”), guarda as informações usadas recentemente para que elas possam ser rapidamente acessadas em um futuro próximo.
Cache é usado amplamente em ciência da computação, e você pode encontrá-lo praticamente todos os lugares. Por exemplo, a própria RAM pode ser considerada como uma forma de cache do código de execução de programas para evitar que a CPU tenha que ler o (lento) disco rígido em pequenos intervalos, milhares e milhares de vezes.
Em geral, com programação web, podemos nos concentrar em vários níveis diferentes de cache:
- Byte Code Cache: é uma característica comum de muitas linguagens interpretadas (PHP, Python, Ruby etc.) e permite evitar interpretar arquivos de código-fonte várias vezes repetidamente se eles não foram alterados desde a última vez. Algumas linguagens têm esse recurso integrado ao núcleo (Python), outras, como o PHP, precisam tê-lo como uma extensão, e várias extensões existem para este fim: APC, eAccelerator, Xcache. Desde o PHP 5.5, podemos usar a extensão Opcache, que foi integrada ao núcleo.
- Application Cache: não confunda com o application cache do HTML5, é a lógica de cache que pensa na sua aplicação específica e é provavelmente o mais importante em termos de desempenho. Você está computando o 1264575° número da sequência de Fibonacci várias vezes em seu aplicativo? Coloque o resultado em um cache e evite ter que recalcular toda vez. Ou, para dar um exemplo mais realista, você está sempre fazendo as mesmas consultas pesadas repetidamente no banco de dados para renderizar a página principal do seu app? Armazene em cache os resultados das consultas (ou até mesmo a saída de toda a página, quando possível) e evite chamar o banco de dados a cada solicitação do usuário. Nesses casos, é uma boa ideia usar servidores de cache como Memcached, Redis ou Gibson.
- HTTP Cache: buscar dados pela rede é lento – um monte de idas e voltas entre o cliente e o servidor são necessárias e muito tempo é desperdiçado antes de o navegador ser capaz de exibir o conteúdo. Não seria útil ter maneiras de dizer ao navegador para reutilizar o conteúdo que ele já baixou? Bem, você pode fazer isso usando cabeçalhos HTTP Cache como Etag e Cache-control. Essa acaba sendo a forma mais barata em termos de recursos de servidor para alavancar o armazenamento em cache (porque tudo já está no navegador, e o servidor não recebe nenhum pedido), você deve apenas ter certeza de usá-la corretamente para evitar que visitantes, ao voltarem, vejam conteúdo obsoleto.
- Proxy Cache: esta técnica refere-se ao uso de um servidor dedicado que recebe todo o tráfego HTTP e pode ter cópias das páginas web solicitadas pelos usuários (muitas vezes chamado de proxy reverso). Nesses casos, ela retorna a cópia da página diretamente, sem exigir que o servidor de aplicativo tenha que re-elaborar o pedido. Ela geralmente mantém a cópia dos dados na memória e evita muitas viagens à rede, por isso é geralmente uma abordagem out-of-the-box para acelerar sites com muito tráfego, nos quais o conteúdo não muda com muita frequência. Servidores proxy famosos são Varnish, Nginx e Squid. O Apache também pode ser configurado para funcionar como um proxy reverso.
De qualquer forma, uma vez que você pega o conceito de armazenamento em cache, é realmente muito fácil de adotá-lo. Os problemas surgem quando você precisa entender se algo mudou, e a versão em cache de seus dados pode não ser mais relevante. Nesses casos, você precisará excluir os dados em cache para ter certeza de que eles serão recalculados corretamente na próxima requisição. Esse processo é chamado de “invalidação de cache”, e geralmente faz com que desenvolvedores cheguem à loucura, a ponto de que uma frase muito famosa exista: “Há apenas duas coisas difíceis em Ciência da Computação: invalidação de cache e nomear as coisas” – Phil Karlton.
Se você está na área de desenvolvimento de software por um tempo, eu tenho certeza de que você já deve ter ouvido isso!
Não há nenhuma bala de prata para tornar invalidação cache algo fácil, ele realmente depende da arquitetura do seu código e dos requisitos de sua aplicação. Em geral, quanto menos camadas de cache você tem, melhor: sempre evite adicionar complexidade!
Aqui seguem alguns artigos que podem ser interessantes para saber mais sobre o armazenamento em cache para aplicações web:
- Web application caching, DocForge
- Fine tune your Opcache configuration to avoid caching surprises, blog Tideways
- Beginners guide to HTTP cache headers, Mobify
- HTTP Caching, optimizing content efficiency, Google
- Using Http headers with Symfony, Symfony
- What is a reverse proxy server, Nginx
- Laravel cache, Laravel
Regra 5: Evite o maldito problema das consultas N+1
O “problema de consultas N+1” é um anti-pattern muito comum, usado especialmente ao lidar com bancos de dados relacionais. Basicamente, ele lê N registros do banco de dados por meio da geração de N+1 consultas (uma para ler as n IDs e uma para cada registro). Dê uma olhada no trecho de código seguinte para ter um caso real (bem… quase real):
<?php function getUsers() { //... retrieve the users from the database (1 query) return $users; } function loadLastLoginsForUsers($users) { foreach ($users as $user) { $lastLogins = ... // load the last logins for the user (1 query, executed n times) $user->setLastLogins($lastLogins); } return $users; } $users = getUsers(); loadLastLoginsForUsers($users);
O pedaço de código acima carrega primeiro uma lista de usuários e, em seguida, para cada usuário, ele carrega seus últimos registros de login do banco de dados. Esse código produz as seguintes consultas N+1:
SELECT id FROM Users; -- ids: 1, 2, 3, 4, 5, 6... SELECT * FROM Logins WHERE user_id = 1; SELECT * FROM Logins WHERE user_id = 2; SELECT * FROM Logins WHERE user_id = 3; SELECT * FROM Logins WHERE user_id = 4; SELECT * FROM Logins WHERE user_id = 5; SELECT * FROM Logins WHERE user_id = 6; -- ...
Isso é obviamente ineficiente e acontece muitas vezes com os relacionamentos “has many” em bases de dados, especialmente quando você está usando algum tipo de ORM mágico e você não sabe exatamente o que está acontecendo fora da caixa (e provavelmente você não o configurou corretamente).
Em geral, você pode resolver esse problema com uma consulta como a seguinte:
SELECT id FROM Users; -- ids: 1, 2, 3, 4, 5, 6... SELECT * FROM Logins WHERE user_id IN (1, 2, 3, 4, 5, 6, ...);
ou usando a sintaxe JOIN sempre que possível.
Esse problema só pode ser abordado quando você está no controle de suas consultas SQL ou se você tiver uma compreensão clara da biblioteca ORM que está usando (se você estiver usando uma). De qualquer modo, mantenha isso em mente e certifique-se de que você não caia na armadilha da consulta N+1, especialmente quando você lidar com grande volume de dados. Muitos profilers PHP permitem inspecionar as consultas geradas para cada solicitação de página; eles podem ser companheiros muito úteis para compreender se você está fazendo as coisas corretamente em termos de evitar o problema das consultas N+1.
Falando de bancos de dados, certifique-se de manter uma única conexão aberta com sua fonte de dados, e não reconecte para cada consulta.
Não espere que isso seja exaustivo. Esse problema é muito amplo e tem um monte de outros casos importantes a serem analisados, portanto, se você quer saber mais a respeito, dê uma olhada nos seguintes artigos e livros:
- Performance: N+1 Query Problem, de Phabricator
- Nested Loops, de Use the Index, Luke
- Laravel’s Eloquent ORM Eager Loading, de Laravel
- Livro Solving The N+1 Problem In PHP, de Paul M. Jones
Regra 6: Escale… horizontalmente!
“Escalabilidade” não é exatamente a mesma coisa que “desempenho”, mas as duas coisas estão intimamente interligadas. Para dar a minha definição pessoal, “escalabilidade” é a capacidade de um sistema para se adaptar e manter-se funcional sem problemas de desempenho perceptíveis quando o número de usuários (e solicitações) cresce.
É um tema muito amplo e complexo, e eu não quero entrar em detalhes aqui. Mas por causa do desempenho vale a pena entender e ter em mente algumas coisas simples que você pode fazer para ter certeza de que seu aplicativo pode ser facilmente escalado horizontalmente. Escalar horizontalmente é uma estratégia de escalar particular, em que você adiciona mais máquinas ao cluster em que seu aplicativo está implantado. Dessa forma, a carga é distribuída entre todas as máquinas e, portanto, todo o sistema pode permanecer performatico, mesmo quando há um grande número de pedidos simultâneos.
Os dois principais problemas a serem considerados quando você vai escalar horizontalmente são as sessões de usuário e a consistência de arquivos do usuário.
- Sessões: muitas vezes (especialmente em aplicações PHP), os dados de sessão do usuário são armazenados no sistema de arquivos local onde o aplicativo é implantado. Usar essa estratégia não só é mais lento, como também não vai funcionar se você tem duas máquinas para lidar com os pedidos (os dados da sessão armazenados em uma máquina serão diferentes dos que estarão armazenados na outra). Uma solução melhor é usar algum tipo de banco de dados para armazenar os dados da sessão do usuário. A maioria dos frameworks permite, de uma forma fácil, que você faça isso com apenas algumas linhas de configuração. No início, quando o seu aplicativo for pequeno e não tão popular, você pode instalar a sua plataforma de armazenamento de sessão favorita na mesma máquina do seu servidor web. Quando você precisar escalar sua arquitetura, pode facilmente mover o armazenamento de sessão para uma máquina separada e conectar todas as suas máquinas web nela.
- Consistência dos arquivos do usuário: o mesmo problema das sessões acontece quando os usuários podem armazenar arquivos dentro de seu aplicativo. Nesses casos, você precisa ter certeza de que em qualquer servidor web que eles estejam, eles serão capazes de ver os seus arquivos. Então, você precisa manter os arquivos em um armazenamento dedicado (como Amazon S3 ou Rackspace CloudFiles). Caso contrário, você pode manter os arquivos localmente nas máquinas, mas precisa encontrar uma maneira de mantê-los sincronizados dentro de todas as máquinas do cluster. Nesse caso, você pode utilizar NFS ou GlusterFS para criar um sistema de arquivos compartilhado.
Aqui está uma lista de outros recursos interessantes para saber mais sobre aplicações web escaláveis:
- Horizontally scalable web applications, de Inviqa
- Horizontally Scaling PHP Applications: A Practical Overview, de Digital Ocean
- Best Practices For Horizontal Application Scaling, de OpenShift
- Scalable Web Architecture and Distributed Systems, de Kate Matsudaira
- Intuitively Showing How To Scale A Web Application Using A Coffee Shop As An Example, de HighScalability
- Livro The art of scalability, de Martin Abbot e Michael Fisher
- Slides 7 Stages of scaling web applications, de Rackspace
Conclusões
Eu realmente espero que este longo artigo tenha sido útil para você. Eu só queria te dar uma ideia de quais são as preocupações gerais que você deve ter em mente quando começar a escrever um aplicativo em que deve levar em consideração o desempenho. Como eu disse na primeira regra, não caia na armadilha de otimização prematura e se concentre apenas em escrever o código certo para o trabalho certo. Enfim, se você tem isso bem claro em sua mente, você vai pensar quase que automaticamente sobre boas soluções para alcançar um bom nível de desempenho e escalabilidade desde as primeiras versões do seu aplicativo, e você também pode tomar algumas decisões inteligentes de arquitetura desde o início.
No caso de você ser um desenvolvedor web experiente, deixe-me saber se eu esqueci de mencionar alguma regra importante, se você costuma seguir essas regras, e o que você pensa sobre elas.
Obrigado por usar seu tempo para ler este artigo.
***
Luciano Mammino faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: http://loige.co/6-rules-of-thumb-to-build-blazing-fast-web-applications/