O dia em que Dodo parou. Script assíncrono

Olá Habr! Cada SRE da nossa equipe sonhava em dormir em paz à noite. Sonhos se tornam realidade. Neste artigo, falarei sobre isso e como alcançamos o desempenho e a estabilidade do nosso sistema Dodo IS.


Uma série de artigos sobre o colapso do sistema Dodo IS * :

1. O dia em que Dodo está parado. Script síncrono.
2. O dia em que o Dodo está parado. Script assíncrono.

* Os materiais foram escritos com base no meu desempenho no DotNext 2018 em Moscou .
Em um artigo anterior, analisamos o bloqueio de problemas de código no paradigma da multitarefa preemptiva. Supunha-se que era necessário reescrever o código de bloqueio em assíncrono / espera. Então nós fizemos. Agora vamos falar sobre quais problemas surgiram quando fizemos isso.

Introduzimos o termo Concorrência


Antes de você assíncrono, você deve inserir o termo simultaneidade.
Na teoria das filas, simultaneidade é o número de clientes que estão atualmente dentro do sistema. A simultaneidade às vezes é confundida com o paralelismo, mas na realidade essas são duas coisas diferentes.
Para aqueles que são novos no Concurrency pela primeira vez, recomendo o vídeo de Rob Pike . Concorrência é quando estamos lidando com muitas coisas ao mesmo tempo, e Paralelismo é quando estamos fazendo muitas coisas ao mesmo tempo.

Nos computadores, muitas coisas não acontecem em paralelo. Uma coisa é computar em vários processadores. O grau de paralelismo é limitado pelo número de threads da CPU.

De fato, o Threads faz parte do conceito de Multitarefa Preemptiva, uma maneira de modelar a Concorrência em um programa quando contamos com o sistema operacional na pergunta Concorrência. Esse modelo permanece útil desde que entendamos que estamos lidando especificamente com o modelo de simultaneidade, e não com simultaneidade.

Async / waitit é o açúcar sintático do State Machine, outro modelo de simultaneidade útil que pode ser executado em um ambiente de thread único. Em essência, isso é multitarefa cooperativa - o modelo em si não leva em conta o paralelismo. Em combinação com o Multithreading, colocamos um modelo em cima do outro, e a vida é muito complicada.

Comparação dos dois modelos


Como funcionou no modelo Multitarefa Preemptiva


Digamos que tenhamos 20 threads e 20 solicitações em processamento por segundo. A imagem mostra um pico - 200 solicitações no sistema ao mesmo tempo. Como isso pôde acontecer:

  • as solicitações podem ser agrupadas se 200 clientes clicarem em um botão ao mesmo tempo;
  • o coletor de lixo pode interromper os pedidos de várias dezenas de milissegundos;
  • os pedidos podem ser atrasados ​​em qualquer fila se o proxy suportar a fila.

Há muitas razões pelas quais as solicitações por um curto período de tempo se acumularam e vêm em um único pacote. De qualquer forma, nada de terrível aconteceu, eles ficaram na fila do Pool de Threads e terminaram lentamente. Não há mais picos, tudo continua, como se nada tivesse acontecido.

Suponha que o algoritmo inteligente do Pool de Threads (e há elementos de aprendizado de máquina lá) tenha decidido que até o momento não há razão para aumentar o número de Threads. O conjunto de conexões no MySql também é 20 porque Threads = 20. Portanto, precisamos apenas de 20 conexões com o SQL.



Nesse caso, o nível de simultaneidade do servidor do ponto de vista do sistema externo = 200. O servidor já recebeu essas solicitações, mas ainda não as concluiu. No entanto, para um aplicativo em execução no paradigma Multithreading, o número de solicitações simultâneas é limitado pelo tamanho atual do Conjunto de Encadeamentos = 20. Portanto, estamos lidando com o grau de Concorrência = 20.

Como tudo agora funciona no modelo assíncrono




Vamos ver o que acontece em um aplicativo executando assíncrono / aguardar com a mesma carga e distribuição de solicitações. Não há fila antes de criar uma tarefa, e a solicitação é processada imediatamente. Obviamente, o Thread do ThreadPool é usado por um curto período de tempo e a primeira parte da solicitação, antes de entrar em contato com o banco de dados, é executada imediatamente. Como o Thread retorna rapidamente ao Pool de Threads, não precisamos de muitos Threads para processar. Neste diagrama, não exibimos nenhum conjunto de threads, ele é transparente.



