IIS上SignalR连接数过载导致API阻塞?排查与解决方案
2026/9/7 19:48:25 网站建设 项目流程

这个标题里的现象,我印象太深了。之前帮朋友排查一个部署在 Windows Server 上的 .NET Core 项目,项目里挂了 SignalR 做实时通知,结果只要在线客户端数量到 10 个左右,Web API 的请求就全部卡住,转圈转到超时。一开始我还以为是代码里有死锁,后来才发现问题出在 IIS、SignalR 和 ASP.NET Core 并发模型这三者的配合上。

如果你也遇到类似情况,建议先别急着改业务代码,这篇文章我会把整个排查过程和解决方案拆开讲清楚:为什么 10 个连接会成为临界点、怎么确认当前 SignalR 到底走的什么传输模式、IIS 上需要做什么配置,以及代码里哪些隐藏坑会直接放大这个问题。

1. 故障现象与问题定位

1.1 现象描述:在线连接数一到 10 个左右,API 全部转圈

先还原一下现场。项目是标准的前后端分离架构,后端是 ASP.NET Core Web API,部署在 Windows Server 的 IIS 上,前端通过 SignalR 客户端接收服务端推送的消息。项目刚发布时一切正常,但运行一段时间后,只要 SignalR 的在线连接数达到 10 个左右,再发起任何一个 Web API 请求,浏览器端就会一直处于 pending 状态,直到 IIS 返回 504 或 503。

这个现象有几个非常明显的特征:

  • SignalR 连接本身没有立刻断开,客户端显示已连接。
  • 新的 HTTP 请求进不来,但已经建立的连接还能维持一段时间。
  • 重启 IIS 应用程序池之后,系统短暂恢复,但连接数再次上来后又开始卡。
  • 卡住的时间不固定,有时几十秒,有时直接超时。

这种“连接数一到某个量级就整体阻塞”的现象,很容易让人误判成数据库连接池耗尽,或者某个 Redis 连接问题。但仔细观察会发现,即使是不查数据库的静态接口也卡住,说明问题出在更底层的 HTTP 请求处理链路上,而不是某个具体业务模块。

1.2 最先想到的检查项:排除死锁和接口慢查询

遇到这种问题,我第一反应是排查代码里有没有同步阻塞,比如在 async 方法里用了 .Result 或者 .Wait()。因为 SignalR 的 Hub 方法和 Web API 是跑在同一个进程里的,如果某个 Hub 方法里有同步阻塞调用,线程池线程会被快速耗尽,新请求自然进不来。

我先做了三件事:

  1. 用日志记录每个请求的进入时间和离开时间,看是根本没进来,还是进来了但处理很慢。
  2. 把数据库、Redis 等外部依赖全部暂时屏蔽,测一个纯内存返回的接口,看是否还会卡。
  3. 检查所有 Service 注册的生命周期,确认没有把 DbContext 这种 Scoped 服务错误注册成 Singleton,导致并发竞争。

结果出乎意料:纯内存接口在连接数上来之后照样卡。也就是说,问题不在这几个常见坑里,而是 IIS 和 SignalR 之间的请求处理方式出了问题。

1.3 明确的排查方向:SignalR 的传输模式

当排除了业务代码后,我把目光放到了 SignalR 的传输机制上。SignalR 为了兼容不同环境,默认支持三种传输方式:WebSocket、Server-Sent Events(SSE)、Long Polling。这里有一个很关键的知识点:并不是所有部署环境都能真正走 WebSocket。

SignalR 客户端启动时会先发一个 negotiate 请求,然后根据服务器返回的能力和当前网络环境选择最优传输方式。如果 IIS 上没装 WebSocket Protocol 功能,或者中间有代理/负载均衡器没有正确转发 Upgrade 头,SignalR 就会自动回退到 SSE 或 Long Polling。

而 SSE 和 Long Polling 有一个致命问题:每一个在线客户端都会长期占住一个 HTTP 请求不释放。服务端处理这些请求需要占用线程或异步连接,当连接数量达到一定临界值后,剩余的处理能力就无法响应新的 API 请求了。这就解释了为什么看起来像是“10 个连接之后开始阻塞”——实际上这取决于服务器配置的并发上限、CPU 核数、线程池初始线程数等因素,10 只是一个常见临界值。

