Arquitetura: Princípios para alcançar Desempenho e Escalabilidade em Aplicações

Otavio Pecego Coelho

Arquiteto de Soluções
DPE - Microsoft Brasil
Novembro - 2004

Introdução

Trabalhando na Consultoria da Microsoft Brasil tive por várias vezes a oportunidade de lidar com problemas de performance e escalabilidade em aplicações. Muitas vezes conseguimos avanços espantosos com pequenas modificações no código ou estrutura dos módulos dos programas.

Hoje, como Arquiteto de Soluções da Microsoft, sou muitas vezes chamado para explicar de forma concisa como conseguir boa performance e escabilidade com a tecnologia .Net. Neste exercício, me dei conta que com alguns poucos princípios gerais conseguimos explicar muitas das boas práticas de desenho e arquitetura que levam a bons resultados.

Este artigo apresenta estes princípios que considero como os mais básicos e enumera algumas das suas conseqüências tanto para o design quanto para a implementação de aplicações.

Princípios

São seis os princípios básicos que encontrei e que podem ajudar na decisão relativa à escolha de uma tecnologia ou padrão de design.

Um ponto importante, que logo vocês irão notar, é que alguns princípios são conflitantes. Isto acontece porque estamos tentando alcançar dois objetivos ao mesmo tempo: desempenho e escalabilidade.

O desempenho está relacionado ao quão rápido uma tarefa computacional pode ser executada. Idealmente, o desempenho não trata da questão do limite físico de uma máquina (seja CPU, memória, disco, etc). Algoritmos podem ser classificados de acordo com a sua estrutura interna. De acordo com este ponto de vista, minimizar o número de passos de uma computação é o objetivo prioritário.

A escalabilidade, por sua vez, incorpora os limites físicos à questão do desempenho. Como se comporta o desempenho quando atingimos os limites físicos das máquinas, e que estratégias devemos utilizar para aumentar a quantidade de processamento disponível.

Duas são as linhas mestras para atingir maior escalabilidade: o incremento com novos hardwares ou a substituição por novos hardwares de maior desempenho.

O primeiro é denominado escalabilidade horizontal. O exemplo típico é um farm Web, onde podemos incorporar novas máquinas ao farm para dar conta do aumento da demanda pelos usuários finais.

O segundo é a escalabilidade vertical, onde uma nova máquina mais possante substitui a antiga.

Na relação entre custo e benefício, é mais comum que a escalabilidade horizontal ganhe, não só devido aos custos reais ($$), mas também devido aos benefícios de segunda ordem, como a melhoria da disponibilidade.

Juntar estas duas perspectivas (desempenho e escalabilidade) é parte da arte de um arquiteto. Neste exercício de equilíbrio, surgiram as arquiteturas cliente-servidor, 3-camadas, n-camadas, uso de caching, monitores transacionais, etc.

Interessante notar que todas estas arquiteturas foram estruturadas de acordo com alguns princípios básicos. Dentre eles, os que entendo serem mais relevantes são:

  • Aproximar Algoritmos aos Dados

  • Aumentar o Paralelismo

  • Não Estabelecer Afinidade

  • Minimizar Contenções

  • Minimizar o Uso de Recursos

  • Pré-alocar e Compartilhar Recursos Caros

Abaixo descrevo o que são e a motivação de cada um destes.

Princípio 1: Aproximar Algoritmos aos Dados

Dados e algoritmos (que operam sobre estes dados) devem estar o mais próximo possível. Se estiverem distantes, o tempo de acesso aos dados se deteriora, causando um desempenho pior. O lugar mais próximo entre os dois ocorre dentro da mesma máquina e do mesmo processo. Se o dado estiver em disco, já existe retardo - que é considerável em relação à memória local. Se estiver em outra máquina interligada via rede, o retardo poderá ser comparativamente imenso.

Este princípio já é utilizado há muito pelos arquitetos de hardware. CPUs costumam ter registradores e estes têm velocidade de acesso muito maior do que a do acesso à memória RAM. Parte do trabalho dos otimizadores dos compiladores modernos é o de alocar corretamente as varáveis de um programa nos registradores da CPU.

Velocidade e latência são os dois atributos principais. Quanto mais veloz o meio para o acesso e quanto menor a latência, melhor. A velocidade pode ser a da luz, mas se a latência devido a distância for muito grande, não temos desempenho.

Podemos imaginar uma rede de alta velocidade, mas se o protocolo entre camadas for inadequado, parte do benefício é roubado.

Princípio 2: Aumentar o Paralelismo

