Fermer

janvier 17, 2023

Résoudre les problèmes de goulots d’étranglement des performances dans les applications .NET 6 —

Résoudre les problèmes de goulots d’étranglement des performances dans les applications .NET 6 —


Les problèmes de performances peuvent survenir lorsque vous vous y attendez le moins. Cela peut avoir des conséquences négatives pour vos clients. À mesure que la base d’utilisateurs augmente, votre application peut prendre du retard car elle n’est pas en mesure de répondre à la demande. Heureusement, il existe des outils et des techniques disponibles pour résoudre ces problèmes en temps opportun.

Nous avons créé cet article en partenariat avec Site24x7. Merci de soutenir les partenaires qui rendent SitePoint possible.

Dans cette prise, j’explorerai les goulots d’étranglement des performances dans une application .NET 6. L’accent sera mis sur un problème de performances que j’ai personnellement rencontré en production. L’intention est que vous puissiez reproduire le problème dans votre environnement de développement local et résoudre le problème.

N’hésitez pas à télécharger l’exemple de code à partir de GitHub ou suivre. La solution a deux API, nommées sans imagination First.Api et Second.Api. La première API appelle la seconde API pour obtenir des données météorologiques. Il s’agit d’un cas d’utilisation courant, car les API peuvent appeler d’autres API, de sorte que les sources de données restent découplées et peuvent évoluer individuellement.

Tout d’abord, assurez-vous d’avoir le SDK .NET 6 installé sur votre machine. Ensuite, ouvrez un terminal ou une fenêtre de console :

> dotnet new webapi --name First.Api --use-minimal-apis --no-https --no-openapi
> dotnet new webapi --name Second.Api --use-minimal-apis --no-https --no-openapi

Ce qui précède peut aller dans un dossier de solution comme performance-bottleneck-net6. Cela crée deux projets Web avec un minimum d’API, pas de HTTPS et pas de fanfaronnade ou d’API ouverte. L’outil échafaude la structure des dossiers, veuillez donc consulter l’exemple de code si vous avez besoin d’aide pour configurer ces deux nouveaux projets.

Le fichier de solution peut aller dans le dossier de solution. Cela vous permet d’ouvrir l’intégralité de la solution via un IDE comme Rider ou Visual Studio :

dotnet new sln --name Performance.Bottleneck.Net6
dotnet sln add First.Api\First.Api.csproj
dotnet sln add Second.Api\Second.Api.csproj

Ensuite, assurez-vous de définir les numéros de port pour chaque projet Web. Dans l’exemple de code, je les ai définis sur 5060 pour la première API et 5176 pour la seconde. Le nombre spécifique n’a pas d’importance, mais je vais les utiliser pour référencer les API tout au long de l’exemple de code. Assurez-vous donc de modifier vos numéros de port ou de conserver ce que l’échafaudage génère et de rester cohérent.

L’application incriminée

Ouvrez le Program.cs fichier dans la deuxième API et mettre en place le code qui répond avec les données météo :

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var summaries = new[]
{
 "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherForecast", async () =>
{
 await Task.Delay(10);
 return Enumerable
   .Range(0, 1000)
   .Select(index =>
     new WeatherForecast
     (
       DateTime.Now.AddDays(index),
       Random.Shared.Next(-20, 55),
       summaries[Random.Shared.Next(summaries.Length)]
     )
   )
   .ToArray()[..5];
});

app.Run();

public record WeatherForecast(
 DateTime Date,
 int TemperatureC,
 string? Summary)
{
 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

La fonctionnalité minimale des API dans .NET 6 permet de garder le code petit et succinct. Cela parcourra mille enregistrements et retardera la tâche pour simuler le traitement asynchrone des données. Dans un projet réel, ce code peut appeler un cache distribué ou une base de données, qui est une opération liée aux E/S.

Maintenant, allez au Program.cs fichier dans la première API et écrivez le code qui utilise ces données météorologiques. Vous pouvez simplement copier-coller ceci et remplacer tout ce que l’échafaudage génère :

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => new HttpClient(
 new SocketsHttpHandler
 {
   PooledConnectionLifetime = TimeSpan.FromMinutes(5)
 })
{
 BaseAddress = new Uri("http://localhost:5176")
});

var app = builder.Build();

