ASP.NET Core 高性能開發最佳實踐


from:https://docs.microsoft.com/zh-cn/aspnet/core/performance/performance-best-practices?view=aspnetcore-3.1

作者:Mike Rousos

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

主動緩存

此文檔的幾個部分討論了緩存。 有關更多信息,請參見響應緩存在 ASP.NET Core

了解熱代碼路徑

在本文檔中,將熱代碼路徑定義為經常調用的代碼路徑和執行時間量。 熱代碼路徑通常限制應用向外縮放和性能,並將在本文檔的幾個部分中進行討論。

避免阻止調用

應將 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 事件表示添加到線程池中的線程。

最小化大型對象分配

.Net Core 垃圾回收器在 ASP.NET Core 應用中自動管理內存的分配和釋放。 自動垃圾回收通常意味着開發人員無需擔心如何或何時釋放內存。 但是,清理未引用的對象會占用 CPU 時間,因此開發人員應最大限度地減少熱代碼路徑中的對象分配。 垃圾回收對於大型對象(> 85 K 字節)特別昂貴。 大型對象存儲在大型對象堆上,並要求進行完整(第2代)垃圾回收。 與第0代和第1代回收不同,第2代回收需要臨時暫停應用執行。 頻繁分配和取消分配大型對象會導致性能不一致。

建議:

  • 請考慮緩存經常使用的大型對象。 緩存大型對象會阻止開銷較高的分配。
  • 使用ArrayPool<t >來存儲大型數組,從而對緩沖區進行緩沖。
  • 不要在熱代碼路徑上分配很多生存期較短的大型對象。

可以通過查看PerfView中的垃圾回收(GC)統計信息並進行檢查來診斷內存問題,例如前面的問題:

  • 垃圾回收暫停時間。
  • 垃圾回收所用的處理器時間百分比。
  • 第0代、第1代和第2代垃圾回收量。

有關詳細信息,請參閱垃圾回收和性能

優化數據訪問和 i/o

與數據存儲和其他遠程服務的交互通常是 ASP.NET Core 應用程序的最慢部分。 有效讀取和寫入數據對於良好的性能至關重要。

建議:

  • 請以異步方式調用所有數據訪問 api。
  • 檢索的數據不是必需的。 編寫查詢以僅返回當前 HTTP 請求所必需的數據。
  • 如果數據可以接受,請考慮緩存經常訪問的從數據庫或遠程服務檢索的數據。 使用MemoryCachemicrosoft.web.distributedcache,具體取決於方案。 有關更多信息,請參見響應緩存在 ASP.NET Core
  • 盡量減少網絡往返次數。 目標是使用單個調用而不是多個調用來檢索所需數據。
  • 在 Entity Framework Core 中,當出於只讀目的訪問數據時,使用 no-tracking 查詢 。 EF Core 可以更有效地返回非跟蹤查詢的結果。
  • 篩選和聚合 LINQ 查詢(例如,使用 .Where.Select或 .Sum 語句),以便數據庫執行篩選。
  • 請考慮 EF Core在客戶端上解析一些查詢運算符,這可能導致查詢執行效率低下。 有關詳細信息,請參閱客戶端評估性能問題
  • 不要對集合使用投影查詢,這可能會導致執行 "N + 1" 個 SQL 查詢。 有關詳細信息,請參閱相關子查詢的優化

請參閱EF 高性能,了解可提高大規模應用程序性能的方法:

建議在提交基本代碼之前測量前面的高性能方法的影響。 已編譯查詢的額外復雜性可能不會提高性能。

通過查看Application Insights或分析工具訪問數據所用的時間,可以檢測到查詢問題。 大多數數據庫還提供有關頻繁執行的查詢的統計信息。

與 HttpClientFactory 建立池 HTTP 連接