O que isso significa para a nossa aplicação? A imagem externa é a mesma - o nível de simultaneidade = 200. Ao mesmo tempo, a situação interna mudou. Anteriormente, as solicitações eram "agrupadas" na fila do ThreadPool, agora o grau de simultaneidade do aplicativo também é 200, porque não temos restrições por parte do TaskScheduler. Viva! Atingimos o objetivo de assíncrono - o aplicativo "lida" com praticamente qualquer grau de simultaneidade!

Consequências: degradação não linear do sistema


O aplicativo tornou-se transparente do ponto de vista da simultaneidade; agora, a simultaneidade é projetada no banco de dados. Agora precisamos de um conjunto de conexões do mesmo tamanho = 200. O banco de dados é a CPU, memória, rede, armazenamento. Este é o mesmo serviço com seus problemas, como qualquer outro. Quanto mais solicitações tentamos executar ao mesmo tempo, mais lento elas são executadas.

Em plena carga no banco de dados, na melhor das hipóteses, o Tempo de resposta diminui linearmente: você fez o dobro de consultas e começou a funcionar duas vezes mais lentamente. Na prática, devido à concorrência de consultas, a sobrecarga ocorrerá necessariamente e pode resultar que o sistema se degradará de maneira não linear.

Por que isso está acontecendo?


Razões para a segunda ordem:

  • Agora, o banco de dados precisa ser mantido simultaneamente na memória da estrutura de dados para atender a mais solicitações;
  • Agora, o banco de dados precisa atender a coleções maiores (e isso é algoritmicamente desvantajoso).

Motivo de primeira ordem:


No final, async luta contra recursos limitados e ... vence! O banco de dados falha e começa a ficar mais lento. Com isso, o servidor aumenta ainda mais a simultaneidade e o sistema não pode mais sair dessa situação com honra.

Síndrome da Morte Súbita do Servidor


Às vezes ocorre uma situação interessante. Nós temos um servidor. Ele trabalha para si mesmo assim, está tudo em ordem. Existem recursos suficientes, mesmo com uma margem. De repente, recebemos uma mensagem dos clientes de que o servidor está ficando mais lento. Observamos o gráfico e vemos que houve um aumento na atividade do cliente, mas agora tudo está normal. Pensando em um ataque ou coincidência do DOS. Agora tudo parece estar bem. Só que agora o servidor continua estúpido e tudo fica mais difícil até que os tempos limite cheguem. Depois de algum tempo, outro servidor que usa o mesmo banco de dados também começa a se curvar. Uma situação familiar?

Por que o sistema morreu?


Você pode tentar explicar isso pelo fato de que, em algum momento, o servidor recebeu um número máximo de solicitações e "quebrou". Mas sabemos que a carga foi reduzida e o servidor depois disso não melhorou por muito tempo, até que a carga desapareceu completamente.

A pergunta retórica: o servidor deveria quebrar devido à carga excessiva? Eles fazem isso?

Simulamos uma situação de falha do servidor


Aqui não analisaremos gráficos de um sistema de produção real. No momento da falha do servidor, muitas vezes não conseguimos esse agendamento. O servidor está ficando sem recurso da CPU e, como resultado, não pode gravar logs, fornecer métricas. Nos diagramas da época do desastre, muitas vezes é observada uma quebra em todos os gráficos.

Os SREs devem ser capazes de produzir sistemas de monitoramento menos propensos a esse efeito. Sistemas que, em qualquer situação, fornecem pelo menos algumas informações e, ao mesmo tempo, são capazes de analisar sistemas post-mortem usando informações fragmentadas. Para fins educacionais, usamos uma abordagem ligeiramente diferente neste artigo.

Vamos tentar criar um modelo que matematicamente funcione como um servidor sob carga. A seguir, estudaremos as características do servidor. Descartamos a não linearidade de servidores reais e simulamos uma situação em que a desaceleração linear ocorre quando a carga cresce acima do nominal. Duas vezes o número de solicitações necessárias - atendemos duas vezes mais devagar.