Aumentar o paralelismo nem sempre é factível - mas costuma ser factível na maioria das vezes. O princípio é simples: quanto mais hardware você conseguir manter trabalhando ao mesmo tempo, maior performance você vai ter.

Hoje, nossos computadores de casa e do escritório já são multi-CPUs e fazem isto para aumentar a performance. Unidades de processamento aritmético, placas gráficas, controladoras de discos rígidos, hyper-threading, etc. Os engenheiros estão sempre encontrando maneiras de paralelizar processamento para ganhar desempenho.

Nesta perspectiva, a grande dificuldade para a programação é evitar que o algoritmo se torne muito complexo.

O interessante é que a pesquisa sobre Processamento Transacional nas últimas 3 décadas facilitou enormemente a implementação do paralelismo entre tarefas. Bancos de Dados é O grande exemplo aqui. Hoje programamos nossas tarefas de negócio assumindo naturalmente que muitas instâncias destas tarefas estarão sendo executadas ao mesmo tempo porque deixamos o controle da concorrência para o Banco de Dados. Ao utilizar esta técnica, aumentamos potencialmente o poder de escalabilidade horizontal, já que novas máquinas poderão estar sendo incorporadas para realizar mais tarefas em paralelo e, portanto, aumentando no tempo o número total de tarefas que podem ser realizadas.

Este é o caso ótimo - não o realista. Afinidade e Contenção são dois pontos que atrapalham o grau de paralelismo que podemos alcançar. Mas isto é tema para os dois próximos princípios.

Princípio 3: Não Estabelecer Afinidade

Afinidade é o nome a que se dá quando certa tarefa fica amarrada a um lugar físico. Por exemplo, se apenas uma máquina possuir um recurso (por exemplo, o Banco de Dados), todos seus usuários estarão amarrados à esta máquina. Isto causa problemas de contenção no acesso ao recurso (todos competem pela rede, CPU, memória e disco do Banco de Dados), quando poderíamos pensar no uso de um pool de recursos (conjunto de Bancos de Dados replicados em máquinas diferentes) para diminuir esta contenção e aumentar o paralelismo.

Este exemplo do Banco de Dados em várias máquinas não é usual, devido à dificuldade de implementá-lo. Quando implementado, a técnica normalmente usada é a da fragmentação de tabelas ou replicação. Estaremos vendo o porquê disto mais adiante. O uso de cache local nos clientes do Banco é outra técnica possível.

Note que podemos aumentar o paralelismo e ainda manter a afinidade. Por exemplo, podemos imaginar o aumento de máquinas para processamento de regras de negócio, mas mantendo a afinidade ao servidor de Banco de Dados. Se o limite do Banco não for atingido rapidamente, esta estratégia de paralelismo pode ser muito eficaz (sendo, creio eu, a técnica mais utilizada hoje em dia).

Outra técnica usual de paralelismo é o pipeline, onde cada máquina ou tarefa executa uma parte do processamento. A afinidade é total, mas a performance pode ser excelente dependendo da estrutura do problema/solução.

Princípio 4: Minimizar Contenções

Contenção significa aguardar numa fila para poder ser servido. Existem várias filas na execução de tarefas em uma arquitetura moderna. Filas de disco, filas de espera da liberação do locking de certos registros ou tabelas do Banco de Dados, etc.

Contenções são difíceis de serem tratadas e podem causar deadlocks. Embora muitas ferramentas implementem schedulers que reordenam seqüências de operações conflitantes (que é o que acontece num Banco de Dados), muitas vezes temos que ajudar com esta ordenação também em nossos algoritmos - como veremos mais adiante.

Princípio 5: Minimizar o Uso de Recursos

Recursos serão sempre escassos a partir de certo número de usuários e tarefas concorrentes - seja cpu, memória, disco, rede, etc.

Normalmente não nos damos conta de que estamos usando recursos demais nos nossos algoritmos. Por exemplo, costumamos passar mais parâmetros do que deveríamos em nossas chamadas a métodos e procedimentos - tudo em nome da generalidade. Outro exemplo é o total descuido que temos quanto ao tempo em que alocamos um recurso.

Já perdi a conta do número de vezes que observei locks de linhas de tabelas de banco de dados serem feitas muito antes do uso real dos valores lidos - aumentando o tempo de contenção de outras tarefas. É comum também ver abuso no armazenamento de dados em variáveis de sessão (uso de memória) ou no envio de conjuntos de dados imensos entre camadas da aplicação.

Um último exemplo e muito comum é o abuso no volume de dados retornado por uma query de banco de dados. Quando fazemos isto estamos gastando tempo, rede e memória.

