ASP.NET Core 性能最佳做法ASP.NET Core Performance Best Practices

本文内容

作者:Mike Rousos

本文提供了有关 ASP.NET Core 的性能最佳做法的准则。

主动缓存Cache aggressively

此文档的几个部分讨论了缓存。有关详细信息,请参阅 响应缓存在 ASP.NET Core

了解热代码路径Understand hot code paths

在本文档中,将热代码路径定义为经常调用的代码路径和执行时间量。热代码路径通常限制应用向外缩放和性能,并将在本文档的几个部分中进行讨论。

避免阻止调用Avoid blocking calls

应将 ASP.NET Core 应用程序设计为同时处理许多请求。异步 Api 允许一小部分线程通过不等待阻止调用来处理上千个并发请求。线程可以处理另一请求,而不是等待长时间运行的同步任务完成。

ASP.NET Core 应用中的常见性能问题是阻止可能是异步的调用。很多同步阻塞调用会导致线程池不足并降低响应时间。

请勿

  • 通过调用task. Waittask.来阻止异步执行。
  • 获取通用代码路径中的锁。当构建为并行运行代码时,ASP.NET Core 应用程序的性能最高。
  • 调用任务。运行并立即等待。ASP.NET Core 已在正常线程池线程上运行应用程序代码,因此调用任务。运行仅会导致额外的不必要的线程池计划。即使计划的代码会阻止线程,任务也不会阻止。

建议做法

  • 使热代码路径处于异步状态。
  • 如果异步 API 可用,则异步调用数据访问、i/o 和长时间运行的操作 Api。不要使用任务。运行以使 synchronus API 成为异步。
  • 使控制器/Razor 页面操作异步。为了受益于async/await模式,整个调用堆栈是异步的。

探查器(如PerfView)可用于查找频繁添加到线程池中的线程。Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start 事件表示添加到线程池中的线程。

最小化大型对象分配Minimize large object allocations

.Net Core 垃圾回收器在 ASP.NET Core 应用中自动管理内存的分配和释放。自动垃圾回收通常意味着开发人员无需担心如何或何时释放内存。但是,清理未引用的对象会占用 CPU 时间,因此开发人员应最大限度地减少热代码路径中的对象分配。垃圾回收对于大型对象(> 85 K 字节)特别昂贵。大型对象存储在大型对象堆上,并要求进行完整(第2代)垃圾回收。与第0代和第1代回收不同,第2代回收需要临时暂停应用执行。频繁分配和取消分配大型对象会导致性能不一致。

建议:

  • 请考虑缓存经常使用的大型对象。缓存大型对象会阻止开销较高的分配。
  • 使用ArrayPool来存储大型数组,从而对缓冲区进行缓冲。
  • 不要热代码路径上分配很多生存期较短的大型对象。

    可以通过查看PerfView中的垃圾回收(GC)统计信息并进行检查来诊断内存问题,例如前面的问题:
  • 垃圾回收暂停时间。
  • 垃圾回收所用的处理器时间百分比。
  • 第0代、第1代和第2代垃圾回收量。

有关详细信息,请参阅垃圾回收和性能

优化数据访问和 i/oOptimize data access and I/O

与数据存储和其他远程服务的交互通常是 ASP.NET Core 应用程序的最慢部分。有效读取和写入数据对于良好的性能至关重要。

建议:

  • 以异步方式调用所有数据访问 api。
  • 检索的数据是必需的。编写查询以仅返回当前 HTTP 请求所必需的数据。
  • 如果数据可以接受,请考虑缓存经常访问的从数据库或远程服务检索的数据。使用MemoryCachemicrosoft.web.distributedcache,具体取决于方案。有关详细信息,请参阅 响应缓存在 ASP.NET Core
  • 尽量减少网络往返次数。目标是使用单个调用而不是多个调用来检索所需数据。
  • 在访问数据时,请不要在 Entity Framework Core 中使用无跟踪查询。EF Core 可以更有效地返回非跟踪查询的结果。
  • 筛选和聚合 LINQ 查询(例如,使用 .Where.Select.Sum 语句),以便数据库执行筛选。
  • 请考虑 EF Core在客户端上解析一些查询运算符,这可能导致查询执行效率低下。有关详细信息,请参阅客户端评估性能问题
  • 不要对集合使用投影查询,这可能会导致执行 "N + 1" 个 SQL 查询。有关详细信息,请参阅相关子查询的优化