盡管HttpClient實現 IDisposable 接口,但它是為重復使用而設計的。 關閉 HttpClient 實例會使套接字在一小段時間內打開 TIME_WAIT 狀態。 如果經常使用創建和釋放 HttpClient 對象的代碼路徑,應用程序可能會耗盡可用的套接字。 ASP.NET Core 2.1 中引入了HttpClientFactory作為此問題的解決方案。 它處理池 HTTP 連接以優化性能和可靠性。

建議:

快速保持通用代碼路徑

您希望所有的代碼都是快速的,通常稱為代碼路徑是最重要的,可進行優化:

  • 應用程序的請求處理管道中的中間件組件,尤其是在管道早期運行的中間件。 這些組件會對性能產生很大的影響。
  • 針對每個請求或每個請求多次執行的代碼。 例如,自定義日志記錄、授權處理程序或暫時性服務的初始化。

建議:

在 HTTP 請求之外完成長時間運行的任務

大多數對 ASP.NET Core 應用程序的請求都可以通過控制器或頁面模型進行處理,該模型調用必要的服務並返回 HTTP 響應。 對於涉及長時間運行的任務的某些請求,最好將整個請求響應過程設為異步處理。

建議:

  • 請不要等待長時間運行的任務在普通的 HTTP 請求處理過程中完成。
  • 請考慮使用后台服務處理長時間運行的請求,或使用Azure 函數處理進程外的請求。 在進程外完成工作對於 CPU 密集型任務特別有用。
  • 請使用實時通信選項(如SignalR)以異步方式與客戶端進行通信。

縮小客戶端資產

具有復雜前端的 ASP.NET Core 應用通常會提供許多 JavaScript、CSS 或圖像文件。 可以通過以下方式改善初始負載請求的性能:

  • 綁定,將多個文件合並到一個文件中。
  • 縮小,它通過刪除空白和注釋來減小文件大小。

建議:

  • 請使用 ASP.NET Core 的內置支持,以便對客戶端資產進行捆綁和縮小。
  • 請考慮其他第三方工具(如Webpack),以實現復雜的客戶端資產管理。

壓縮響應

減小響應大小通常會顯著提高應用程序的響應能力。 減少負載大小的一種方法是壓縮應用的響應。 有關詳細信息,請參閱響應壓縮

使用最新 ASP.NET Core 版本

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 的當前版本。

最小化異常

異常應極少。 相對於其他代碼流模式,引發和捕獲異常的速度很慢。 因此,不應使用異常來控制正常的程序流。

建議:

  • 不要使用引發或捕獲異常作為正常程序流的方法,尤其是在熱代碼路徑中。
  • 在應用程序中包括邏輯,以檢測和處理會導致異常的情況。
  • 引發或捕獲異常或意外情況的異常。

應用診斷工具(如 Application Insights)可幫助識別應用中可能影響性能的常見異常。

性能和可靠性

以下各節提供了性能提示以及已知的可靠性問題和解決方案。

避免 HttpRequest/Httpresponse.cache 正文上的同步讀取或寫入

ASP.NET Core 中的所有 IO 都是異步的。 服務器實現 Stream 接口,該接口具有同步和異步重載。 應首選異步文件以避免阻塞線程池線程。 阻塞線程可能會導致線程池不足。

請勿執行此操作: 下面的示例使用 ReadToEnd。 此方法阻止當前線程等待結果。 這是一個通過異步同步的示例。

C#
public class BadStreamReaderController : Controller { [HttpGet("/contoso")] public ActionResult<ContosoData> Get() { var json = new StreamReader(Request.Body).ReadToEnd(); return JsonSerializer.Deserialize<ContosoData>(json); } } 

在前面的代碼中,Get 以同步方式將整個 HTTP 請求正文讀入內存中。 如果客戶端緩慢上傳,則應用通過異步執行同步。 應用通過異步同步,因為 Kestrel不支持同步讀取。

執行以下操作: 下面的示例使用 ReadToEndAsync,在讀取時不會阻止線程。