Todos estes são exemplos da nossa tendência natural a gastar mais do que precisamos - o que fará falta em condições de stress.

Princípio 6: Pré-alocar e Compartilhar Recursos Caros

Recursos caros devem ser pré-alocados e compartilhados. Recursos podem ter um tempo muito longo de construção (inicialização), como conexões de banco de dados ou rede. Tê-los construídos antes da hora do uso é parte do truque para obter melhor desempenho.

Porém, nem sempre estes recursos estarão em uso. Por que não deixá-lo descansando para a próxima vez? Este é um dos princípios do uso da técnica de Cache ou pool de recursos (pool de objetos, conexões, etc).

Princípio 7: Manter a Corretude e Simplicidade

Sim, este princípio não estava listado. Nem seria preciso caso não tivéssemos a mania de, por vezes, procurar mais paralelismo e desempenho esquecendo-nos da corretude e simplicidade.

Nossos sistemas devem ser corretos - ponto!

A simplicidade nos ajuda a mantê-los corretos e com um custo aceitável. Realizamos isto via abstrações - como tarefas, monitores de transação, etc.

Princípio 8: Use um Algoritmo e Estruturas de Dados Eficientes

Este também não estava listado... É óbvio, mas infelizmente ainda existe pouco conhecimento das técnicas de análise da complexidade de algoritmos entre os desenvolvedores que tenho conhecido. É freqüente o uso de estruturas de dados e algoritmos ineficientes. Existem boas referências sobre este assunto, embora recheadas de matemática pesada...

Técnicas para Performance e Escalabilidade

Os princípios aqui listados implicam em conseqüências de design. Como exercício, comento abaixo algumas práticas atuais que são decorrentes do uso destes princípios.

Técnica 1: Minimize Interações entre Fronteiras

Fronteiras são caras (Princípio 1) mas podem escalar horizontalmente pois, muitas vezes, permitem a possibilidade do aumento do paralelismo em processos ou CPUs diferentes (Princípio 2).

Este é um caso típico de conflito entre princípios, mas onde aceitamos pagar o preço de ter fronteiras (o que significa retardos) para ganhar paralelismo.

Como queremos o benefício de processos em separado, e como sabemos que existe um custo associado, a solução é minimizar as interações, seja minimizado o número de chamadas, e/ou minimizando o volume de dados passados.

O bom uso da primeira técnica é quando em uma única chamada passamos todos os parâmetros necessários para que a outra parte faça o seu trabalho e quando esta devolve apenas os dados necessários do resultado. Nada de muitas idas e voltas de parâmetros e resultados devido a chamadas intensas de métodos e acesso a propriedades.

Um exemplo de dados necessários está no retorno consciencioso das colunas realmente utilizadas pelos clientes, no caso de uma query ao banco de dados.

Outro exemplo de conflito entre princípios está no retorno de um grande result set. Embora isto minimize idas e vindas, esta técnica vai contra o princípio 5, pois causa excesso de uso de memória e rede. Melhor usar a técnica de paginação já no acesso ao banco de dados.

Técnica 2: Evite Referências para objetos Remotos

Aqui estamos no famoso caso do stateless contra stateful. Referências a objetos remotos são o caso do stateful e implicam em afinidade (o que vai contra o Princípio 3). Além disso, como os objetos remotos ficam presos na memória esperando sua liberação pelo cliente, o uso de referências vai contra o Princípio 5.

Técnica 3: Obtenha o mais tarde e Libere o Mais Cedo

Esta é uma recomendação bem entendida, mas muito pouco obedecida pelos desenvolvedores. São os Princípios 4 e 5 que pedem que esta regra seja obedecida, mas na maior parte dos casos, o designer das classes ou do algoritmo não leva em conta a performance. É comum fazer um modelo de classes, determinar seus métodos, e perder o contexto do uso dos recursos que são utilizados por estes métodos. Logo estamos fazendo o diagrama de interação e não sabemos mais quando começaram as operações que fazem lock no banco de dados ou que simplesmente alocam conexões de um pool. Em conseqüência, aumentamos o tempo de contenção desnecessariamente, deteriorando o desempenho do sistema em momentos de stress.

Um esboço para um padrão de codificação que garanta uma performance melhor é:

  1. Coletar todos os dados possíveis que não precisem do Banco de Dados (parâmetros, cache, etc);

  2. Conectar com o Banco;

  3. Coletar todos os dados (com o mínimo de interações) que não impliquem em lock, necessários para realizar a ação;

  4. Realizar contas e procedimentos possíveis de serem feitas só com estes dados;

  5. Ir ao banco de dados (com o mínimo de interações) para realizar operações que exigem lock;

  6. Liberar a conexão;