请参阅EF 高性能,了解可提高大规模应用程序性能的方法:

建议在提交基本代码之前测量前面的高性能方法的影响。已编译查询的额外复杂性可能不会提高性能。

通过查看Application Insights或分析工具访问数据所用的时间,可以检测到查询问题。大多数数据库还提供有关频繁执行的查询的统计信息。

与 HttpClientFactory 建立池 HTTP 连接Pool HTTP connections with HttpClientFactory

尽管HttpClient实现 IDisposable 接口,但它是为重复使用而设计的。关闭 HttpClient 实例会使套接字在一小段时间内打开 TIME_WAIT 状态。如果经常使用创建和释放 HttpClient 对象的代码路径,应用程序可能会耗尽可用的套接字。ASP.NET Core 2.1 中引入了HttpClientFactory作为此问题的解决方案。它处理池 HTTP 连接以优化性能和可靠性。

建议:

快速保持通用代码路径Keep common code paths fast

您希望所有的代码都是快速的,通常称为代码路径是最重要的,可进行优化:

  • 应用程序的请求处理管道中的中间件组件,尤其是在管道早期运行的中间件。这些组件会对性能产生很大的影响。
  • 针对每个请求或每个请求多次执行的代码。例如,自定义日志记录、授权处理程序或暂时性服务的初始化。

建议:

在 HTTP 请求之外完成长时间运行的任务Complete long-running Tasks outside of HTTP requests

大多数对 ASP.NET Core 应用程序的请求都可以通过控制器或页面模型进行处理,该模型调用必要的服务并返回 HTTP 响应。对于涉及长时间运行的任务的某些请求,最好将整个请求响应过程设为异步处理。

建议:

  • 不要等待长时间运行的任务在普通的 HTTP 请求处理过程中完成。
  • 请考虑使用后台服务处理长时间运行的请求,或使用Azure 函数处理进程外的请求。在进程外完成工作对于 CPU 密集型任务特别有用。
  • 请使用实时通信选项(如SignalR)以异步方式与客户端进行通信。

缩小客户端资产Minify client assets

具有复杂前端的 ASP.NET Core 应用通常会提供许多 JavaScript、CSS 或图像文件。可以通过以下方式改善初始负载请求的性能:

  • 绑定,将多个文件合并到一个文件中。
  • 缩小,它通过删除空白和注释来减小文件大小。

建议:

  • 使用 ASP.NET Core 的内置支持,以便对客户端资产进行捆绑和缩小。
  • 请考虑其他第三方工具(如Webpack),以实现复杂的客户端资产管理。

压缩响应Compress responses

减小响应大小通常会显著提高应用程序的响应能力。减少负载大小的一种方法是压缩应用的响应。有关详细信息,请参阅响应压缩

使用最新 ASP.NET Core 版本Use the latest ASP.NET Core release

ASP.NET Core 的每个新版本都包括性能改进。.NET Core 和 ASP.NET Core 中的优化意味着较新版本通常优于较旧的版本。例如,.NET Core 2.1 添加了对跨<t >中已编译的正则表达式和获益的支持。ASP.NET Core 2.2 添加了对 HTTP/2 的支持。ASP.NET Core 3.0 添加了许多改进,减少了内存使用量并提高了吞吐量。如果性能是优先考虑的,请考虑升级到 ASP.NET Core 的当前版本。

最小化异常Minimize exceptions

异常应极少。相对于其他代码流模式,引发和捕获异常的速度很慢。因此,不应使用异常来控制正常的程序流。

建议:

  • 不要使用引发或捕获异常作为正常程序流的方法,尤其是在热代码路径中。
  • 在应用程序中包括逻辑,以检测和处理会导致异常的情况。
  • 引发或捕获异常或意外情况的异常。

应用诊断工具(如 Application Insights)可帮助识别应用中可能影响性能的常见异常。

性能和可靠性Performance and reliability

