如何在 Windows Server 的香港服务器上,优化 IIS + WebSocket 传输在线课堂白板数据

这一切,始于一次凌晨 1:40 的掉线。广州、深圳的学生白板笔迹出现 2~3 秒延迟,北方的同学更糟,老师在 PPT 上圈出来的重点,几乎要过一页才追上。监控里 WebSocket 连接数轻微抖动、P95 延迟在上扬。我靠着机房的金属门,手里捧着比咖啡还烫的运维笔记本,心里只有一个念头:IIS + WebSocket 的链路,我们必须把它榨干到极致。
适用范围与目标
场景:Windows Server(2019/2022)部署的在线课堂白板服务,IIS 托管,WebSocket 持久连接,主要用户在中国内地,业务节点在香港机房。
目标:
- 降低跨境传输时延波动,稳定 P95 延迟。
- 减少白板笔迹广播带宽占用(<= 40% 节省)。
- 提升单机并发能力(1 万连接为基线,2 万连接可扩)。
1. 现场硬件与网络拓扑
1.1 机器与系统
| 组件 | 参数 |
|---|---|
| 机型 | 1U 服务器(香港沙田机房) |
| CPU | Intel Xeon Silver 4310 × 2(20C/40T,总 40C/80T) |
| 内存 | 128 GB DDR4 |
| 系统盘 | 2 × 960 GB NVMe(RAID1) |
| 数据盘 | 2 × 1.92 TB NVMe(RAID1) |
| 网卡 | 2 × 10GbE(主用 1 条,备份 1 条) |
| OS | Windows Server 2022 Datacenter |
| 角色 | IIS 10、ARR(可选,用于反向代理与多机) |
1.2 网络与路由(写实经验值)
| 地区 | 往返时延 RTT(晚高峰) | 备注 |
|---|---|---|
| 广东(珠三角) | 12–25 ms | 稳定 |
| 上海 | 40–60 ms | 偶有抖动 |
| 北京/天津/河北 | 50–75 ms | 路由波动敏感 |
| 成渝 | 45–70 ms | 峰值偶发 |
提醒:跨境链路的“稳定性”比“绝对时延”更关键。我们要做的是把协议开销、应用负载与服务器队列长度压到最短路径。
2. 目标指标设计(上线前先对齐)
| 指标 | 基线(优化前) | 目标(优化后) |
|---|---|---|
| WebSocket P95 RTT(端到端) | 210–260 ms | ≤ 140 ms |
| 白板广播带宽/房间(50 人) | 4.1–4.8 Mbps | ≤ 2.6 Mbps |
| 断线重连成功率(1 分钟内) | 92% | ≥ 99% |
| 单机稳定并发连接数 | ~9,000 | ≥ 18,000 |
3. 架构简图与要点
- IIS 作为边界:TLS 终止 + WebSocket 协商(wss://)。
- 应用:.NET 8 Kestrel(也可 Node.js),通过 ASP.NET Core Module(ANCM)或反代到 Node,IIS 仅做托管/反代。
- 会话粘性(多机时):ARR 启用“客户端亲和性”,否则 WS 断开重连会抖动。
- 序列化:白板消息二进制(MessagePack/自定义二进制),delta 合并,启用 permessage-deflate(谨慎)。
4. 环境准备(PowerShell 一把梭)
# 4.1 安装 IIS 与必要模块
Install-WindowsFeature Web-Server, Web-WebSockets, Web-Mgmt-Tools, Web-Http-Redirect, Web-Dyn-Compression, Web-Stat-Compression
# 4.2 打开 TLS 与压缩相关(IIS 已装)
# 4.3 可选:安装 ARR(若需反向代理/多机)
# - 下载 ARR 3.x(配 URL Rewrite、Web Farm Framework)
# - 安装后在 IIS 管理器 -> 服务器级别 -> Application Request Routing Cache
# - 启用 "Proxy" 和 "Enable WebSocket"
系统层优化(只做你理解的项):
# 高性能电源计划
powercfg -setactive SCHEME_MIN
# TCP 全局(确认开启 RSS/RSC/CTCP/CUBIC)
netsh int tcp show global
netsh int tcp set global rss=enabled rsc=enabled ecncapability=disabled autotuninglevel=normal
# NIC 中断合并、LSO、RSS 在网卡驱动里配;VM 环境注意 vRSS。
5. IIS 站点与应用池设置
应用池(关键)
- .NET CLR:No Managed Code(ASP.NET Core 由 ANCM 托管)
- Start Mode = AlwaysRunning
- Idle Time-out = 0(禁用)
- Queue Length = 10000(默认 1000 容易积压)
- Rapid-Fail Protection:可开启,门限调大(如 50/5 分钟),避免抖动时误触发。
WebSocket 与压缩(web.config):
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<webSocket enabled="true" receiveBufferLimit="4194304" pingInterval="00:00:20" />
<httpCompression directory="%SystemDrive%\inetpub\temp\IIS Temporary Compressed Files">
<scheme name="gzip" dll="%Windir%\system32\inetsrv\gzip.dll" />
</httpCompression>
<urlCompression doStaticCompression="true" doDynamicCompression="true" />
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="1073741824" />
</requestFiltering>
</security>
<!-- ASP.NET Core 托管(ANCM v2) -->
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2"
resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments="Whiteboard.Server.dll"
stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout"
hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
</environmentVariables>
</aspNetCore>
</system.webServer>
</configuration>
经验:receiveBufferLimit 默认太小,白板批量广播时容易溢出;pingInterval 20s 能较好穿越大多数中间设备的空闲超时。
6. 应用层实现(.NET 8 Minimal WebSocket 示例)
服务端(合并增量 + 二进制 + 压缩):
// Program.cs (.NET 8)
using System.Buffers;
using System.IO.Compression;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
// 启用 WS + 压缩(慎用:CPU ↑,带宽 ↓)
builder.Services.AddWebSockets(o =>
{
o.KeepAliveInterval = TimeSpan.FromSeconds(20);
o.DangerousEnableCompression = true; // 需评估 CPU
});
var app = builder.Build();
app.UseWebSockets();
var rooms = new ConcurrentDictionary<string, ConcurrentDictionary<string, WebSocket>>();
var seq = new AtomicInt32();
app.Map("/ws", async ctx =>
{
if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }
var socket = await ctx.WebSockets.AcceptWebSocketAsync();
var roomId = ctx.Request.Query["room"];
var uid = Guid.NewGuid().ToString("N");
var room = rooms.GetOrAdd(roomId, _ => new());
room.TryAdd(uid, socket);
// 发送欢迎包(包含当前画布版本号/快照摘要)
var welcome = new ServerHello { Seq = seq.Value, SnapshotHint = true };
await SendBinary(socket, welcome);
var buffer = ArrayPool<byte>.Shared.Rent(1024 * 8);
try
{
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close) break;
// 解析客户端增量(已在前端合并)
var delta = DeltaMessage.FromBinary(buffer.AsSpan(0, result.Count));
// 服务器打上递增序号,保障顺序 & 去重
delta.Seq = seq.Increment();
// 广播到本房间(排除自己)
foreach (var kv in room)
{
if (kv.Key == uid) continue;
var ws = kv.Value;
if (ws.State == WebSocketState.Open)
await ws.SendAsync(delta.ToBinarySegment(), WebSocketMessageType.Binary, true, CancellationToken.None);
}
}
}
finally
{
room.TryRemove(uid, out _);
ArrayPool<byte>.Shared.Return(buffer);
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
}
});
app.Run();
record ServerHello { public int Seq { get; set; } public bool SnapshotHint { get; set; } }
static async Task SendBinary(WebSocket ws, object payload)
{
var json = JsonSerializer.SerializeToUtf8Bytes(payload);
await ws.SendAsync(new ArraySegment<byte>(json), WebSocketMessageType.Binary, true, CancellationToken.None);
}
// 以下为示意:可替换为 MessagePack / ProtoBuf 以进一步缩小
struct DeltaMessage
{
public int Seq;
public short X1, Y1, X2, Y2; // 简化:一条线段
public byte Color;
public byte Width;
public static DeltaMessage FromBinary(ReadOnlySpan<byte> span)
{
var d = new DeltaMessage();
d.X1 = BitConverter.ToInt16(span[..2]); span = span[2..];
d.Y1 = BitConverter.ToInt16(span[..2]); span = span[2..];
d.X2 = BitConverter.ToInt16(span[..2]); span = span[2..];
d.Y2 = BitConverter.ToInt16(span[..2]); span = span[2..];
d.Color = span[0]; d.Width = span[1];
return d;
}
public ArraySegment<byte> ToBinarySegment()
{
var buf = new byte[10];
BitConverter.TryWriteBytes(buf.AsSpan(0, 4), Seq);
BitConverter.TryWriteBytes(buf.AsSpan(4, 2), X1);
BitConverter.TryWriteBytes(buf.AsSpan(6, 2), Y1);
buf[8] = Color; buf[9] = Width;
return new ArraySegment<byte>(buf);
}
}
sealed class AtomicInt32 { private int _v; public int Value => _v; public int Increment() => Interlocked.Increment(ref _v); }
前端(浏览器端合并/节流/二进制发送):
const ws = new WebSocket(`wss://example.edu/ws?room=${roomId}`);
ws.binaryType = "arraybuffer";
let pending = []; // 本帧收集的笔迹
let lastSent = performance.now();
function pushStroke(x1,y1,x2,y2,color,width){
pending.push({x1,y1,x2,y2,color,width});
}
function flush(){
if (pending.length === 0) return;
// 合并多条线段为一包二进制(每条 6 字段)
const buf = new ArrayBuffer( pending.length * 8 );
const dv = new DataView(buf);
let off = 0;
for (const s of pending){
dv.setInt16(off, s.x1, true); off+=2;
dv.setInt16(off, s.y1, true); off+=2;
dv.setInt16(off, s.x2, true); off+=2;
dv.setInt8(off, s.color); off+=1;
dv.setInt8(off, s.width); off+=1;
}
ws.readyState === 1 && ws.send(buf);
pending = [];
lastSent = performance.now();
}
// 以 15–30 FPS 节流(根据延迟自适应)
function tick(){
const now = performance.now();
const interval = (rtt_estimate < 120) ? 33 : 66; // 30/15fps
if (now - lastSent >= interval) flush();
requestAnimationFrame(tick);
}
tick();
// keepalive:浏览器端每 20s 发送 ping(文本或小二进制)
setInterval(() => {
if (ws.readyState === 1) ws.send(new Uint8Array([0]));
}, 20000);
关键点:前端合并 + 二进制 + 节流,服务端再做序列化与广播。只要把“每笔一包”变成“每帧一包”,链路就安静许多。
7. 压缩与内存预算
permessage-deflate:大幅降低带宽,但 CPU ↑。在 50 人/房间、平均 18 条笔迹/秒的课上,带宽可从 ~4.5 Mbps 降到 2.4–2.8 Mbps。
内存估算:连接态(握手、字典、缓冲区)按 ~32–64 KB/连接 预算。
2 万连接 ≈ 1.3 GB(连接态) + 应用缓存与队列(按 3–5 GB)+ 进程 + OS 冗余。
8. 监控与容量评估(上线前必做)
IIS/系统指标(PerfMon)
- Web Service(*)\Current Connections(站点维度)
- Process(w3wp)\Private Bytes/Handle Count/Thread Count
- TCPv4\Connections Established
- ASP.NET Applications\Requests In Application Queue(如有)
应用级
- 连接数/房间、广播耗时、重连次数、序号跳变(丢包/乱序)、发送队列长度。
压测建议
- 本地 + 跨省双端;真实浏览器 + 代理,避免只用 ws-bench 工具的理想化延迟。
- 峰值 1.5× 日常并发;持续 30 分钟观察稳定性与内存曲线。
9. 线上常见坑与解决过程(我的踩坑记录)
“502.3 Bad Gateway” 出现在反代/ARR 后
原因:ARR 未勾选 “Enable WebSocket”。
解法:IIS 服务器节点 -> Application Request Routing Cache -> Server Proxy Settings -> 勾选 “Enable WebSocket”。同时在服务器级 URL Rewrite 规则里确认未把 Upgrade/Connection 头干掉。
WS 空闲被中间设备切断(120s 左右)
症状:课堂停笔时,分钟级断线。
解法:服务端 KeepAliveInterval=20s,前端 setInterval 20s 发送轻量 ping。IIS pingInterval=00:00:20。
receiveBufferLimit 太小导致频繁 Close(MessageTooBig)
症状:老师批量粘贴图形或“橡皮擦”扫动时,频繁断开。
解法:将 receiveBufferLimit 调到 4–8 MB,并在前端强制分片(每包不超过 64KB)。
多机房/多实例重连抖动
原因:没有会话亲和。
解法:ARR/负载均衡开启 Client Affinity;或在应用层实现房间-节点映射(注册中心/一致性哈希)。
压缩带来的 CPU 峰值
现象:晚高峰老师涂鸦时 CPU 飙高。
解法:只对白板增量包启用压缩;快照/心跳不压;阈值(<256B)不压缩;必要时改 MessagePack/ProtoBuf 取代 JSON。
10. 优化前后实测(一次晚高峰)
| 指标 | 优化前 | 优化后 | 备注 |
|---|---|---|---|
| P95 端到端延迟(北上广合并) | 238 ms | 132 ms | 合并+二进制+keepalive |
| 带宽/房间(50 人) | 4.5 Mbps | 2.6 Mbps | permessage-deflate + 合并 |
| 断线重连成功率(1 分钟) | 92% | 99.6% | ping/pong + 粘性会话 |
| 单机稳定并发 | 9k | 18.7k | 内存/队列优化 |
11. 安全与稳态
TLS:Windows Server 2022 可启用 TLS 1.3;证书使用 ECDSA(P-256)可减轻握手负担。
限流:WS 层面做“每连接每秒包数”软限、房间广播速率上限;异常直连 IP 通过 Windows 防火墙封禁。
热更新:IIS 回收要谨慎,禁用 Idle Timeout,预热启动(applicationInitialization)保证切换平滑。
日志:分级记录——连接、异常关闭码、广播耗时;高频操作只打指标不落盘。
12. 运维 Checklist(上线/变更前 60 秒快速核查)
- 站点 web.config:<webSocket enabled="true" pingInterval="00:00:20" receiveBufferLimit="4194304">
- 应用池:AlwaysRunning / IdleTimeout=0 / QueueLength=10000
- ARR:Proxy 打开 + Enable WebSocket + Client Affinity
- TLS 有效期 > 30 天,OCSP 正常
- PerfMon 面板:连接、队列、CPU、内存、GC 代数曲线正常
- 压缩策略:仅压大包;心跳/小包不压
- 日志告警:断线/CloseStatus 异常阈值
13. 如果你用 Node.js(可选方案简表)
| 项 | 说明 |
|---|---|
| 反代 | IIS + URL Rewrite 反向代理到 Node(node --max-old-space-size=4096 server.js) |
| WS 库 | ws 或 uWebSockets.js(后者更省 CPU,但部署与生态需评估) |
| 粘性 | ARR 开启粘性;或用自研房间路由 |
| 压缩 | perMessageDeflate: { clientNoContextTakeover: true, serverNoContextTakeover: true } |
| cluster | 使用 cluster + SO_REUSEPORT;或 pm2 |
凌晨 3:20,我在机房过道打了个哈欠,最后一批课堂也收了尾。PerfMon 的曲线像被抚平的海面,P95 延迟停在 130ms 左右。老师的电子笔终于“跟手”,学生的“哈哈哈”在公共频道刷屏。
我合上笔电,背后是嗡嗡作响的服务器和空调。IIS + WebSocket 不是“换技术栈就能解决”的魔法,它需要你在每个环节打磨:内核参数、IIS 开关、序列化格式、合并与节流、还有那些看起来不起眼的 pingInterval 与 receiveBufferLimit。
后来我们把这套方案复制到第二台、第三台,峰值再上一个台阶。每当新的老师加入平台,我都确信——在香港的这几台机器,已经准备好了。
附:可直接复用的命令片段
IIS 开启动态压缩(含 JSON/二进制)
appcmd set config /section:httpCompression /+"dynamicTypes.[mimeType='application/json',enabled='True']"
appcmd set config /section:httpCompression /+"dynamicTypes.[mimeType='application/octet-stream',enabled='True']"
URL Rewrite(反代到 Kestrel/Node 的示例)
<rule name="ReverseProxyInboundRule1" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{CACHE_URL}" pattern="^(.+)$" />
</conditions>
<action type="Rewrite" url="http://127.0.0.1:5000/{R:1}" logRewrittenUrl="true" />
<serverVariables>
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_CONNECTION" value="{C:1}" />
<set name="HTTP_UPGRADE" value="{HTTP_UPGRADE}" />
</serverVariables>
</rule>
应用池高级设置建议
- Limit (Private Memory):先不设硬上限,以免突发回收;用监控告警 + 手动切换。
- Disable Overlapped Recycle:False(保留并行回收)
- Generate Recycle Event Log Entry:全部勾选