做 ASP.NET 项目这些年,“首页性能”是我被问得最多、也是坑踩得最多的话题之一。很多产品迭代到后期,首页会变成各种功能块堆叠的“重灾区”:轮播图、公告、推荐位、统计数字、用户信息全挤在同一屏,每次打开都要一锅端地渲染一遍。首页慢不只是体验问题,它直接影响用户对整个系统的第一印象,也决定了很多业务指标的起点。
这篇文章梳理的十条做法,都是我在 .NET Framework 和 ASP.NET Core 项目里实际验证过的手段,覆盖缓存策略、数据层优化、静态资源、网络传输和基础设施几个维度。每一条我都会说清楚“为什么要这么做”“常见坑在哪”,以及我在排查中攒下来的经验。如果你正在优化一个典型的 MVC 或 Razor Pages 项目,按这个顺序过一遍,通常能很快看到变化。
1. 首页性能优化,先搞清楚瓶颈到底在哪
1.1 首页慢通常是组合式爆发
首页性能差,往往不是某一个环节造成的。我拆过很多项目,发现最典型的几类情况是:数据层太重,页面每次刷新都要实时查数据库,甚至一个接口里藏着 N 次查询;静态资源太肥,JS、CSS、图片不做任何压缩和合并,动辄几十个请求同时发出;请求链路太长,认证、会话、日志、权限每个环节都往响应时间里加一笔。
这三类问题叠加起来,用户感知到的就是“转圈好几秒才能看到东西”。如果一上来就盯着某个环节死磕,很容易做了半天没效果。正确做法是先量化,再对症下药。首页性能优化的本质,不是把单个请求的速度做到极致,而是把用户从点击到页面可交互的完整链路压缩到合理范围。
1.2 先定指标,再谈优化
没有指标就动手,基本等于盲人摸象。我习惯用这几个角度看首页:
| 指标 | 含义 | 建议关注点 |
|---|---|---|
| TTFB | 从发起请求到收到第一个字节的时间 | 反映服务端处理速度,目标尽量压到 500ms 内 |
| FCP / LCP | 首屏内容出现、最大内容绘制时间 | 反映浏览器渲染与资源加载速度 |
| 资源请求数 | 首页加载产生的 HTTP 请求总量 | 请求越多,网络往返越重,需配合 HTTP/2 与合并策略 |
| 接口耗时 p95 | 大多数请求的耗时上限 | 平均耗时会掩盖尾延迟,关注 p95 更贴近用户真实感受 |
浏览器 DevTools 的 Network 面板就能看 TTFB 和资源加载瀑布图。如果 TTFB 高,问题大概率在后端;如果 TTFB 低但整页加载还是慢,问题大概率在资源和渲染侧。先用这个方式把方向定下来,再往下走。
1.3 我给首页优化的固定顺序
遇到具体项目,我基本按照“改动越小、见效越快”的顺序推进:先开压缩和静态资源缓存(改配置就能完成),再做数据层的缓存与异步化,然后处理查询量,最后才动架构层面的会话与集群配置。这个顺序能帮团队先拿到确定性收益,再做需要更多投入的优化,避免一上来就重构却迟迟看不到结果。
2. 缓存三招:让首页从数据库里“解脱”出来
2.1 做法一:用 ResponseCache 做 HTTP 层缓存
ASP.NET Core 自带响应缓存中间件,可以把一部分可公开的页面片段或接口响应直接缓存起来。注册方式很简单:
// Program.cs 或 Startup.ConfigureServices builder.Services.AddResponseCaching(); // Configure 中启用,放在 UseRouting 之后、UseEndpoints 之前 app.UseResponseCaching();然后在控制器或最小 API 上打特性:
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "page" })] public async Task<IActionResult> Index(int page = 1) { return View(await _service.GetHomeDataAsync(page)); }Duration=60 表示 60 秒内相同 URL 的请求可以直接命中缓存;VaryByQueryKeys 让带不同查询参数的请求分别缓存。这个做法很适合首页中不需要登录、内容更新不频繁的区块,比如公告列表、行业资讯、公共数据面板。
说几个踩过的坑。第一,响应缓存中间件只对 GET 或 HEAD 请求生效,而且要求响应里有正确的 Cache-Control 头,很多新手会发现配置了却“没反应”,先检查响应头。第二,不要给依赖登录态的页面加这个缓存,否则用户会看到别人的数据。第三,如果部署在 IIS 后面,HTTP.sys 内核缓存也可能参与工作,调试时容易分不清是哪一层缓存生效,可以在测试时临时禁用内核缓存来定位问题。
2.2 做法二:IMemoryCache 缓存高频计算结果
首页上很多数据本身不是“查询慢”,而是“每次都重新计算太浪费”。比如从多个服务聚合出的汇总数据、需要二次加工的对象列表,用内存缓存是最经济的选择。
依赖注入后直接使用:
public class HomeService { private readonly IMemoryCache _cache; public HomeService(IMemoryCache cache) { _cache = cache; } public async Task<HomeSummaryDto> GetSummaryAsync() { return await _cache.GetOrCreateAsync("home:summary", async entry => { entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); entry.SetSlidingExpiration(TimeSpan.FromMinutes(1)); return await LoadSummaryFromDatabaseAsync(); }); } }GetOrCreateAsync 是常用模式,缓存没有命中时自动执行委托并写入缓存。我建议绝对过期时间(AbsoluteExpiration)和滑动过期时间(SlidingExpiration)组合使用。滑动过期解决“缓存永不过期”的隐患,绝对过期兜底,防止缓存项在活跃状态下无限期存活。
关于内存缓存,有两点需要强调。它默认是进程内缓存,部署多个实例时各实例数据独立,如果业务对一致性要求高,光靠 IMemoryCache 不够。另外要注意“缓存穿透”场景,某个 key 在高并发下同时失效,会有大批请求同时打到数据库,可以给 IMemoryCache 设置合理的并发锁,或者结合分布锁来保护。
2.3 做法三:Redis 分布式缓存解决多实例一致性
负载均衡环境里,多台实例共用一份分布式缓存往往是必要的。ASP.NET Core 官方封装了 Redis 实现:
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "your-redis-server:6379"; options.InstanceName = "home:"; });之后用 IDistributedCache 操作数据。Redis 做分布式缓存的好处是数据一致性有保障,天然适合多实例共享;坏处是会引入一次网络往返和序列化开销。我通常在“数据量较大且一定要跨实例共享”时才使用,比如会话数据、用户状态、跨服务器共享的热点配置。
组合玩法是“二级缓存”:实例本地先用 IMemoryCache 做一层短时间缓存,Redis 做第二层兜底。读数据时先查本地,没命中再查 Redis,再回源数据库。这样既减少了 Redis 压力,也提高了读取速度。缺点是写操作要同时更新或清理两层缓存,逻辑复杂度会上升,适合首页这类读量远大于写量的场景。
2.4 缓存键和失效设计才是真正的分水岭
很多项目引入缓存后反而出了问题,大多栽在键设计和失效策略上。缓存键不是随手拼接的字符串,我习惯用“业务域:模块:版本:参数”的格式,比如 home:products:v1:category-12,版本号可以在数据格式发生变化时快速淘汰旧缓存。
失效策略上,被动过期(缓存到期自然失效)是最简单的,但会出现“缓存刚过期、流量高峰瞬间打垮数据库”的时刻。更稳的方案是“主动失效 + 被动兜底”:数据变更时主动删除对应缓存键,同时保留一个合理的过期时间作为兜底。比如后台修改了公告,立即调用 RemoveAsync 清理首页公告缓存,用户不需要等缓存自然过期。
还要提醒一个容易踩的坑:不要把 null 值也缓存在内。如果数据库查询结果为空,而缓存把这个空结果存下来,后续请求全都会读到空数据,更难排查。我一般对空结果直接返回,不写入缓存。
3. 数据层三件事:别让首页每次请求都“搬仓库”
3.1 做法四:全链路异步,别占着线程池
ASP.NET Core 的线程池规模是有限的,同步阻塞 I/O 会让线程长时间空等数据库或外部 API 返回,导致线程池耗尽,新请求被挂起。解决办法就是让整条链路保持 async。
控制器和数据库调用要完整地 async/await:
public async Task<IActionResult> Index() { var items = await _db.Products .AsNoTracking() .Where(p => p.IsActive) .Take(20) .ToListAsync(); return View(items); }注意不要混合阻塞调用,比如在 async 方法里用 .Result 或 .Wait()。这个反模式会让异步方法退化成同步阻塞,还容易引发死锁,尤其在还有同步上下文的环境里。我也见过很多人把“async 方法”写上,但里层调用的还是同步 API,这等于白写。
这里想说明一个容易被误解的点:异步不会让单个请求变得更快,它提升的是系统吞吐量。同一台服务器,线程池能同时处理的请求多了,高峰期首页的响应时间就不会因为线程饥饿而暴涨。对首页这种高并发入口来说,这个价值非常大。
3.2 做法五:把首页的数据库请求量砍到最少
首页慢最容易查出来的典型原因,是页面只有几个模块,代码却发了几十条 SQL。一个点击动作背后拖着几十次数据库往返,再小的查询积累起来也会质变。
我处理首页查询的思路是先把页面模块列出来,区分“服务端必须渲染的数据”和“客户端可后加载的数据”。服务端必须渲染的,比如用户身份信息、核心内容列表,保留在主请求里;那些统计数字、推荐位、运营位,完全可以先给占位结构,页面加载后再用异步接口填充。
第二个手段是合并查询。在同一个聚合场景里,尽量一次把关联数据取出来。EF Core 的投影很适合:
var vm = await _db.Users .Where(u => u.Id == userId) .Select(u => new HomeViewModel { UserName = u.Name, RecentOrders = u.Orders .Where(o => o.Status == OrderStatus.Paid) .OrderByDescending(o => o.CreatedAt) .Take(5) .Select(o => new OrderBriefDto { Id = o.Id, Amount = o.Amount }) .ToList() }) .FirstOrDefaultAsync();一次数据库往返拿到页面需要的数据,比多次小查询快得多。但我也要说另一面:不要为了合并强行把低频模块绑进主查询。首页某个模块如果只有少数用户会看到,或者展示优先级不高,就把它拆成异步加载,别拖累所有人的首屏。
3.3 做法六:EF Core 查询与索引优化
EF Core 本身只是工具,真正的性能瓶颈经常在生成的 SQL 和数据库索引上。我常用的优化动作有三个:只读查询加 AsNoTracking、重复执行的查询用预编译查询、给常用筛选字段建索引。
AsNoTracking 很好理解,告诉 EF 不用做变更追踪,省掉大量内存和计算开销。预编译查询有点门槛但收益稳定:
private static readonly Func<AppDbContext, int, Task<HomeData>> GetHomeData = EF.CompileAsyncQuery((AppDbContext db, int id) => db.Users .Where(u => u.Id == id) .Select(u => new HomeData { UserName = u.Name }) .FirstOrDefault());编译一次查询,后续执行直接复用表达式树,省去每次查询的编译开销。适合首页中高频执行的固定查询。但要注意 CompileAsyncQuery 内部不能用额外服务或非常规调用,限于纯 EF 查询表达式。
索引方面我的习惯是对首页查询的 WHERE 条件字段、JOIN 字段、排序字段综合建索引,并且优先用包含索引(覆盖索引)减少回表。如果发现某个查询执行计划里还有隐式类型转换,比如字符串列和整型参数比较,即便有索引也可能失效。最直观的办法是把 EF Core 输出的 SQL 拿到 SQL Server Management Studio 里跑一下执行计划,看实际扫描行数和连接类型。
3.4 关于 N+1 和被忽视的“重复查询”
N+1 经典问题是:先查列表,再循环里逐条查关联。EF Core 里表现为导航属性延迟加载,页面上一循环就触发大量 SQL。解决思路有两种:如果需要完整关联数据,用 Include 预加载;如果只需要部分字段,用投影 Select 让 EF 生成 JOIN。前者直观但可能带出太多列,后者更精简,我更推荐投影方案。
排查时可以开启 EF 日志,观察一次首页请求实际生成了几条 SQL:
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Information);我遇到过很多次“ SQL 都很简单,但数量爆炸”的情况,日志一开立见分晓。每次都提醒团队:首页这种入口级的请求,查询瘦身比单个查询微调收益大得多。
4. 资源与网络优化四招:剩下的性能藏在浏览器到服务器的路上
4.1 做法七:静态资源上 CDN 和独立域名
首页里占比最大的网络开销,往往是 JS、CSS、图片这些静态资源。把这些资源放到 CDN 并采用独立子域名或者独立域名,效果非常明显。
核心原因有三个:第一,不走业务域,就不会带上业务 Cookie,减少了请求头和网络体积;第二,浏览器对同一域名有并发连接限制,独立域名可以分散请求,让资源加载更并行;第三,CDN 节点离用户更近,静态资源的传输时间大幅缩短。
资源地址直接改成 CDN 域名即可,比如:
<script src="https://cdn.example.com/js/site.min.js?v=1.0.3"></script>这里版本号很重要,文件内容一改,版本号必须变。否则浏览器和 CDN 很可能继续用旧缓存,线上出现“改了代码没生效”的诡异现象。
4.2 做法八:启用 Brotli 和 Gzip 压缩
很多项目上线后忘了开启压缩,HTML、JS、CSS 都以原始大小传输,首屏多出几倍的流量。ASP.NET Core 启用压缩非常简单:
builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add<BrotliCompressionProvider>(); options.Providers.Add<GzipCompressionProvider>(); options.MimeTypes = new[] { "text/html", "text/css", "application/javascript", "application/json", "image/svg+xml" }; }); app.UseResponseCompression();Brotli 的压缩率通常比 Gzip 更高,现代浏览器都已支持,按上面的顺序把它放在首位即可。压缩范围建议只针对文本类 MIME,图片(JPEG/PNG/WebP)本身已经压缩过,再压一遍只会浪费 CPU。同时要注意,压缩会消耗 CPU,所以像 API 返回的 JSON 数据如果很大且调用频繁,也要结合缓存来平衡。
4.3 做法九:资源打包与懒加载
传统“少请求数”的思路是合并 JS/CSS。ASP.NET Core 里可以用 WebOptimizer 这类工具:
builder.Services.AddWebOptimizer(options => { options.AddCssBundle("/css/bundle.min.css", "/css/base.css", "/css/home.css"); options.AddJavaScriptBundle("/js/bundle.min.js", "/js/util.js", "/js/home.js"); }); app.UseWebOptimizer();这样页面只需引用一个合并后的文件。不过随着 HTTP/2 普及,合并的收益受限了,因为多路复用使多个请求可以共用一个连接。所以我现在的判断标准是:HTTP/1.1 环境多合并,HTTP/2 环境更要关注“每个资源是否足够小、是否被合理缓存”。
懒加载更关键。首屏只需要显示视觉核心区域和必要交互,图片这种非首屏内容应该加上 loading="lazy",让浏览器滚动到附近时再加载。我见过太多首页首屏加载了十几张轮播大图,用户根本来不及看到,白白浪费带宽和时间。
4.4 做法十:HTTP/2 与浏览器缓存头
HTTP/2 在 Kestrel 下默认启用,只要部署环境支持就能直接受益。多路复用让首页不用再为了并发限制强行合并资源,连接握手次数也少了。如果部署在 IIS 后面,需要确认 Windows Server 和站点配置支持 HTTP/2,一般是操作系统层面的事。
浏览器缓存头这块,推荐给静态资源设置一个较长周期的 Cache-Control,同时配合文件名版本号更新。可以在静态文件中间件里统一配置:
app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => { ctx.Context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromDays(30), Public = true }; } });这样一来,首次访问后会缓存 30 天,下次访问不再向服务器发资源请求。版本号一变,URL 就变,又会触发新缓存。这里有个常见误区是只用查询字符串版本号,部分代理和 CDN 会把查询字符串相同的资源视为相同,所以更稳妥的方式是把版本号放进文件名,比如 site-1.0.3.min.js。
5. 容易被忽略的基础设施隐患:会话状态、负载均衡与监控
5.1 会话状态也可能拖慢首页
默认情况下 ASP.NET Core 的会话是用内存存、Cookie 识别。首页并发一大,每一次请求都可能读写会话数据,锁定和序列化都是成本。更严重的是,多实例部署后内存会话无法共享,用户轮流访问不同实例就会频繁丢会话。
我的建议分两步。第一步重新审视首页到底需不需要会话数据,很多公共首页对匿名用户是完全不需要会话的,请求里却依然携带会话 Cookie,白白增加请求体量和存储压力。第二步,如果真的需要会话共享,改用 Redis 或 SQL Server 存储,而不是依赖单机内存。会话存储的选择不直接缩短响应时间,但能避免全站性的“随机掉线”问题。
5.2 负载均衡场景下的缓存一致性问题
多实例部署之后,IMemoryCache 和本地响应缓存会变得“各为其主”,同一页面在不同实例上可能返回不一样的内容。解决方向有两个:要么接受弱一致,把缓存过期时间压短;要么把关键数据切到 Redis 分布式缓存。我在实际项目中一般把“用户无感知的数据”放本地缓存,把“业务要求强一致的数据”放分布式缓存,找到成本和效果的平衡点。
另外,负载均衡的健康检查地址不要做成重页面。很多团队把首页本身当成健康检查地址,导致监测系统每几秒就真实访问一次首页,既产生无效请求,又会干扰性能数据。健康检查应单独用轻量端点。
5.3 首页性能监控与排查清单
优化做完不是终点,得让性能持续可见。我会用 Application Insights 或类似 APM 工具给首页关键路径加跟踪,记录 TTFB、数据库耗时、依赖调用次数。部署后用 dotnet-counters 看一眼线程池状态,如果线程数持续上涨,就去查是不是有同步阻塞调用;用 dotnet-trace 抓热路径,定位耗时最高的函数。
日常排查时我习惯用一张检查表,按可能性排序:
| 现象 | 常见原因 | 优先处理 |
|---|---|---|
| TTFB 高 | 数据库查询多、N+1、缺缓存 | 先查日志看 SQL 数量,加索引和缓存 |
| 首页内容慢但接口快 | 静态资源未压缩、未缓存 | 开启压缩、配置 Cache-Control、上 CDN |
| 并发一高就慢 | 线程池耗尽、会话锁、同步阻塞 I/O | 异步化、检查线程池指标 |
| 多实例表现不一致 | 内存缓存/内存会话 | 切到 Redis |
| 页面资源请求特多 | 没合并、没懒加载、HTTP/1.1 | 合并资源、懒加载、开启 HTTP/2 |
这套清单能覆盖大多数首页缓慢的根因。搭配持续监控,你就能保证这次优化不是“一次性止血”,而是长期稳定的性能水位。
我自己在实际项目里最常用的顺序,是先做“性价比最高”的改动:压缩、静态资源缓存头、浏览器缓存,这三步配置级改动就能明显改善加载体验;然后再做数据层优化,最后才是架构级的会话和分布式改造。首页性能往往不是一锤子买卖,它会随着业务增长不断出现新瓶颈。关键是一边优化一边把判断依据沉淀下来,不要凭感觉猜。给团队成员留一份清晰的问题定位路径,比单纯把代码调快更有价值。