我们要聊的这个问题,几乎所有维护过 Windows 服务器的同行都撞上过:IIS 服务跑得好好的,CPU、内存都不高,但用户每天早上第一次打开系统,页面就是转圈,等个十几秒甚至半分钟才出来,点开之后又一切正常。用户问是不是服务器晚上被人攻击了,实际上八成是 IIS 的"冷启动"机制在作祟。
这个问题的完整名称叫IIS 首次加载慢,核心特征就是"第一脚油门特别肉"。如果你负责的站点是内部 OA、报表系统、进销存后台这类访问频率不高但对响应时间敏感的应用,大概率中过招。这篇文章我把排查思路、根因原理、可落地的配置方案全部分享出来,包括我踩过的坑和实测数据,可以直接照着操作。
1. 先判断"慢"到底发生在哪一层:别急着改配置
IIS 首次加载慢并不是单一原因,网络、进程、应用三分面都可能拖后腿。我第一次处理这个问题时走了弯路,上来就把应用程序池的"闲置超时"改成 0,结果治标不治本,第二天还是慢。原因很简单:没有先确认慢的环节到底在哪。
1.1 用浏览器开发者工具拆解时间线
最直接的定位手段是 F12 开发者工具。打开 Network 面板,清空缓存后重新访问首页,重点看两个指标:
- TTFB(Time To First Byte):从发起请求到收到第一个字节的时间,这个时间最能反映服务端处理效率。
- Content Download:内容下载耗时,一般只跟带宽和静态资源体积有关。
如果 TTFB 高达十秒以上,但 Content Download 只有几十毫秒,基本可以断定问题出在服务端,不是网络。我当时处理的一个案例里,TTFB 是 12.8 秒,下载时间 0.3 秒,这个落差已经足够说明方向了。
1.2 区分三种不同的"慢"
同为首次加载慢,根因可能完全不同,我把它们归纳成三类,排查时可以逐一对照:
| 现象 | 可能原因 | 验证方式 |
|---|---|---|
| 首次访问任何页面都慢,后续快 | 应用程序池被回收,CLR 冷启动 + 动态编译 | 查看事件日志中应用程序池回收记录 |
| 每次重启服务后慢,平时正常 | 应用初始化未开启,进程启动后未预热依赖 | 观察 w3wp.exe 启动时间与请求时间差 |
| 特定页面首次慢,其他页面正常 | 动态编译未预编译,复杂页面首次需实时编译 | 多次刷新同一页面,观察耗时变化 |
这里有个容易忽略的细节:如果应用池在深夜被计划任务回收,第二天第一个用户访问时,等于要重新经历一次完整的"进程创建 + 程序集加载 + JIT 编译 + 静态资源读取"流程,这一整套下来十几秒很正常。所以第一件事不是改配置,而是查事件日志,看回收到底发生在什么时候。
2. 应用程序池的回收机制:默认配置就是在定时"断电"
IIS 应用程序池的默认回收策略是为稳定性设计的,但对加载速度极不友好。默认情况下,应用池有几种回收触发条件,全部命中时,工作进程 w3wp.exe 会被杀掉重建。
2.1 三种回收触发器,每一个都是定时炸弹
- 闲置超时(Idle Timeout):默认 20 分钟。也就是工作进程连续 20 分钟没有任何请求,IIS 就把它回收掉。内部系统下班后没人访问,第二天早上必然触发冷启动。
- 定期回收(Regular Time Interval):默认每 29 小时回收一次。即使站点一直有人访问,到点后也会强制重启进程,这会造成"访问高峰期突然卡一下"的诡异现象。
- 虚拟内存/私有内存限制:默认情况下设置了内存上限,超过阈值就回收。对于内存占用大的应用,这个触发器可能意外触发。
大多数运维同事都盯着内存和 CPU 看,却忽略了这些回收设置。我处理过的某内部业务系统,日志显示每天凌晨 2:00 应用池被回收,恰好是默认的定期回收时间,而那个系统晚上根本没人用,等于每天白白冷启动一次。
2.2 如何修改回收策略
打开 IIS 管理器,找到对应应用程序池,右键"回收",分别设置:
- 固定时间间隔:取消勾选,或改为 0。如果担心内存泄漏,可以不取消,而是设置一个低峰期时间点(比如凌晨 4:00),但必须配合下文的预热机制。
- 闲置超时:改为 0,也就是永不因闲置回收。
- 特定时间:如果设置了定期回收,建议填凌晨低峰时段。
用命令行也可以,很多生产服务器不方便开图形界面,PowerShell 方式如下:
Import-Module WebAdministration Set-ItemProperty -Path 'IIS:\AppPools\DefaultAppPool' -Name recycling.periodicRestart.time -Value '00:00:00' Set-ItemProperty -Path 'IIS:\AppPools\DefaultAppPool' -Name processModel.idleTimeout -Value '00:00:00'注意,recycling.periodicRestart.time设为00:00:00表示禁用定期回收,这与"不回收"是等价的。如果怕内存泄漏,可以交给内存阈值去兜底,但阈值不要设太紧,避免正常业务流量触发回收。
3. AlwaysRunning 与启动模式:让工作进程常驻内存
只改回收还不够,因为 IIS 7.5 及更早版本中,应用池进程是"按需启动"的。也就是说,即使你设置了不回收,服务器重启之后,第一个请求到达之前 w3wp.exe 根本不会启动,用户还是要等。
3.1 startMode 的两种模式差异
IIS 8.0 起,应用程序池增加了启动模式配置:
- OnDemand(按需启动):默认模式,有请求才拉起进程。
- AlwaysRunning(始终运行):W3SVC 服务启动后立即启动工作进程,不等请求。
这个配置和"闲置超时"是两回事。闲置超时管的是进程运行中是否被回收,AlwaysRunning 管的是服务启动时是否立即拉起进程。两者配合,才能做到"进程一直在,请求来时不打哆嗦"。
3.2 配置 AlwaysRunning 的完整步骤
在 IIS 管理器中,选中应用程序池,右键"高级设置",找到"启动模式"(Start Mode),改为AlwaysRunning。
命令行方式:
Set-ItemProperty -Path 'IIS:\AppPools\DefaultAppPool' -Name startMode -Value 'AlwaysRunning'也可以直接改applicationHost.config,不过强烈不建议手改这个文件,格式错一点整个 IIS 服务起不来。以下配置仅供参考理解:
<applicationPools> <add name="DefaultAppPool" managedRuntimeVersion="v4.0" startMode="AlwaysRunning" /> </applicationPools>需要特别提醒:AlwaysRunning 只是让进程启动,不等于应用完成初始化。进程创建之后,ASP.NET 应用的Application_Start事件、依赖的数据库连接、缓存预热等措施,仍然要等第一个请求进来才会执行。这就是为什么很多人在这一步配置完成后,测试时发现首屏慢的问题依旧存在。真正的临门一脚是 Application Initialization 模块。
4. Application Initialization 模块:把第一个请求提前"虚构"出来
IIS 8.0 内置了一个专门解决冷启动问题的功能模块,叫 Application Initialization(应用初始化)。它的原理很朴素:应用池启动后,IIS 主动向站点发送一个"预热请求",等这个请求完全处理完,确认应用状态就绪,才开始接受真实用户的请求。
4.1 安装与启用模块
Windows Server 2012 及之后版本,默认安装了 IIS 8.0,模块已经内置,但需要在"服务器管理器 - 角色 - Web 服务器(IIS) - 应用程序开发"中确认勾选"应用程序初始化"功能。如果没勾选,需要重新添加。
IIS 7.5 环境下则要单独下载模块安装包,再在applicationHost.config中注册模块。
4.2 配置初始化页面
在站点对应的web.config的<system.webServer>节点下添加如下配置:
<system.webServer> <applicationInitialization doAppInitAfterRestart="true" skipManagedModules="true" remapManagedRequestsToStartupPage="true"> <add initializationPage="/home" /> </applicationInitialization> </system.webServer>各项参数含义如下:
doAppInitAfterRestart:设置为 true 后,应用池每次重启(包括 W3SVC 服务重启),都会自动执行预热请求。initializationPage:指定一个相对路径作为预热请求目标。注意,这个页面必须是真实存在且能正常响应的地址。我当时犯过一个错误,用根路径/做初始化页,结果站点根路径恰好是个重定向,预热请求只拿到 302,根本没触发内部初始化逻辑,问题依旧。skipManagedModules:如果站点依赖某些托管模块(如 URL 重写),预热请求可能被这些模块干扰,建议设 false 让预热请求走完完整的管线。具体要根据应用情况调整。
预热页面的选择有讲究。从效率出发,可以单独建一个轻量接口,比如HealthCheck.ashx或/api/health,在接口里把数据库连接、缓存、关键配置提前加载一遍:
public void ProcessRequest(HttpContext context) { // 触发数据库初始化 var conn = new SqlConnection(ConfigurationManager.ConnectionStrings["MainDb"].ConnectionString); conn.Open(); conn.Close(); // 预加载常用缓存 var cache = MemoryCache.Default; cache.Get("Preheated_Data", () => LoadDataFromDatabase()); context.Response.Write("ok"); }这样既不会影响用户看到的真实首页,又能把耗时最重的依赖项全部拉起来。
4.3 预热请求的超时与排队细节
使用 Application Initialization 时要留意一个行为:预热请求处理期间,真实用户请求会被 IIS 暂时挂起,直到预热完成才返回。如果预热页面本身很慢,用户会感觉"页面一直在等待",体验反而更糟。所以在当前阶段,建议把预热页面控制在 1 到 2 秒内完成。
还有一种情况是多个 worker process(应用池配置了 Maximum Worker Processes 大于 1)时,每个进程都需要各自完成预热,配置会复杂不少。一般内部系统不需要考虑这种场景,但如果你用到了 Web Garden 模式,务必确认所有进程都被初始化过,否则总会有某个进程是冷的。
5. 预编译:把首次访问时的编译成本转移到发布阶段
应用池不回收、Application Initialization 也在跑,但我后来发现一个隐蔽问题:预热请求只访问了一个页面,其他页面在第一次被访问时仍然要经历 ASP.NET 的实时编译,该卡还是卡。
5.1 传统 ASP.NET 的动态编译与预编译
传统 Web Forms 和旧版 MVC 项目,在未预编译部署的情况下,每个.aspx页面第一次被请求时,运行时都会把它编译成程序集。这是个 CPU 密集操作,慢一点的服务器单页耗时能到 3 到 5 秒。访问量低的时候,这个时间会被误认为是"网络慢"。
解决方式是发布时使用 ASP.NET 预编译工具。Visual Studio 发布选项中可以选择"在发布前预编译此站点",命令行工具是:
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\aspnet_compiler.exe -p D:\Site\SourceCode -v / -f D:\Site\Published参数说明:
-p:指定源代码物理路径。-v:虚拟路径,一般填/。-f:强制覆盖目标目录。
预编译完成后,发布目录中会出现大量bin下的程序集和.compiled文件,部署到服务器后,所有页面已经变成编译好的二进制,首次访问的编译开销被转移到了发布那一刻。
5.2 ASP.NET Core 场景的差异
如果你的站点是 ASP.NET Core 托管在 IIS 上,情况则完全不同。不存在动态编译页面,但首次访问的慢多数来自进程冷启动时 JIT 编译和静态文件缓存冷加载。如果结合了前文方案,预热请求会触发 JIT 编译,后续访问会明显改善。还可以配合ReadyToRun发布模式,让 JIT 编译量大幅减少,但部署包体积会显著增加,需要权衡。
无论哪种技术栈,都建议在发布流程里加入"发布后自动预热"这一条,比如 CI/CD 流水线在部署完成后,马上请求一次健康检查地址,确保服务上线后就是热状态。
6. 别忘了依赖项:数据库连接池与缓存同样会冷启动
这是最容易被忽略的一块。就算 IIS 进程和页面全部预热完毕,你的应用连的数据库连接池、Redis 连接、WCF 服务代理,只要进程一重启,全部要重新握手建立。连接池冷启动的耗时往往比 JIT 编译更可怕,因为涉及到网络往返和依赖服务端的处理时间。
6.1 连接池回收机制与首次连接开销
SQL Server 连接池按连接字符串维度维护,进程重启后池为空。首次执行数据库操作时,需要完成 TCP 连接建立、SSL 握手、登录认证等全过程。在负载高或数据库服务器较远的场景,这个时间甚至可以到 5 秒以上。所以预热请求里如果包含一次简单的数据库查询,比如SELECT 1,连接池就会被填满,后续业务请求直接复用连接。
6.2 一个实操过的预热脚本范例
生产环境不一定要依赖 Application Initialization 模块,也可以用计划任务定时跑预热脚本。我在一个老系统上就是双保险:IIS 模块负责进程重启时自动预热,Windows 计划任务每天凌晨把关键页面全部打一遍。
这个脚本用 PowerShell 实现,逻辑很简单:
$urls = @( 'http://localhost/', 'http://localhost/api/health', 'http://localhost/dashboard' ) foreach ($url in $urls) { try { $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 60 Write-Host "$url => $($response.StatusCode)" } catch { Write-Host "$url => 预热失败: $($_.Exception.Message)" } }注意脚本里的地址尽量用localhost,避免经过负载均衡时被路由到别的节点。如果是多服务器负载均衡架构,每台服务器都要单独执行预热脚本,否则始终会有一台节点在第一个请求时掉链子。
另外务必确认计划任务的"使用最高权限"选项,否则访问http://localhost可能因权限不足而失败。这类细节虽然小,却能让整个方案真正落地。
7. 验证效果与长期维护:调完配置不算结束
配置改完了,预热脚本也跑了,很多人觉得大功告成。但如果不量化验证,过阵子可能又复发,而且不知道是哪个改动失效了。
7.1 量化测试方法:冷启动时间对比
我习惯用的测试方法是这样的:
- 手工回收应用池:
C:\Windows\System32\inetsrv\appcmd.exe recycle apppool /apppool.name:DefaultAppPool - 立即用 curl 发起请求并记录耗时:
curl -w "@%{time_total}" -o /dev/null -s http://localhost/home - 连续请求 5 次,分别记录耗时。
- 对比优化前后的数据。
以下是我在实际项目中拿到的对比数据,可以直观看到差距:
| 场景 | 首次请求耗时 | 后续请求平均耗时 |
|---|---|---|
| 未优化(默认配置) | 14.6 秒 | 0.8 秒 |
| 只改 AlwaysRunning + 闲置超时 | 11.2 秒 | 0.7 秒 |
| 增加 Application Initialization | 1.9 秒 | 0.7 秒 |
| 再启用预编译 + 依赖预热 | 1.1 秒 | 0.6 秒 |
这里有个细节:即使全部配置到位,首次请求也不可能完全等于后续请求的耗时。因为预热请求之后,工作进程中还是会有部分程序集没有 JIT 编译,依赖也会有一定程度的新建开销。目标是把不可接受的十几秒降落到一两秒,而不是追求绝对相等。
7.2 日常监控与常见复发原因
配置是一次性的,但运行环境会变化。建议关注以下几类信号:
- 事件日志中"应用程序池被回收"记录:如果莫名出现回收,优先检查是否有人手动回收、是否安装了会触发的部署工具、是否达到内存阈值。
- w3wp.exe 进程启动时间:如果进程启动时间离当前时间很近,说明刚刚经历过冷启动。
- TTFB 周期性升高:每隔固定周期出现首屏慢,基本可以断定是定时回收或计划任务冲突。
我遇到过一种复发场景:运维同事为了排查内存泄漏,在 IIS 管理器里勾选了"回收所有应用程序池"的快捷操作,导致每天下午固定时间所有站点集体冷启动。这个操作太隐蔽了,不查事件日志根本发现不了。
这类长期维护工作,本质上是在"稳定性和资源占用"之间找平衡。不回收应用池确实会增加内存占用,但对低并发内部系统来说,几十 MB 常驻内存换几秒的访问体验,完全划算。如果站点流量极大,可以结合负载均衡做灰度回收,每次只回收一台服务器的应用池,保证总有热节点在服务。
综合来看,最关键的还是先定位问题在哪一层,再针对性地组合配置。只改回收策略、只加预热模块、只做预编译,都可能单独失效,但组合起来就是一套完整方案。我现在接到这类问题,第一反应就是按这个顺序排查,基本一次搞定。