2. 为什么 10 个连接就能把请求堵死:核心原理拆解

2.1 IIS 托管 .NET Core 的请求处理模型

要理解阻塞的根因,必须先知道 .NET Core 应用发布到 IIS 后请求是怎么流动的。当前主流的部署方式是进程内托管(In-Process),也就是说 ASP.NET Core 应用直接跑在 IIS 的应用程序池工作进程里,IIS 接收到 HTTP 请求后,直接交给应用内的 Kestrel 服务器处理,不再经过一个独立的 Kestrel 进程。

进程内托管的好处是性能更好,少一次进程间通信。但它也意味着应用和 IIS 共享同一个进程的资源,包括线程池。IIS 本身对并发请求是有队列管理的,应用程序池有一个“队列长度”参数,默认是 1000。也就是说,如果请求处理不过来,新请求会在 IIS 队列里排队,而不是直接进到应用代码里。

但这里要注意:IIS 的队列长度默认 1000,理论上不会在 10 个连接时就爆掉。真正的瓶颈出现在 ASP.NET Core 的线程池和 SignalR 连接模型之间的冲突上。

2.2 SignalR 的三种传输方式与连接占用差异

SignalR 的三种传输方式对连接资源的占用是完全不同的:

  • WebSocket:一次 HTTP 握手后,协议升级为长连接,数据双向实时传输。这种连接不占用 HTTP 请求处理线程,对服务器压力最小。
  • Server-Sent Events:服务器向客户端单向推送,客户端通过 EventSource 接收。服务器会保持一个 HTTP 响应连接长期不关闭,每个客户端占一个请求槽。
  • Long Polling:客户端发一个请求,服务器有消息就立即返回,没消息就挂住,等有消息或超时后再返回,客户端收到后立刻发起下一个请求。这种模式下,每个客户端在任何时刻也至少会有一个挂起的 HTTP 请求。

麻烦就出在后两种模式。如果 SignalR 因为某种原因没有升级到 WebSocket,而是回退到了 SSE 或 Long Polling,那么每一个在线客户端就相当于一个永不结束的 HTTP 请求。

在 ASP.NET Core 中,这种长时间挂起的请求会占用线程池的可用线程。虽然异步请求不会一直占着线程不放,但 SignalR 在处理消息调度、连接生命周期等逻辑时,仍然需要线程池分配线程来执行回调。一旦这种半挂起的任务数量多了,线程池就会进入“饥饿”状态。

2.3 线程池饥饿:真正压垮系统的最后一根稻草

.NET 的线程池有一个动态调节机制。它会根据任务的到达速率和完成速率自动增加或减少线程数。增加线程是有成本的,需要消耗 CPU 和内存,所以线程池有一定的“惰性”。当任务量突然增大且部分任务长时间不完成时,线程池会尝试每 500 毫秒左右增加一个线程。

听起来好像问题不大,但如果你的 SignalR 连接模式是 SSE 或 Long Polling,这些连接会把线程池的“活跃请求数”撑起来,但每个请求都不结束。线程池判断系统仍然繁忙,于是不断加线程,直到线程数达到上限。而线程数到达上限后,新进来的 API 请求会进入线程池的全局队列。

上面说的是理论上最典型的情况,还有一个容易被忽略的技术细节:线程池的“最小线程数”。默认情况下,.NET 线程池的最小线程数通常等于 CPU 核心数。如果一个部署环境是 4 核甚至 2 核,那么最小线程数很低,一旦连接数稍微上来一点,线程池就很容易进入饥饿策略。

实际上,线程池饥饿时不一定真的加不上线程,而是加线程的速度赶不上请求堆积的速度。加上很多开发者在 Hub 方法里习惯性地写了同步代码,比如用 HttpClient 的 .Result、用 Thread.Sleep、用 lock(obj),这会让线程被真正占用而不是异步挂起,进一步加速线程池耗尽。

2.4 为什么临界点看起来是“10 个连接”而不是其他数字

很多人在排查时会纠结“为什么是 10”,甚至去 IIS 配置里找有没有哪项限制是 10。我排查过不少案例,得出的结论是:10 并不是 IIS 写死的数字,而是你的服务器资源、线程池配置和应用代码共同决定的临界值。