Essa abordagem permitirá:

  • considere o que acontecerá na melhor das hipóteses;
  • faça métricas precisas.

Navegação agendada:

  • azul - o número de solicitações para o servidor;
  • respostas do servidor verde;
  • amarelo - timeouts;
  • cinza escuro - solicitações que foram incluídas nos recursos do servidor porque o cliente não esperou por uma resposta de tempo limite. Às vezes, um cliente pode relatar isso ao servidor por uma desconexão, mas, em geral, esse luxo pode não ser tecnicamente viável, por exemplo, se o servidor executar um trabalho vinculado à CPU sem cooperação com o cliente.




Por que o gráfico de solicitações do cliente (azul no diagrama) acabou sendo assim? Normalmente, a programação de pedidos em nossas pizzarias cresce suavemente pela manhã e diminui à noite. Mas observamos três picos no fundo da curva uniforme usual. Esta forma do gráfico não foi escolhida para o modelo por acaso, mas sim. O modelo nasceu durante a investigação de um incidente real com o servidor do contact center da pizzaria na Rússia durante a Copa do Mundo.

Caso "Copa do Mundo"


Sentamos e esperamos por mais pedidos. Preparados para o campeonato, agora os servidores poderão passar por um teste de força.

O primeiro pico - os fãs de futebol vão assistir ao campeonato, estão com fome e compram pizza. Durante o primeiro semestre, eles estão ocupados e não podem pedir. Mas as pessoas que são indiferentes ao futebol podem, então, no gráfico, tudo continua como sempre.

E então a primeira metade termina e o segundo pico chega. Os fãs ficaram nervosos, famintos e fizeram três vezes mais pedidos do que no primeiro pico. Pizza é comprada a uma taxa terrível. Então a segunda metade começa, e novamente não para pizza.

Enquanto isso, o servidor do contact center começa a se curvar lentamente e a atender solicitações cada vez mais lentamente. O componente do sistema, neste caso, o servidor da web Call Center, está desestabilizado.

O terceiro pico chegará quando a partida terminar. Os fãs e o sistema aguardam uma penalidade.

Analisamos os motivos da falha do servidor


O que aconteceu O servidor pode conter 100 solicitações condicionais. Entendemos que ele foi projetado para esse poder e não o suportará mais. Chega um pico, que por si só não é tão grande. Mas a área cinzenta da simultaneidade é muito maior.

O modelo foi projetado para que a simultaneidade seja numericamente igual ao número de pedidos por segundo; portanto, visualmente no gráfico, ele deve ter a mesma escala. No entanto, é muito maior porque se acumula.

Vemos uma sombra do gráfico aqui - são solicitações que começaram a retornar ao cliente, executadas (mostradas pela primeira seta vermelha). A escala de tempo é condicional para ver o deslocamento da hora. O segundo pico já derrubou nosso servidor. Ele caiu e começou a processar quatro vezes menos solicitações do que o habitual.



Na segunda metade do gráfico, fica claro que alguns pedidos ainda foram executados no início, mas depois apareceram pontos amarelos - os pedidos pararam completamente.



Mais uma vez toda a programação. Pode-se ver que a simultaneidade está ficando louca. Uma montanha enorme aparece.



Normalmente, analisávamos métricas completamente diferentes: quão lentamente a solicitação foi concluída, quantas solicitações por segundo. Nem olhamos para a simultaneidade, nem pensamos nessa métrica. Mas em vão, porque é exatamente essa quantidade que melhor mostra o momento da falha do servidor.

Mas de onde veio uma montanha tão grande? O maior pico de carga já passou!

Little Law


A lei de Little governa a simultaneidade.

L (número de clientes dentro do sistema) = λ (velocidade da estadia) ∗ W (tempo que eles passam dentro do sistema)

Esta é uma média. No entanto, nossa situação está se desenvolvendo dramaticamente, a média não nos convém. Vamos diferenciar essa equação e depois integrar. Para fazer isso, olhe o livro de John Little, que inventou essa fórmula, e veja a integral lá.



Temos o número de entradas no sistema e o número daqueles que saem do sistema. A solicitação chega e sai quando tudo estiver concluído. Abaixo está uma região de crescimento gráfico correspondente ao crescimento linear da simultaneidade.