C#
public class GoodStreamReaderController : Controller { [HttpGet("/contoso")] public async Task<ActionResult<ContosoData>> Get() { var json = await new StreamReader(Request.Body).ReadToEndAsync(); return JsonSerializer.Deserialize<ContosoData>(json); } } 

前面的代碼異步將整個 HTTP 請求正文讀入內存中。

 警告

如果請求很大,則將整個 HTTP 請求正文讀取到內存中可能會導致內存不足(OOM)。 OOM 可能會導致拒絕服務。 有關詳細信息,請參閱本文檔中的避免將大型請求正文或響應正文讀入內存中。

執行以下操作: 下面的示例使用非緩沖請求正文完全異步:

C#
public class GoodStreamReaderController : Controller { [HttpGet("/contoso")] public async Task<ActionResult<ContosoData>> Get() { return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body); } } 

前面的代碼將請求正文異步反序列化為C#對象。

首選 ReadFormAsync over 請求。窗體

使用 HttpContext.Request.ReadFormAsync 而非 HttpContext.Request.Form。 僅在以下情況下,才能安全地讀取 HttpContext.Request.Form

  • 已通過調用 ReadFormAsync讀取了窗體,且
  • 正在使用讀取緩存的窗體值 HttpContext.Request.Form

請勿執行此操作: 下面的示例使用 HttpContext.Request.Form。 HttpContext.Request.Form通過異步使用同步,可能會導致線程池不足。

C#
public class BadReadController : Controller { [HttpPost("/form-body")] public IActionResult Post() { var form = HttpContext.Request.Form; Process(form["id"], form["name"]); return Accepted(); } 

執行以下操作: 下面的示例使用 HttpContext.Request.ReadFormAsync 以異步方式讀取窗體體。

C#
public class GoodReadController : Controller { [HttpPost("/form-body")] public async Task<IActionResult> Post() { var form = await HttpContext.Request.ReadFormAsync(); Process(form["id"], form["name"]); return Accepted(); } 

避免將大型請求正文或響應正文讀入內存

在 .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。

使用同步數據處理 API

使用僅支持同步讀和寫的序列化程序/反序列化程序(例如, JSON.NET)時:

  • 將數據異步緩沖到內存中,然后將其傳遞給序列化程序/反序列化程序。

 警告

如果請求很大,則可能導致內存不足(OOM)。 OOM 可能會導致拒絕服務。 有關詳細信息,請參閱本文檔中的避免將大型請求正文或響應正文讀入內存中。

默認情況下,ASP.NET Core 3.0 使用 System.Text.Json 進行 JSON 序列化。 System.Text.Json

  • 以異步方式讀取和寫入 JSON。
  • 針對 UTF-8 文本進行了優化。
  • 通常比 Newtonsoft.Json 性能更高。

不要在字段中存儲 IHttpContextAccessor

從請求線程訪問時, IHttpContextAccessor將返回活動請求的 HttpContext。 IHttpContextAccessor.HttpContext不應存儲在字段或變量中。

請勿執行此操作: 下面的示例將 HttpContext 存儲在字段中,然后稍后嘗試使用它。

C#
public class MyBadType { private readonly HttpContext _context; public MyBadType(IHttpContextAccessor accessor) { _context = accessor.HttpContext; } public void CheckAdmin() { if (!_context.User.IsInRole("admin")) { throw new UnauthorizedAccessException("The current user isn't an admin"); } } } 

前面的代碼在構造函數中頻繁捕獲 null 或不正確的 HttpContext

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