可以做一个简单推算:假设服务器 CPU 是 2 核,线程池默认最小线程数可能就是 2。当有 10 个 SignalR 客户端在线,且每个客户端因为传输模式回退都保持着一个 SSE 或 Long Polling 请求,这时候线程池里至少有 10 个长期挂起的异步操作。如果其中一部分 Hub 方法还做了同步阻塞调用,那么真正能用来处理新请求的线程可能已经所剩无几。新请求进来后排队等待,表现得就像被“阻塞”了一样。

所以重点不是去找一个隐藏的 10 连接上限,而是先确认:你的 SignalR 在 IIS 上到底走了哪种传输模式?如果走的是 WebSocket,10 个长连接根本不叫事;如果走的是 SSE 或 Long Polling,50 个、100 个连接迟早会暴露问题。

2.5 另一个隐藏因素:ASP.NET Core 版本和托管模式差异

不同版本的 ASP.NET Core 在 IIS 上的并发行为也有差别。比如 .NET Core 3.1 到 .NET 6、.NET 8,虽然整体模型一致,但在 ThreadPool 的默认策略和 ANCM 的转发细节上会有微调。另外,如果你的部署选择了进程外托管(Out-Of-Process),请求会先经过 IIS,再由 ANCM 转发给独立的 Kestrel 进程。这种模式下,IIS 和 Kestrel 之间有额外的进程间通信开销,同时 IIS 的一些机制可能会在传输升级时产生额外的握手消耗,也会影响连接临界值。

如果你不确定自己项目是进程内还是进程外托管,最简单的方法是看 web.config 里的 aspNetCore 节点。hostingModel="inprocess" 就是进程内,hostingModel="outofprocess" 就是进程外。

3. 排查实录:从复现到定位的完整过程

3.1 第一步:复现问题并确认连接方式和数量

排查任何这类问题,复现是第一位的。我写了一个简单的 SignalR 客户端模拟器,循环创建 30 个连接,连接到目标 Hub。每建立一个连接,就调用一次 Web API,观察是否出现阻塞。

复现的同时,在浏览器开发者工具里查看 SignalR 的 negotiate 请求和后续请求协议。如果能看到 101 Switching Protocols,说明走的是 WebSocket。如果看不到,而是一直有状态为 pending 的 GET 请求,那基本可以确定走的是 SSE 或 Long Polling。

如果项目是 C# 客户端,还可以在 HubConnectionBuilder 里直接指定传输方式来做对照试验:

var connection = new HubConnectionBuilder() .WithUrl("https://your-server/notificationHub", options => { options.Transports = HttpTransportType.WebSockets; options.SkipNegotiation = true; }) .WithAutomaticReconnect() .Build();

注意,SkipNegotiation 只有在你明确使用 WebSocket 传输时才可以使用。如果服务器端不支持 WebSocket,这种写法会直接抛异常,而不是自动回退。这在排查时非常有用:如果指定了 WebSocket 后全部连接失败,说明问题就出在 WebSocket 握手链路不通。

我在那个项目里做了同样的验证,发现客户端指定 WebSocket 传输后,连接全部失败。再回头查 IIS 功能,果然WebSocket Protocol 没有安装。问题到这里已经锁定了八成。

3.2 第二步:检查 IIS 的 WebSocket Protocol 功能

Windows Server 上 IIS 默认不会安装所有功能模块。WebSocket Protocol 属于“应用程序开发”类别下的一个子功能,如果在安装 IIS 时没有手动勾选,默认是缺失的。

可以用 PowerShell 检查当前是否安装了 WebSocket Protocol:

Get-WindowsFeature Web-WebSockets

如果 Installed 状态是 False,说明 IIS 缺少 WebSocket 支持。安装命令也很简单:

Install-WindowsFeature Web-WebSockets

安装完成后,建议重启 IIS:

iisreset

这里有一个容易踩的坑:WebSocket Protocol 的安装可能需要重启服务器或者至少重启 IIS 服务才能生效。如果只装了功能不重启,SignalR 握手仍然可能失败。

3.3 第三步:用 dotnet-counters 观察线程池状态

