ASP.NET Core 性能优化最佳实践

本文提供了 ASP.NET Core 的性能最佳实践指南。

ASP.NET Core 性能优化最佳实践

译文原文地址:https://docs.microsoft.com/en-us/aspnet/core/performance/performance-best-practices?view=aspnetcore-3.1

积极利用缓存

这里有一篇文档在多个部分中讨论了如何积极利用缓存。 有关详细信息,请参阅︰ https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-3.1.

了解代码中的热点路径

在本文档中, 代码热点路径 定义为频繁调用的代码路径以及执行时间的大部分时间。 代码热点路径通常限制应用程序的扩展和性能,并在本文档的多个部分中进行讨论。

避免阻塞式调用

ASP.NET Core 应用程序应设计为同时处理许多请求。 异步 API 可以使用一个小池线程通过非阻塞式调用来处理数以千计的并发请求。 线程可以处理另一个请求,而不是等待长时间运行的同步任务完成。

ASP.NET Core 应用程序中的常见性能问题通常是由于那些本可以异步调用但却采用阻塞时调用而导致的。 同步阻塞会调用导致 线程池饥饿 和响应时间降级。

不要:

  • 通过调用 Task.Wait 或 Task.Result 来阻止异步执行。
  • 在公共代码路径中加锁。 ASP.NET Core 应用程序应设计为并行运行代码,如此才能使得性能最佳。
  • 调用 Task.Run 并立即 await 。 ASP.NET Core 本身已经是在线程池线程上运行应用程序代码了,因此这样调用 Task.Run 只会导致额外的不必要的线程池调度。 而且即使被调度的代码会阻止线程, Task.Run 也并不能避免这种情况,这样做没有意义。

要:

  • 确保 代码热点路径 全部异步化。
  • 如在进行调用数据读写、I/O 处理和长时间操作的 API 时,存在可用的异步 API。那么务必选择异步 API 。 但是,不要 使用 Task.Run 来包装同步 API 使其异步化。
  • 确保 controller/Razor Page actions 异步化。 整个调用堆栈是异步的,就可以利用 async/await 模式的性能优势。
    使用性能分析程序 ( 例如 PerfView) 可用于查找频繁添加到 线程池 的线程。 Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start 事件表示新线程被添加到线程池。

使用 IEumerable<T> 或 IAsyncEnumerable<T> 作为返回值

在 Action 中返回 IEumerable<T> 将会被序列化器中进行同步迭代 。 结果是可能导致阻塞或者线程池饥饿。 想要要避免同步迭代集合,可以在返回迭代集合之前使用 ToListAsync 使其异步化。

从 ASP.NET Core 3.0 开始, IAsyncEnumerable<T> 可以用作为 IEumerable<T> 的替代方法,以异步方式进行迭代。 有关更多信息,请参阅 Controller Action 的返回值类型

尽可能少的使用大对象

.NET Core 垃圾收集器 在 ASP.NET Core 应用程序中起到自动管理内存的分配和释放的作用。 自动垃圾回收通常意味着开发者不需要担心如何或何时释放内存。 但是,清除未引用的对象将会占用 CPU 时间,因此开发者应最小化 代码热点路径 中的分配的对象。 垃圾回收在大对象上代价特大 (> 85 K 字节) 。 大对象存储在 large object heap 上,需要 full (generation 2) garbage collection 来清理。 与 generation 0 和 generation 1 不同,generation 2 需要临时暂挂应用程序。 故而频繁分配和取消分配大型对象会导致性能耗损。

建议 :

  • 要 考虑缓存频繁使用的大对象。 缓存大对象可防止昂贵的分配开销。
  • 要使用 ArrayPool<T> 作为池化缓冲区以保存大型数组。
  • 不要 在代码热点路径 上分配许多短生命周期的大对象。

可以通过查看 PerfView 中的垃圾回收 (GC) 统计信息来诊断并检查内存问题,其中包括:

  • 垃圾回收挂起时间。
  • 垃圾回收中耗用的处理器时间百分比。
  • 有多少垃圾回收发生在 generation 0, 1, 和 2.

有关更多信息,请参阅 垃圾回收和性能