Técnica 4: Minimize o uso de Stored Procedures

O Princípio 1 diria: "use apenas stored procedures". No entanto, esta decisão pode causar grandes problemas para o Princípio 2 devido à escalabilidade.

Stored Procedures têm várias vantagens. Primeiro, são pré-compiladas - o que significa menos processamento. Segundo, não há nada mais perto de um dado no Banco de Dados.

No entanto, Banco de Dados são Monitores de Transação muito especializados em quatro funções: Sort, Merge, gerência de Locks e Log para recuperação em caso de acidentes.

De todos estes, a gerência de Lock é uma das tarefas mais críticas para a implementação de algoritmos distribuídos. Este é o real motivo de existirem poucos Bancos de Dados que possam implementar a escabilidade horizontal. A gerência de Locks pede memória compartilhada (Princípio 1) o que impede arquiteturas distribuídas (à menos que o problema de intercomunicação seja minimizada - o que estão tentando fazer com HPC - High Performance Computing).

O que isto tem a ver com stored procedures? Bem, se as máquinas de Banco de Dados têm dificuldade de escalar horizontalmente, ela é um recurso escasso e precioso. Temos então que otimizar seu uso para que ela faça apenas o que sabe fazer bem.

Daí o conselho freqüente de deixar de fora do banco toda a manipulação de strings, cálculos e decisões de negócio e implementar em stored procedures os comandos mais básicos. Como estão em stored procedures, não são recompiladas. Como fazem apenas o que os Bancos melhor sabem fazer, utilizamos melhor o recurso e adiamos, ao final, a necessidade de escalar verticalmente.

Técnica 5: Utilize Pool de objetos e Cache

Os Princípios 1, 5 e 6 nos levam ao uso de Pool e Cache.

Muitos dados são lidos com muita freqüência e são raramente modificados (um exemplo é o uso de tabelas de metadados e de certos cadastros básicos). Gastar tempo, banda e Cpu do Banco de Dados para trazê-los fere os Princípios 1 e 5. Melhor trazê-los no início (Princípio 6) e garantir que estão locais (Princípio 1).

No entanto, dois cuidados devem ser levados em conta:

  1. Não use espaço demasiado da memória - contradizendo o Princípio 5 para o item memória.

  2. Se houver escrita e precisarmos de propriedades ACID, estaremos incorrendo no problema de sincronização e lock entre vários caches - já que um cache naturalmente introduz afinidade (ver Princípio 3). Se a política de renovar o cache de tempos em tempos não for suficiente, o melhor, neste caso, usar o Banco de Dados.

Objetos que custam caro para inicializar merecem uma técnica semelhante: o pool. Nele, varos objetos são pré-inicializados e fica à espera de chamadas. Uma vez chamados, ficam alocados à tarefa corrente até que, uma vez liberados, retornem ao pool.

Técnica 6: Use, quando possível, Técnicas Assíncronas

Filas podem ser criadas por bons motivos: seja para manter a consistência de dados (ex.: filas de contenção devido à locks), ou para diminuir o risco do uso indiscriminado de recursos.

O uso de técnicas assíncronas, quando permitido pelas regras de negócio, traz algumas oportunidades de otimização no uso de recursos. Com o enfileiramento de mensagens em sistemas de filas (como o MSMQ), podemos alocar recursos limitados e suficientes para o tratamento das mensagens. Com isto, poderemos consumir aos poucos os elementos da fila sem aumentar os recursos em momentos de pico. Exemplo: disponibilizamos 10 threads para tratamento de um tipo de requisição e não estaremos usando mais recursos caso haja um pico de requisições. O tempo médio do tratamento será maior (devido ao tempo de espera), mas estaremos garantindo um limite no uso dos recursos (Princípio 5)

Conclusão

Muitas outras técnicas de performance podem ter seu impacto medido de acordo com os princípios apresentados. O texto Improving .NET Application Performance and Scalability (https://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnpag/html/scalenet.asp) é uma referência bem completa sobre este assunto. Meu conselho é tê-los em mente em toda decisão de design a ser feita em um projeto. O balanceamento destes princípios pode significar o sucesso ou fracasso da sua aplicação.

Lembre-se também que você não precisa de performance em todos os momentos. Como a exigência de performance pode levar à técnicas de razoável complexidade, você poderá estar onerando em demasia o seu projeto. Por isto, estabeleça na fase de requerimentos os pontos críticos de performance a serem obtidos. Com isto, você poderá priorizar questões como reuso e manutenabilidade e utilizar técnicas de performance em situações realmente necessárias.