为了拿到更直接的数据,我使用了 dotnet-counters 这个诊断工具。如果你的服务器上还没装,可以先安装:

dotnet tool install --global dotnet-counters

然后找到应用进程 ID,开始监控线程池和 CPU 状态:

dotnet-counters monitor --process-id <PID> --counters System.Runtime

重点关注这几个指标:ThreadPool Thread Count(线程池当前线程数)、ThreadPool Queue Length(线程池队列长度)、CPU Usage(CPU 使用率)。

在复现阻塞时,观察到的现象往往是:线程池线程数在不停上涨,但队列长度也在增长,CPU 使用率却不高。这说明系统不是忙到算不过来,而是有大量线程被“卡住”了,典型特征就是线程饥饿。

如果 ThreadPool Queue Length 持续高于 0,而 Thread Count 已经达到一个较高值(比如几百),几乎可以断定代码或信号连接中存在着长时间占用线程的操作。

3.4 第四步:开启 stdout 日志确认 ANCM 的报错信息

在 IIS 上排查 ASP.NET Core 问题,一个很大的困惑是:应用崩溃或连接异常时,错误信息不会直接显示在浏览器里,而是被 ANCM(ASP.NET Core Module)吞掉了。这时候可以临时开启 stdout 日志来看 ANCM 层面的错误。

在 web.config 中修改 aspNetCore 节点:

<aspNetCore processPath="dotnet" arguments=".\YourApp.dll" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="inprocess"> <environmentVariables> <environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Development" /> </environmentVariables> </aspNetCore>

注意,保存 web.config 后 IIS 会自动重启应用,这个特性有时候很贴心,有时候也会造成连接闪断。开启日志后,让问题复现一次,再去 logs 目录下查看最新的 stdout 日志。日志里可能不会直接写“WebSocket 不可用”,但往往能看到连接被拒绝或升级失败的相关记录。

另外,如果你有 Failed Request Tracing 的权限,也可以配置一条跟踪规则,专门监听 HTTP 500 和 HTTP 502 请求,能够看到更详细的内部错误链。对于长期运行的生产环境,我建议在 Web.config 里保留 stdoutLogEnabled="false",改成按需开启,避免日志文件暴涨占用磁盘空间。

3.5 第五步:查看应用池的队列长度与回收策略

如果 WebSocket 已经安装了、代码也确认没有明显的同步阻塞,问题仍然存在,那就要看 IIS 应用程序池的配置了。

在 IIS 管理器中,右键应用程序池,选择“高级设置”,会看到几个关键参数:

  • 队列长度(Queue Length):默认 1000。如果请求堆积超过这个数,IIS 会直接返回 503。
  • 最大工作进程数(Maximum Worker Processes):默认 1。如果你的应用配置成了多个,需要额外注意会话一致性和 SignalR 跨进程消息的问题。SignalR 默认是无粘性的,多个进程时需要用 Redis Backplane 之类的方式同步消息。
  • 回收时间(Regular Time Interval):默认 1740 分钟,即 29 小时。但如果你在代码里手动调用了 GC.Collect 或某些特殊操作,应用池回收也会造成连接全断。

对于 SignalR 场景,不要在应用池上开启“重叠回收”,因为 SignalR 连接在回收期间会全部断开,客户端虽然会自动重连,但会产生一次明显的消息丢失窗口。合理的做法是把应用池回收时间改到凌晨低峰期,或者设置成根据虚拟内存/私有内存使用量来回收。

3.6 第六步:代码里的静态全局状态排查

线程池饥饿还有一个不太容易察觉的原因:SignalR Hub 中使用了静态变量或静态集合,并且没有加锁。比如一个在线用户列表,如果是用静态 Dictionary 维护,多个连接同时写入时会造成竞争。下面这种写法在并发下很危险:

public class NotificationHub : Hub { private static readonly Dictionary<string, string> _connections = new(); public override async Task OnConnectedAsync() { _connections[Context.ConnectionId] = Context.UserIdentifier; await base.OnConnectedAsync(); } }