app.MapGet("https://www.sitepoint.com/", async (HttpClient client) =>
{
 var result = new List<List<WeatherForecast>?>();

 for (var i = 0; i < 100; i++)
 {
   result.Add(
     await client.GetFromJsonAsync<List<WeatherForecast>>(
       "/weatherForecast"));
 }

 return result[Random.Shared.Next(0, 100)];
});

app.Run();

public record WeatherForecast(
 DateTime Date,
 int TemperatureC,
 string? Summary)
{
 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Les HttpClient est injecté en tant que singleton, car cela rend le client évolutif. Dans .NET, un nouveau client crée des sockets dans le système d’exploitation sous-jacent. Une bonne technique consiste donc à réutiliser ces connexions en réutilisant la classe. Ici, le client HTTP définit une durée de vie du pool de connexions. Cela permet au client de s’accrocher aux sockets aussi longtemps que nécessaire.

Une adresse de base indique simplement au client où aller, alors assurez-vous qu’elle pointe vers le numéro de port correct défini dans la deuxième API.

Lorsqu’une requête arrive, le code boucle cent fois, puis appelle la deuxième API. Il s’agit de simuler, par exemple, un certain nombre d’enregistrements nécessaires pour effectuer des appels vers d’autres API. Les itérations sont codées en dur, mais dans un projet réel, il peut s’agir d’une liste d’utilisateurs, qui peut s’allonger sans limite à mesure que l’entreprise se développe.

Maintenant, concentrez votre attention sur la boucle, car cela a des implications dans la théorie des performances. Dans une analyse algorithmique, une seule boucle a une complexité linéaire Big-O, ou O(n). Mais, la deuxième API boucle également, ce qui fait passer l’algorithme à une complexité quadratique ou O(n^2). De plus, la boucle passe par une limite d’E/S pour démarrer, ce qui réduit les performances.

Cela a un effet multiplicatif, car pour chaque itération dans la première API, la seconde API boucle mille fois. Il y a 100 * 1000 itérations. N’oubliez pas que ces listes ne sont pas liées, ce qui signifie que les performances se dégradent de manière exponentielle à mesure que les ensembles de données augmentent.

Lorsque des clients en colère spamment le centre d’appels pour exiger une meilleure expérience utilisateur, utilisez ces outils pour essayer de comprendre ce qui se passe.

CURL et NBomber

Le premier outil aidera à identifier l’API sur laquelle se concentrer. Lors de l’optimisation du code, il est possible de tout optimiser à l’infini, évitez donc les optimisations prématurées. L’objectif est d’obtenir des performances « juste assez bonnes », et cela a tendance à être subjectif et motivé par les exigences de l’entreprise.

Tout d’abord, appelez chaque API individuellement à l’aide de CURL, par exemple, pour avoir une idée de la latence :

> curl -i -o /dev/null -s -w %{time_total} http://localhost:5060
> curl -i -o /dev/null -s -w %{time_total} http://localhost:5176

Le numéro de port 5060 appartient à la première API et 5176 appartient à la seconde. Vérifiez s’il s’agit des bons ports sur votre machine.

La deuxième API répond en quelques fractions de seconde, ce qui est assez bon et probablement pas le coupable. Mais la première API met près de deux secondes à répondre. Ceci est inacceptable, car les serveurs Web peuvent expirer des requêtes qui prennent autant de temps. De plus, une latence de deux secondes est trop lente du point de vue du client, car il s’agit d’un délai perturbateur.

Ensuite, un outil comme NBomber aidera à comparer l’API problématique.

Revenez à la console et, dans le dossier racine, créez un projet de test :

dotnet new console -n NBomber.Tests
cd NBomber.Tests
dotnet add package NBomber
dotnet add package NBomber.Http
cd ..
dotnet sln add NBomber.Tests\NBomber.Tests.csproj

Dans le Program.cs fichier, écrivez les cas-tests :

using NBomber.Contracts;
using NBomber.CSharp;
using NBomber.Plugins.Http.CSharp;

var step = Step.Create(
 "fetch_first_api",
 clientFactory: HttpClientFactory.Create(),
 execute: async context =>
 {
   var request = Http
     .CreateRequest("GET", "http://localhost:5060/")
     .WithHeader("Accept", "application/json");
   var response = await Http.Send(request, context);

   return response.StatusCode == 200
     ? Response.Ok(
       statusCode: response.StatusCode,
       sizeBytes: response.SizeBytes)
     : Response.Fail();
 });

var scenario = ScenarioBuilder
 .CreateScenario("first_http", step)
 .WithWarmUpDuration(TimeSpan.FromSeconds(5))
 .WithLoadSimulations(
   Simulation.InjectPerSec(rate: 1, during: TimeSpan.FromSeconds(5)),
   Simulation.InjectPerSec(rate: 2, during: TimeSpan.FromSeconds(10)),
   Simulation.InjectPerSec(rate: 3, during: TimeSpan.FromSeconds(15))
 );

NBomberRunner
.RegisterScenarios(scenario)
.Run();

Le NBomber ne spamme l’API qu’au rythme d’une requête par seconde. Puis, à intervalles réguliers, deux fois par seconde pendant les dix secondes suivantes. Enfin, trois fois par seconde pendant les 15 secondes suivantes. Cela évite que la machine de développement locale ne soit surchargée avec trop de requêtes. Le NBomber utilise également des sockets réseau, alors soyez prudent lorsque l’API cible et l’outil de référence s’exécutent sur la même machine.

L’étape de test suit le code de réponse et le place dans la valeur de retour. Cela permet de suivre les échecs de l’API. Dans .NET, lorsque le serveur Kestrel reçoit trop de requêtes, il rejette celles avec une réponse d’échec.

Maintenant, inspectez les résultats et vérifiez les latences, les demandes simultanées et le débit.

Résultats d'application offensants

Les latences du P95 affichent 1,5 seconde, ce que la plupart des clients verront. Le débit reste faible, car l’outil a été calibré pour n’aller que jusqu’à trois requêtes par seconde. Dans une machine de développement locale, il est difficile de comprendre la simultanéité, car les mêmes ressources qui exécutent l’outil de référence sont également nécessaires pour répondre aux demandes.

Analyse dotTrace

Ensuite, choisissez un outil capable de faire une analyse algorithmique comme dotTrace. Cela aidera à isoler davantage où le problème de performances pourrait être.

Pour effectuer une analyse, exécutez dotTrace et prenez un instantané après que NBomber ait spammé l’API aussi fort que possible. Le but est de simuler une charge lourde pour identifier d’où vient la lenteur. Les points de repère déjà mis en place sont suffisamment bons, alors assurez-vous d’utiliser dotTrace avec NBomber.

analyse dotTrace

D’après cette analyse, environ 85 % du temps est consacré à la GetFromJsonAsync appel. Fouiner dans l’outil révèle que cela vient du client HTTP. Cela est en corrélation avec la théorie des performances, car cela montre que la boucle asynchrone avec une complexité O (n ^ 2) pourrait être le problème.

Un outil de référence fonctionnant localement aidera à identifier les goulots d’étranglement. L’étape suivante consiste à utiliser un outil de surveillance capable de suivre les demandes dans un environnement de production en direct.

Les enquêtes de performance consistent à collecter des informations et vérifient que chaque outil raconte au moins une histoire cohérente.

Surveillance du site 24h/24 et 7j/7

Un outil comme Site24x7 peut aider à résoudre les problèmes de performances.

Pour cette application, vous souhaitez vous concentrer sur les latences P95 dans les deux API. C’est l’effet d’entraînement, car les API font partie d’une série de services interconnectés dans une architecture distribuée. Lorsqu’une API commence à avoir des problèmes de performances, d’autres API en aval peuvent également rencontrer des problèmes.

L’évolutivité est un autre facteur crucial. À mesure que la base d’utilisateurs augmente, l’application peut commencer à prendre du retard. Il est utile de suivre le comportement normal et de prédire comment l’application évolue au fil du temps. La boucle asynchrone imbriquée trouvée sur cette application peut bien fonctionner pour un nombre N d’utilisateurs, mais peut ne pas évoluer car le nombre n’est pas lié.

Enfin, lorsque vous déployez des optimisations et des améliorations, il est essentiel de suivre les dépendances de version. À chaque itération, vous devez être en mesure de savoir quelle version est meilleure ou moins bonne pour les performances.

Un outil de surveillance approprié est nécessaire, car les problèmes ne sont pas toujours faciles à repérer dans un environnement de développement local. Les hypothèses faites localement peuvent ne pas être valables en production, car vos clients peuvent avoir une opinion différente. Commencez votre essai gratuit de 30 jours de Site24x7.

Une solution plus performante

Avec l’arsenal d’outils jusqu’à présent, il est temps d’explorer une meilleure approche.

CURL a déclaré que la première API est celle qui a des problèmes de performances. Cela signifie que toutes les améliorations apportées à la deuxième API sont négligeables. Même s’il y a un effet d’entraînement ici, réduire de quelques millisecondes la deuxième API ne fera pas beaucoup de différence.

NBomber a corroboré cette histoire en montrant que les P95 étaient à près de deux secondes dans la première API. Ensuite, dotTrace a distingué la boucle asynchrone, car c’est là que l’algorithme a passé la plupart de son temps. Un outil de surveillance tel que Site24x7 aurait fourni des informations complémentaires en affichant les latences P95, l’évolutivité dans le temps et la gestion des versions. Il est probable que la version spécifique qui a introduit la boucle imbriquée aurait des latences élevées.

Selon la théorie des performances, la complexité quadratique est une grande préoccupation, car les performances se dégradent de manière exponentielle. Une bonne technique consiste à écraser la complexité en réduisant le nombre d’itérations à l’intérieur de la boucle.

Une limitation dans .NET est que, chaque fois que vous voyez une attente, la logique bloque et envoie une seule demande à la fois. Cela arrête l’itération et attend que la deuxième API renvoie une réponse. C’est une triste nouvelle pour le spectacle.

Une approche naïve consiste simplement à écraser la boucle en envoyant toutes les requêtes HTTP en même temps :

app.MapGet("https://www.sitepoint.com/", async (HttpClient client) =>
 (await Task.WhenAll( 
   Enumerable
     .Range(0, 100)
     .Select(_ =>
       client.GetFromJsonAsync<List<WeatherForecast>>( 
         "/weatherForecast")
     )
   )
 )
 .ToArray()[Random.Shared.Next(0, 100)]);

Cela va nucléariser l’attente à l’intérieur de la boucle et ne bloquera qu’une seule fois. Les Task.WhenAll envoie tout en parallèle, ce qui casse la boucle.

Cette approche peut fonctionner, mais elle risque de spammer la deuxième API avec trop de requêtes à la fois. Le serveur Web peut rejeter les demandes, car il pense qu’il pourrait s’agir d’une attaque DoS. Une approche beaucoup plus durable consiste à réduire les itérations en n’en envoyant que quelques-unes à la fois :

var sem = new SemaphoreSlim(10); 

app.MapGet("https://www.sitepoint.com/", async (HttpClient client) =>
 (await Task.WhenAll(
   Enumerable
     .Range(0, 100)
     .Select(async _ =>
     {
       try
       {
         await sem.WaitAsync(); 
         return await client.GetFromJsonAsync<List<WeatherForecast>>(
           "/weatherForecast");
       }
       finally
       {
         sem.Release();
       }
     })
   )
 )
 .ToArray()[Random.Shared.Next(0, 100)]);

Cela fonctionne un peu comme un videur dans un club. La capacité maximale est de dix. Au fur et à mesure que les demandes entrent dans le pool, seules dix peuvent entrer à la fois. Cela permet également des requêtes simultanées, donc si une requête sort du pool, une autre peut immédiatement entrer sans avoir à attendre dix requêtes.

Cela réduit la complexité algorithmique d’un facteur dix et soulage la pression de toutes les boucles folles.

Avec ce code en place, exécutez NBomber et vérifiez les résultats.

Une solution plus performante

Les latences P95 sont maintenant un tiers de ce qu’elles étaient. Une réponse d’une demi-seconde est beaucoup plus raisonnable que tout ce qui prend plus d’une seconde. Bien sûr, vous pouvez continuer et optimiser davantage, mais je pense que vos clients en seront très satisfaits.

Conclusion

Les optimisations de performances sont une histoire sans fin. Au fur et à mesure que l’entreprise se développe, les hypothèses autrefois formulées dans le code peuvent devenir invalides avec le temps. Par conséquent, vous avez besoin d’outils pour analyser, établir des points de repère et surveiller en permanence l’application pour vous aider à résoudre les problèmes de performances.




Source link