Ajuste de desempenho – Vários serviços de back-end

AKS (Serviço de Kubernetes do Azure)
Azure Cosmos DB

Este artigo descreve como uma equipe de desenvolvimento usou métricas para encontrar gargalos e melhorar o desempenho de um sistema distribuído. O artigo é baseado no teste de carga real que foi feito para um aplicativo de exemplo. O aplicativo é da Linha de Base do Serviço de Kubernetes do Azure (AKS) para microsserviços, juntamente com um projeto de teste de carga do Visual Studio usado para gerar os resultados.

Este artigo faz parte de uma série. Leia a primeira parte aqui.

Cenário: chame vários serviços de back-end para recuperar informações e, em seguida, agregar os resultados.

Esse cenário envolve um aplicativo de entrega de drones. Os clientes podem consultar uma API REST para obter suas informações de fatura mais recentes. A fatura inclui um resumo das entregas, pacotes e utilização total do drone do cliente. Este aplicativo usa uma arquitetura de microsserviços em execução no AKS, e as informações necessárias para a fatura são distribuídas por vários microsserviços.

Em vez de o cliente chamar cada serviço diretamente, o aplicativo implementa o padrão de agregação de gateway. Usando esse padrão, o cliente faz uma única solicitação para um serviço de gateway. O gateway, por sua vez, chama os serviços de back-end em paralelo e, em seguida, agrega os resultados em um único payload de resposta.

Diagram showing the Gateway Aggregation pattern

Teste 1: Desempenho de linha de base

Para definir uma linha de base, a equipe de desenvolvimento começou com um teste de carga em etapas, aumentando a carga de um usuário simulado para até 40 usuários, com uma duração total de 8 minutos. O gráfico a seguir, retirado do Visual Studio, mostra os resultados. A linha roxa mostra a carga do usuário e a linha laranja, taxa de transferência (média de solicitações por segundo).

Graph of Visual Studio load test results

A linha vermelha ao longo da parte inferior do gráfico mostra que nenhum erro foi retornado ao cliente, o que é ótimo. No entanto, a taxa de transferência média atinge o pico na metade do teste e, em seguida, cai para o restante, mesmo enquanto a carga continua a aumentar. Isso indica que o back-end não consegue acompanhar. O padrão visto aqui é comum quando um sistema começa a atingir limites de recursos. Depois de atingir um máximo, a taxa de transferência cai significativamente. Contenção de recursos, erros transitórios ou um aumento na taxa de exceções podem contribuir para esse padrão.

Vamos nos aprofundar nos dados de monitoramento para saber o que está acontecendo no sistema. O próximo gráfico é retirado do Application Insights. Ele mostra as durações médias das chamadas HTTP do gateway para os serviços de back-end.

Graph of HTTP call durations

Este gráfico mostra que uma operação em particular, GetDroneUtilization, leva muito mais tempo em média — por ordem de magnitude. O gateway faz essas chamadas em paralelo, portanto, a operação mais lenta determina quanto tempo leva para que toda a solicitação seja concluída.

Claramente, o próximo passo é explorar a operação GetDroneUtilization e procurar quaisquer gargalos. Uma possibilidade é o esgotamento de recursos. Talvez esse serviço de back-end específico esteja ficando sem CPU ou memória. Para um cluster do AKS, essas informações estão disponíveis no portal do Azure por meio do recurso insights de contêiner do Azure Monitor. Os gráficos a seguir mostram a utilização de recursos no nível do cluster:

Graph of AKS node utilization

Nesta captura de tela, os valores médio e máximo são mostrados. É importante analisar mais do que apenas a média, porque a média pode ocultar picos nos dados. Aqui, a utilização média da CPU permanece abaixo de 50%, mas há alguns picos para 80%. Isso está perto da capacidade, mas ainda dentro das tolerâncias. Outra coisa está causando o gargalo.