如果写入时没有线程安全的保护,可能在低并发时没问题,但到一定并发量后触发内部哈希碰撞或扩容问题,导致请求卡住。这个和 IIS 无关,但会叠加在 SignalR 的并发问题上,让系统看起来更早到达临界点。建议要么使用 ConcurrentDictionary,要么给字典操作加锁。这里用 ConcurrentDictionary 就行:

private static readonly ConcurrentDictionary<string, string> _connections = new();

排查这一步时,可以在静态变量的写入逻辑处加日志,观察阻塞发生时这些静态操作是否耗时异常。

4. 解决方案:从 IIS 配置到代码改造的完整落地

4.1 优先保证 SignalR 走 WebSocket,而不是降级到 SSE

解决这个问题的第一优先级是让 SignalR 尽可能使用 WebSocket。只要传输模式是 WebSocket,就不会出现“每个客户端占住一个 HTTP 请求”的情况,后面讲的很多配置优化才真正有意义。

服务端确保支持 WebSocket 的代码很简单。在 Program.cs 或 Startup.cs 中,需要显式调用 UseWebSockets:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddSignalR(); var app = builder.Build(); app.UseWebSockets(); app.MapHub<NotificationHub>("/notificationHub"); app.Run();

这里有一个顺序问题:UseWebSockets 必须放在 UseRouting 之后、MapHub 之前调用,同时要放在任何可能短路请求的中间件之前,比如身份验证中间件通常要放在它之前,否则有些请求在验证时就被拦截了。

4.2 安装并验证 IIS 的 WebSocket 支持

前文已经提到安装命令,这里再整理一遍图形化操作路径:

  1. 打开“服务器管理器”。
  2. 点击“添加角色和功能”。
  3. 选择“基于角色或基于功能的安装”。
  4. 在“Web 服务器(IIS)”下,展开“Web 服务器” -> “应用程序开发”。
  5. 勾选“WebSocket 协议”。
  6. 完成安装后执行 iisreset。

安装完成后,可以用握手测试确认 WebSocket 已经启用。最直接的方式是通过浏览器的开发者工具观察 SignalR 连接是否返回 101 状态码。如果项目不是托管在默认站点下,而是部署在虚拟目录或使用 https 协议,要确认 WebSocket 的升级请求能正常到达应用,而不是被 URL Rewrite 等规则拦截了。

4.3 调整线程池最小线程数,提升系统抗突发能力

即使解决了 WebSocket 支持,我仍然不建议跳过线程池优化。因为 SignalR 的消息调度、心跳检测、断线重连等机制仍然会异步地使用线程池。生产环境中偶尔的线程池饥饿可能不表现为“10 连接就阻塞”,而是表现为“高峰期 CPU 不高但接口响应极慢”。

可以在应用启动早期调整线程池最小线程数。我会在 Program.cs 的 Main 方法最开始处加这样一段:

ThreadPool.GetMinThreads(out int workerThreads, out int ioThreads); ThreadPool.SetMinThreads(workerThreads + 100, ioThreads + 100);

这段代码的意思是把线程池最小工作线程数和 IO 线程数各增加 100。这里的 100 不是固定标准值,而是根据实际并发量来估算的。如果你的 SignalR 同时在线连接在 2000 以内,加 100 已经能有效缓解饥饿;如果更高,建议做压测来找到更适合的值。

但要注意,不要一次性把最小线程数调得过大,比如直接设成 1000。线程池的最小线程数决定了系统在请求峰值到来时能多快创建线程。设得太大,意味着程序一启动就会创建大量线程,白白消耗内存;设得太小,则在高并发时线程池会花时间慢慢加线程,表现为响应时间一路上升。

4.4 用配置来强制 SignalR 只走 WebSocket(可选方案)

如果在你的业务场景里,客户端环境完全可控(比如是自家公司的内部系统、PC 端浏览器统一为现代浏览器),可以考虑强制 SignalR 只使用 WebSocket,这样可以从根上消除 SSE 和 Long Polling 造成的请求占用问题。

服务端在 MapHub 之前加一个检查中间件:

app.Use(async (context, next) => { if (context.Request.Path.StartsWithSegments("/notificationHub") && !context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("WebSocket only."); return; } await next(); });

客户端也需要做对应的设置。在前端 JavaScript 中,创建连接时指定传输方式:

