site logo

Marico's space

ASP.NET Core Web API 性能优化:从 900ms 到 3ms(廉价托管)

前端技术 2026-10-16 14:50:39 6

最近折腾了个个人项目,踩了几个网络延迟的坑,这篇把问题说清楚。

我的 ASP.NET Core 9 应用部署在一台机器上,SQL Server 放在另一台共享托管的服务器上——完全不在同一个机房。每次查询都要跨过我控制不了的网络。

首页的冷启动读取耗时 200 到 1000 毫秒,问题不在 SQL 查询计划慢,而在物理距离。

现在热读取响应时间稳定在 2 到 3 毫秒。没有用 C++ 重写任何东西,也没有上 Redis。就是做了 7 处改动,每一处都能在最低配的托管环境里实现。

先说这个值得专门提的约束

大多数 ASP.NET Core Web API 性能建议默认你的数据库和应用在同一个数据中心。在托管云上是这样,但在大多数小项目实际运行的共享托管上,不是。

当数据库距离你 200 毫秒时,关键动作不再是"让查询更快",而是"减少往返次数,别重复已经做过的请求"。下面几乎每一条都从这句话引出来。

1. 直接投影到 DTO,永远别加载实体

整个优化中最关键的一步。Web API 中每条读取路径直接从 IQueryable 投影到 DTO,让 EF Core 只生成一条扁平 SELECT 语句,只包含实际要序列化的列:

public static readonly Expression<Func<BlogPost, PostSummaryDto>> ToSummary = p => new PostSummaryDto( p.Id, p.Title, p.Slug, p.Excerpt, p.PublishedAt, p.Categories.Select(c => new TermDto(c.Id, c.Name, c.Slug)).ToList());

它避免的东西比它做的事情更重要:

  • 没有 Include 扇出。 把文章连同分类和标签作为实体加载,会把每行每列都拉过来,包括列表页根本不展示的完整正文。
  • 没有变更追踪。 投影到非实体类型完全绕过跟踪器,没有什么需要快照。
  • 没有延迟加载的坑。 缺少 Include 不会在后续变成 N+1 问题,因为根本没有导航属性可以走。

把这些投影存成 Expression 字段而不是方法,保持了可组合性:放在一个文件里,每个端点复用同一个结构。当 DTO 增加一个字段时,一处修改覆盖整个 API。

2. 连接池化 DbContext,默认不追踪

两行代码,应用启动时配一次:

builder.Services.AddDbContextPool<ApplicationDbContext>(options =>
{ options.UseSqlServer(connectionString, sql => { sql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5)); sql.CommandTimeout(30); }); options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}, poolSize: 128);

AddDbContextPool 复用 DbContext 实例,而不是每个请求新建一个,所以模型和依赖注入配置不会被每分钟解析几千次。NoTracking 作为默认值是安全的,恰恰因为上一条——这里根本不需要变更追踪器,只有少数写路径会显式调用 AsTracking()。

在共享托管上,EnableRetryOnFailure 不是可选项。 远程数据库连接会断开。没有重试就是访客看到的 500,有了重试只是没人察觉的抖动。

3. 输出缓存,加上标签

这就是 3 毫秒的来源。响应缓存通常被跳过,因为有个明显的反对理由:如果缓存五分钟,我的编辑内容要五分钟才生效。 基于标签的失效完全消除了这个顾虑。

按内容类型注册策略:

options.AddPolicy("posts-policy", p => p .Expire(TimeSpan.FromMinutes(5)) .Tag("posts") .SetVaryByQuery("page", "pageSize", "category", "tag", "search", "sort"));

然后每个写操作精确清除它触碰的标签:

await cacheStore.EvictByTagAsync("posts", ct);
await cacheStore.EvictByTagAsync("taxonomy", ct);

结果是一个没有陈旧成本的缓存。发布文章时清除帖子标签,变更立即生效;没人改过的标签继续从内存提供。热读取完全不碰数据库,这就是网络距离不再重要的原因。

SetVaryByQuery 配错的话,缓存就变成 bug。 每个改变响应的查询参数都必须列出来。漏掉一个——比如 author 过滤——两个不同的请求就会共享一个缓存的响应体,这是个穿着性能优化外衣的数据泄露。

4. 一个聚合端点,替代五次往返

首页需要精选文章、最新文章、热门文章、分类和标签。五个端点是整洁的设计,但在远程数据库上也是五倍的延迟。

GET /api/v1/site/home 一个响应返回全部五项。客户端发一次请求,服务器跑一次数据库查询。这不叫聪明,只是会数数——在慢速链路上,数往返次数比微优化任何一个都管用。

5. 永远别让读请求等写操作

每次文章浏览要递增一个计数器。同步做的话,意味着读请求要等一次跨越大半个中国的数据库写操作,就为了读取一个早已渲染好的内容。

改成把浏览事件放到有界 Channel,后台任务批量处理:

private readonly Channel<PostViewEvent> _channel = Channel.CreateBounded<PostViewEvent>(new BoundedChannelOptions(10_000) { FullMode = BoundedChannelFullMode.DropWrite, SingleReader = true, });

一个后台托管服务每五秒消费一次,最多 200 个事件,一次批量写入。有两个细节是故意的:

  • 有界,而不是无界。 无界 Channel 遇上流量峰值就是带队列前台的内存泄漏。
  • 满的时候 DropWrite。 如果要在丢失几条分析数据和让真实访客等待之间选择,分析数据认栽。在注释里写清楚这点,这样后来的人不会"修"掉它。

