Otimização do desempenho de transações comerciais distribuídas

Azure Kubernetes Service (AKS)
Azure Cache for Redis

Este artigo descreve como uma equipa de desenvolvimento utilizou métricas para encontrar estrangulamentos e melhorar o desempenho de um sistema distribuído. O artigo baseia-se em testes de carga reais que fizemos para uma aplicação de exemplo. A aplicação é da Linha de Base do Azure Kubernetes Service (AKS) para microsserviços.

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

Cenário: uma aplicação cliente inicia uma transação empresarial que envolve vários passos.

Este cenário envolve uma aplicação de entrega de drones que é executada no AKS. Os clientes utilizam uma aplicação Web para agendar entregas por drone. Cada transação requer vários passos efetuados por microsserviços separados no back-end:

  • O serviço de Entrega gere as entregas.
  • O serviço Drone Scheduler agenda drones para recolha.
  • O serviço Pacote gere pacotes.

Existem outros dois serviços: um serviço de Ingestão que aceita pedidos de cliente e os coloca numa fila para processamento e um serviço de Fluxo de Trabalho que coordena os passos no fluxo de trabalho.

Diagrama a mostrar o fluxo de trabalho distribuído

Para obter mais informações sobre este cenário, veja Estruturar uma arquitetura de microsserviços.

Teste 1: Linha de Base

Para o primeiro teste de carga, a equipa criou um cluster do AKS de seis nós e implementou três réplicas de cada microsserviço. O teste de carga foi um teste de carregamento de passos, começando em dois utilizadores simulados e aumentando para 40 utilizadores simulados.

Definição Valor
Nós do cluster 6
Pods 3 por serviço

O gráfico seguinte mostra os resultados do teste de carga, conforme mostrado no Visual Studio. A linha roxa desenha a carga do utilizador e a linha laranja desenha o total de pedidos.