Existem alguns pedidos ecológicos. Estes são os que realmente estão sendo implementados. Os azuis são aqueles que vêm. Entre os tempos, temos o número usual de solicitações, a situação é estável. Mas a concorrência ainda está crescendo. O servidor não irá mais lidar com esta situação em si. Isso significa que ele cairá em breve.

Mas por que a concorrência está aumentando? Observamos a integral da constante. Nada muda em nosso sistema, mas a integral parece uma função linear que cresce apenas.

Vamos jogar?


A explicação com integrais é complicada se você não se lembra da matemática. Aqui, proponho me aquecer e jogar o jogo.

Número do jogo 1


Pré - requisitos : O servidor recebe solicitações, cada uma requer três períodos de processamento na CPU. O recurso da CPU é dividido igualmente entre todas as tarefas. É semelhante à maneira como os recursos da CPU são consumidos durante a multitarefa preemptiva. O número na célula significa a quantidade de trabalho restante após esta medida. Para cada etapa condicional, uma nova solicitação chega.

Imagine que você recebeu uma solicitação. Apenas 3 unidades de trabalho. No final do primeiro período de processamento, restam 2 unidades.

No segundo período, outra solicitação é em camadas, agora as duas CPUs estão ocupadas. Eles fizeram uma unidade de trabalho para as duas primeiras consultas. Resta completar 1 e 2 unidades para a primeira e a segunda solicitação, respectivamente.

Agora o terceiro pedido chegou e a diversão começa. Parece que a primeira solicitação deveria ter sido concluída, mas nesse período três solicitações já compartilham o recurso da CPU; portanto, o grau de conclusão das três solicitações agora é fracionário no final do terceiro período de processamento:



Ainda mais interessante! A quarta solicitação foi adicionada e agora o grau de simultaneidade já é 4, pois todas as quatro solicitações exigiram um recurso nesse período. Enquanto isso, a primeira solicitação até o final do quarto período já foi concluída, não segue para o próximo período e possui 0 trabalhos restantes para a CPU.

Como a primeira solicitação já foi concluída, vamos resumir para ele: ficou um terço a mais do que esperávamos. Supunha-se que a duração de cada tarefa horizontalmente idealmente = 3, de acordo com a quantidade de trabalho. Marcamos com laranja, como um sinal de que não estamos completamente satisfeitos com o resultado.



O quinto pedido chega. O grau de simultaneidade ainda é 4, mas vemos que na quinta coluna o trabalho restante é mais total. Isso ocorre porque resta mais trabalho na quarta coluna do que na terceira.

Continuamos mais três períodos. À espera de respostas.
- Servidor, olá!
- ...



"Sua ligação é muito importante para nós ..."



Bem, finalmente veio a resposta para o segundo pedido. Os tempos de resposta são duas vezes maiores que o esperado.



O grau de simultaneidade já triplicou e nada indica que a situação mudará para melhor. Não desenhei mais, porque o tempo de resposta à terceira solicitação não se encaixa mais na imagem.

Nosso servidor entrou em um estado indesejável, do qual nunca sairá por conta própria. Fim do jogo

O que caracteriza o estado GameOver do servidor?


As solicitações são acumuladas na memória indefinidamente. Cedo ou tarde, a memória simplesmente terminará. Além disso, com um aumento de escala, a sobrecarga da CPU para atender a várias estruturas de dados aumenta. Por exemplo, o pool de conexões agora deve rastrear o tempo limite para mais conexões, o coletor de lixo agora deve verificar novamente mais objetos na pilha e assim por diante.

Explorar todas as possíveis conseqüências do acúmulo de objetos ativos não é o objetivo deste artigo, mas mesmo um simples acúmulo de dados na RAM já é suficiente para encher o servidor. Além disso, já vimos que o servidor cliente projeta seus problemas de simultaneidade no servidor de banco de dados e em outros servidores que ele usa como cliente.

O mais interessante: agora, mesmo que você envie uma carga menor ao servidor, ele ainda não será recuperado. Todas as solicitações terminam com um tempo limite e o servidor consome todos os recursos disponíveis.

E o que realmente esperávamos ?! Afinal, conscientemente, demos ao servidor uma quantidade de trabalho que ele não conseguiu lidar.

