这个标题里的现象,我印象太深了。之前帮朋友排查一个部署在 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 方法里有同步阻塞调用,线程池线程会被快速耗尽,新请求自然进不来。
我先做了三件事:
- 用日志记录每个请求的进入时间和离开时间,看是根本没进来,还是进来了但处理很慢。
- 把数据库、Redis 等外部依赖全部暂时屏蔽,测一个纯内存返回的接口,看是否还会卡。
- 检查所有 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 支持
前文已经提到安装命令,这里再整理一遍图形化操作路径:
- 打开“服务器管理器”。
- 点击“添加角色和功能”。
- 选择“基于角色或基于功能的安装”。
- 在“Web 服务器(IIS)”下,展开“Web 服务器” -> “应用程序开发”。
- 勾选“WebSocket 协议”。
- 完成安装后执行 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 下。
这时要做的是尽量减小“每个连接占一个请求”的负面影响。可以从几个方向入手:
- 提高 ASP.NET Core 请求队列的处理能力,也就是合理地增加线程池最小线程数,让系统在 1000 个挂起请求时仍能保持部分线程响应 API。
- 给 SignalR Hub 方法设置合理的超时时间,避免某个连接长时间不释放。
- 将 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 轮询模式去硬扛。