Graph of Visual Studio load test results (Graph of Visual Studio load test results) (Graph of Visual Studio load test

A primeira coisa a ter em conta neste cenário é que os pedidos de cliente por segundo não são uma métrica útil de desempenho. Isto deve-se ao facto de a aplicação processar pedidos de forma assíncrona, pelo que o cliente recebe imediatamente uma resposta. O código de resposta é sempre HTTP 202 (Aceite), o que significa que o pedido foi aceite, mas o processamento não está concluído.

O que realmente queremos saber é se o back-end está a acompanhar a taxa de pedidos. A fila do Service Bus pode absorver picos, mas se o back-end não conseguir lidar com uma carga sustentada, o processamento irá ficar cada vez mais para trás.

Eis um gráfico mais informativo. Desenha o número de mensagens recebidas e enviadas na fila do Service Bus. As mensagens recebidas são apresentadas a azul claro e as mensagens enviadas são apresentadas a azul escuro:

Gráfico de mensagens recebidas e enviadas

Este gráfico mostra que a taxa de mensagens recebidas aumenta, atinge um pico e, em seguida, recua para zero no final do teste de carga. Mas o número de mensagens enviadas atinge o pico no início do teste e, em seguida, diminui. Isto significa que o serviço Fluxo de Trabalho, que processa os pedidos, não está a acompanhar. Mesmo depois de o teste de carga terminar (por volta das 9:22 no gráfico), as mensagens continuam a ser processadas à medida que o serviço Fluxo de Trabalho continua a drenar a fila.

O que está a abrandar o processamento? A primeira coisa a procurar são erros ou exceções que possam indicar um problema sistemático. O Mapa da Aplicação no Azure Monitor mostra o gráfico de chamadas entre componentes e é uma forma rápida de detetar problemas e, em seguida, clicar para obter mais detalhes.

Com certeza, o Mapa da Aplicação mostra que o serviço Fluxo de Trabalho está a receber erros do serviço de Entrega:

Captura de ecrã do Mapa da Aplicação

Para ver mais detalhes, pode selecionar um nó no gráfico e clicar numa vista de transação ponto a ponto. Neste caso, mostra que o serviço de Entrega está a devolver erros HTTP 500. As mensagens de erro indicam que está a ser emitida uma exceção devido aos limites de memória no Cache do Azure para Redis.

Captura de ecrã da vista de transação ponto a ponto

Poderá reparar que estas chamadas para Redis não aparecem no Mapa da Aplicação. Isto deve-se ao facto de a biblioteca .NET do Application Insights não ter suporte incorporado para controlar o Redis como uma dependência. (Para obter uma lista do que é suportado fora da caixa, veja Coleção automática de dependências.) Como contingência, pode utilizar a API TrackDependency para controlar qualquer dependência. Os testes de carga revelam frequentemente estes tipos de lacunas na telemetria, que podem ser remediadas.

Teste 2: aumento do tamanho da cache

Para o segundo teste de carga, a equipa de desenvolvimento aumentou o tamanho da cache em Cache do Azure para Redis. (Veja Como Dimensionar Cache do Azure para Redis.) Esta alteração resolveu as exceções de memória esgotada e agora o Mapa da Aplicação mostra zero erros:

Captura de ecrã do Mapa da Aplicação a mostrar que o aumento do tamanho da cache resolveu as exceções de memória esgotada.

No entanto, continua a existir um atraso dramático no processamento de mensagens. No pico do teste de carga, a taxa de mensagens recebidas é superior a 5× a taxa de saída:

Gráfico de mensagens recebidas e enviadas que mostram que a taxa de mensagens recebidas é superior a 5x a taxa de saída.

O gráfico seguinte mede o débito em termos de conclusão de mensagens, ou seja, a taxa a que o serviço fluxo de trabalho marca as mensagens do Service Bus como concluídas. Cada ponto no gráfico representa 5 segundos de dados, mostrando o débito máximo de ~16 segundos.

Gráfico de débito de mensagens

Este gráfico foi gerado ao executar uma consulta na área de trabalho do Log Analytics com a linguagem de consulta Kusto:

let start=datetime("2020-07-31T22:30:00.000Z");
let end=datetime("2020-07-31T22:45:00.000Z");
dependencies
| where cloud_RoleName == 'fabrikam-workflow'
| where timestamp > start and timestamp < end
| where type == 'Azure Service Bus'
| where target has 'https://dev-i-iuosnlbwkzkau.servicebus.windows.net'
| where client_Type == "PC"
| where name == "Complete"
| summarize succeeded=sumif(itemCount, success == true), failed=sumif(itemCount, success == false) by bin(timestamp, 5s)
| render timechart

Teste 3: Aumentar horizontalmente os serviços de back-end

Parece que o back-end é o estrangulamento. Um passo seguinte fácil é aumentar horizontalmente os serviços empresariais (Package, Delivery e Drone Scheduler) e ver se o débito melhora. Para o próximo teste de carga, a equipa aumentou verticalmente estes serviços de três réplicas para seis réplicas.

Definição Valor
Nós do cluster 6
Serviço de ingestão 3 réplicas
Serviço de fluxo de trabalho 3 réplicas
Serviços do Package, Delivery, Drone Scheduler 6 réplicas cada

Infelizmente, este teste de carga mostra apenas melhorias modestas. As mensagens a enviar ainda não estão a acompanhar as mensagens recebidas:

Gráfico de mensagens recebidas e enviadas a mostrar que as mensagens a enviar ainda não estão a acompanhar as mensagens recebidas.

O débito é mais consistente, mas o máximo alcançado é praticamente o mesmo que o teste anterior:

Gráfico de débito de mensagens que mostra que o máximo alcançado é praticamente o mesmo que o teste anterior.

Além disso, ao analisar as informações de contentor do Azure Monitor, parece que o problema não é causado pelo esgotamento de recursos no cluster. Primeiro, as métricas ao nível do nó mostram que a utilização da CPU permanece abaixo dos 40% mesmo no percentil 95 e a utilização da memória é de cerca de 20%.

Graph of AKS node usetion (Graph of AKS node utilization)

Num ambiente do Kubernetes, é possível que os pods individuais estejam limitados a recursos mesmo quando os nós não estão. Mas a vista ao nível do pod mostra que todos os pods estão em bom estado de funcionamento.

Gráfico da utilização do pod do AKS

Neste teste, parece que apenas adicionar mais pods ao back-end não vai ajudar. O próximo passo consiste em analisar mais detalhadamente o serviço Fluxo de Trabalho para compreender o que está a acontecer quando processa mensagens. O Application Insights mostra que a duração média da operação do Process serviço Fluxo de Trabalho é de 246 ms.

Captura de ecrã do Application Insights

Também podemos executar uma consulta para obter métricas sobre as operações individuais em cada transação:

destino percentile_duration_50 percentile_duration_95
https://dev-i-iuosnlbwkzkau.servicebus.windows.net/ | dev-i-iuosnlbwkzkau 86.66950203 283.4255578
contínua 37 57
package 12 17
dronescheduler 21 41

A primeira linha nesta tabela representa a fila do Service Bus. As outras linhas são as chamadas para os serviços de back-end. Para referência, eis a consulta do Log Analytics para esta tabela:

let start=datetime("2020-07-31T22:30:00.000Z");
let end=datetime("2020-07-31T22:45:00.000Z");
let dataset=dependencies
| where timestamp > start and timestamp < end
| where (cloud_RoleName == 'fabrikam-workflow')
| where name == 'Complete' or target in ('package', 'delivery', 'dronescheduler');
dataset
| summarize percentiles(duration, 50, 95) by target

Captura de ecrã do resultado da consulta do Log Analytics

Estas latências parecem razoáveis. Mas aqui está a informação chave: se o tempo total da operação for ~250 ms, isso coloca um limite superior estrito sobre a rapidez com que as mensagens podem ser processadas em série. Por conseguinte, a chave para melhorar o débito é o paralelismo maior.

Isto deve ser possível neste cenário, por duas razões:

  • Estas são chamadas de rede, pelo que a maior parte do tempo é despendida à espera da conclusão de E/S
  • As mensagens são independentes e não precisam de ser processadas por ordem.

Teste 4: Aumentar o paralelismo

Para este teste, a equipa focou-se no aumento do paralelismo. Para tal, ajustaram duas definições no cliente do Service Bus utilizado pelo serviço Fluxo de Trabalho:

Definições Descrição Predefinição Valor novo
MaxConcurrentCalls O número máximo de mensagens a processar em simultâneo. 1 20
PrefetchCount Quantas mensagens o cliente irá obter antecipadamente na cache local. 0 3.000

Para obter mais informações sobre estas definições, veja Best Practices for performance improvements using Service Bus Messaging (Melhores Práticas para melhorar o desempenho com as Mensagens do Service Bus). A execução do teste com estas definições produziu o seguinte gráfico:

Gráfico de mensagens recebidas e enviadas que mostram o número de mensagens a enviar que excedem o número total de mensagens recebidas.

Lembre-se de que as mensagens recebidas são apresentadas a azul claro e as mensagens enviadas são apresentadas a azul escuro.

À primeira vista, este é um gráfico muito estranho. Durante algum tempo, a taxa de mensagens a enviar controla exatamente a taxa de entrada. Mas, então, com cerca de 2:03, a taxa de mensagens recebidas diminui, enquanto o número de mensagens a enviar continua a aumentar, excedendo na verdade o número total de mensagens recebidas. Parece impossível.

A pista para este mistério pode ser encontrada na vista Dependências no Application Insights. Este gráfico resume todas as chamadas que o serviço de Fluxo de Trabalho efetuou ao Service Bus:

Gráfico de chamadas de dependência

Repare que a entrada para DeadLetter. Estas chamadas indicam que as mensagens estão a entrar na fila de cartas não entregues do Service Bus.

Para compreender o que está a acontecer, tem de compreender a semântica Peek-Lock no Service Bus. Quando um cliente utiliza Peek-Lock, o Service Bus obtém e bloqueia atomicamente uma mensagem. Enquanto o bloqueio é mantido, é garantido que a mensagem não será entregue a outros recetores. Se o bloqueio expirar, a mensagem fica disponível para outros recetores. Após um número máximo de tentativas de entrega (o que é configurável), o Service Bus colocará as mensagens numa fila de cartas não entregues, onde podem ser examinadas mais tarde.

Lembre-se de que o serviço Fluxo de Trabalho está a pré-instalar grandes lotes de mensagens — 3000 mensagens de cada vez). Isto significa que o tempo total para processar cada mensagem é mais longo, o que resulta no tempo limite das mensagens, voltar à fila e, eventualmente, entrar na fila de letras mortas.