const connection = new signalR.HubConnectionBuilder() .withUrl("/notificationHub", signalR.HttpTransportType.WebSockets) .build();

如果你看到请求直接返回 400,而且服务端没有其他日志,那基本可以断定服务器端不支持 WebSocket,需要回过头检查 IIS 功能和网络代理。

这种强制方案的缺点也很明显:如果服务器前面还有一层负载均衡器(比如 F5、Nginx),需要负载均衡器也支持并放行 WebSocket 的升级请求。很多负载均衡器默认只转发普通 HTTP,对 Upgrade 头处理不当,会导致强制 WebSocket 后所有信号连接直接失败。因此在强制方案前,务必确认全链路都支持 WebSocket。

4.5 如果 WebSocket 不可用,退而求其次的调优方案

有些团队的技术栈限制比较大,比如客户端位于某些安全等级很高的内网环境,网关会屏蔽掉所有非 80/443 端口的协议升级。这种情况下 WebSocket 可能真的无法使用,SignalR 只能长期跑在 SSE 或 Long Polling 下。

这时要做的是尽量减小“每个连接占一个请求”的负面影响。可以从几个方向入手:

  1. 提高 ASP.NET Core 请求队列的处理能力,也就是合理地增加线程池最小线程数,让系统在 1000 个挂起请求时仍能保持部分线程响应 API。
  2. 给 SignalR Hub 方法设置合理的超时时间,避免某个连接长时间不释放。
  3. 将 SignalR 和 Web API 拆分到两个不同的应用池甚至两台服务器上,这样实时连接即使拖垮了 SignalR 应用池,也不会影响主站 API。

拆分方案是我比较推荐的。因为从架构上看,实时推送和 REST API 对资源的占用模型差异很大。实时推送是长连接密集型,REST API 是短请求密集型。混跑时,长连接容易饿死短请求。分开部署后,两边可以各自调优,互不干扰。

4.6 使用进程外托管模式减少 IIS 层影响

如果你排查了很久,发现是 IIS 层对连接升级或请求队列的处理有问题,而你的项目又恰好是进程内托管,可以考虑切换为进程外托管。

进程外托管的优势在于:ASP.NET Core 应用跑在独立的 Kestrel 进程中,IIS 只作为反向代理。这种模式下,请求在 IIS 和 Kestrel 之间多了一次转发,单请求性能会略降,但隔离性更好。如果你的应用本身没有性能瓶颈,只是被 IIS 的某些连接管理机制拖住,这种隔离可能会让问题消失。

修改 web.config:

<aspNetCore processPath="dotnet" arguments=".\YourApp.dll" stdoutLogEnabled="false" hostingModel="outofprocess"> </aspNetCore>

进程外托管模式下,SignalR 连接的实际承载者是 Kestrel,IIS 只负责将升级后的 WebSocket 流量透明转发。在 Windows 的 HTTP.sys 层,这种转发的处理效率和稳定性通常比进程内更好。缺点是进程外模式会额外占用一个进程的内存,部署结构相对复杂一些。

5. 常见问题与避坑经验速查表

5.1 连接数到临界点就整体卡死,怀疑是 IIS 限制

现象可能原因处理方法
连接数到 10-20 之后就阻塞SignalR 走 SSE/Long Polling,占满线程池安装 WebSocket Protocol,让 SignalR 升级为 WebSocket
纯内存 API 也卡死,CPU 却不高线程池饥饿调大线程池最小线程数,检查 Hub 内同步阻塞代码
浏览器 Network 里没有看到 101 状态码WebSocket 握手未完成检查 IIS 功能、反向代理的 Upgrade 头配置
请求直接返回 503应用池队列长度超限或应用无响应检查应用池队列长度,确认应用池未频繁回收
指定 WebSocket 传输后连接全失败服务器端不支持 WebSocket确认 Web-WebSockets 功能已安装

5.2 SignalR 断线自动重连时的隐藏坑

SignalR 的自动重连机制在客户端断线后会以指数退避的方式尝试重新连接。每次重连都会重新发起 negotiate 请求,如果客户端量非常大,重连风暴造成的瞬时并发会对服务器造成巨大压力。