优化数据操作和 I/O

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

建议 :

  • 要 以异步方式调用所有数据访问 API 。
  • 不要 读取不需要的数据。 编写查询时,仅返回当前 HTTP 请求所必需的数据。
  • 要 考虑缓存从数据库或远程服务检索的频繁访问的数据 (如果稍微过时的数据是可接受的话) 。 根据具体的场景,可以使用 MemoryCache 或 DistributedCache。 有关更多信息,请参阅 https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-3.1.
  • 要 尽量减少网络往返。 能够单次调用完成就不应该多次调用来读取所需数据。
  • 要 在 Entity Framework Core 访问数据以用作只读情况时, 使用 no-tracking 方式查询。 EF Core 可以更高效地返回 no-tracking 查询的结果。
  • 要 使用过滤器和聚集 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 连接的方式从而优化性能和可靠性。

建议 :

确保公共代码路径快若鹰隼

如果你想要所有的代码都保持高速, 高频调用的代码路径就是优化的最关键路径。 优化措施包括:

  • 考虑优化应用程序请求处理管道中的 Middleware ,尤其是在管道中排在更前面运行的 Middleware 。 这些组件对性能有很大影响。
  • 考虑优化那些每个请求都要执行或每个请求多次执行的代码。 例如,自定义日志,身份认证与授权或 transient 服务的创建等等。

建议 :

在 HTTP 请求之外运行长时任务

对 ASP.NET Core 应用程序的大多数请求可以由调用服务的 controller 或页面模型处理,并返回 HTTP 响应。 对于涉及长时间运行的任务的某些请求,最好使整个请求 - 响应进程异步。

建议 :

  • 不要把等待长时间运行的任务完成,作为普通 HTTP 请求处理的一部分。
  • 要 考虑使用 后台服务 或 Azure Function 处理长时间运行的任务。 在应用外执行任务特别有利于 CPU 密集型任务的性能。
  • 要 使用实时通信,如 SignalR,以异步方式与客户端通信。

缩小客户端资源

复杂的 ASP.NET Core 应用程序经常包含很有前端文件例如 JavaScript, CSS 或图片文件。 可以通过以下方法优化初始请求的性能:

  • 打包,将多个文件合并为一个文件。
  • 压缩,通过除去空格和注释来缩小文件大小。

建议 :

  • 要 使用 ASP.NET Core 的 内置支持 用于打包和压缩客户端资源文件的组件。
  • 要 考虑其他第三方工具,如 Webpack,用于复杂客户资产管理。

压缩 Http 响应

减少响应的大小通常会显着提高应用程序的响应性。 而减小内容大小的一种方法是压缩应用程序的响应。 有关更多信息,请参阅 响应压缩

使用最新的 ASP.NET Core 发行版

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

最小化异常

异常应该竟可能少。 相对于正常代码流程来说,抛出和捕获异常是缓慢的。 因此,不应使用异常来控制正常程序流。

建议 :

  • 不要 使用抛出或捕获异常作为正常程序流的手段,特别是在 代码热点路径 中。
  • 要 在应用程序中包含用于检测和处理导致异常的逻辑。
  • 要 对意外的执行情况抛出或捕获异常。

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

性能和可靠性

下文将提供常见性能提示和已知可靠性问题的解决方案。

避免在 HttpRequest/HttpResponse body 上同步读取或写入

ASP.NET Core 中的所有 I/O 都是异步的。 服务器实现了 Stream 接口,它同时具有同步和异步的方法重载。 应该首选异步方式以避免阻塞线程池线程。 阻塞线程会导致线程池饥饿。

不要使用如下操作: https://docs.microsoft.com/en-us/dotnet/api/System.IO.StreamReader.ReadToEnd。 它会阻止当前线程等待结果。 这是 sync over async 的示例。

 
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 不 支持同步读取。

应该采用如下操作: https://docs.microsoft.com/en-us/dotnet/api/System.IO.StreamReader.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 request body 读取到内存中。

[!WARNING] 如果请求很大,那么将整个 HTTP request body 读取到内存中可能会导致内存不足 (OOM) 。 OOM 可导致应用奔溃。 有关更多信息,请参阅 避免将大型请求主体或响应主体读取到内存中