Também pode ver este comportamento nas exceções, onde são registadas MessageLostLockException inúmeras exceções:

Captura de ecrã das exceções do Application Insights que mostram inúmeras exceções messageLostLockException.

Teste 5: Aumentar a duração do bloqueio

Para este teste de carga, a duração do bloqueio de mensagens foi definida como 5 minutos, para evitar tempos limite de bloqueio. O gráfico de mensagens recebidas e enviadas mostra agora que o sistema está a acompanhar a taxa de mensagens recebidas:

Gráfico de mensagens recebidas e enviadas a mostrar que o sistema está a acompanhar a taxa de mensagens recebidas.

Durante a duração total do teste de carga de 8 minutos, a aplicação concluiu 25 operações K, com um débito máximo de 72 operações/seg, o que representa um aumento de 400% no débito máximo.

Gráfico de débito de mensagens a mostrar um aumento de 400% no débito máximo.

No entanto, executar o mesmo teste com uma duração mais longa mostrou que a aplicação não conseguiu suportar esta taxa:

Gráfico de mensagens recebidas e enviadas a mostrar que a aplicação não conseguiu suportar esta taxa.

As métricas de contentor mostram que a utilização máxima da CPU foi próxima de 100%. Neste momento, a aplicação parece estar vinculada à CPU. Dimensionar o cluster pode melhorar o desempenho agora, ao contrário da tentativa anterior de aumentar horizontalmente.