  • 將 IHttpContextAccessor 存儲在字段中。
  • 在正確的時間使用 HttpContext 字段並檢查 null
C#
public class MyGoodType { private readonly IHttpContextAccessor _accessor; public MyGoodType(IHttpContextAccessor accessor) { _accessor = accessor; } public void CheckAdmin() { var context = _accessor.HttpContext; if (context != null && !context.User.IsInRole("admin")) { throw new UnauthorizedAccessException("The current user isn't an admin"); } } } 

不要從多個線程訪問 HttpContext

HttpContext是線程安全的。 並行訪問來自多個線程的 HttpContext 可能會導致未定義的行為,如掛起、崩潰和數據損壞。

請勿執行此操作: 下面的示例執行三個並行請求,並在傳出 HTTP 請求之前和之后記錄傳入的請求路徑。 可以從多個線程訪問請求路徑,可能會並行進行。

C#
public class AsyncBadSearchController : Controller { [HttpGet("/search")] public async Task<SearchResults> Get(string query) { var query1 = SearchAsync(SearchEngine.Google, query); var query2 = SearchAsync(SearchEngine.Bing, query); var query3 = SearchAsync(SearchEngine.DuckDuckGo, query); await Task.WhenAll(query1, query2, query3); var results1 = await query1; var results2 = await query2; var results3 = await query3; return SearchResults.Combine(results1, results2, results3); } private async Task<SearchResults> SearchAsync(SearchEngine engine, string query) { var searchResults = _searchService.Empty(); try { _logger.LogInformation("Starting search query from {path}.", HttpContext.Request.Path); searchResults = _searchService.Search(engine, query); _logger.LogInformation("Finishing search query from {path}.", HttpContext.Request.Path); } catch (Exception ex) { _logger.LogError(ex, "Failed query from {path}", HttpContext.Request.Path); } return await searchResults; } 

執行以下操作: 下面的示例在發出三個並行請求之前復制傳入請求中的所有數據。

C#
public class AsyncGoodSearchController : Controller { [HttpGet("/search")] public async Task<SearchResults> Get(string query) { string path = HttpContext.Request.Path; var query1 = SearchAsync(SearchEngine.Google, query, path); var query2 = SearchAsync(SearchEngine.Bing, query, path); var query3 = SearchAsync(SearchEngine.DuckDuckGo, query, path); await Task.WhenAll(query1, query2, query3); var results1 = await query1; var results2 = await query2; var results3 = await query3; return SearchResults.Combine(results1, results2, results3); } private async Task<SearchResults> SearchAsync(SearchEngine engine, string query,  string path)  { var searchResults = _searchService.Empty(); try { _logger.LogInformation("Starting search query from {path}.", path); searchResults = await _searchService.SearchAsync(engine, query); _logger.LogInformation("Finishing search query from {path}.", path); } catch (Exception ex) { _logger.LogError(ex, "Failed query from {path}", path); } return await searchResults; } 

請求完成后,不要使用 HttpContext

只要 ASP.NET Core 管道中存在活動 HTTP 請求,HttpContext 才有效。 整個 ASP.NET Core 管道是一系列執行每個請求的委托。 從此鏈返回的 Task 完成后,HttpContext 將被回收。

請勿執行此操作: 下面的示例使用 async void,這會在達到第一個 await 時完成 HTTP 請求:

  • 在 ASP.NET Core 應用程序中,這始終是一種不好的做法。
  • HTTP 請求完成后,訪問 HttpResponse
  • 崩潰進程。
C#
public class AsyncBadVoidController : Controller { [HttpGet("/async")] public async void Get() { await Task.Delay(1000); // The following line will crash the process because of writing after the // response has completed on a background thread. Notice async void Get() await Response.WriteAsync("Hello World"); } } 

執行以下操作: 下面的示例將 Task 返回到框架,以便在操作完成之前,不會完成 HTTP 請求。

C#
public class AsyncGoodTaskController : Controller { [HttpGet("/async")] public async Task Get() { await Task.Delay(1000); await Response.WriteAsync("Hello World"); } } 

不要捕獲后台線程中的 HttpContext

請勿執行此操作: 下面的示例演示關閉從 Controller 屬性捕獲 HttpContext。 這是一種不好的做法,因為工作項可以:

  • 在請求范圍之外運行。
  • 嘗試讀取錯誤的 HttpContext
C#
[HttpGet("/fire-and-forget-1")] public IActionResult BadFireAndForget() { _ = Task.Run(async () => { await Task.Delay(1000); var path = HttpContext.Request.Path; Log(path); }); return Accepted(); } 

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