Ao lidar com a arquitetura de sistema distribuída, é útil pensar em como as pessoas comuns resolvem esses problemas. Tome, por exemplo, uma boate. Parará de funcionar se muitas pessoas entrarem nele. O segurança lida com o problema simplesmente: parece quantas pessoas estão lá dentro. Uma esquerda - lança outra. Um novo convidado chegará e apreciará o tamanho da fila. Se a fila for longa, ele irá para casa. E se você aplicar esse algoritmo ao servidor?



Vamos jogar de novo.

Número do jogo 2


Pré - requisitos : Novamente, temos duas CPUs, as mesmas tarefas de 3 unidades, chegando a cada período, mas agora definiremos o segurança e as tarefas serão inteligentes - se elas virem que o comprimento da fila é 2, voltam para casa imediatamente.





O terceiro pedido chegou. Nesse período, ele fica na fila. Ele tem o número 3 no final do período. Não há números fracionários nos resíduos, porque duas CPUs executam duas tarefas, uma por um período.

Embora tenhamos três solicitações em camadas, o grau de simultaneidade dentro do sistema = 2. A terceira está na fila e não conta.



O quarto veio - a mesma imagem, embora mais trabalho já tenha sido acumulado.


...
...

No sexto período, a terceira solicitação foi concluída com um terceiro atraso e o grau de simultaneidade já é = 4.



O grau de simultaneidade dobrou. Ela não pode mais crescer, porque estabelecemos uma proibição clara disso. Com velocidade máxima, apenas os dois primeiros pedidos foram concluídos - aqueles que vieram primeiro ao clube, enquanto havia espaço suficiente para todos.

As solicitações amarelas ficaram no sistema por mais tempo, mas permaneceram na fila e não atrasaram o recurso da CPU. Portanto, aqueles que estavam lá dentro estavam se divertindo. Isso poderia continuar até que um homem chegasse e dissesse que não ficaria na fila, mas que voltaria para casa. Este é um pedido com falha:



A situação pode ser repetida sem parar, enquanto o tempo de execução da consulta permanece no mesmo nível - exatamente o dobro do tempo que gostaríamos.



Vemos que uma simples restrição no nível de simultaneidade elimina o problema de viabilidade do servidor.

Como aumentar a viabilidade do servidor por meio do limite de nível de simultaneidade


Você pode escrever o "bouncer" mais simples. Abaixo está o código usando o semáforo. Não há limite para o comprimento da linha externa. , .

const int MaxConcurrency = 100; SemaphoreSlim bulkhead = new SemaphoreSlim(MaxConcurrency, MaxConcurrency); public async Task ProcessRequest() { if (!await bulkhead.WaitAsync()) { throw new OperationCanceledException(); } try { await ProcessRequestInternal(); return; } finally { bulkhead.Release(); } } 

Para criar uma fila limitada, você precisa de dois semáforos. Para isso, a biblioteca Polly , recomendada pela Microsoft, é adequada. Preste atenção ao padrão Antepara. Traduzido literalmente como "anteparo" - um elemento estrutural que permite que o navio não afunde. Para ser sincero, acho que o termo segurança é mais adequado. O importante é que esse padrão permita ao servidor sobreviver em situações sem esperança.

Primeiro, esprememos tudo o que é possível no banco de carga do servidor até determinarmos quantas solicitações ele pode conter. Por exemplo, determinamos que é 100. Colocamos anteparo.

Além disso, o servidor ignorará apenas o número necessário de solicitações, o restante ficará na fila. Seria sensato escolher um número um pouco menor para que haja estoque. Não tenho recomendações prontas sobre esse assunto, porque existe uma forte dependência do contexto e da situação específica.

  1. Se o comportamento do servidor depender de forma estável da carga em termos de recursos, esse número poderá se aproximar do limite.
  2. Se o meio estiver sujeito a flutuações de carga, um número mais conservador deve ser escolhido, levando em consideração o tamanho dessas flutuações. Tais flutuações podem ocorrer por vários motivos, por exemplo, o ambiente de desempenho com o GC é caracterizado por pequenos picos de carga na CPU.
  3. Se o servidor executar tarefas periódicas em um agendamento, isso também deve ser considerado. Você pode até desenvolver um anteparo adaptável que calcule quantas consultas podem ser enviadas simultaneamente sem degradação do servidor (mas isso já está além do escopo deste estudo).