Gráfico da utilização do nó do AKS que mostra que a utilização máxima da CPU foi próxima de 100%.

Teste 6: Aumentar horizontalmente os serviços de back-end (novamente)

Para o teste de carga final da série, a equipa aumentou horizontalmente o cluster e os pods do Kubernetes da seguinte forma:

Definição Valor
Nós do cluster 12
Serviço de ingestão 3 réplicas
Serviço de fluxo de trabalho 6 réplicas
Serviços do Package, Delivery, Drone Scheduler 9 réplicas cada

Este teste resultou num débito sustentado mais elevado, sem atrasos significativos no processamento de mensagens. Além disso, a utilização da CPU do nó manteve-se abaixo dos 80%.

Gráfico de débito de mensagens a mostrar um débito sustentado mais elevado, sem atrasos significativos no processamento de mensagens.

Resumo

Para este cenário, foram identificados os seguintes estrangulamentos:

  • Exceções de memória esgotada no Cache do Azure para Redis.
  • Falta de paralelismo no processamento de mensagens.
  • Duração insuficiente do bloqueio de mensagens, o que leva a que sejam colocados tempos limite de bloqueio e mensagens na fila de letras não entregues.
  • Esgotamento da CPU.

Para diagnosticar estes problemas, a equipa de desenvolvimento baseou-se nas seguintes métricas:

  • A taxa de mensagens do Service Bus recebidas e enviadas.
  • Mapa da Aplicação no Application Insights.
  • Erros e exceções.
  • Consultas personalizadas do Log Analytics.
  • Utilização da CPU e da memória nas informações de contentor do Azure Monitor.

Passos seguintes

Para obter mais informações sobre a conceção deste cenário, veja Estruturar uma arquitetura de microsserviços.