应该采用如下操作: 使用不缓冲的方式完成 request body 操作:

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

上述代码采用异步方式将 request body 序列化为 C# 对象。

优先选用 Request.Form 的 ReadFormAsync

应该使用 HttpContext.Request.ReadFormAsync 而不是 HttpContext.Request.Form。 HttpContext.Request.Form 只能在以下场景用安全使用。

  • 该表单已被 ReadFormAsync 调用,并且
  • 数据已经被从 HttpContext.Request.Form 读取并缓存

不要使用如下操作: 例如以下方式使用 HttpContext.Request.Form。 HttpContext.Request.Form 使用了 sync over async ,这将导致线程饥饿.

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

避免将大型 request body 或 response body 读取到内存中

在 .NET 中,大于 85 KB 的对象会被分配在大对象堆 (LOH )。 大型对象的开销较大,包含两方面:

  • 分配大对象内存时需要对被分配的内存进行清空,这个操作成本较高。 CLR 会保证清空所有新分配的对象的内存。(将内存全部设置为 0)
  • LOH 只会在内存剩余不足时回收。 LOH 需要在 full garbage collection 或者 Gen2 collection 进行回收。

此 博文 很好描述了该问题:

当分配大对象时,它会被标记为 Gen 2 对象。 而不像是 Gen 0 那样的小对象。 这样的后果是,如果你在使用 LOH 时耗尽内存, GC 会清除整个托管堆,而不仅仅是 LOH 部分。 因此,它将清理 Gen 0, Gen 1 and Gen 2 (包括 LOH) 。 这称为 full garbage collection,是最耗时的垃圾回收。 对于很多应用,这是可以接受的。 但绝对不适用于高性能 Web 服务器,因为高性能 Web 服务器需要更多的内存用于处理常规 Web 请求 ( 从套接字读取,解压缩,解码 JSON 等等 )。

天真地将一个大型 request 或者 response body 存储到单个 byte[] 或 string 中:

  • 这可能导致 LOH 的剩余空间快速耗尽。
  • 因此产生的 full GC 可能会导致应用程序的性能问题。

使用同步 API 处理数据

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

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

[!WARNING] 如果请求较大,那么可能导致内存不足 (OOM) 。 OOM 可导致应用奔溃。 有关更多信息,请参阅 避免将大型请求主体或响应主体读取到内存

ASP.NET Core 3.0 默认情况下使用 https://docs.microsoft.com/en-us/dotnet/api/system.text.json 进行 JSON 序列化,这将带来如下好处。 https://docs.microsoft.com/en-us/dotnet/api/system.text.json:

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

不要将 IHttpContextAccessor.HttpContext 存储在字段中

IHttpContextAccessor.HttpContext 返回当前请求线程中的 HttpContextIHttpContextAccessor.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

应该采用如下操作:

 
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

HttpContext 只有在 ASP.NET Core 管道处理活跃的 HTTP 请求时才可用。 整个 ASP.NET Core 管道是由异步代理组成的调用链,用于处理每个请求。 当 Task 从调用链完成并返回时,HttpContext 就会被回收。

不要进行如下操作: 以下示例使用 async void ,这将使得 HTTP 请求在第一个 await 时处理完成,进而就会导致:

  • 在 ASP.NET Core 应用程序中, 这是一个完全错误 的做法
  • 在 HTTP 请求完成后访问 HttpResponse
  • 进程崩溃。
public class AsyncBadVoidController : Controller
{
[HttpGet("/async")]
public async void Get()
{
await Task.Delay(); // 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(); await Response.WriteAsync("Hello World");
}
}

不要在后台线程中使用 HttpContext

不要使用如下操作: 以下示例使用一个闭包从 Controller 属性读取 HttpContext。 这是一种错误做法,因为这将导致:

  • 代码运行在 Http 请求作用域之外。
  • 尝试读取错误的 HttpContext
 
[HttpGet("/fire-and-forget-1")]
public IActionResult BadFireAndForget()
{
_ = Task.Run(async () =>
{
await Task.Delay(); var path = HttpContext.Request.Path;
Log(path);
}); return Accepted();
}