Experiências de consulta


Dê uma olhada neste post-mortem por último, não veremos isso novamente.

Toda essa pilha cinzenta se correlaciona inequivocamente com a falha do servidor. Gray é a morte para o servidor. Vamos cortar e ver o que acontece. Parece que um certo número de solicitações vai para casa, simplesmente não será atendido. Mas quanto?

100 dentro, 100 fora



Aconteceu que nosso servidor começou a viver muito bem e divertido. Ele constantemente ara na potência máxima. É claro que, quando ocorre um pico, o chuta, mas não por muito tempo.

Inspirados pelo sucesso, tentaremos garantir que ele não seja devolvido. Vamos tentar aumentar o comprimento da fila.

100 dentro, 500 fora




Melhorou, mas a cauda cresceu. Essas são as solicitações que são executadas por um longo tempo depois.

100 dentro, 1000 fora


Como algo se tornou melhor, vamos tentar levá-lo ao ponto do absurdo. Vamos resolver o comprimento da fila 10 vezes mais do que podemos atender simultaneamente:



Se falamos da metáfora do clube e dos seguranças, essa situação é quase impossível - ninguém quer esperar mais na entrada do que passar um tempo no clube. Também não vamos fingir que esta é uma situação normal para o nosso sistema.

É melhor não servir o cliente do que atormentá-lo no site ou no aplicativo móvel carregando cada tela por 30 segundos e estragando a reputação da empresa. É melhor dizer imediatamente a uma pequena parte dos clientes que agora não podemos atendê-los. Caso contrário, atenderemos a todos os clientes várias vezes mais devagar, porque o gráfico mostra que a situação persiste por algum tempo.

Há mais um risco - outros componentes do sistema podem não ser projetados para esse comportamento do servidor e, como já sabemos, a simultaneidade é projetada nos clientes.

Portanto, voltamos à primeira opção "100 por 100" e pensamos em como dimensionar nossas capacidades.

Vencedor - 100 dentro, 100 fora




¯ \ _ (ツ) _ / ¯

Com esses parâmetros, a maior degradação no tempo de execução é exatamente 2 vezes a "nominal". Ao mesmo tempo, é 100% de degradação no tempo de execução da consulta.

Se o seu cliente é sensível ao tempo de execução (e isso geralmente acontece tanto com clientes humanos quanto com servidores), você pode pensar em reduzir ainda mais o comprimento da fila. Nesse caso, podemos obter uma porcentagem da simultaneidade interna e teremos certeza de que o serviço não se degradará no tempo de resposta em mais do que essa porcentagem, em média.

Na verdade, não estamos tentando criar uma fila, estamos tentando nos proteger de flutuações de carga. Aqui, assim como no caso de determinar o primeiro parâmetro da antepara (quantidade interna), é útil determinar quais flutuações na carga o cliente pode causar. Portanto, saberemos em que casos, grosso modo, perderemos o lucro de um serviço em potencial.

É ainda mais importante determinar quais flutuações da latência podem suportar outros componentes do sistema que interagem com o servidor. Portanto, saberemos que estamos realmente tirando o máximo proveito do sistema existente sem o risco de perder completamente o serviço.

Diagnóstico e tratamento


Estamos tratando simultaneidade não controlada com isolamento de anteparo.
Esse método, como os outros discutidos nesta série de artigos, é convenientemente implementado pela biblioteca Polly .

A vantagem do método é que será extremamente difícil desestabilizar um componente individual do sistema como tal. O sistema adquire um comportamento muito previsível em termos de tempo para solicitações bem-sucedidas e chances muito maiores de solicitações completas bem-sucedidas.

No entanto, não resolvemos todos os problemas. Por exemplo, o problema da energia insuficiente do servidor. Nessa situação, você deve obviamente decidir “soltar o lastro” no caso de um salto na carga, que consideramos excessivo.

Outras medidas que nosso estudo não aborda podem incluir, por exemplo, escala dinâmica.

Source: https://habr.com/ru/post/pt461081/


All Articles