O próximo gráfico revela o verdadeiro culpado. Este gráfico mostra códigos de resposta HTTP do banco de dados de back-end do serviço de entrega, que neste caso é o Azure Cosmos DB. A linha azul representa códigos de êxito (HTTP 2xx), enquanto a linha verde representa erros HTTP 429. Um código de retorno HTTP 429 significa que o Azure Cosmos DB está limitando temporariamente as solicitações, porque o chamador está consumindo mais unidades de recursos (RU) do que o provisionado.

Graph of throttled requests

Para obter mais informações, a equipe de desenvolvimento usou o Application Insights para exibir a telemetria de ponta a ponta para uma amostra representativa de solicitações. Aqui está um exemplo:

Screenshot of end-to-end transaction view

Essa exibição mostra as chamadas relacionadas a uma única solicitação do cliente, juntamente com informações de tempo e códigos de resposta. As chamadas de nível superior são feitas do gateway para os serviços de back-end. A chamada para GetDroneUtilization é expandida para mostrar chamadas para dependências externas — neste caso, para o Azure Cosmos DB. A chamada em vermelho retornou um erro HTTP 429.

Observe a grande lacuna entre o erro HTTP 429 e a próxima chamada. Quando a biblioteca de cliente do Azure Cosmos DB recebe um erro HTTP 429, ela recua automaticamente e aguarda para tentar novamente a operação. Essa exibição mostra é que, durante os 672 ms dessa operação, a maior parte desse tempo foi gasto esperando para o Azure Cosmos DB tentar novamente.

Aqui está outro gráfico interessante para esta análise. Ele mostra o consumo de RU por partição física em relação às RUs provisionadas por partição física:

Graph of RU consumption per partition

Para entender esse gráfico, você precisa entender como o Azure Cosmos DB gerencia partições. As coleções no Azure Cosmos DB podem ter uma chave de partição. Cada valor de chave possível define uma partição lógica dos dados dentro da coleção. O Azure Cosmos DB distribui essas partições lógicas em uma ou mais partições físicas. O gerenciamento de partições físicas é tratado automaticamente pelo Azure Cosmos DB. À medida que você armazena mais dados, o Azure Cosmos DB pode mover partições lógicas para novas partições físicas, a fim de distribuir a carga pelas partições físicas.

Para esse teste de carga, a coleção do Azure Cosmos DB foi provisionada com 900 RUs. O gráfico mostra 100 RUs por partição física, o que implica um total de nove partições físicas. Embora o Azure Cosmos DB lide automaticamente com a fragmentação de partições físicas, conhecer o número de partições pode fornecer informações sobre o desempenho. A equipe de desenvolvimento usará essas informações mais tarde, conforme a otimização continuar. Quando a linha azul cruza a linha horizontal roxa, o consumo de RU excedeu as RUs provisionadas. Esse é o ponto em que o Azure Cosmos DB começará a limitar as chamadas.

Teste 2: Aumentar unidades de recursos

Para o segundo teste de carga, a equipe dimensionou a coleção do Azure Cosmos DB de 900 RUs para 2.500 RUs. A taxa de transferência aumentou de 19 solicitações/segundo para 23 solicitações/segundo, e a latência média caiu de 669 ms para 569 ms.

Métrica Teste 1 Teste 2
Taxa de transferência (sol/seg) 19 23
Latência média (ms) 669 569
Solicitações bem-sucedidas 9.8 K 11 K

Esses não são grandes ganhos, mas observe o gráfico ao longo do tempo para um quadro mais completo:

Graph of Visual Studio load test results showing more consistent throughput.

Enquanto o teste anterior mostrou um pico inicial seguido por uma queda acentuada, este teste mostra uma taxa de transferência mais consistente. No entanto, a taxa de transferência máxima não é significativamente maior.

Todas as solicitações para o Azure Cosmos DB retornaram um status 2xx e os erros HTTP 429 desapareceram:

Graph of Azure Cosmos DB calls

