如何在香港服务器的 Windows Server 2016 上优化 IIS 队列与应用池,提升百万级请求的吞吐能力?

香港服务器第一次被“打穿”是在一个潮湿的周一清晨,监控墙上一片红:503.2—Queue Full、应用池频繁回收、平均延迟飙到秒级。客户活动提前上线,流量像海水一样灌进来。我一只手拎起螺丝刀把一根松掉的理线扣安好,另一只手在 RDP 里敲下第一条 appcmd。接下来六个小时,我们把 IIS 从“捱打”变成“抗打”。这篇文章,就是那次现场从排障到调优的完整复盘。
场景与目标
目标:在单台香港物理服务器(低延迟回国链路)上,优化 Windows Server 2016 + IIS 10 的队列、应用池与相关内核/网络参数,使网站在高并发活动中稳定承载**百万级请求(按日)**的吞吐,峰值 QPS 稳定、P95 延迟可控且无 503.2 队列溢出。
基础环境(真实可落地)
| 项目 | 参数 |
|---|---|
| 机房 | 香港将军澳(BGP 多线,回国优先 CN2/GIA) |
| 服务器 | Dell R740(或同级),单机作战 |
| CPU | Intel Xeon Silver 4210R ×1(10C/20T) |
| 内存 | 64GB DDR4 |
| 系统盘 | 2×480GB SATA SSD(RAID1) |
| 数据盘 | 2×1.92TB NVMe(RAID1/镜像) |
| 网卡 | 2×10GbE(绑定 LACP,启用 RSS/RSC) |
| OS | Windows Server 2016 Standard(1607+ 补丁齐全) |
| Web | IIS 10.0(含动态压缩、URL Rewrite、Kernel Cache) |
| 运行时 | .NET Framework 4.7.2 / ASP.NET Core 6(ANCM 反代)二选一的思路均覆盖 |
| 压测 | wrk / bombardier(压测机同城,不限速,开启 keep-alive) |
备注:硬件不是“越猛越好”,而是“平衡”:CPU 足够、SSD 减少日志与临时文件 IO 抖动、10GbE 让内核栈不成为瓶颈。
痛点画像:IIS 是怎么“堵车”的?
- 503.2 – Queue Full:应用池队列 queueLength(默认 1000)被打满。
- 回收/重启抖动:定时回收与内存限制触发,连接迁移造成短时可用性下降。
- ASP.NET 与 HTTP.sys 双重排队:经典管道/集成管道差异、队列位置不同导致误判。
- 线程与 GC 压力:CLR 线程池迟迟扩容、工作线程饱和,GC 模式不匹配。
- 网络栈默认值保守:RSS/RSC 未开启或队列数偏低,导致单核过载。
- 日志 IO:W3C 日志落在系统盘或与应用共享卷,峰值时写入抖动放大延迟。
总体策略图(从外到内)
- 网络栈:NIC/RSS/RSC、多队列中断调优 → 减少内核抢占与单核瓶颈
- HTTP.sys / IIS 内核态缓存:能进内核缓存的尽量不进用户态
- IIS 队列/应用池:放大且“有序”地放大(避免一味无限)
- ASP.NET/ANCM/Kestrel:并发、线程池、GC、回收策略成体系
- 日志与磁盘:把日志与临时文件移到 NVMe,降落地延迟
- 观测:PerfMon/ETW 采样 + 压测闭环,按数据迭代参数
逐项实施(可直接抄)
1)网络栈与 NIC 调优
查看全局 TCP 设置
netsh int tcp show global
推荐设置(按需调整)
netsh int tcp set global rss=enabled autotuninglevel=normal ecncapability=disabled chimney=disabled rsc=enabled
NIC 队列与 RSS(在网卡驱动里确认启用 RSS,多队列≥CPU 物理核心数的一半;10GbE 一般支持 4–8 队列。)
同时在电源管理里关闭“允许计算机关闭此设备以节约电源”。
效果:降低单核中断风暴,减少队列堆积前的内核抖动。
2)启用内核模式缓存(Kernel Cache)与压缩策略
安装必要组件
dism /online /enable-feature /featurename:IIS-HttpCompressionStatic /NoRestart
dism /online /enable-feature /featurename:IIS-HttpCompressionDynamic /NoRestart
dism /online /enable-feature /featurename:IIS-Caching /NoRestart
开启压缩与缓存(站点级)
%windir%\system32\inetsrv\appcmd set config -section:urlCompression /doStaticCompression:"True" /commit:apphost
%windir%\system32\inetsrv\appcmd set config -section:urlCompression /doDynamicCompression:"True" /commit:apphost
%windir%\system32\inetsrv\appcmd set config -section:httpCompression /+"dynamicTypes.[mimeType='application/json',enabled='True']" /commit:apphost
%windir%\system32\inetsrv\appcmd set config -section:system.webServer/caching /enableKernelCache:"True" /commit:apphost
典型动态页面短缓存(命中即可“直出”内核)
<!-- web.config (示例:对可缓存的 JSON 接口缓存 10 秒) -->
<configuration>
<system.webServer>
<caching enabled="true" enableKernelCache="true">
<profiles>
<add extension="*" policy="CacheForTimePeriod" duration="00:00:10"
kernelCachePolicy="CacheForTimePeriod" location="Any" />
</profiles>
</caching>
<httpCompression directory="%SystemDrive%\inetpub\temp\IIS Temporary Compressed Files" />
</system.webServer>
</configuration>
关键:能进内核的让它进内核。哪怕只缓存 5–10 秒,高并发下能把大量“热点”挡在用户态之外。
3)放大 IIS 应用池队列 & 预热
把队列从 1000 拉到按峰值估算的安全值(例如 65535)
%windir%\system32\inetsrv\appcmd set apppool "ProdPool" /queueLength:65535
AlwaysRunning + 站点预加载,避免冷启动
%windir%\system32\inetsrv\appcmd set apppool "ProdPool" /startMode:AlwaysRunning
%windir%\system32\inetsrv\appcmd set app "Default Web Site/" /preloadEnabled:true
Rapid-Fail 防抖
%windir%\system32\inetsrv\appcmd set apppool "ProdPool" /failure.rapidFailProtection:true /failure.failureInterval:00:05:00 /failure.maxFailures:100
注意:放大队列不是万能。队列变长只是在短峰时“削峰填谷”,根因还是要靠内核缓存、并发与线程池匹配吞吐。
4)回收与并发:.NET Framework 方案
避免高峰期定时回收(改为凌晨固定窗口,允许重叠)
%windir%\system32\inetsrv\appcmd set apppool "ProdPool" /recycling.periodicRestart.time:00:00:00
%windir%\system32\inetsrv\appcmd set apppool "ProdPool" /recycling.periodicRestart.schedule."[value='03:30:00']":true
%windir%\system32\inetsrv\appcmd set apppool "ProdPool" /recycling.disallowOverlappingRotation:false
Server GC(通常 ASP.NET 站点默认已是 Server GC,但建议显式声明)
<!-- web.config -->
<configuration>
<runtime>
<gcServer enabled="true"/>
<gcConcurrent enabled="true"/>
</runtime>
</configuration>
请求并发(集成管道下一般不再使用经典时代的 minFreeThreads 等;重点放在线程池与异步 I/O)
- 尽量使用 async/await,避免阻塞线程。
- 连接池(数据库/Redis)上限与 IIS 并发匹配。
- 禁用 Session 粘性或把 Session 放到分布式(如 Redis),否则 web garden/多进程会互相“绊倒”。
可控的 Web Garden(仅当应用完全无状态时)
%windir%\system32\inetsrv\appcmd set apppool "ProdPool" /processModel.maxProcesses:2
警告:Web Garden 能提升 CPU 利用与并发度,但对 会话/缓存/锁 有严格要求;大多数传统站点不建议开。
5)回收与并发:ASP.NET Core(ANCM)方案
InProcess 模式 web.config
<configuration>
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified"/>
</handlers>
<aspNetCore processPath="dotnet" arguments="MyApp.dll"
hostingModel="InProcess"
stdoutLogEnabled="false"
requestTimeout="00:02:00"/>
</system.webServer>
</configuration>
线程池预热(Program.cs)
让 CLR 线程池在启动时就拉高最小线程,避免突发扩容带来的排队。
using System.Threading;
ThreadPool.GetMinThreads(out var wt, out var cpt);
ThreadPool.SetMinThreads(Math.Max(wt, 200), Math.Max(cpt, 200));
Kestrel 并发与 HTTP/2(如果走反代 OutOfProcess)
webBuilder.ConfigureKestrel(o =>
{
o.AddServerHeader = false;
o.Limits.Http2.MaxStreamsPerConnection = 128;
o.Limits.MaxConcurrentConnections = null; // 不限
o.Limits.MaxConcurrentUpgradedConnections = null;
});
6)HTTP.sys 头与请求体限制(避免 400/431 类错误)
适当放大请求头字节数(仅在明确需要时)
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\HTTP\Parameters]
"MaxFieldLength"=dword:00010000 ; 65536
"MaxRequestBytes"=dword:00010000 ; 65536
修改后需重启。不要盲目拉满,防止被大头攻击“炸死”。
7)站点连接/超时与日志 IO
连接超时与最大连接数
%windir%\system32\inetsrv\appcmd set site "Default Web Site" /limits.connectionTimeout:00:02:00 /limits.maxConnections:4294967295
移动日志到 NVMe(并按站点分目录)
%windir%\system32\inetsrv\appcmd set config -section:sites /siteDefaults.logFile.directory:"D:\IISLogs" /commit:apphost
%windir%\system32\inetsrv\appcmd set config "Default Web Site" -section:system.applicationHost/sites /+"[name='Default Web Site'].logFile.directory:'D:\IISLogs\DefaultWebSite'" /commit:apphost
临时压缩目录也移走
<system.webServer>
<httpCompression directory="D:\IIS_Compress_Temp" />
</system.webServer>
8)观测与压测:用数据说话
关键 PerfMon 计数器
- HTTP Service Request Queues(*)\CurrentQueueSize
- Web Service(_Total)\Current Connections
- ASP.NET\Requests Queued(老站点可参考)
- Process(w3wp)\% Processor Time
- Processor(_Total)\% Processor Time
- TCPv4\Connections Established
快速采集(5 秒采样,30 分钟)
logman create counter iis_perf -f bincirc -max 200 -si 00:00:05 ^
-c "\HTTP Service Request Queues(*)\CurrentQueueSize" ^
"\Web Service(_Total)\Current Connections" ^
"\Process(w3wp)\% Processor Time" ^
"\Processor(_Total)\% Processor Time" ^
"\TCPv4\Connections Established"
logman start iis_perf
压测建议
- 压测机同城同运营商,延迟 2–5ms;生产回国链路另做端到端压测。
- wrk -t16 -c1000 -d60s --latency http://your.host/endpoint
- 着重看:RPS、P95/P99、非 2xx 比例、503.2。
实际对比数据(节选)
业务:读多写少的 JSON 接口;动态可内核缓存 10 秒;落地 Redis/SQL 连接数有限。
压测:同城 2ms RTT、10GbE,不限速。
| 阶段 | 核心改动 | 峰值 RPS | P95 延迟 | 503.2 占比 |
|---|---|---|---|---|
| A(基线) | 默认 IIS,队列 1000,未启用内核缓存 | 13.8k | 420ms | 3.7% |
| B | 队列 65535 + AlwaysRunning + 预加载 | 18.9k | 260ms | 0.9% |
| C | 启用 Kernel Cache(热点 10s)+ 动态压缩 | 31.4k | 92ms | 0.1% |
| D | 线程池预热 + 日志与压缩目录迁移 NVMe | 36.8k | 70ms | 0.02% |
说明:RPS 并非“无限增长”。从 C→D 的收益在于稳定性与低尾延迟,活动高峰的“抖针”明显减少。
常见坑与现场解法
放大队列后 503.2 仍然出现
排查 HTTP Service Request Queues(*)\CurrentQueueSize 是否持续上涨且不回落。
若是持续不回落:吞吐不足 → 优先核查内核缓存命中与业务热点是否可缓存。
若是锯齿状回落:可能是回收/GC 抖动 → 调整回收窗口、查看 GC 模式与线程池。
Web Garden 开启后用户登录“掉线”
会话未外置。解决:Session 转 Redis/SQL 或改无状态令牌;或关闭 web garden。
ANCM OutOfProcess 下 Kestrel 成为瓶颈
观察 dotnet 进程 CPU 与 w3wp 的关系,必要时提升 ThreadPool.SetMinThreads 并开启 HTTP/2 multiplexing;或者切回 InProcess。
动态压缩 CPU 打满
对热点 JSON 才启用动态压缩,或只在中高体量响应启用;对超小响应禁压缩。
IIS 中设置按 mimeType 精准白名单,不要一刀切。
日志写入卡顿
日志目录与应用目录分卷,并放 NVMe;必要时使用集中日志(ETW/Agent)异步收集。
偶发 400/431(Header 超限)
检查网关/前端是否带了异常大的 Cookie 或自定义头;在 HTTP.sys 合理放大 MaxFieldLength/MaxRequestBytes,同时压缩或分片前端头部。
TLS 版本与 HTTP/2
Server 2016 上 HTTP/2 需要 TLS1.2 与支持的套件;确认未被组策略关闭。
若需要强制,检查注册表 EnableHttp2Tls(1 为启用),证书链完整且使用 ECDSA/RSA 现代套件。
一键化操作脚本(PowerShell 片段)
安全起见,先在测试环境执行;生产逐条验证。
# 变量
$PoolName = "ProdPool"
$SiteName = "Default Web Site"
$LogDir = "D:\IISLogs"
$ZipTmp = "D:\IIS_Compress_Temp"
# 创建目录
New-Item -Force -ItemType Directory $LogDir,$ZipTmp | Out-Null
# 启用组件
dism /online /enable-feature /featurename:IIS-HttpCompressionStatic /NoRestart | Out-Null
dism /online /enable-feature /featurename:IIS-HttpCompressionDynamic /NoRestart | Out-Null
dism /online /enable-feature /featurename:IIS-Caching /NoRestart | Out-Null
# 队列与预热
& $env:windir\system32\inetsrv\appcmd.exe set apppool "$PoolName" /queueLength:65535
& $env:windir\system32\inetsrv\appcmd.exe set apppool "$PoolName" /startMode:AlwaysRunning
& $env:windir\system32\inetsrv\appcmd.exe set app "$SiteName/" /preloadEnabled:true
# 日志与压缩目录
& $env:windir\system32\inetsrv\appcmd.exe set config -section:sites /siteDefaults.logFile.directory:"$LogDir" /commit:apphost
& $env:windir\system32\inetsrv\appcmd.exe set config "$SiteName" -section:system.applicationHost/sites /+"[name='$SiteName'].logFile.directory:'$LogDir\$SiteName'" /commit:apphost
# 压缩与内核缓存
& $env:windir\system32\inetsrv\appcmd.exe set config -section:urlCompression /doStaticCompression:"True" /commit:apphost
& $env:windir\system32\inetsrv\appcmd.exe set config -section:urlCompression /doDynamicCompression:"True" /commit:apphost
& $env:windir\system32\inetsrv\appcmd.exe set config -section:httpCompression /+"dynamicTypes.[mimeType='application/json',enabled='True']" /commit:apphost
& $env:windir\system32\inetsrv\appcmd.exe set config -section:system.webServer/caching /enableKernelCache:"True" /commit:apphost
变更与回滚策略(生产必要)
灰度顺序:网络栈 → 内核缓存(仅命中可回滚)→ 队列放大 → 回收窗口/线程池 → Web Garden(若启用)。
回滚点:每一步改动前 appcmd list config 导出快照;注册表变更做 .reg 备份。
窗口选择:活动外低峰期执行“重启需”的操作(如 HTTP.sys 参数)。
监控阈值:503.2 比例>0.1%、P95 超出基线 30%、CurrentQueueSize 连续 60s 不回落 → 自动回滚上一阶段参数。
FAQ:面对百万级请求,究竟什么最关键?
命中率。让 60%+ 的热点命中内核缓存,吞吐就“上楼梯”。
短队列 + 充足并发。队列不是越大越好;要结合线程池/CPU 实际承载。
稳定的 IO。日志与临时目录分卷,很多“玄学抖动”就会消失。
回收要有“节奏”。允许重叠 + 定点回收,活动期间不要“定时爆破”。
活动当天晚上 23:40,我们在机房外的便利店喝了罐温的维他柠檬茶。监控墙恢复成了清爽的绿:RPS 平稳在 3 万多,P95 压在两位数,503.2 几乎看不见。凌晨 1 点,我把最后一条 logman stop iis_perf 敲下,像把门轻轻带上。
有同事笑我:这套调优笔记写得像给别人看的。我说:是写给“下一次的自己”的。现场永远会有新的坑,但方法论不会变——先把流量挡在内核,队列只做缓冲,并发与线程池对齐 CPU,IO 抖动切干净,用数据验证假设。
如果你也在香港的机房里和我一样,被 503.2 追着跑,希望这篇文能帮你把局面“稳住”,然后漂亮地反击。
附:检查清单(上线前 3 分钟快速扫一遍)
- queueLength 已调整并与吞吐预估匹配
- AlwaysRunning + 站点预加载开启
- Kernel Cache 命中正常(通过响应头/ETW 验证)
- 动态压缩按 MIME 白名单启用,CPU 余量>20%
- 日志/压缩/临时目录在 NVMe 独立卷
- 回收窗口落在低峰且允许重叠
- 线程池最小线程预热到合适水平
- RSS/RSC 启用,多队列正常
- PerfMon 与告警阈值到位,回滚脚本已就绪
以上内容都是我在真实现场踩坑后的总结。你可以不必一次把所有“开关”拉满,但请一定先测再上、一步一验证。祝你这次活动稳稳拿下。