解决.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。
根据此分析,大约 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 延迟现在是过去的三分之一。半秒的反应比任何超过一秒的反应都合理得多。当然,您可以继续优化并进一步优化,但我认为您的客户会对此感到非常满意。
结论
性能优化是一个永无止境的故事。随着业务的增长,一旦在代码中做出的假设可能会随着时间的推移而变得无效。因此,您需要工具来分析、绘制基准并持续监控应用程序以帮助平息性能问题。