以下各节提供了性能提示以及已知的可靠性问题和解决方案。

避免 HttpRequest/Httpresponse.cache 正文上的同步读取或写入Avoid synchronous read or write on HttpRequest/HttpResponse body

ASP.NET Core 中的所有 IO 都是异步的。服务器实现 Stream 接口,该接口具有同步和异步重载。应首选异步文件以避免阻塞线程池线程。阻塞线程可能会导致线程池不足。

请勿执行此操作: 下面的示例使用 ReadToEnd此方法阻止当前线程等待结果。这是一个通过异步同步的示例。

  1. public class BadStreamReaderController : Controller
  2. {
  3. [HttpGet("/contoso")]
  4. public ActionResult<ContosoData> Get()
  5. {
  6. var json = new StreamReader(Request.Body).ReadToEnd();
  7. return JsonSerializer.Deserialize<ContosoData>(json);
  8. }
  9. }

在前面的代码中,Get 以同步方式将整个 HTTP 请求正文读入内存中。如果客户端缓慢上传,则应用通过异步执行同步。应用通过异步同步,因为 Kestrel不支持同步读取。

执行以下操作: 下面的示例使用 ReadToEndAsync,在读取时不会阻止线程。

  1. public class GoodStreamReaderController : Controller
  2. {
  3. [HttpGet("/contoso")]
  4. public async Task<ActionResult<ContosoData>> Get()
  5. {
  6. var json = await new StreamReader(Request.Body).ReadToEndAsync();
  7. return JsonSerializer.Deserialize<ContosoData>(json);
  8. }
  9. }

前面的代码异步将整个 HTTP 请求正文读入内存中。

警告

如果请求很大,则将整个 HTTP 请求正文读取到内存中可能会导致内存不足(OOM)。OOM 可能会导致拒绝服务。有关详细信息,请参阅本文档中的避免将大型请求正文或响应正文读入内存中。

执行以下操作: 下面的示例使用非缓冲请求正文完全异步:

  1. public class GoodStreamReaderController : Controller
  2. {
  3. [HttpGet("/contoso")]
  4. public async Task<ActionResult<ContosoData>> Get()
  5. {
  6. return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body);
  7. }
  8. }

前面的代码将请求正文异步反序列化为C#对象。

首选 ReadFormAsync over 请求。窗体Prefer ReadFormAsync over Request.Form

使用 HttpContext.Request.ReadFormAsync 而非 HttpContext.Request.Form仅在以下情况下,才能安全地读取 HttpContext.Request.Form

  • 已通过调用 ReadFormAsync读取了窗体,且
  • 正在使用读取缓存的窗体值 HttpContext.Request.Form

