我见过太多 .NET 应用上线时,其部署方式还像在 “我笔记本上按 F5” 一样随意。以下是我希望多年前就有人能塞给我的清单。它带有个人见解,注重实用,且可以直接复制粘贴。
1) 发布命令与 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 托管
默认情况下,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 响应压缩
AddResponseCompression() 启用 HTTP 响应压缩。它会在将响应发送给客户端之前将其压缩。生成更小的负载以实现更快的响应,并使用更少的带宽。默认的压缩方法是 Gzip。你也可以添加 Brotli 压缩。Brotli 非常适合返回 JSON 或文本的 API。如果你的 CPU 已经繁忙,保持默认的 Gzip 方法即可。
builder.Services.AddResponseCompression(options =>
{
options.Providers.Add<BrotliCompressionProvider>();
options.EnableForHttps = true;
});
HTTP 响应缓存
为数据不经常更改的 GET 端点使用缓存(例如,配置、参考数据)。ETags 和 Last-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

Comments