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. Wait或task.來阻止異步執行。
- 獲取通用代碼路徑中的鎖。 當構建為並行運行代碼時,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 請求所必需的數據。
- 如果數據可以接受,請考慮緩存經常訪問的從數據庫或遠程服務檢索的數據。 使用MemoryCache或microsoft.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 連接以優化性能和可靠性。
建議:
- 不要直接創建和釋放
HttpClient
實例。 - 請使用HttpClientFactory檢索
HttpClient
實例。 有關詳細信息,請參閱使用 HttpClientFactory 實現復原 HTTP 請求。
快速保持通用代碼路徑
您希望所有的代碼都是快速的,通常稱為代碼路徑是最重要的,可進行優化:
- 應用程序的請求處理管道中的中間件組件,尤其是在管道早期運行的中間件。 這些組件會對性能產生很大的影響。
- 針對每個請求或每個請求多次執行的代碼。 例如,自定義日志記錄、授權處理程序或暫時性服務的初始化。
建議:
- 不要將自定義中間件組件用於長時間運行的任務。
- 請使用性能分析工具(如Visual Studio 診斷工具或PerfView)來識別熱代碼路徑。
在 HTTP 請求之外完成長時間運行的任務
大多數對 ASP.NET Core 應用程序的請求都可以通過控制器或頁面模型進行處理,該模型調用必要的服務並返回 HTTP 響應。 對於涉及長時間運行的任務的某些請求,最好將整個請求響應過程設為異步處理。
建議:
- 請不要等待長時間運行的任務在普通的 HTTP 請求處理過程中完成。
- 請考慮使用后台服務處理長時間運行的請求,或使用Azure 函數處理進程外的請求。 在進程外完成工作對於 CPU 密集型任務特別有用。
- 請使用實時通信選項(如SignalR)以異步方式與客戶端進行通信。
縮小客戶端資產
具有復雜前端的 ASP.NET Core 應用通常會提供許多 JavaScript、CSS 或圖像文件。 可以通過以下方式改善初始負載請求的性能:
- 綁定,將多個文件合並到一個文件中。
- 縮小,它通過刪除空白和注釋來減小文件大小。
建議:
壓縮響應
減小響應大小通常會顯著提高應用程序的響應能力。 減少負載大小的一種方法是壓縮應用的響應。 有關詳細信息,請參閱響應壓縮。
使用最新 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。 此方法阻止當前線程等待結果。 這是一個通過異步同步的示例。
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,在讀取時不會阻止線程。
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 可能會導致拒絕服務。 有關詳細信息,請參閱本文檔中的避免將大型請求正文或響應正文讀入內存中。
執行以下操作: 下面的示例使用非緩沖請求正文完全異步:
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
通過異步使用同步,可能會導致線程池不足。
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
以異步方式讀取窗體體。
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)中結束。 大型對象的開銷很大:
此博客文章簡單介紹了問題:
分配大型對象時,會將其標記為第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
存儲在字段中,然后稍后嘗試使用它。
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
。
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 請求之前和之后記錄傳入的請求路徑。 可以從多個線程訪問請求路徑,可能會並行進行。
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; }
執行以下操作: 下面的示例在發出三個並行請求之前復制傳入請求中的所有數據。
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
。 - 崩潰進程。
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 請求。
public class AsyncGoodTaskController : Controller { [HttpGet("/async")] public async Task Get() { await Task.Delay(1000); await Response.WriteAsync("Hello World"); } }
不要捕獲后台線程中的 HttpContext
請勿執行此操作: 下面的示例演示關閉從 Controller
屬性捕獲 HttpContext
。 這是一種不好的做法,因為工作項可以:
- 在請求范圍之外運行。
- 嘗試讀取錯誤的
HttpContext
。
[HttpGet("/fire-and-forget-1")] public IActionResult BadFireAndForget() { _ = Task.Run(async () => { await Task.Delay(1000); var path = HttpContext.Request.Path; Log(path); }); return Accepted(); }
執行以下操作: 下面的示例:
- 在請求過程中復制后台任務所需的數據。
- 不從控制器引用任何內容。
[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
。
[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
。
[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
。
[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 響應正文。 第一次寫入響應時:
- 標頭將與主體塊區一起發送到客戶端。
- 不能再更改響應標頭。
請勿執行此操作: 以下代碼在響應已啟動之后嘗試添加響應標頭:
app.Use(async (context, next) => { await next(); context.Response.Headers["test"] = "test value"; });
在前面的代碼中,如果 next()
已寫入響應,則 context.Response.Headers["test"] = "test value";
會引發異常。
執行以下操作: 下面的示例在修改標頭之前檢查 HTTP 響應是否已啟動。
app.Use(async (context, next) => { await next(); if (!context.Response.HasStarted) { context.Response.Headers["test"] = "test value"; } });
執行以下操作: 下面的示例使用 HttpResponse.OnStarting
在將響應標頭刷新到客戶端之前設置標頭。
如果檢查響應是否尚未啟動,則允許注冊將在寫入響應標頭之前調用的回調。 檢查響應是否尚未開始:
- 提供了隨時追加或重寫標頭的功能。
- 不需要了解管道中的下一個中間件。
app.Use(async (context, next) => { context.Response.OnStarting(() => { context.Response.Headers["someheader"] = "somevalue"; return Task.CompletedTask; }); await next(); });
如果已開始寫入響應正文,則不調用 next ()
僅當組件可以處理和操作響應時,才應調用組件。