优化您的 .NET 应用以用于生产环境 - 完整清单 (Part 1)

Avatar
不若风吹尘
2025-12-24T13:24:33
37
0

我见过太多 .NET 应用上线时,其部署方式还像在 “我笔记本上按 F5” 一样随意。以下是我希望多年前就有人能塞给我的清单。它带有个人见解,注重实用,且可以直接复制粘贴。


1) 发布命令与 CSPROJ 设置

发布命令与 CSPROJ 设置

绝对不要使用调试构建版本上生产环境!请参考以下命令,它会为生产环境正确发布一个 .NET 应用。

dotnet publish -c Release -o out -p:PublishTrimmed=true -p:PublishSingleFile=true -p:ReadyToRun=true

用于优化生产发布的 csproj 配置:

<PropertyGroup>
  <PublishReadyToRun>true</PublishReadyToRun>
  <PublishTrimmed>true</PublishTrimmed>
  <InvariantGlobalization>true</InvariantGlobalization>
  <TieredCompilation>true</TieredCompilation>
</PropertyGroup>
  • PublishTrimmed:它会裁剪程序集。这是什么意思?!它会从你的应用及其依赖项中移除未使用的代码,从而减少输出文件的大小。
  • PublishReadyToRun:通常你构建 .NET 应用时,你的 C# 代码会被编译成 IL(中间语言)。当你的应用运行时,JIT 编译器会将 IL 代码转换为原生 CPU 指令。但这在启动时会花费大量时间。当你启用 PublishReadyToRun 时,构建过程会预先将你的 IL 编译为原生代码,这被称为 AOT(预先编译)。因此你的应用启动更快……但缺点是:输出文件现在会稍大一些。另一件事是:它只会为特定的操作系统(如 Windows)编译,并且无法再在 Linux 上运行。
  • 自包含 (Self-contained):当你以这种方式发布 .NET 应用时,它会将 .NET 运行时包含在你的应用文件中。这样即使在没有安装 .NET 的机器上也能运行。输出体积会变大,但运行时版本与你构建时使用的完全一致。

2) Kestrel 托管

Kestrel 托管

默认情况下,ASP.NET Core 应用只监听 localhost,这意味着它只接受来自本机内部的请求。当你部署到 Docker 或 Kubernetes 时,容器的内部网络需要将应用暴露给外部世界。为此,你可以通过如下环境变量进行设置:

ASPNETCORE_URLS=http://0.0.0.0:8080

另外,如果你正在构建一个内部 API 或非多语言的容器化微服务,那么也请添加以下设置。它会禁用操作系统的全球化功能,以减少镜像大小和依赖项。

DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1

清理 Program.cs 启动项

这是一个仅包含必需中间件和设置的最小化 Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddConsole();

builder.Services.AddResponseCompression();
builder.Services.AddResponseCaching();
builder.Services.AddHealthChecks();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.UseResponseCompression();
app.UseResponseCaching();

app.MapHealthChecks("/health");
app.MapGet("/error", () => Results.Problem(statusCode: 500));

app.Run();

3) 垃圾回收与线程池

垃圾回收与线程池

GC 内存清理模式

GC(垃圾回收)是 .NET 自动释放内存的方式。主要有两种模式:

  • 工作站 GC: 适用于桌面应用(侧重于响应性)
  • 服务器 GC: 适用于服务器(侧重于吞吐量)

以下环境变量告诉 .NET 运行时使用 服务器垃圾回收器(Server GC) 而不是 工作站 GC。因为我们的 ASP.NET Core 应用必须为服务器优化,而不是为个人电脑。

COMPlus_gcServer=1

GC 限制内存使用

为托管堆(.NET GC 控制的内存)最多使用总可用内存的 60%。因此,如果你的容器或虚拟机有 4 GB 内存,.NET 将尝试将 GC 堆保持在 2.4 GB 以下(4 GB 的 60%)。尤其是在容器中运行应用时,不要让 GC 假定拥有主机内存:

COMPlus_GCHeapHardLimitPercent=60

线程池预热

当你的 .NET 应用运行时,它会使用一个线程池。这用于处理后台工作,如 HTTP 请求、异步任务、I/O 操作等。默认情况下,线程池起始较小,并随着负载增加而动态增长。这对桌面应用很好,但对服务器应用来说太慢了!因为在突发流量高峰期间,应用可能会浪费时间创建线程,而不是处理请求。因此,下面的代码确保至少有 200 个工作线程和 200 个 I/O 完成线程准备就绪,即使它们处于空闲状态。