6. Split Query 的选择,以及为什么这里的默认值是错的

文章投影要拉两个集合——分类和标签。EF Core 会警告这个,建议 AsSplitQuery(),它通过发独立查询来避免笛卡尔积。

在当前这个配置上,这个建议反了。Split Query 用重复行换额外往返,而往返次数正是这里最贵的。一篇文章只有几个标签,重复行的代价远低于又跳一次 200 毫秒。所以显式设置行为:

sql.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);

显式设置也诚实地的关闭了警告——这个设置是决策,不是疏忽。如果数据库在本地,或者一篇文章有几百个标签,正确的答案会翻转。量一下你自己的链路再复制任何一种选择。

7. 压缩,以及缓存前面的缓存

JSON 响应上开 Brotli 和 Gzip,用最快压缩级别而不是最小体积:

builder.Services.Configure<BrotliCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);

最大压缩用 CPU 换那些根本不是瓶颈的字节数。最快级别拿到了大部分体积缩减,只花了一小部分代价。

在这整套架构前面,Next.js 前端自己还有个数据缓存,所以没变化的页面根本不问 API。两层缓存听起来多余,直到你注意到它们是独立失效的——如果一层冷了,另一层仍然能扛住。

我没折腾的

诚实比列个更长清单有用:

  • Redis。 又要多跑一个服务、要付钱、要监控,就为了解决内存输出缓存在一台机器上已经解决了的问题。扩展到多实例时值得上。在那之前不值得。
  • 预编译查询。 有真实的、可测量的收益。但在 200 毫秒网络跳面前完全淹没在噪声里。
  • 手写 SQL。 投影已经生成了我会写的那个查询。Dapper 能快几微秒,但让我丢了迁移功能。
  • 一开始加索引。 索引解决慢查询。我的查询不慢——慢的是网络。先量再猜。

怎么找出你自己的时间花在哪

动手改之前,先拿数据。开发环境开 EF Core 命令日志,看它实际发了什么:

options.LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging();

然后按顺序问三个问题:

  • 每个请求几个查询? 如果列表端点每行发一个查询,先停这里修 N+1。其他的都没用。
  • 查询有多宽? 一个 SELECT 列出四十列就为了渲染一张卡片,就是少了投影。
  • 数据库有多远? 跑个简单查询计时——SELECT 1。这个数字是你以后每个请求的地板,它决定你应该优化查询还是消灭查询。

最后一个数字重新定义了整个问题。一旦知道地板是 200 毫秒,缓存就不再是优化手段,而是架构。

复制之前需要知道的

这些选择适合读取密集型的慢链路 Web API。写入密集型的内网工具在本地数据库上,几乎要反过来:开追踪、关缓存、Split Query、真索引。

真正可以迁移的不是这些设置。先量地板,然后判断你的问题是慢工作还是重复工作。我的情况是重复工作,修复重复工作的成本很低。

如果你是想了解在哪里跑这些,ASP.NET Core 不需要 Windows 托管——在 Docker 容器里跑 SQL Server 可以把整套栈放在一台便宜的 Linux 机器上。

FAQ

怎么让 ASP.NET Core Web API 更快?

先量时间花在哪。大多数内容 API 的收益排序是:投影到 DTO 而不是加载实体、开带标签的输出缓存让重复读取永不进数据库、DbContext 连接池化加默认不追踪、把浏览计数这类写操作移出请求线程。

什么是 EF Core 投影,为什么它更快?

投影直接从 IQueryable 选到 DTO,EF 生成的 SQL 只返回你用到的列。它避免了 Include 扇出、完全跳过变更追踪、也消除了 N+1 的可能,因为没有导航属性可以延迟加载。

输出缓存会让我的 API 提供过期数据吗?

如果你用标签就不会。每个策略给一个标签,然后每个写操作清除它影响的标签。发布的文章立即清除帖子标签,所以编辑马上生效,而没动过的数据继续从内存提供。

我应该用 AsSplitQuery 吗?

看你的数据库在哪。Split Query 避免了笛卡尔积但要多往返。如果数据库是远程的、每跳要 200ms,一条带一些重复行的单查询更快。如果数据库在本地且集合很大,Split Query 赢。先量往返再选。

AddDbContextPool 实际做什么?

复用请求间的 DbContext 实例,而不是每次构建新的,所以 EF 不用每次调用都重新解析模型和依赖图。改一行,是安全的,只要你不在 context 上存请求级的状态。

我需要 Redis 来缓存 ASP.NET Core API 吗?

单实例不需要。内置输出缓存跑在内存里,不需要额外服务来运行、付钱或监控。扩展到多实例、需要在它们之间共享缓存时 Redis 才值得。

为什么我的 API 慢,即使查询很快?

通常是网络距离。对你的生产数据库跑个简单查询如 SELECT 1 计时——那个数字是你每个请求的地板。如果它是 200ms,没有查询调优能让你低于它,答案就是减少往返、缓存已经做过的。

怎么防止页面浏览计数拖慢请求?

异步写。把事件推到有界 Channel,让后台托管服务每几秒批量刷新一次。用满时丢弃写入的有界 Channel,这样流量峰值让你丢分析数据而不是丢内存或响应时间。