Microsserviço 13
Quando precisamos escalar um sistema, normalmente fazemos isso por dois motivos:
Primeiro, porque escalar um sistema nos permite melhorar seu desempenho, seja lidando com mais carga de trabalho, seja reduzindo a latência. Segundo, porque escalar um sistema nos permite torná-lo mais robusto.
Não existe uma forma correta, uma receita de bolo, de como escalar sistemas, pois a técnica a ser usada dependerá do tipo de limitação existente. Temos vários modos diferentes de escalar à disposição para nos apoiar na decisão por desempenho, robustez ou ambos.
Um modelo que pode ser utilizado para apoiar a visualização das diferentes formas de escalabilidade é o Scale Cube (cubo da escalabilidade), o qual divide as opções de escalar em três categorias: decomposição funcional, duplicação horizontal e partição de dados. Entretanto, podemos complementá-las com mais uma categoria, a escalabilidade vertical.
| Categoria | Ideia central | Complexidade |
|---|---|---|
| Escalabilidade vertical | Adquirir uma máquina mais potente e robusta | Baixa |
| Duplicação horizontal | Ter vários itens que executam o mesmo trabalho | Baixa/Média |
| Partição de dados | Dividir o dado com base em algum atributo | Alta |
| Decomposição funcional | Separar o trabalho (ex.: decomposição em microsserviços) | Alta |
Ao escalar um sistema, pense sempre em começar pela forma mais simples.
Escalabilidade Vertical
A escalabilidade vertical acaba sendo a opção mais rápida e simples de ser implementada. Algumas operações podem se beneficiar simplesmente por haver mais CPU e mais I/O. Entretanto, a depender do cenário e do workload, ela não resolve o problema a longo prazo.
A principal vantagem é a simplicidade de implementação, que em muitos cenários dispensa qualquer alteração no código ou no banco de dados. Com a adoção de uma infraestrutura virtualizada, o problema de superestimar máquinas (já que, em muitos ambientes corporativos, existe a dificuldade de obter aprovações de orçamento) acabou sendo atenuado. Adicionar mais recursos passou a ser uma tarefa rápida.
Todavia, como nos últimos 5 a 10 anos temos visto o aumento no número de núcleos de CPU, e não da velocidade de clock propriamente dita, tirar proveito de um hardware multicore pode sim significar mudanças nos sistemas.
Duplicação Horizontal
Na duplicação horizontal, duplicamos o sistema para que ele lide com mais cargas de trabalho. A ideia é dividir o workload entre as duplicatas.
A partir da duplicação, fazemos uso de um load balancer para distribuir os requests entre as instâncias.
Os recursos dos load balancers diferem, mas as duas principais features esperadas são: que tenham algum método de distribuir a carga entre os nós e que consigam detectar quando um nó está indisponível, removendo-o do pool do load balancer.
Outra abordagem de duplicação horizontal é o padrão de consumidores concorrentes (competing consumers pattern). Nesse padrão, temos uma fila de tarefas a serem processadas e um pool de workers consumindo e processando os itens dessa fila. São competing porque os workers competem pelos itens da fila.
A duplicação horizontal pode ser usada inclusive em banco de dados, por exemplo, no cenário em que existem muitas leituras no banco de dados principal. Podemos adicionar réplicas de leitura, reduzindo a carga de leitura do banco principal e disponibilizando recursos para lidar melhor com as escritas.
A principal vantagem da duplicação horizontal é a simplicidade. É raro que a aplicação precise ser atualizada, já que o trabalho de distribuir a carga entre as instâncias do sistema é feito em outros lugares (message broker ou load balancer).
Entretanto, a duplicação horizontal exige mais infraestrutura, aumentando o custo, e também pode ser um desperdício de recurso, já que estaríamos executando o sistema completo ainda que somente parte dele estivesse "sofrendo" com problemas relacionados à escalabilidade.
Sistemas que possuem o requisito de sticky session limitam os métodos de distribuição de carga que podem ser considerados. Evite criar sistemas com esse requisito.
Partição de Dados
Na partição de dados (sharding), a complexidade aumenta. Agora, precisamos distribuir a carga com base em algum aspecto dos dados, por exemplo, talvez com base no usuário.
Funciona tomando-se uma chave associada à carga de trabalho e aplicando a ela uma função. O resultado será a partição (shard). Para exemplificar, uma abordagem bem rudimentar seria: se a primeira letra do nome do usuário estiver entre A e M, enviamos a requisição para o banco de dados X; caso a primeira letra esteja entre N e Z, enviamos a requisição para o banco de dados Y. Claro que uma partição em nível de banco de dados faz mais sentido se o banco em uso aceita esse conceito de modo nativo. Uma alternativa mais sensata seria fazer a partição com base em um ID único atribuído a cada usuário. É muito mais provável que isso resulte em uma distribuição mais uniforme da carga.
O fato de termos implementado uma partição de dados deve ser tratado como um detalhe de implementação interno ao microsserviço que estiver sendo usado.
Outro exemplo de partição de dados é fazê-la com base na localização geográfica, por exemplo, partição por país, por região, etc.
A principal vantagem da partição de dados é escalar muito bem para cargas de trabalho transacionais. Se o sistema é limitado por escritas, a partição de dados pode proporcionar grandes melhorias, além de também funcionar bem com a duplicação horizontal — cada partição poderia ser composta de vários nós capazes de lidar com o workload.
Porém, se uma partição falhar, as requisições direcionadas a essa partição também falharão. Por isso a combinação com a duplicação horizontal, para aumentar a robustez.
Uma partição de dados implica mais trabalho, porque provavelmente exigirá muitas alterações nos dados do sistema atual.
Decomposição Funcional
Na decomposição funcional, extraímos uma funcionalidade e permitimos que ela seja escalada de modo independente. Por exemplo, extrair para um microsserviço uma funcionalidade do sistema que esteja sendo muito requisitada. Com isso, a parte do sistema que possui requisitos mais modestos poderia ser implantada em máquinas menos robustas, e a parte extraída para um novo microsserviço poderia ter mais hardware à sua disposição.
Esta abordagem permite:
- dimensionar melhor a infraestrutura necessária para executar os workloads;
- mais flexibilidade para otimizar o custo da infraestrutura de que precisamos;
- empregar diferentes tecnologias que permitam escalar o microsserviço decomposto.
Claro que separar as funcionalidades pode ser uma atividade complexa, já que causará impacto no código da aplicação e pode exigir um volume de trabalho significativo na camada de dados, sendo improvável que traga benefícios no curto prazo. Também deve-se ter em mente que, com a decomposição, o número de microsserviços em execução acabará aumentando, o que elevará a complexidade geral do sistema (mais componentes a serem mantidos).
Assim, quando se trata de escalar um sistema, procure esgotar todas as outras possibilidades antes de considerar a decomposição funcional. Claro que, quando uma empresa escolhe migrar para microsserviços, tem-se a oportunidade tanto de escalar o negócio (mais funcionalidades) quanto de aumentar o time de desenvolvimento.
A combinação dos modelos de escalabilidade é sim uma abordagem a ser considerada. A proposta do Scale Cube é fazer com que paremos de pensar de modo restrito em um só tipo de forma de escalar os sistemas e passemos a considerar escalar ao longo de vários eixos. Embora o objetivo de escalar um sistema não seja necessariamente escalar em todos os eixos, devemos estar cientes de que temos diferentes métodos à disposição.
Otimizar sistemas para resolver problemas que não existem é uma ótima maneira de desperdiçar um tempo que poderia ser mais bem gasto em outras atividades. Qualquer forma de otimização deve ser motivada por uma necessidade real.
Agora, comece aos poucos, em escala pequena. No contexto de escalar sistemas para lidar com uma carga, ter uma suíte de testes de carga automatizados, por exemplo, pode ser extremamente conveniente.
Caching
A utilização de caching é uma otimização de desempenho comum, em que o resultado anterior de uma operação é armazenado, de modo que requisições subsequentes utilizem esse valor armazenado. Se as requisições subsequentes encontram o dado no cache, temos um cache hit. Se não o encontram, temos um cache miss.
Os caches podem armazenar desde resultados de consultas simples até o resultado de um cálculo complexo.
Como os dados podem mudar ao longo do tempo, precisamos de alguma forma de invalidar as entradas no cache. Como há diversos métodos de invalidação, bem como lugares em que é possível utilizar um cache, precisamos entender os problemas que o cache pode ajudar a resolver.
Desempenho
Podemos diminuir a latência e o custo na comunicação entre microsserviços, evitando fazer chamadas de rede e, consequentemente, reduzindo a carga nos microsserviços downstream. Dados complexos podem ser cacheados de modo a otimizar o acesso.
Escalabilidade
Ao direcionar as leituras dos dados para o cache, podemos evitar uma contenção em partes do sistema, ou seja, situações em que a origem seja um ponto de contenção. Colocar caches entre os clientes e a origem pode ajudar a reduzir a carga na origem, permitindo escalar com mais facilidade.
Robustez
Caso exista um conjunto de dados completo disponível em cache, será possível responder solicitações mesmo que a origem esteja indisponível. O ponto principal é que provavelmente será preciso configurar um método de invalidação de cache que não invalide os dados desatualizados de forma automática, mantendo-os no cache até que possam ser atualizados.
Usar um cache para ter robustez em uma situação na qual a origem está indisponível significa favorecer a disponibilidade em relação à consistência.
Onde usar
Os diferentes lugares para um cache têm relações de custo-benefício, ou seja, vantagens e desvantagens no seu uso. O cache pode ser aplicado no lado do cliente, no lado do servidor e como cache de requisições.
No lado do cliente, podemos ter uma tabela hash em memória com um mapeamento dos dados. Por exemplo, poderíamos ter uma tabela que relacione IDs a algumas informações obtidas de outro microsserviço. Em geral, tendem a ser muito eficazes, pois evitam chamadas de rede para o microsserviço downstream.
Porém, essa estratégia tem algumas desvantagens. Em primeiro lugar, há a tendência de haver mais restrições quanto às opções de métodos de invalidação, por exemplo, quando um dado for alterado no serviço downstream. Em segundo lugar, quando há um grande volume de caching no lado do cliente, pode haver um certo grau de inconsistência. Quanto mais clientes houver, mais problemática provavelmente será essa situação. Formas de atenuar esse problema podem ser a invalidação baseada em notificação ou ter um cache compartilhado (redis ou memcached), já que eliminamos a inconsistência entre os diferentes clientes, mas, em contrapartida, os clientes terão de fazer um acesso de ida e volta ao cache compartilhado (além de ser necessário definir quem é responsável por esse cache compartilhado).
No lado do servidor, o próprio serviço downstream mantém dados em cache para os consumidores, tendo total responsabilidade pelo gerenciamento do cache. Ter um cache no lado do servidor também facilita evitar o problema de diferentes consumidores verem diferentes valores em cache, além de fazer com que o desempenho dos consumidores seja melhorado de modo transparente. Essa abordagem vai além de manter o código de cache na instância do microsserviço. Poderíamos, por exemplo, manter um proxy reverso dentro da fronteira lógica do microsserviço. Um ponto de atenção é que essa estratégia tem um escopo reduzido para otimização com vistas à latência, pois um acesso de ida e volta ainda continuará sendo necessário.
Com o cache de requisições, armazenamos em cache a resposta da requisição original, por exemplo, no cenário de armazenar os dez itens mais vendidos de um e-commerce. As requisições subsequentes solicitando os dez itens mais vendidos receberão o resultado já cacheado. É uma abordagem bastante eficiente, embora precisemos reconhecer que essa forma de cache é extremamente específica.
Invalidação
A invalidação é o processo de retirarmos dados do cache. É uma ideia conceitualmente simples, porém complexa quanto à execução, simplesmente pelo fato de haver inúmeras opções de implementação. Basicamente, precisamos decidir em quais situações um dado deve ser removido do cache, seja porque uma nova versão do dado está disponível, seja porque supomos que nossa cópia está desatualizada e precisamos buscar uma nova cópia na origem.
As abordagens de invalidação podem ser resumidas assim:
| Abordagem | Complexidade | Janela de dado obsoleto | Quando usar |
|---|---|---|---|
| TTL | Baixa | Alta (até o TTL) | Ponto de partida simples |
| GETs condicionais | Média | Baixa | Response caro de gerar |
| Notificação | Alta | Mínima | Já existe um broker disponível |
| Write-through | Média | ~Nula | Cache no lado do servidor |
| Write-behind | Média | ~Nula | Aceita risco de perda de dados |
Tempo de vida (TTL)
É o método mais simples de invalidação de cache. Cada entrada no cache será válida somente por um determinado período. Passado esse período, o dado será invalidado e buscamos uma nova cópia. Esse prazo de validade é especificado utilizando um TTL (Time To Live).
O próprio HTTP aceita um TTL por meio do header Cache-Control ou um timestamp por meio do header Expires. Com isso, a própria origem é capaz de dizer aos clientes downstream por quanto tempo eles devem supor que os dados estão atualizados.
Um TTL simples é um ponto de partida razoável. Assim, deve haver uma ponderação entre a simplicidade da implementação e o nível de tolerância que temos para operar com um dado desatualizado.
GETs condicionais
Além dos headers Cache-Control e Expires, que podemos especificar nos responses, também temos as ETags (entity tags, ou tags de entidade). Uma ETag é usada para determinar se o valor de um recurso mudou. Caso tenha mudado, o recurso será o mesmo, porém o valor será diferente, e a ETag também será diferente.
Por exemplo, buscamos um registro do cliente e a ETag retornada é 08t2jfk. Ao enviar um request subsequente, podemos passar o header If-None-Match. Isso informa ao server que queremos o recurso especificado, a menos que ele já corresponda a esse valor de ETag:
# Primeira requisição
GET /clientes/123
→ 200 OK
ETag: "08t2jfk"
# Requisição subsequente informando a ETag que já temos
GET /clientes/123
If-None-Match: "08t2jfk"
→ 304 Not Modified # continuamos com a versão mais recente
Caso já tenhamos a versão atualizada, o server retorna um 304 Not Modified, dizendo que temos a versão mais recente. Caso exista uma versão mais nova, receberemos um 200 OK com o recurso alterado e uma nova ETag para o recurso.
Vale a pena observar que, mesmo com um GET condicional, ainda fazemos o request para o servidor. Sua utilidade está em evitar o custo de gerar o recurso novamente, sem que seja necessário. Se o custo de criar o response for alto, talvez exigindo um conjunto custoso de queries no banco de dados, as requisições com GET condicional poderão ser um método eficiente.
Invalidação baseada em notificação
Usamos eventos para ajudar os componentes interessados a saber se suas entradas no cache local precisam ser invalidadas. De forma geral, essa abordagem tende a ser a mais elegante de invalidação, embora devamos considerar a relativa complexidade em comparação à invalidação baseada em TTL.
O funcionamento basicamente ocorre quando um serviço gera um evento de mudança de algum dado, e qualquer outro serviço que tenha se registrado para receber esse evento o trata.
A principal vantagem dessa abordagem é a redução do possível intervalo de tempo no qual o cache pode fornecer dados desatualizados, limitado ao tempo de envio e processamento da notificação.
A desvantagem está na complexidade de implementação, pois precisamos que a origem seja capaz de gerar notificações e que as partes interessadas sejam capazes de responder a essas notificações. Além disso, há um overhead para gerenciar um middleware de troca de mensagens, e usar um broker somente para esse propósito acaba sendo um exagero. Caso já esteja usando brokers para outras formas de comunicação entre microsserviços, faz sentido usar essa tecnologia que já está à disposição.
Ao usar uma invalidação baseada em notificações, é necessário saber se o sistema de notificações está realmente funcionando. Para isso, poderíamos enviar um evento de heartbeat para permitir que os serviços registrados saibam que as notificações continuam chegando, mas que nada realmente mudou. Se um evento de heartbeat não for recebido, o cliente poderá supor que há um problema e fazer o que for mais apropriado.
Devemos balancear entre enviar uma notificação de que houve uma mudança sem dizer qual foi a mudança (o que fará com que os consumidores tenham que obter os novos dados na origem) e enviar uma notificação com o estado atual dos dados (os consumidores poderão carregá-los diretamente para o seu cache local).
Write-through
O cache é atualizado ao mesmo tempo que o estado na origem, ou seja, atualizamos o banco de dados e o cache na mesma transação. Claro que, se o cache estiver em outro lugar, o ao mesmo tempo pode mudar. Por causa dessa dificuldade, geralmente o cache write-through é usado em arquiteturas de microsserviços do lado do servidor.
A vantagem dessa abordagem é que o intervalo durante o qual um cliente poderia ver dados desatualizados seria praticamente eliminado.
Write-behind
O cache é atualizado antes, e então a origem é atualizada.
A principal preocupação com essa abordagem está na possibilidade de haver perda de dados. Se o cache em questão não for durável, poderíamos perder os dados antes que fossem escritos na origem.
Regra de ouro
Não utilize cache em todos os lugares. Quanto mais caches houver entre você e a fonte de dados atualizados, mais desatualizados eles poderão estar e mais difícil será determinar se os dados que um cliente vê estão atualizados. Também é mais difícil saber em quais lugares os dados precisam ser invalidados.
Uma otimização prematura pode ser causa de muitos problemas. O caching acrescenta complexidade, e queremos o acréscimo do mínimo possível de complexidade.
O número ideal de lugares para usar cache é zero.
Imagine o seguinte cenário: solicitamos uma nova cópia de um dado que tenha um TTL de cinco minutos. Um segundo depois, o dado é atualizado na origem. Estaremos trabalhando com um cache desatualizado pelos próximos 4 minutos e 59 segundos. Se isso for inaceitável, podemos reduzir o tempo do TTL para um minuto. Isso significa que o período em que teremos dados desatualizados será reduzido para um quinto do que tínhamos antes. Porém, faremos 5x mais requests à origem. Assim, precisamos considerar a latência associada e o impacto na carga. O ponto-chave é encontrar o equilíbrio entre os requisitos do usuário final e do sistema mais amplo.
Trate o uso de caching como uma otimização com foco em desempenho. Utilize caching no mínimo possível de lugares, para que seja mais fácil saber quão atualizados estão os dados. Quanto menos cache houver, mais fácil será entender o sistema.
Escalabilidade automática
Escalar microsserviços automaticamente é ter um provisionamento de hosts virtuais totalmente automatizado, bem como automatizar por completo a implantação de instâncias.
A ação de escalar poderia ser disparada por tendências conhecidas. Por exemplo, sabendo que a carga de pico ocorre entre 09h e 17h, podemos adicionar mais instâncias às 08h45. Para isso, precisamos ter dados que permitam saber como a carga muda com o tempo.
Ter uma boa suíte de testes de carga é quase sempre fundamental, já que podem ser usados para testar as regras de escalabilidade automática do sistema. Sem esses testes para reproduzir as diferentes cargas que dispararão a operação de escalar, você descobrirá que suas regras estão incorretas somente no ambiente de produção.
Assim que quiser começar a usar a escalabilidade automática, certifique-se de ser muito cauteloso para não reduzir os recursos de modo excessivamente prematuro.
Start Over
A arquitetura com a qual você começou pode não ser a arquitetura que continuará em uso quando seu sistema tiver que lidar com volumes muito diferentes de carga. Em determinados momentos, porém, será preciso fazer algo bastante radical e modificar a arquitetura do sistema para adequá-la ao próximo patamar de crescimento.
Uma remodelagem pode significar a separação de um sistema monolítico existente; ou pode significar escolher novos bancos de dados para lidar melhor com a carga; ou pode significar mudar a abordagem de um sistema síncrono para um sistema assíncrono baseado em eventos; adotar novas plataformas de implantação; mudar conjuntos completos de tecnologias; e qualquer mudança desse tipo.
A necessidade de modificar nossos sistemas para lidar com escala não é sinal de fracasso, é sinal de sucesso.
Conclusão
Escalar sistemas não é uma decisão única nem definitiva: é um processo contínuo, guiado por necessidades reais e por dados concretos sobre o comportamento da carga. O Scale Cube nos oferece um mapa útil — escalabilidade vertical, duplicação horizontal, partição de dados e decomposição funcional —, mas o princípio que atravessa todas essas técnicas é o mesmo: comece pela solução mais simples e só avance em complexidade quando houver justificativa concreta.
O mesmo raciocínio vale para o caching. Ele é uma poderosa ferramenta de desempenho, escalabilidade e robustez, porém cada camada de cache adicionada cobra seu preço em complexidade e em risco de servir dados desatualizados. Por isso, a regra de ouro: cacheie no menor número de lugares possível e seja deliberado quanto à estratégia de invalidação.
Por fim, lembre-se de que evoluir a arquitetura para acompanhar o crescimento não é um sinal de fracasso — é a consequência natural do sucesso. O objetivo não é escalar em todos os eixos ao mesmo tempo, mas conhecer as opções disponíveis e aplicar a técnica certa, no momento certo, motivada por uma necessidade real.