请勿执行此操作: 下面的示例使用 HttpContext.Request.FormHttpContext.Request.Form通过异步使用同步,可能会导致线程池不足。

  1. public class BadReadController : Controller
  2. {
  3. [HttpPost("/form-body")]
  4. public IActionResult Post()
  5. {
  6. var form = HttpContext.Request.Form;
  7. Process(form["id"], form["name"]);
  8. return Accepted();
  9. }

执行以下操作: 下面的示例使用 HttpContext.Request.ReadFormAsync 以异步方式读取窗体体。

  1. public class GoodReadController : Controller
  2. {
  3. [HttpPost("/form-body")]
  4. public async Task<IActionResult> Post()
  5. {
  6. var form = await HttpContext.Request.ReadFormAsync();
  7. Process(form["id"], form["name"]);
  8. return Accepted();
  9. }

避免将大型请求正文或响应正文读入内存Avoid reading large request bodies or response bodies into memory

在 .NET 中,大于 85 KB 的每个对象分配将在大型对象堆(LOH)中结束。大型对象的开销很大:

  • 分配开销较高,因为必须清除新分配的大型对象的内存。CLR 确保清除所有新分配对象的内存。
  • LOH 随堆的其余部分一起收集。LOH 需要完整的垃圾回收Gen2 集合

博客文章简单介绍了问题:


分配大型对象时,会将其标记为第2代对象。 对于小对象,不是0代。 后果是,如果在 LOH 中用尽内存,GC 将清除整个托管堆,而不仅是 LOH。 因此,它会清除第0代第1代和第2代,包括 LOH。 这称为完整垃圾回收,是最耗费时间的垃圾回收。 许多应用程序都可以接受。 但一定不要用于高性能的 web 服务器,在这种情况下,需要少量的大内存缓冲区来处理平均 web 请求(从套接字读取、解压缩、解码 JSON & 更多)。


将大型请求或响应正文存储到单个 byte[]string中的 Naively:

  • 可能会导致 LOH 中的空间快速耗尽。
  • 可能导致应用程序出现性能问题,因为正在运行完全 Gc。

使用同步数据处理 APIWorking with a synchronous data processing API

使用仅支持同步读和写的序列化程序/反序列化程序(例如, JSON.NET)时:

  • 将数据异步缓冲到内存中,然后将其传递给序列化程序/反序列化程序。

警告

如果请求很大,则可能导致内存不足(OOM)。OOM 可能会导致拒绝服务。有关详细信息,请参阅本文档中的避免将大型请求正文或响应正文读入内存中。

默认情况下,ASP.NET Core 3.0 使用 System.Text.Json 进行 JSON 序列化。System.Text.Json设置用户帐户 :

  • 以异步方式读取和写入 JSON。
  • 针对 UTF-8 文本进行了优化。
  • 通常比 Newtonsoft.Json 性能更高。

不要在字段中存储 IHttpContextAccessorDo not store IHttpContextAccessor.HttpContext in a field

从请求线程访问时, IHttpContextAccessor将返回活动请求的 HttpContextIHttpContextAccessor.HttpContext应存储在字段或变量中。

请勿执行此操作: 下面的示例将 HttpContext 存储在字段中,然后稍后尝试使用它。

  1. public class MyBadType
  2. {
  3. private readonly HttpContext _context;
  4. public MyBadType(IHttpContextAccessor accessor)
  5. {
  6. _context = accessor.HttpContext;
  7. }
  8. public void CheckAdmin()
  9. {
  10. if (!_context.User.IsInRole("admin"))
  11. {
  12. throw new UnauthorizedAccessException("The current user isn't an admin");
  13. }
  14. }
  15. }

前面的代码在构造函数中频繁捕获 null 或不正确的 HttpContext

执行以下操作: 下面的示例:

  • IHttpContextAccessor 存储在字段中。
  • 在正确的时间使用 HttpContext 字段并检查 null
  1. public class MyGoodType
  2. {
  3. private readonly IHttpContextAccessor _accessor;
  4. public MyGoodType(IHttpContextAccessor accessor)
  5. {
  6. _accessor = accessor;
  7. }
  8. public void CheckAdmin()
  9. {
  10. var context = _accessor.HttpContext;
  11. if (context != null && !context.User.IsInRole("admin"))
  12. {
  13. throw new UnauthorizedAccessException("The current user isn't an admin");
  14. }
  15. }
  16. }

不要从多个线程访问 HttpContextDo not access HttpContext from multiple threads

HttpContext是线程安全的。并行访问来自多个线程的 HttpContext 可能会导致未定义的行为,如挂起、崩溃和数据损坏。

请勿执行此操作: 下面的示例执行三个并行请求,并在传出 HTTP 请求之前和之后记录传入的请求路径。可以从多个线程访问请求路径,可能会并行进行。

  1. public class AsyncBadSearchController : Controller
  2. {
  3. [HttpGet("/search")]
  4. public async Task<SearchResults> Get(string query)
  5. {
  6. var query1 = SearchAsync(SearchEngine.Google, query);
  7. var query2 = SearchAsync(SearchEngine.Bing, query);
  8. var query3 = SearchAsync(SearchEngine.DuckDuckGo, query);
  9. await Task.WhenAll(query1, query2, query3);
  10. var results1 = await query1;
  11. var results2 = await query2;
  12. var results3 = await query3;
  13. return SearchResults.Combine(results1, results2, results3);
  14. }
  15. private async Task<SearchResults> SearchAsync(SearchEngine engine, string query)
  16. {
  17. var searchResults = _searchService.Empty();
  18. try
  19. {
  20. _logger.LogInformation("Starting search query from {path}.",
  21. HttpContext.Request.Path);
  22. searchResults = _searchService.Search(engine, query);
  23. _logger.LogInformation("Finishing search query from {path}.",
  24. HttpContext.Request.Path);
  25. }
  26. catch (Exception ex)
  27. {
  28. _logger.LogError(ex, "Failed query from {path}",
  29. HttpContext.Request.Path);
  30. }
  31. return await searchResults;
  32. }

执行以下操作: 下面的示例在发出三个并行请求之前复制传入请求中的所有数据。

  1. public class AsyncGoodSearchController : Controller
  2. {
  3. [HttpGet("/search")]
  4. public async Task<SearchResults> Get(string query)
  5. {
  6. string path = HttpContext.Request.Path;
  7. var query1 = SearchAsync(SearchEngine.Google, query,
  8. path);
  9. var query2 = SearchAsync(SearchEngine.Bing, query, path);
  10. var query3 = SearchAsync(SearchEngine.DuckDuckGo, query, path);
  11. await Task.WhenAll(query1, query2, query3);
  12. var results1 = await query1;
  13. var results2 = await query2;
  14. var results3 = await query3;
  15. return SearchResults.Combine(results1, results2, results3);
  16. }
  17. private async Task<SearchResults> SearchAsync(SearchEngine engine, string query,
  18. string path)
  19. {
  20. var searchResults = _searchService.Empty();
  21. try
  22. {
  23. _logger.LogInformation("Starting search query from {path}.",
  24. path);
  25. searchResults = await _searchService.SearchAsync(engine, query);
  26. _logger.LogInformation("Finishing search query from {path}.", path);
  27. }
  28. catch (Exception ex)
  29. {
  30. _logger.LogError(ex, "Failed query from {path}", path);
  31. }
  32. return await searchResults;
  33. }

请求完成后,不要使用 HttpContextDo not use the HttpContext after the request is complete

只要 ASP.NET Core 管道中存在活动 HTTP 请求,HttpContext 才有效。整个 ASP.NET Core 管道是一系列执行每个请求的委托。从此链返回的 Task 完成后,HttpContext 将被回收。

请勿执行此操作: 下面的示例使用 async void,这会在达到第一个 await 时完成 HTTP 请求:

  • 在 ASP.NET Core 应用程序中,这始终是一种不好的做法。
  • HTTP 请求完成后,访问 HttpResponse
  • 崩溃进程。
  1. public class AsyncBadVoidController : Controller
  2. {
  3. [HttpGet("/async")]
  4. public async void Get()
  5. {
  6. await Task.Delay(1000);
  7. // The following line will crash the process because of writing after the
  8. // response has completed on a background thread. Notice async void Get()
  9. await Response.WriteAsync("Hello World");
  10. }
  11. }

执行以下操作: 下面的示例将 Task 返回到框架,以便在操作完成之前,不会完成 HTTP 请求。

  1. public class AsyncGoodTaskController : Controller
  2. {
  3. [HttpGet("/async")]
  4. public async Task Get()
  5. {
  6. await Task.Delay(1000);
  7. await Response.WriteAsync("Hello World");
  8. }
  9. }

不要捕获后台线程中的 HttpContextDo not capture the HttpContext in background threads

请勿执行此操作: 下面的示例演示关闭从 Controller 属性捕获 HttpContext这是一种不好的做法,因为工作项可以:

  • 在请求范围之外运行。
  • 尝试读取错误的 HttpContext
  1. [HttpGet("/fire-and-forget-1")]
  2. public IActionResult BadFireAndForget()
  3. {
  4. _ = Task.Run(async () =>
  5. {
  6. await Task.Delay(1000);
  7. var path = HttpContext.Request.Path;
  8. Log(path);
  9. });
  10. return Accepted();
  11. }

执行以下操作: 下面的示例:

  • 在请求过程中复制后台任务所需的数据。
  • 不从控制器引用任何内容。
  1. [HttpGet("/fire-and-forget-3")]
  2. public IActionResult GoodFireAndForget()
  3. {
  4. string path = HttpContext.Request.Path;
  5. _ = Task.Run(async () =>
  6. {
  7. await Task.Delay(1000);
  8. Log(path);
  9. });
  10. return Accepted();
  11. }

应将后台任务作为托管服务实现。有关详细信息,请参阅使用托管服务的后台任务

不要捕获注入到后台线程控制器的服务Do not capture services injected into the controllers on background threads

请勿执行此操作: 下面的示例演示关闭从 Controller 操作参数捕获 DbContext这是一种不好的做法。工作项可以在请求范围之外运行。ContosoDbContext 的作用域限定为请求,导致 ObjectDisposedException

  1. [HttpGet("/fire-and-forget-1")]
  2. public IActionResult FireAndForget1([FromServices]ContosoDbContext context)
  3. {
  4. _ = Task.Run(async () =>
  5. {
  6. await Task.Delay(1000);
  7. context.Contoso.Add(new Contoso());
  8. await context.SaveChangesAsync();
  9. });
  10. return Accepted();
  11. }

执行以下操作: 下面的示例:

  • 注入 IServiceScopeFactory 以便在后台工作项中创建作用域。IServiceScopeFactory 为单一实例。
  • 在后台线程中创建新的依赖项注入范围。
  • 不从控制器引用任何内容。
  • 不捕获传入请求中的 ContosoDbContext
  1. [HttpGet("/fire-and-forget-3")]
  2. public IActionResult FireAndForget3([FromServices]IServiceScopeFactory
  3. serviceScopeFactory)
  4. {
  5. _ = Task.Run(async () =>
  6. {
  7. await Task.Delay(1000);
  8. using (var scope = serviceScopeFactory.CreateScope())
  9. {
  10. var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();
  11. context.Contoso.Add(new Contoso());
  12. await context.SaveChangesAsync();
  13. }
  14. });
  15. return Accepted();
  16. }

以下突出显示的代码:

  • 在后台操作的生存期内创建一个范围,并从中解析服务。
  • 使用来自正确范围的 ContosoDbContext
  1. [HttpGet("/fire-and-forget-3")]
  2. public IActionResult FireAndForget3([FromServices]IServiceScopeFactory
  3. serviceScopeFactory)
  4. {
  5. _ = Task.Run(async () =>
  6. {
  7. await Task.Delay(1000);
  8. using (var scope = serviceScopeFactory.CreateScope())
  9. {
  10. var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();
  11. context.Contoso.Add(new Contoso());
  12. await context.SaveChangesAsync();
  13. }
  14. });
  15. return Accepted();
  16. }

请不要在响应正文开始后修改状态代码或标头Do not modify the status code or headers after the response body has started

ASP.NET Core 不会缓冲 HTTP 响应正文。第一次写入响应时:

  • 标头将与主体块区一起发送到客户端。
  • 不能再更改响应标头。

请勿执行此操作: 以下代码在响应已启动之后尝试添加响应标头:

  1. app.Use(async (context, next) =>
  2. {
  3. await next();
  4. context.Response.Headers["test"] = "test value";
  5. });

在前面的代码中,如果 next() 已写入响应,则 context.Response.Headers["test"] = "test value"; 会引发异常。

执行以下操作: 下面的示例在修改标头之前检查 HTTP 响应是否已启动。

  1. app.Use(async (context, next) =>
  2. {
  3. await next();
  4. if (!context.Response.HasStarted)
  5. {
  6. context.Response.Headers["test"] = "test value";
  7. }
  8. });

执行以下操作: 下面的示例使用 HttpResponse.OnStarting 在将响应标头刷新到客户端之前设置标头。

如果检查响应是否尚未启动,则允许注册将在写入响应标头之前调用的回调。检查响应是否尚未开始:

  • 提供了随时追加或重写标头的功能。
  • 不需要了解管道中的下一个中间件。
  1. app.Use(async (context, next) =>
  2. {
  3. context.Response.OnStarting(() =>
  4. {
  5. context.Response.Headers["someheader"] = "somevalue";
  6. return Task.CompletedTask;
  7. });
  8. await next();
  9. });

如果已开始写入响应正文,则不调用 next ()Do not call next() if you have already started writing to the response body

仅当组件可以处理和操作响应时,才应调用组件。