ThreadPool.SetMinThreads(200, 200);

4) HTTP 性能

HTTP Performance

HTTP 响应压缩

AddResponseCompression() 启用 HTTP 响应压缩。它会在将响应发送给客户端之前将其压缩。生成更小的负载以实现更快的响应,并使用更少的带宽。默认的压缩方法是 Gzip。你也可以添加 Brotli 压缩。Brotli 非常适合返回 JSON 或文本的 API。如果你的 CPU 已经繁忙,保持默认的 Gzip 方法即可。

builder.Services.AddResponseCompression(options =>
{
    options.Providers.Add<BrotliCompressionProvider>();
    options.EnableForHttps = true;
});

HTTP 响应缓存

为数据不经常更改的 GET 端点使用缓存(例如,配置、参考数据)。ETagsLast-Modified 头告诉浏览器或代理,如果数据没有更改,则跳过下载。

  • ETag = 资源的版本令牌。
  • Last-Modified = 最后更改的时间戳。

如果客户端发送 If-None-Match: "abc123" 而你的资源的 ETag 没有更改,.NET 会自动返回 304 Not Modified

HTTP/2 或 HTTP/3

这些较新的协议使网络请求更快、更流畅。它非常适合微服务或进行大量 API 调用的前端。

  • HTTP/2:多路复用(一个 TCP 连接上承载多个请求)。
  • HTTP/3:使用 QUIC(UDP)以进一步降低延迟。

你可以在反向代理(Nginx、Caddy、Kestrel)上启用它们…… .NET 开箱即用地支持两者,如果你的环境允许。

使用 DTO 实现最小负载

这里的最佳实践是:绝不发送/接收整个数据库实体,使用 DTO。在 DTO 中仅包含客户端实际需要的字段,这样做将使响应更小,甚至更安全。另外,优先使用 System.Text.Json(现在它比 Newtonsoft.Json 更快),对于流量极高的 API,使用源生成来消除反射开销。

// 定义你的实体 DTO
[JsonSerializable(typeof(MyDto))]
internal partial class MyJsonContext : JsonSerializerContext { }

// 然后像这样简单地序列化
var json = JsonSerializer.Serialize(dto, MyJsonContext.Default.MyDto)

5) 数据层(大多数应用变慢的地方!)

数据层

通过工厂(池化)复用 DbContext

为每个查询创建一个新的 DbContext 开销很大!使用 IDbContextFactory<TContext>,它从池中给你提供池化的 DbContext 实例,这些实例会被复用,而不是从头创建。

services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

然后注入工厂:

using var db = _contextFactory.CreateDbContext();

同时,确保你的数据库服务器(SQL Server、PostgreSQL……)启用了连接池


N+1 查询问题

当你的应用运行 一次查询获取主数据,然后再进行 N 次查询来获取相关实体时,就会发生 N+1 问题。这会严重降低性能!!!

错误做法:

var users = await context.Users.Include(u => u.Orders).ToListAsync();

正确做法: 使用 .Select() 投影到 DTO,这样 EF Core 会生成单个优化的 SQL 查询:

var users = await context.Users.Select(u => new UserDto
   {
        Id = u.Id,
        Name = u.Name,
        OrderCount = u.Orders.Count
    }).ToListAsync();

索引

使用 EF Core 日志记录、SQL Server Profiler 或 EXPLAIN(Postgres/MySQL)来查找慢查询。仅在需要的地方添加缺失的索引。例如 在此页面 ,他编写了一个 SQL 查询来列出缺失索引( Microsoft 文档 中也有另一个版本 )。这种性能改进大多是在应用运行一段时间后应用的。


数据库迁移

在生产环境中,手动运行迁移,绝不要在应用启动时执行。这样你可以审查模式更改、备份数据并避免破坏实时数据库。


使用 Polly 实现弹性

使用 Polly 为你的数据库或 HTTP 调用提供重试、超时和熔断器。它能优雅地处理短暂中断。

译自:https://abp.io/community/articles/optimize-your-dotnet-app-for-production-for-any-.net-app-wa24j28e

Last Modification : 12/29/2025 6:59:48 PM


In This Document