应该采用如下操作:

  • 在请求处理阶段将后台线程需要的数据全部进行复制。
  • 不要使用 controller 的所有引用
 
[HttpGet("/fire-and-forget-3")]
public IActionResult GoodFireAndForget()
{
string path = HttpContext.Request.Path;
_ = Task.Run(async () =>
{
await Task.Delay(); Log(path);
}); return Accepted();
}

后台任务最好采用托管服务进行操作。 有关更多信息,请参阅 采用托管服务运行后台任务 。

不要在后台线程获取注入到 controller 中的服务

不要采用如下做法: 以下示例使用闭包从 controller 获取 DbContext 进行操作。 这是一个错误的做法。 这将导致代码云在请求的作用域之外。 而 ContocoDbContext 是基于请求作用域的,因此这样将引发 ObjectDisposedException

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

应该采用如下操作:

 
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory
serviceScopeFactory)
{
_ = Task.Run(async () =>
{
await Task.Delay(); using (var scope = serviceScopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>(); context.Contoso.Add(new Contoso()); await context.SaveChangesAsync();
}
}); return Accepted();
}

以下高亮的的代码说明:

  • 为后台操作创建新的作用域,并且从中获取需要的服务。
  • 在正确的作用域中使用 ContocoDbContext,即只能在请求作用域中使用该对象。
 
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory
serviceScopeFactory)
{
_ = Task.Run(async () =>
{
await Task.Delay(); using (var scope = serviceScopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>(); context.Contoso.Add(new Contoso()); await context.SaveChangesAsync();
}
}); return Accepted();
}

不要在响应正文已经开始发送时尝试修改 status code 或者 header

ASP.NET Core 不会缓冲 HTTP 响应正文。 当正文一旦开始发送:

  • Header 就会与正文的数据包一起发送到客户端。
  • 此时就无法修改 header 了。

不要使用如下操作: 以下代码尝试在响应启动后添加响应头:

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

在上述的代码中,如果 next() 已经开始写入响应,则 context.Response.Headers["test"] = "test value"; 将会抛出异常。

应该采用如下操作: 以下示例检查 HTTP 响应在修改 Header 之前是否已启动。

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

应该采用如下操作: 以下示例使用 HttpResponse.OnStarting 来设置 Header,这样便可以在响应启动时将 Header 一次性写入到客户端。

通过这种方式,响应头将在响应开始时调用已注册的回调进行一次性写入。 如此这般便可以:

  • 在恰当的时候进行响应头的修改或者覆盖。
  • 不需要了解管道中的下一个 middleware 的行为。
 
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
context.Response.Headers["someheader"] = "somevalue";
return Task.CompletedTask;
}); await next();
});

如果已开始写入响应主体,则请不要调用 next ()

仅当后续组件能够处理响应或时才调用它们,因此如果当前已经开始写入响应主体,后续操作就已经不再需要,并有可能引发异常情况。

托管于 IIS 应该使用 In-process 模式

使用 in-process 模式托管, ASP.NET Core 应用程序将与 IIS 工作进程在同一进程中运行。 In-process 模式拥有比 out-of-process 更加优秀的性能表现,因为这样不需要将请求通过回环网络适配器进行代理中转。 回环网络适配器是将本机发送的网络流量重新转回本机的的网络适配器。 IIS 进程管理由 Windows Process Activation Service (WAS) 来完成。

在 ASP.NET Core 3.0 和更高版本中的默认将采用 in-process 模式进行托管。

有关更多信息,请参阅 在 Windows 上使用 IIS 托管 ASP.NET Core


Newbe.Translations

您所阅读的当前文章源自于 Newbe.Translations 项目参与的翻译贡献,您可以通过右侧链接一同参与该项目:https://www.newbe.pro/Newbe.Translations/Newbe.Translations/

翻译内容具有一定的时效性,不会随着原文内容实时更新,如果内容存在一定过时,您也可以联系我们。

ASP.NET Core 性能优化最佳实践

上一篇:.NET Core应用中使用分布式缓存及内存缓存


下一篇:Redis 入门与 ASP.NET Core 缓存