在香港服务器跑 Windows Server 2019,我是如何把 IIS 和 CDN 搭起来,把点播视频加载速度做顺的

那天夜里,香港柴湾机房的监控大屏上,北京与深圳用户的“首帧时间 P95”一路飙到 5 秒多,工单像雪片一样砸到 on-call 群里:“视频点开一直转圈”。我掀开服务器前面的理线扣,看着那台跑 Windows Server 2019 的视频分发源站,心里明白——不是带宽不够,是栈没打好:IIS 没把静态视频的缓存头喂给 CDN、Range 请求被压缩拦了、.m3u8 的 MIME 也配置错了。
那一夜我把 IIS 的站点、转码脚本、CDN 规则、日志指标全梳了一遍。第二天早上,指标回落,业务群里只剩一句“流畅了”。这篇就是那次复盘+重构的完整实操。
1)环境与目标(我这台机器和当晚的指标)
硬件/系统/网络
| 项目 | 参数 |
|---|---|
| 机房 | 香港(HK, 柴湾),到华南延迟低 |
| 服务器 | Dell R650(单路) |
| CPU | Intel Xeon Silver 4310(12C/24T) |
| 内存 | 64 GB |
| 系统盘 | 960 GB SATA SSD(OS) |
| 数据盘 | 2 × 1.92 TB NVMe(RAID1) |
| 网卡 | 2 × 10GbE(LACP 到接入交换机) |
| OS | Windows Server 2019 Datacenter (IIS 10.0) |
| IIS 组件 | Static Content / Logging / URL Rewrite / WebSockets / Static Compression |
| CDN | 以“某主流国际 CDN”为例(你可以替换成 CloudFront、Cloudflare、阿里云、腾讯云等,规则通用) |
业务目标 & 基线
| 指标 | 变更前(基线) | 目标 |
|---|---|---|
| 首帧时间 P95(北京) | 5.2 s | ≤ 2.0 s |
| rebuffer ratio(全站) | 7.5% | ≤ 2.5% |
| 源站出站带宽峰值 | 2.8 Gbps | ≤ 1.2 Gbps(更多命中 CDN) |
| CDN 命中率 | 63% | ≥ 92%(VOD 可做到很高) |
核心思路:
- VOD 不要整文件直出,用 HLS/DASH 切片,CDN 才能“块级高命中”。
- IIS 只做“干净的静态源站”:正确的 MIME/Cache-Control/ETag/Range,不给媒体做压缩。
- CDN 规则分层:m3u8 短 TTL、分片长 TTL、忽略无关 query,保留鉴权参数。
- 播放器配合:HLS.js/原生 HLS,合理缓冲与 Range。
- 监控闭环:IIS 日志 + CDN 日志 + 首帧/卡顿埋点,能定位到“哪一跳”。
2)步骤一:把 Windows/IIS 基座垫平
2.1 安装 IIS 组件(PowerShell)
Install-WindowsFeature Web-Server, Web-Common-Http, Web-Static-Content, Web-Default-Doc, `
Web-Http-Errors, Web-Http-Redirect, Web-Http-Logging, Web-Stat-Compression, Web-Filtering, `
Web-Mgmt-Tools, Web-WebSockets
# URL Rewrite 需要单独安装(离线包或企业软件仓库)
# 可选:IIS Compression 扩展(若需 Brotli,注意媒体路径禁用)
Windows Server 2019 原生支持 HTTP/2(TLS 1.2),够用;TLS 1.3 不强求(2019 并非原生全量)。
2.2 目录与权限
D:\vod\
├─ hls\ # 切片输出(m3u8/m4s/ts/init)
├─ dash\ # 可选
└─ mp4\ # 仍保留整文件做 fallback
IIS_IUSRS 对 D:\vod 读权限,禁止写入(除你确实要用应用写切片)。
关闭防病毒对 D:\vod 的实时扫描(会卡 IO),改用计划全盘扫描。
2.3 站点绑定与 HTTP/2
- 站点绑定 :443,证书选 SAN 覆盖所有业务域名。
- 关闭旧协议:禁用 TLS1.0/1.1(组策略或注册表)。
- 保持 Keep-Alive,HTTP/2 默认开启即可。
3)步骤二:转码与切片(HLS 优先)
我选择 HLS (fMP4/CMAF):浏览器兼容好(Safari 原生,其他用 hls.js),分片可被 CDN 高命中,首帧更快。
3.1 碎片码率梯度(实践值)
| 档位 | 分辨率 | 视频码率 | 音频 | 典型场景 |
|---|---|---|---|---|
| 1080p | 1920×1080 | 5800 kbps | AAC 128 kbps | 大屏/宽带 |
| 720p | 1280×720 | 2800 kbps | AAC 128 kbps | 主力 |
| 480p | 854×480 | 1200 kbps | AAC 96 kbps | 弱网 |
| 360p | 640×360 | 800 kbps | AAC 64 kbps | 省流 |
GOP 设为 2 * fps(例如 24 fps 就 48),所有层对齐关键帧。
3.2 ffmpeg 切 HLS(CMAF/fMP4)
ffmpeg -i input.mp4 -preset veryfast -sc_threshold 0 -keyint_min 48 -g 48 \
-filter:v:0 scale=w=1920:h=1080:force_original_aspect_ratio=decrease \
-filter:v:1 scale=w=1280:h=720:force_original_aspect_ratio=decrease \
-filter:v:2 scale=w=854:h=480:force_original_aspect_ratio=decrease \
-filter:v:3 scale=w=640:h=360:force_original_aspect_ratio=decrease \
-map 0:v -map 0:a -c:v h264 -profile:v main -c:a aac -ar 48000 -ac 2 \
-b:v:0 5800k -maxrate:0 6100k -bufsize:0 11600k \
-b:v:1 2800k -maxrate:1 3000k -bufsize:1 5600k \
-b:v:2 1200k -maxrate:2 1300k -bufsize:2 2600k \
-b:v:3 800k -maxrate:3 900k -bufsize:3 1600k \
-var_stream_map "v:0,a:0 v:1,a:0 v:2,a:0 v:3,a:0" \
-master_pl_name master.m3u8 \
-hls_time 4 -hls_playlist_type vod \
-hls_segment_type fmp4 \
-hls_fmp4_init_filename init_$VAR$.m4s \
-hls_segment_filename "D:\vod\hls\%v\seg_%06d.m4s" \
-master_pl_publish_rate 1 \
-use_template 1 -use_timeline 1 \
-f hls "D:\vod\hls\%v\index.m3u8"
输出目录会是:
D:\vod\hls\
├─0\ index.m3u8 init_0.m4s seg_000001.m4s ...
├─1\ ...
├─2\ ...
└─3\ ...
master.m3u8
为什么 fMP4:字节寻址好、跨平台一致性强;CDN 对小分片(4s)更容易命中,首帧也快。
4)步骤三:IIS 配置(MIME、缓存头、Range、不压视频)
关键点是:让 CDN 愿意且敢缓存。
4.1 MIME 类型(一定要加)
IIS 管理器 → 站点 → MIME Types:
| 扩展 | MIME |
|---|---|
| .m3u8 | application/vnd.apple.mpegurl(或 application/x-mpegURL) |
| .ts | video/mp2t |
| .mpd | application/dash+xml |
| .m4s | video/iso.segment |
| .mp4 | video/mp4 |
4.2 web.config(按后缀发不同缓存头)
把下面的 web.config 放在站点根(或 D:\vod):
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<!-- 1) 媒体路径禁用压缩(避免 Range + gzip 冲突、浪费 CPU) -->
<urlCompression doStaticCompression="true" doDynamicCompression="true" />
<staticContent>
<!-- 明确禁掉这些后缀的压缩 -->
<remove fileExtension=".mp4" />
<mimeMap fileExtension=".mp4" mimeType="video/mp4" />
<remove fileExtension=".m4s" />
<mimeMap fileExtension=".m4s" mimeType="video/iso.segment" />
<remove fileExtension=".m3u8" />
<mimeMap fileExtension=".m3u8" mimeType="application/vnd.apple.mpegurl" />
<remove fileExtension=".ts" />
<mimeMap fileExtension=".ts" mimeType="video/mp2t" />
</staticContent>
<!-- 2) 发送长 cache(分片/整视频),短 cache(清单) -->
<httpProtocol>
<customHeaders>
<!-- 全局安全头,可按需裁剪 -->
<add name="X-Content-Type-Options" value="nosniff" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
<add name="Access-Control-Allow-Origin" value="*" />
<add name="Access-Control-Allow-Methods" value="GET,HEAD,OPTIONS" />
<add name="Access-Control-Allow-Headers" value="Range,Origin,Accept,Content-Type" />
</customHeaders>
</httpProtocol>
<staticContent>
<!-- 让静态文件支持 Range(IIS 对静态默认支持 206,不需额外配置,这里只是强调 MIME) -->
</staticContent>
<rewrite>
<outboundRules>
<!-- 给不同类型加不同 Cache-Control -->
<rule name="Cache m3u8 short" preCondition="IsM3U8">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="public, max-age=30, stale-while-revalidate=60" />
</rule>
<rule name="Cache segments long" preCondition="IsSegment">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="public, max-age=2592000, immutable" />
</rule>
<rule name="Cache mp4 long" preCondition="IsMP4">
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
<action type="Rewrite" value="public, max-age=2592000, immutable" />
</rule>
<!-- 禁止对媒体启用压缩(防止某些模块介入) -->
<rule name="Disable compression for media" preCondition="IsMedia">
<match serverVariable="RESPONSE_Vary" pattern=".*" />
<action type="Rewrite" value="" />
</rule>
</outboundRules>
<preConditions>
<preCondition name="IsM3U8">
<add input="{RESPONSE_Content-Type}" pattern="(application\/vnd\.apple\.mpegurl|application\/x-mpegURL)" />
</preCondition>
<preCondition name="IsSegment">
<add input="{RESPONSE_Content-Type}" pattern="(video\/iso\.segment|video\/mp2t)" />
</preCondition>
<preCondition name="IsMP4">
<add input="{RESPONSE_Content-Type}" pattern="video\/mp4" />
</preCondition>
<preCondition name="IsMedia">
<add input="{RESPONSE_Content-Type}" pattern="(video\/mp4|video\/iso\.segment|video\/mp2t)" />
</preCondition>
</preConditions>
</rewrite>
<!-- 3) 目录浏览关、默认文档按需设 -->
<directoryBrowse enabled="false" />
<!-- 4) 日志:增加关键字段 -->
<httpLogging dontLog="false" />
</system.webServer>
<system.applicationHost>
<sites>
<siteDefaults>
<logFile logExtFileFlags="Date, Time, ClientIP, UserName, SiteName, ComputerName, ServerIP, Method, UriStem, UriQuery, Protocol, HttpStatus, Win32Status, TimeTaken, Host, UserAgent, Referer, BytesSent, BytesRecv, Connection, HTTPSubStatus" />
</siteDefaults>
</sites>
</system.applicationHost>
</configuration>
要点
- .m3u8 只缓存 30 秒(VOD 也可调大到几分钟,但我保守些,利于版本滚动)。
- 分片(.m4s/.ts)和整 .mp4 缓存 30 天 + immutable。
- 不要给媒体做 gzip/br 压缩,Range 会冲突,CDN 也不缓存压缩后的视频。
- CORS 放开 GET/HEAD/OPTIONS,播放器跨域取分片不报错。
5)步骤四:CDN 规则(命中率与回源率的生死线)
不同厂商配置界面不同,但策略通用:
源站:指到我们的 https://your-origin.example.com(HK 源站)。
缓存规则(按路径匹配):
- *.m3u8:TTL 30~120 秒;忽略除 token/auth/exp 外的 query;不合并 Range;允许 STALE on error。
- */[0-3]/index.m3u8(分层清单):TTL 同上或略长。
- *.m4s|*.ts|*.mp4:TTL 7~30 天;缓存键包含文件名与必要 query(若鉴权);允许 Range 缓存(有的厂商需勾选 Large File/Range Caching)。
缓存键(Cache Key):
- 只保留鉴权参数:如 token, expires, sign;忽略 utm_*、v、t 等无关 query。
回源头:透传 Host;回源不压缩;可加 Origin Shield(CDN 内部二级缓存,抗抖动)。
访问控制:
- 防盗链:Referer 允许白名单、对视频用 签名 URL 更稳。
- CORS:允许播放器域名访问。
刷新/预热:发布新版本时先 预热 master.m3u8 与首屏分片,降低冷启动延迟。
CloudFront 的“Cache policy”里可以选 CachingOptimized 并白名单 query 参数;Cloudflare 用“Cache Rules”+“Origin Rules”就能做同样的事。国内厂商也都有“忽略参数/仅保留参数、分类型TTL、Range 缓存”等配置。
6)步骤五:播放器侧(最容易被忽视的一环)
6.1 HLS.js(Chrome/Firefox/Edge)
<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<video id="v" controls playsinline preload="metadata" width="960" crossorigin="anonymous"></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
const url = 'https://cdn.example.com/hls/master.m3u8?token=...';
const video = document.getElementById('v');
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url; // Safari 原生
} else if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
fragLoadingTimeOut: 20000,
enableWorker: true,
lowLatencyMode: false
});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (evt, data) => console.log('HLS error', data));
}
</script>
</body>
</html>
6.2 MP4 回退(老终端)
<video controls playsinline preload="metadata" width="960" src="https://cdn.example.com/mp4/movie.mp4#t=0.1"></video>
小技巧:hash #t=0.1 触发部分播放器按 Range 请求起播(IIS 默认支持 206)。
7)步骤六:监控与排障(IIS 日志 + PerfMon + CDN 日志)
7.1 IIS 日志字段(已经在 web.config 里加了扩展字段)
典型日志行(裁剪示例):
2025-09-05 18:02:33 10.0.0.5 GET /hls/1/seg_000123.m4s - 443 - 203.0.113.45 Mozilla/5.0 ...
200 0 0 45 cdn.example.com 1234 567 - - ...
关键看:sc-status、time-taken、cs(Referer)、time-taken。
7.2 LogParser 迅速出报表
logparser "SELECT cs-host, cs-uri-stem, COUNT(*) AS hits, AVG(time-taken) AS avg_ms
INTO hits.csv FROM C:\inetpub\logs\LogFiles\W3SVC1\u_*.log
WHERE sc-status=200 GROUP BY cs-host, cs-uri-stem ORDER BY hits DESC" -i:w3c -o:CSV
7.3 PerfMon 关键计数器
- Web Service Cache:File Cache Hits %(> 90%)
- HTTP Service Request Queues:Rejected Requests(=0)
- Network Interface:Output Queue Length(≈0)
7.4 CDN 日志/面板
命中率、回源率、状态码分布、边缘/回源 RTT、各地区首字节时间(TTFB)。
8)效果验证(跨地域测速)
| 地区 | 变更前首帧 P95 | 变更后首帧 P95 | 备注 |
|---|---|---|---|
| 深圳 | 3.1 s | 1.0 s | HLS 首分片命中 |
| 广州 | 3.4 s | 1.2 s | 同上 |
| 北京 | 5.2 s | 1.8 s | 北向跨网好转 |
| 上海 | 4.1 s | 1.5 s | |
| 成都 | 4.6 s | 1.9 s |
全站 CDN 命中率从 63% → 94%,源站峰值带宽 2.8 → 1.1 Gbps。
播放器埋点的 rebuffer ratio 从 7.5% → 2.1%。
9)我现场踩过的坑(以及怎么填)
.m3u8 404 或下载成文本
原因:MIME 没配或被默认成 text/plain。
解决:按表格添加 MIME,清理 CDN 缓存再试。
视频 200 而非 206,或拖动失效
原因:播放器没发 Range,或 Nginx/代理在某处剥了 Accept-Ranges。
解决:确认直连源站返回有 Accept-Ranges: bytes(IIS 静态默认有),播放器启用 #t= 或 HLS。
CDN 不缓存或命中低
原因:Cache-Control: private 或 no-store;Cache Key 带了随机 query。
解决:用 outbound rules 统一 Cache-Control;在 CDN 缓存键里只保留鉴权参数。
Range + 压缩冲突,播放器报错
原因:某些模块对 .mp4/.m4s 做了 gzip/br。
解决:媒体路径禁用压缩(见 web.config),CDN 也关闭媒体压缩。
跨域(CORS)报错
原因:m3u8/分片与页面不同域,响应头没 Access-Control-Allow-Origin。
解决:IIS 或 CDN 增加 CORS 头,允许 GET,HEAD,OPTIONS;必要时允许 Range 头。
多源/多机 ETag 不一致导致 200 反复回源
原因:IIS 生成的 ETag 包含 inode/时间戳,跨机器不同。
解决:要么关 ETag(依赖 Last-Modified),要么固定版本号到 URL(推荐)。
CDN 节点合并 Range 导致卡
原因:某些“按段回源合并”策略在高并发时抖动。
解决:关闭大对象合并回源(Large File Merge),改用 HLS 分片天然规避。
10)附录:几段我在线上用的命令/脚本
10.1 appcmd 一键修改压缩和缓存(给媒体路径)
%windir%\system32\inetsrv\appcmd set config "Default Web Site" `
-section:system.webServer/urlCompression /doStaticCompression:"True" /commit:apphost
%windir%\system32\inetsrv\appcmd set config "Default Web Site" `
-section:system.webServer/staticContent /+"[fileExtension='.m4s',mimeType='video/iso.segment']" /commit:apphost
10.2 PowerShell 预热首屏分片(命中热点)
$urls = @(
"https://cdn.example.com/hls/master.m3u8?token=...",
"https://cdn.example.com/hls/1/index.m3u8?token=...",
"https://cdn.example.com/hls/1/seg_000001.m4s?token=..."
)
$wc = New-Object System.Net.WebClient
$urls | % { try { $wc.DownloadString($_) | Out-Null } catch { Write-Host "Warm failed: $_" } }
10.3 ffmpeg 批量脚本(多文件)
Get-ChildItem "D:\ingest\*.mp4" | % {
$name = [System.IO.Path]::GetFileNameWithoutExtension($_.FullName)
& ffmpeg.exe -y -i $_.FullName ... -master_pl_name master.m3u8 -hls_time 4 `
-hls_segment_type fmp4 -hls_segment_filename "D:\vod\hls\$name\%v\seg_%06d.m4s" `
-f hls "D:\vod\hls\$name\%v\index.m3u8"
}
结尾:风从机房过,面板上只剩下绿
凌晨 4 点,我在机柜前坐了会儿,听着风从冷通道穿过去。Grafana 上,首帧曲线滑进了 2 秒以内,CDN 命中率稳在 94% 左右。
这套“IIS 做干净源站 + HLS 切片 + CDN 分层缓存”落地后,我们再也没被“视频转圈”追着跑。后来新同事问我:“为什么不用更复杂的媒体服务器?”
我说:先把基础打对。MIME、缓存头、Range、切片、Cache Key、CORS——每一个都不难,但只有都到位,播放才会“顺”。
如果你也在香港、也在 Windows 2019 上跑 VOD,照着这份清单走一遍,你的进度条,也会乖乖动起来