上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-21 10:36 阅读量:973


这一切,始于一次凌晨 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 库 wsuWebSockets.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:全部勾选
目录结构
全文