我曾见过一个项目,因为半夜发布导致所有连接断开,客户端默认配置在断开后 0 秒、2 秒、10 秒、30 秒等时间点集中重连。当时同时在线约 5000 个客户端,重连风暴直接把 API 打垮。后来在客户端加了随机延迟:

const connection = new signalR.HubConnectionBuilder() .withUrl("/notificationHub") .withAutomaticReconnect([0, 1000, 5000, 15000, 30000]) .build();

同时也建议服务端配置 ClientTimeoutInterval 和 KeepAliveInterval,避免服务端因为网络抖动误杀活跃连接。一个常见的配置是:

services.AddSignalR(options => { options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); options.KeepAliveInterval = TimeSpan.FromSeconds(15); options.EnableDetailedErrors = true; });

5.3 Hub 生命周期与依赖注入的坑

ASP.NET Core 的 SignalR Hub 在每次方法调用时是瞬时创建的,不是单例。如果你在 Hub 的构造函数里注入了 DbContext,而 DbContext 本身是 Scoped,这通常没问题。但如果你在 Hub 里使用了 IHubContext 来从外部推送消息,并且这个 IHubContext 被注入到一个 Singleton 服务中,那么在这条调用链上使用 DbContext 就会出问题,因为 Singleton 服务无法拿到 Scoped 的 DbContext。

这种问题在高并发时不一定会立刻报错,但当连接数多了以后,可能会出现数据库连接被意外释放或线程阻塞的诡异现象。排查时可以看看 Hub 中是否有复杂的依赖链,如果有,尽量让 Hub 的方法只做消息转发,把业务逻辑下沉到独立的 Service 中,并明确生命周期。

5.4 使用反向代理时 WebSocket 升级头被过滤

如果你在 IIS 前面配置了 Nginx、ARR 或其他反代,需要确认以下请求头能够正确传递:

Upgrade: websocket Connection: Upgrade

在 Nginx 中,针对 WebSocket 的代理需要额外配置 Upgrade 头。虽然标题场景是 IIS,但很多企业网络是 IIS 外层还套一个 Nginx 统一入口。这种情况下,IIS 本身的 WebSocket 配置正确还不够,外层 Nginx 也得放行。如果你在生产环境看到“服务器已安装 WebSocket 功能,但客户端始终无法升级”的情况,大概率就是反代层过滤了 Upgrade 请求头。

5.5 不要忽视 DNS 和负载均衡层的超时设置

SignalR 长连接在传输层上是保持不关闭的。很多负载均衡器默认有一个“空闲连接超时”,比如 60 秒内无数据传输就自动断开连接。如果 SignalR 的心跳间隔大于这个超时时间,连接会被负载均衡器静默切断。客户端表现为连接断开,随后自动重连,重连成功后再次被切断,形成周期性断连。

解决方法是调节 SignalR 的心跳频率,或者把负载均衡器的空闲超时时间调大。服务端和客户端都有对应的 KeepAlive 配置,必须保证心跳间隔小于负载均衡器的空闲超时时长。我建议在部署前先向网络团队确认反代层的空闲连接超时时间,再反推 SignalR 的心跳间隔设置。

6. 最后一次压测验证与实际心得

解决完全部问题后,用压测工具模拟了 2000 个 SignalR 并发连接,同时持续调用 Web API。这次的现象和之前完全不一样了:API 不会阻塞,SignalR 连接也都稳定在 WebSocket 状态。最初那台服务器配置并不高,4 核 8GB 内存,通过合理配置后,承载 2000 个长连接没有太大压力。

回顾这次排查,我最大的体会是:遇到 IIS + .NET Core + SignalR 的组合问题,不要一上来就怀疑 IIS 版本或框架 bug。90% 的情况是传输模式没有按预期走 WebSocket,或者代码中某个不起眼的同步阻塞放大了并发问题。下次你再看到“连接数到 10 个就卡死”这类描述,第一件事就去确认浏览器 Network 面板里有没有 101 Switching Protocols。如果始终没有,那跟线程池较劲没意义,先把 WebSocket 的握手链路打通再说。SignalR 本身的设计是支持大规模长连接的,IIS 上要做的只是把这条通道正确建起来,别让它降级到 HTTP 轮询模式去硬扛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询