O gráfico de consumo de RU em relação a RUs provisionadas mostra que há muito espaço para a cabeça. Há cerca de 275 RUs por partição física, e o teste de carga atingiu o pico de cerca de 100 RUs consumidas por segundo.

Graph of RU consumption versus provisioned RUs showing there is plenty of headroom.

Outra métrica interessante é o número de chamadas para o Azure Cosmos DB por operação bem-sucedida:

Métrica Teste 1 Teste 2
Chamadas por operação 11 9

Supondo que não haja erros, o número de chamadas deve corresponder ao plano de consulta real. Nesse caso, a operação envolve uma consulta de partição cruzada que atinge todas as nove partições físicas. O valor mais alto no primeiro teste de carga reflete o número de chamadas que retornaram um erro 429.

Essa métrica foi calculada executando uma consulta personalizada do Log Analytics:

let start=datetime("2020-06-18T20:59:00.000Z");
let end=datetime("2020-07-24T21:10:00.000Z");
let operationNameToEval="GET DroneDeliveries/GetDroneUtilization";
let dependencyType="Azure DocumentDB";
let dataset=requests
| where timestamp > start and timestamp < end
| where success == true
| where name == operationNameToEval;
dataset
| project reqOk=itemCount
| summarize
    SuccessRequests=sum(reqOk),
    TotalNumberOfDepCalls=(toscalar(dependencies
    | where timestamp > start and timestamp < end
    | where type == dependencyType
    | summarize sum(itemCount)))
| project
    OperationName=operationNameToEval,
    DependencyName=dependencyType,
    SuccessRequests,
    AverageNumberOfDepCallsPerOperation=(TotalNumberOfDepCalls/SuccessRequests)

Em resumo, o segundo teste de carga mostra melhorias. No entanto, a operação GetDroneUtilization ainda leva cerca de uma ordem de magnitude a mais do que a operação mais lenta. Analisar as transações de ponta a ponta ajuda a explicar o porquê:

Screenshot of the second load test showing improvement.

Como mencionado anteriormente, a operação GetDroneUtilization envolve uma consulta entre partições para o Azure Cosmos DB. Isso significa que o cliente do Azure Cosmos DB precisa distribuir a consulta para cada partição física e coletar os resultados. Como mostra a exibição de transação de ponta a ponta, essas consultas estão sendo executadas em série. A operação leva tanto tempo quanto a soma de todas as consultas — e esse problema só vai piorar à medida que o tamanho dos dados cresce e mais partições físicas são adicionadas.

Teste 3: Consultas paralelas

Com base nos resultados anteriores, uma maneira óbvia de reduzir a latência é emitir as consultas em paralelo. O SDK do cliente do Azure Cosmos DB tem uma configuração que controla o grau máximo de paralelismo.

Valor Descrição
0 Sem paralelismo (padrão)
> 0 Número máximo de chamadas paralelas
-1 O SDK do cliente seleciona um grau ideal de paralelismo

Para o terceiro teste de carga, essa configuração foi alterada de 0 para -1. A tabela a seguir resume os resultados:

Métrica Teste 1 Teste 2 Teste 3
Taxa de transferência (sol/seg) 19 23 42
Latência média (ms) 669 569 215
Solicitações bem-sucedidas 9.8 K 11 K 20 mil
Solicitações limitadas 2.72 K 0 0

A partir do gráfico de teste de carga, não só a taxa de transferência geral é muito maior (a linha laranja), mas também acompanha o ritmo da carga (a linha roxa).

Graph of Visual Studio load test results showing higher overall throughput that keeps pace with load.

Podemos verificar se o cliente do Azure Cosmos DB está fazendo consultas em paralelo examinando a exibição de transação de ponta a ponta:

Screenshot of end-to-end transaction view showing that the Azure Cosmos DB client is making queries in parallel.

Curiosamente, um efeito colateral do aumento da taxa de transferência é que o número de RUs consumidas por segundo também aumenta. Embora o Azure Cosmos DB não tenha limitado nenhuma solicitação durante esse teste, o consumo estava próximo do limite de RU provisionado:

Graph of RU consumption close to the provisioned RU limit.

Esse gráfico pode ser um sinal para expandir ainda mais o banco de dados. No entanto, acontece que podemos otimizar a consulta.

Etapa 4: Otimizar a consulta

O teste de carga anterior mostrou melhor desempenho em termos de latência e taxa de transferência. A latência média de solicitações foi reduzida em 68% e a taxa de transferência aumentou 220%. No entanto, a consulta entre partições é uma preocupação.

O problema com consultas entre partições é que você paga por RU em todas as partições. Se a consulta for executada apenas ocasionalmente — digamos, uma vez por hora — talvez não importe. Mas sempre que você vir uma carga de trabalho de leitura pesada que envolva uma consulta entre partições, verifique se a consulta pode ser otimizada incluindo uma chave de partição. (Talvez seja necessário reprojetar a coleção para usar uma chave de partição diferente.)

Aqui está a consulta para este cenário específico:

SELECT * FROM c
WHERE c.ownerId = <ownerIdValue> and
      c.year = <yearValue> and
      c.month = <monthValue>

Essa consulta seleciona registros que correspondem a um ID de proprietário e mês/ano específicos. No design original, nenhuma dessas propriedades é a chave de partição. Isso requer que o cliente distribua a consulta para cada partição física e reúna os resultados. Para melhorar o desempenho da consulta, a equipe de desenvolvimento alterou o design para que o ID do proprietário seja a chave de partição da coleção. Dessa forma, a consulta pode ter como alvo uma partição física específica. (O Azure Cosmos DB lida com isso automaticamente, então você não precisa gerenciar o mapeamento entre valores de chave de partição e partições físicas.)

Depois de mudar a coleção para a nova chave de partição, houve uma melhoria dramática no consumo de RU, o que se traduz diretamente em custos mais baixos.

Métrica Teste 1 Teste 2 Teste 3 Teste 4
RUs por operação 29 29 29 3.4
Chamadas por operação 11 9 10 1

A exibição de transação de ponta a ponta mostra que, como previsto, a consulta lê apenas uma partição física:

Screenshot of end-to-end transaction view showing that the query reads only one physical partition.

O teste de carga mostra taxa de transferência e latência aprimoradas:

Métrica Teste 1 Teste 2 Teste 3 Teste 4
Taxa de transferência (sol/seg) 19 23 42 59
Latência média (ms) 669 569 215 176
Solicitações bem-sucedidas 9.8 K 11 K 20 mil 29 K
Solicitações limitadas 2.72 K 0 0 0

Uma consequência do desempenho aprimorado é que a utilização da CPU do nó aumenta muito:

Graph showing high node CPU utilization.

No final do teste de carga, a CPU média atingiu cerca de 90% e a CPU máxima atingiu 100%. Essa métrica indica que a CPU é o próximo gargalo no sistema. Se for necessária uma taxa de transferência mais alta, a próxima etapa pode ser dimensionar o serviço de entrega para mais instâncias.

Resumo

Para esse cenário, foram identificados os seguintes gargalos:

  • Solicitações de limitação do Azure Cosmos DB devido a RUs insuficientes provisionadas.
  • Alta latência causada pela consulta de várias partições de banco de dados em série.
  • Consulta de partição cruzada ineficiente, porque a consulta não incluiu a chave de partição.

Além disso, a utilização da CPU foi identificada como um gargalo potencial em maior escala. Para diagnosticar esses problemas, a equipe de desenvolvimento analisou:

  • Latência e taxa de transferência do teste de carga.
  • Erros do Azure Cosmos DB e consumo de RU.
  • Exibição de transação de ponta a ponta no Application Insight.
  • Utilização da CPU e da memória nos insights de contêiner do Azure Monitor.

Próximas etapas

Examinar os antipadrões de desempenho