解决.NET 6应用程序中的性能瓶颈,性能问题可能会在您最意想不到的时候出现。这可能会对您的客户产生负面影响。随着用户群的增长,您的应用程序可能会滞后,因为它无法满足需求。幸运的是,有一些工具和技术可以及时解决这些问题。

在此过程中,将探讨 .NET 6 应用程序中的性能瓶颈。重点将放在个人在生产中看到的性能问题上。目的是让您能够在本地开发环境中重现问题并解决问题。

随意从GitHub下载示例代码或跟随。该解决方案有两个 API,名字毫无想象力,First.Api名为Second.Api. 第一个 API 调用第二个 API 以获取天气数据。这是一个常见的用例,因为 API 可以调用其他 API,因此数据源保持解耦并且可以单独扩展。

首先,确保您的计算机上安装了.NET 6 SDK。然后,打开终端或控制台窗口:

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

以上内容可以放在解决方案文件夹中,例如performance-bottleneck-net6. 这将创建两个具有最少 API、无 HTTPS、无 swagger 或 Open API 的 Web 项目。该工具支持文件夹结构,因此如果您在设置这两个新项目时需要帮助,请查看示例代码。

解决方案文件可以放在解决方案文件夹中。这允许您通过 Rider 或 Visual Studio 等 IDE 打开整个解决方案:

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

接下来,一定要为每个 Web 项目设置端口号。在示例代码中,我将第一个 API 设置为 5060,第二个设置为 5176。具体数字并不重要,但我将在整个示例代码中使用这些来引用 API。因此,请确保您要么更改端口号,要么保留脚手架生成的内容并保持一致。

违规申请

在第二个 API 中打开Program.cs文件并放置响应天气数据的代码:

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);
}

.NET 6 中的最小 API 功能有助于保持代码小而简洁。这将循环遍历一千条记录,并执行任务延迟以模拟异步数据处理。在实际项目中,这段代码可以调用分布式缓存,或者数据库,这是一个 IO-bound 操作。

现在,转到Program.cs第一个 API 中的文件并编写使用此天气数据的代码。您可以简单地复制粘贴并替换脚手架生成的任何内容:

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("/", 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);
}

HttpClient作为单例注入,因为这使客户端可扩展。在 .NET 中,新的客户端在底层操作系统中创建套接字,因此一个好的技术是通过重用类来重用这些连接。此处,HTTP 客户端设置连接池生存期。这允许客户端在必要时挂在套接字上。

基地址只是告诉客户端去哪里,因此请确保它指向第二个 API 中设置的正确端口号。

当一个请求进来时,代码循环一百次,然后调用第二个 API。例如,这是为了模拟调用其他 API 所需的大量记录。迭代是硬编码的,但在实际项目中,这可以是一个用户列表,随着业务的增长,它可以无限增长。

现在,将注意力集中在循环上,因为这对性能理论有影响。在算法分析中,单个循环具有 Big-O 线性复杂度或 O(n)。但是,第二个 API 也会循环,这会使算法达到二次或 O(n^2) 复杂度。此外,循环通过 IO 边界进行引导,这会降低性能。

这具有乘法效应,因为对于第一个 API 中的每次迭代,第二个 API 循环一千次。有 100 * 1000 次迭代。请记住,这些列表是未绑定的,这意味着性能将随着数据集的增长呈指数级下降。

当愤怒的客户向呼叫中心发送垃圾邮件要求更好的用户体验时,请使用这些工具来尝试弄清楚发生了什么。

CURL 和 NBomber

第一个工具将帮助挑选出要关注的 API。优化代码时,可能会无休止地优化所有内容,因此请避免过早优化。目标是让性能“足够好”,这往往是主观的,并且受业务需求驱动。

首先,使用 CURL 单独调用每个 API,例如,感受一下延迟:

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

端口号5060属于第一个API,5176属于第二个。验证这些端口是否是您机器上的正确端口。

第二个 API 在几分之一秒内响应,这已经足够好了而且可能不是罪魁祸首。但是第一个 API 几乎需要两秒钟才能响应。这是不可接受的,因为 Web 服务器可以使花费这么长时间的请求超时。此外,从客户的角度来看,两秒的延迟太慢了,因为这是一个破坏性的延迟。

接下来,像 NBomber 这样的工具将帮助对有问题的 API 进行基准测试。

返回控制台,在根文件夹中创建一个测试项目:

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

Program.cs文件中,写入基准:

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();

NBomber 仅以每秒一个请求的速率向 API 发送垃圾邮件。然后,在接下来的十秒内每隔一秒每秒两次。最后,在接下来的 15 秒内每秒 3 次。这可以防止本地开发机器因太多请求而超载。NBomber 还使用网络套接字,因此当目标 API 和基准测试工具在同一台机器上运行时要小心谨慎。

测试步骤跟踪响应代码并将其放入返回值中。这会跟踪 API 故障。在 .NET 中,当 Kestrel 服务器收到过多请求时,它会拒绝那些响应失败的请求。