  • 在請求過程中復制后台任務所需的數據。
  • 不從控制器引用任何內容。
C#
[HttpGet("/fire-and-forget-3")] public IActionResult GoodFireAndForget() { string path = HttpContext.Request.Path; _ = Task.Run(async () => { await Task.Delay(1000); Log(path); }); return Accepted(); } 

應將后台任務作為托管服務實現。 有關詳細信息,請參閱使用托管服務的后台任務

不要捕獲注入到后台線程控制器的服務

請勿執行此操作: 下面的示例演示關閉從 Controller 操作參數捕獲 DbContext。 這是一種不好的做法。 工作項可以在請求范圍之外運行。 ContosoDbContext 的作用域限定為請求,導致 ObjectDisposedException

C#
[HttpGet("/fire-and-forget-1")] public IActionResult FireAndForget1([FromServices]ContosoDbContext context) { _ = Task.Run(async () => { await Task.Delay(1000); context.Contoso.Add(new Contoso()); await context.SaveChangesAsync(); }); return Accepted(); } 

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

  • 注入 IServiceScopeFactory 以便在后台工作項中創建作用域。 IServiceScopeFactory 為單一實例。
  • 在后台線程中創建新的依賴項注入范圍。
  • 不從控制器引用任何內容。
  • 不捕獲傳入請求中的 ContosoDbContext
C#
[HttpGet("/fire-and-forget-3")] public IActionResult FireAndForget3([FromServices]IServiceScopeFactory serviceScopeFactory) { _ = Task.Run(async () => { await Task.Delay(1000); using (var scope = serviceScopeFactory.CreateScope()) { var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>(); context.Contoso.Add(new Contoso()); await context.SaveChangesAsync(); } }); return Accepted(); } 

以下突出顯示的代碼:

  • 在后台操作的生存期內創建一個范圍,並從中解析服務。
  • 使用來自正確范圍的 ContosoDbContext
C#
[HttpGet("/fire-and-forget-3")] public IActionResult FireAndForget3([FromServices]IServiceScopeFactory serviceScopeFactory) { _ = Task.Run(async () => { await Task.Delay(1000); using (var scope = serviceScopeFactory.CreateScope()) { var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>(); context.Contoso.Add(new Contoso()); await context.SaveChangesAsync(); } }); return Accepted(); } 

請不要在響應正文開始后修改狀態代碼或標頭

ASP.NET Core 不會緩沖 HTTP 響應正文。 第一次寫入響應時:

  • 標頭將與主體塊區一起發送到客戶端。
  • 不能再更改響應標頭。

請勿執行此操作: 以下代碼在響應已啟動之后嘗試添加響應標頭:

C#
app.Use(async (context, next) => { await next(); context.Response.Headers["test"] = "test value"; }); 

在前面的代碼中,如果 next() 已寫入響應,則 context.Response.Headers["test"] = "test value"; 會引發異常。

執行以下操作: 下面的示例在修改標頭之前檢查 HTTP 響應是否已啟動。

C#
app.Use(async (context, next) => { await next(); if (!context.Response.HasStarted) { context.Response.Headers["test"] = "test value"; } }); 

執行以下操作: 下面的示例使用 HttpResponse.OnStarting 在將響應標頭刷新到客戶端之前設置標頭。

如果檢查響應是否尚未啟動,則允許注冊將在寫入響應標頭之前調用的回調。 檢查響應是否尚未開始:

  • 提供了隨時追加或重寫標頭的功能。
  • 不需要了解管道中的下一個中間件。
C#
app.Use(async (context, next) => { context.Response.OnStarting(() => { context.Response.Headers["someheader"] = "somevalue"; return Task.CompletedTask; }); await next(); }); 

如果已開始寫入響應正文,則不調用 next ()

僅當組件可以處理和操作響應時,才應調用組件。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM