
我们要聊的这个问题几乎所有维护过 Windows 服务器的同行都撞上过IIS 服务跑得好好的CPU、内存都不高但用户每天早上第一次打开系统页面就是转圈等个十几秒甚至半分钟才出来点开之后又一切正常。用户问是不是服务器晚上被人攻击了实际上八成是 IIS 的冷启动机制在作祟。这个问题的完整名称叫IIS 首次加载慢核心特征就是第一脚油门特别肉。如果你负责的站点是内部 OA、报表系统、进销存后台这类访问频率不高但对响应时间敏感的应用大概率中过招。这篇文章我把排查思路、根因原理、可落地的配置方案全部分享出来包括我踩过的坑和实测数据可以直接照着操作。1. 先判断慢到底发生在哪一层别急着改配置IIS 首次加载慢并不是单一原因网络、进程、应用三分面都可能拖后腿。我第一次处理这个问题时走了弯路上来就把应用程序池的闲置超时改成 0结果治标不治本第二天还是慢。原因很简单没有先确认慢的环节到底在哪。1.1 用浏览器开发者工具拆解时间线最直接的定位手段是 F12 开发者工具。打开 Network 面板清空缓存后重新访问首页重点看两个指标TTFBTime 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 nameDefaultAppPool managedRuntimeVersionv4.0 startModeAlwaysRunning / /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 doAppInitAfterRestarttrue skipManagedModulestrue remapManagedRequestsToStartupPagetrue 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 Initialization1.9 秒0.7 秒再启用预编译 依赖预热1.1 秒0.6 秒这里有个细节即使全部配置到位首次请求也不可能完全等于后续请求的耗时。因为预热请求之后工作进程中还是会有部分程序集没有 JIT 编译依赖也会有一定程度的新建开销。目标是把不可接受的十几秒降落到一两秒而不是追求绝对相等。7.2 日常监控与常见复发原因配置是一次性的但运行环境会变化。建议关注以下几类信号事件日志中应用程序池被回收记录如果莫名出现回收优先检查是否有人手动回收、是否安装了会触发的部署工具、是否达到内存阈值。w3wp.exe 进程启动时间如果进程启动时间离当前时间很近说明刚刚经历过冷启动。TTFB 周期性升高每隔固定周期出现首屏慢基本可以断定是定时回收或计划任务冲突。我遇到过一种复发场景运维同事为了排查内存泄漏在 IIS 管理器里勾选了回收所有应用程序池的快捷操作导致每天下午固定时间所有站点集体冷启动。这个操作太隐蔽了不查事件日志根本发现不了。这类长期维护工作本质上是在稳定性和资源占用之间找平衡。不回收应用池确实会增加内存占用但对低并发内部系统来说几十 MB 常驻内存换几秒的访问体验完全划算。如果站点流量极大可以结合负载均衡做灰度回收每次只回收一台服务器的应用池保证总有热节点在服务。综合来看最关键的还是先定位问题在哪一层再针对性地组合配置。只改回收策略、只加预热模块、只做预编译都可能单独失效但组合起来就是一套完整方案。我现在接到这类问题第一反应就是按这个顺序排查基本一次搞定。