现在,检查结果并检查延迟、并发请求和吞吐量。

违规应用结果

P95 延迟显示 1.5 秒,这是大多数客户都会体验到的。吞吐量仍然很低,因为该工具被校准为每秒最多只能处理三个请求。在本地开发机器中,很难弄清楚并发性,因为运行基准测试工具的相同资源对于服务请求也是必需的。

点迹分析

接下来,选择一个可以进行算法分析的工具,例如 dotTrace。这将有助于进一步隔离可能出现性能问题的位置。

要进行分析,请运行 dotTrace 并在 NBomber 尽可能地向 API 发送垃圾邮件后拍摄快照。目标是模拟重负载以确定缓慢的来源。已经实施的基准测试已经足够好了,因此请确保您正在运行 dotTrace 和 NBomber。

dotTrace分析

根据此分析,大约 85% 的时间花在GetFromJsonAsync通话上。在该工具中四处寻找发现这是来自 HTTP 客户端。这与性能理论相关,因为这表明复杂度为 O(n^2) 的异步循环可能是问题所在。

在本地运行的基准测试工具将有助于识别瓶颈。下一步是使用可以在实时生产环境中跟踪请求的监控工具。

性能调查都是关于收集信息,他们会交叉检查每个工具是否至少在讲述一个有凝聚力的故事。

Site24x7 监控

Site24x7这样的工具可以帮助解决性能问题。

对于此应用程序,您希望关注两个 API 中的 P95 延迟。这就是连锁反应,因为 API 是分布式架构中一系列互连服务的一部分。当一个 API 开始出现性能问题时,下游的其他 API 也会遇到问题。

可扩展性是另一个关键因素。随着用户群的增长,应用程序可能会开始滞后。跟踪正常行为并预测应用程序如何随时间扩展会有所帮助。此应用程序中的嵌套异步循环可能适用于 N 个用户,但可能无法扩展,因为数量未绑定。

最后,在部署优化和改进时,跟踪版本依赖性是关键。对于每次迭代,您必须能够知道哪个版本的性能更好或更差。

适当的监控工具是必要的,因为在本地开发环境中并不总是容易发现问题。本地做出的假设在生产中可能无效,因为您的客户可能有不同的意见。

更高效的解决方案

有了目前的工具库,是时候探索更好的方法了。

CURL 说第一个 API 是有性能问题的。这意味着对第二个 API 所做的任何改进都可以忽略不计。即使这里有连锁反应,从第二个 API 中减少几毫秒也不会有太大的不同。

NBomber 通过在第一个 API 中显示 P95 几乎达到两秒来证实了这个故事。然后,dotTrace 挑出了异步循环,因为这是算法花费大部分时间的地方。像 Site24x7 这样的监控工具可以通过显示 P95 延迟、随时间推移的可扩展性和版本控制来提供支持信息。引入嵌套循环的特定版本可能会出现延迟。

根据性能理论,二次复杂度是一个大问题,因为性能呈指数级下降。一个好的技术是通过减少循环内的迭代次数来降低复杂性。

.NET 中的一个限制是,每次您看到等待时,逻辑都会阻塞并一次仅发送一个请求。这将停止迭代并等待第二个 API 返回响应。这对表演来说是个不幸的消息。

一种天真的方法是通过同时发送所有 HTTP 请求来简单地破坏循环:

app.MapGet("/", async (HttpClient client) =>
 (await Task.WhenAll( // blocks only once
   Enumerable
     .Range(0, 100)
     .Select(_ =>
       client.GetFromJsonAsync<List<WeatherForecast>>( // Oof
         "/weatherForecast")
     )
   )
 )
 .ToArray()[Random.Shared.Next(0, 100)]);

这将使循环内的 await 失效,并且只会阻塞一次。Task.WhenAll并行发送所有内容,这打破了循环。

这种方法可能有效,但它冒着一次向第二个 API 发送过多请求的风险。Web 服务器可以拒绝请求,因为它认为这可能是 DoS 攻击。一种更可持续的方法是通过一次只发送几个来减少迭代:

var sem = new SemaphoreSlim(10); // 10 at a time

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

这很像俱乐部的保镖。最大容量为十。当请求进入池时,一次只能进入十个。这也允许并发请求,因此如果一个请求退出池,另一个可以立即进入,而无需等待十个请求。

这将算法的复杂性降低了十分之一,并减轻了所有疯狂循环带来的压力。

使用此代码,运行 NBomber 并检查结果。

更高效的解决方案

P95 延迟现在是过去的三分之一。半秒的反应比任何超过一秒的反应都合理得多。当然,您可以继续优化并进一步优化,但我认为您的客户会对此感到非常满意。

结论

性能优化是一个永无止境的故事。随着业务的增长,一旦在代码中做出的假设可能会随着时间的推移而变得无效。因此,您需要工具来分析、绘制基准并持续监控应用程序以帮助平息性能问题。

解决.NET 6应用程序中的性能瓶颈 推荐阅读

2023如何学习SQL——初学者终极指南

优惠券