ASP.NET SignalR 2.4.3 聊天室实战:解决线程安全、状态持久化与IIS部署坑

📅 发布时间:2026/10/16 10:10:47
ASP.NET SignalR 2.4.3 聊天室实战:解决线程安全、状态持久化与IIS部署坑
简介这是一份基于ASP.NET开发的轻量级在线聊天程序源码面向C#初学者与Web开发入门者适用于快速理解即时通信IM基础逻辑、客服系统雏形搭建及前后端协同交互原理。资源包共14个文件含3个核心C#业务逻辑文件如MsgList.cs、Default.aspx.cs、3个JavaScript脚本Default.js、Common.js、Ajax.js负责前端交互与异步消息收发2个ASPX页面实现用户界面辅以CSS样式、Web.config配置及编译生成的DLL与PDB调试文件整体仅16KB结构紧凑、依赖精简便于逐层剖析。已有237人学习下载适合新手通过可运行实例掌握Session管理、AJAX轮询、页面间数据传递等关键Web开发技能并可作为二次开发起点拓展为多用户群聊或集成至现有企业网站客服模块。1. 为什么用 ASP.NET 写一个“简单”在线聊天程序反而最容易踩进线程、状态和部署三重坑很多人第一次想做个能跑通的实时聊天界面看到“ASP.NET 在线聊天源码”就直接下载 ZIP 解压——结果本地 IIS Express 能发消息一上服务器就断连或者两人同时发消息对方收不到更常见的是页面刷新后聊天记录全丢用户以为系统崩了。这不是代码写得烂而是 ASP.NET 的请求生命周期、Session 默认行为、以及 WebForms/MVC/Blazor 混用场景下对“状态”的理解错位导致的典型翻车。这个标题里的“简单”恰恰是最容易让人放松警惕的陷阱它不考算法不拼模型但直击 Web 开发中三个最隐蔽的硬伤——HTTP 无状态与实时交互的天然矛盾、服务器内存状态的生命周期管理、以及 .NET 框架版本迁移时 SignalR 配置的静默失效。适合刚从 WinForms 或控制台转过来、熟悉 C# 但没系统做过 Web 实时交互的开发者也适合需要快速交付内部协作 Demo 的中小团队。本文不讲 WebSocket 协议原理也不堆砌 SignalR Hub 的所有 API只聚焦一个可立即运行、可验证、可部署到 Windows Server/IIS 的最小可行方案基于 ASP.NET Framework 4.7.2 SignalR 2.4.3 的单页聊天室含完整状态持久化兜底逻辑。2. 从零搭起可运行的 ASP.NET 聊天骨架项目创建、SignalR 初始化与基础 Hub 定义2.1 创建兼容 SignalR 2.x 的传统 ASP.NET Web Forms 项目非 Core提示必须选ASP.NET Web Forms Application不是 MVC更不是 .NET Core/5。SignalR 2.x 与 .NET Framework 绑定紧密强行在 Core 项目里降级引用会触发System.Web缺失错误这是新手第一道墙。打开 Visual Studio 2019或 VS2022 启用 .NET Framework 工具集新建项目 → 选择ASP.NET Web Forms Application→ 目标框架选.NET Framework 4.7.2低于 4.6.1 可能缺async/await支持高于 4.8 在某些旧 IIS 上有兼容问题。项目名建议用ChatRoomWebForms避免空格和中文。创建后通过 NuGet 包管理器控制台执行Install-Package Microsoft.AspNet.SignalR -Version 2.4.3 Install-Package jQuery -Version 3.6.0注意Microsoft.AspNet.SignalR是 SignalR 2.x 的官方包而Microsoft.AspNetCore.SignalR是 Core 版本二者完全不兼容。版本锁死2.4.3是因为它是最后一个支持 IE10 且无重大内存泄漏的稳定版社区实测在 Windows Server 2012 R2 IIS 8.5 下长期运行 7×24 小时无连接堆积。安装完成后项目自动添加Scripts/jquery-3.6.0.min.js和Scripts/jquery.signalR-2.4.3.min.js。若未生成手动右键“Scripts”文件夹 → “添加” → “现有项”从packages\jQuery.3.6.0\Content\Scripts\下引入。2.2 注册 SignalR 路由并启用跨域关键否则前端连不上在Global.asax.cs的Application_Start方法中必须在RouteConfig.RegisterRoutes(RouteTable.Routes)之前插入 SignalR 路由注册// Global.asax.cs protected void Application_Start(object sender, EventArgs e) { // ✅ 正确顺序先注册 SignalR再注册 MVC/WebForms 路由 RouteTable.Routes.MapHubs(new HubConfiguration() { EnableCrossDomain true, // 允许前端页面不在同一域名下访问如 localhost:3000 调 localhost:5000 EnableJavaScriptProxies true // 自动生成 /signalr/hubs 代理脚本 }); // ❌ 错误放在这里会导致 Hub 路由被覆盖 // RouteConfig.RegisterRoutes(RouteTable.Routes); }参数说明EnableCrossDomain true底层调用HttpContext.Current.Response.AppendHeader(Access-Control-Allow-Origin, *)解决前端静态页如file://协议或 Vue/React 开发服务器跨域问题生产环境应替换为具体域名如https://admin.example.comEnableJavaScriptProxies true启用后浏览器可通过script src/signalr/hubs/script自动加载强类型 Hub 代理省去手写$.connection.chatHub的字符串拼接降低 typo 导致的ReferenceError概率。2.3 定义 ChatHub仅保留最简方法拒绝过度设计在项目根目录新建文件夹Hubs添加类ChatHub.cs// Hubs/ChatHub.cs using Microsoft.AspNet.SignalR; using System; using System.Collections.Concurrent; using System.Threading.Tasks; public class ChatHub : Hub { // ✅ 使用 ConcurrentDictionary 线程安全存储在线用户Key: ConnectionId, Value: UserName private static readonly ConcurrentDictionarystring, string _users new ConcurrentDictionarystring, string(); // ✅ 客户端调用此方法发送消息服务端广播给所有人含自己 public void Send(string message, string userName) { var connectionId Context.ConnectionId; var displayName _users.GetOrAdd(connectionId, userName); // 广播消息Clients.All.broadcastMessage(displayName, message, DateTime.Now.ToString(HH:mm:ss)); Clients.All.broadcastMessage(displayName, message, DateTime.Now.ToString(HH:mm:ss)); } // ✅ 连接建立时触发用于记录用户上线 public override Task OnConnected() { // 不在此处设置用户名用户名应由客户端首次 Send 时传入避免伪造 return base.OnConnected(); } // ✅ 连接断开时清理字典防止内存泄漏 public override Task OnDisconnected(bool stopCalled) { string unused; _users.TryRemove(Context.ConnectionId, out unused); return base.OnDisconnected(stopCalled); } }关键逻辑说明ConcurrentDictionary替代Dictionarystring,stringOnConnected和OnDisconnected可能被不同线程并发调用普通 Dictionary 会抛InvalidOperationException绝不信任客户端传来的用户名Send()方法中才用GetOrAdd设置用户名确保每个 ConnectionId 对应唯一标识防止 A 用户伪造 B 的名字发消息Clients.All.broadcastMessage(...)是 SignalR 2.x 的标准广播语法参数顺序必须与 JS 端client.broadcastMessage function(name, msg, time) { ... }严格一致否则回调不触发。3. 前端页面实现HTML jQuery SignalR 代理调用绕过 TypeScript 编译陷阱3.1 创建 Default.aspx纯 HTML 结构零后端代码块干扰删除Default.aspx中自动生成的asp:Content和所有% %服务端代码只保留标准 HTML 骨架!-- Default.aspx -- % Page LanguageC# AutoEventWireuptrue CodeBehindDefault.aspx.cs InheritsChatRoomWebForms.Default % !DOCTYPE html html head runatserver titleASP.NET 聊天室/title script srcScripts/jquery-3.6.0.min.js/script script srcScripts/jquery.signalR-2.4.3.min.js/script script src/signalr/hubs/script !-- ✅ 自动生成的代理脚本路径必须是 /signalr/hubs -- style #chatWindow { height: 400px; overflow-y: scroll; border: 1px solid #ccc; padding: 10px; } .message { margin: 5px 0; } .self { color: #007acc; } .other { color: #333; } /style /head body form idform1 runatserver div h2在线聊天室ASP.NET SignalR 2.4.3/h2 div label昵称input typetext idtxtName value游客 //label label消息input typetext idtxtMsg placeholder按回车发送 //label button typebutton idbtnSend发送/button /div div idchatWindow/div /div /form script $(function () { // ✅ 1. 创建 Hub 连接对象注意大小写chatHub 对应 ChatHub 类名 var chat $.connection.chatHub; // ✅ 2. 定义客户端接收广播的方法方法名必须与 Hub 中 Clients.All.broadcastMessage 参数名一致 chat.client.broadcastMessage function (name, msg, time) { var $chatWindow $(#chatWindow); var isSelf name $(#txtName).val(); var cls isSelf ? self : other; $chatWindow.append(div classmessage ${cls}strong[${time}] ${name}/strong${msg}/div); $chatWindow.scrollTop($chatWindow[0].scrollHeight); // 滚动到底部 }; // ✅ 3. 启动连接必须在定义 client 方法之后 $.connection.hub.start().done(function () { console.log(SignalR 连接已建立ConnectionId, $.connection.hub.id); $(#txtMsg).focus(); }).fail(function (e) { alert(连接失败请检查浏览器控制台错误 e); }); // ✅ 4. 发送消息事件绑定支持回车和按钮点击 $(#btnSend, #txtMsg).on(click keypress, function (e) { if (e.type keypress e.which ! 13) return; // 非回车键忽略 e.preventDefault(); var name $(#txtName).val().trim(); var msg $(#txtMsg).val().trim(); if (!name || !msg) return; // ✅ 调用 Hub 的 Send 方法参数顺序必须与 ChatHub.Send 方法一致 chat.server.send(msg, name); $(#txtMsg).val().focus(); }); }); /script /body /html关键细节解析script src/signalr/hubs的路径是硬编码的不能改成~/signalr/hubs或相对路径SignalR 2.x 路由引擎只认/signalr/hubschat.client.broadcastMessage的函数名必须小写开头且与Clients.All.broadcastMessage(...)中的broadcastMessage完全一致SignalR 通过字符串反射匹配大小写错误即静默失效chat.server.send(msg, name)的send必须小写对应ChatHub.Send()方法名C# 中方法名首字母大写但 SignalR 代理自动转为小写$.connection.hub.start()必须在chat.client.xxx定义之后调用否则客户端回调注册失败消息来了也没人处理。3.2 验证连接是否成功三步定位前端连不上 Hub 的根源当页面打开后控制台报Failed to load resource: the server responded with a status of 404 (Not Found)指向/signalr/hubs时按顺序排查检查Global.asax.cs中MapHubs()是否执行在Application_Start开头加System.Diagnostics.Debug.WriteLine(SignalR registered);启动调试时看输出窗口是否有该日志确认web.config中system.webServer节点包含handlers配置VS 新建项目通常已包含但手动创建项目可能缺失system.webServer handlers add nameSignalR pathsignalr verb* typeMicrosoft.AspNet.SignalR.Hosting.AspNetHandler preConditionintegratedMode / /handlers /system.webServer禁用浏览器扩展干扰某些广告拦截插件如 uBlock Origin会屏蔽/signalr/路径临时禁用后重试。4. 状态持久化兜底当 IIS 应用池回收时如何不让聊天记录凭空消失4.1 为什么默认内存存储在生产环境必然丢失IIS 应用池默认29 小时回收一次可在 IIS 管理器 → 应用池 → 高级设置 → “定期回收工作进程” 查看回收时整个 w3wp.exe 进程重启ConcurrentDictionary中所有在线用户和历史消息全部清空。用户正在打字页面突然断连刷新后发现“刚才聊的全没了”体验灾难。这不是 Bug是 ASP.NET 的设计哲学内存即瞬态持久化必须显式落地。4.2 用轻量级 SQLite 实现消息历史存档零配置、免服务、单文件提示不用 SQL Server —— 小型聊天室不需要高并发事务不用 Redis —— 多一层运维复杂度SQLite 文件数据库完美匹配“单机、低写入、需重启不丢数据”场景。通过 NuGet 安装Install-Package System.Data.SQLite -Version 1.0.115.5 Install-Package System.Data.SQLite.Core -Version 1.0.115.5在App_Code文件夹若无则新建中添加ChatRepository.cs// App_Code/ChatRepository.cs using System; using System.Data.SQLite; using System.IO; using System.Text; public class ChatRepository { private static readonly string DbPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, App_Data, chat.db); static ChatRepository() { // ✅ 确保 App_Data 目录存在SQLite 文件自动创建 Directory.CreateDirectory(Path.GetDirectoryName(DbPath)); InitializeDatabase(); } private static void InitializeDatabase() { using (var conn new SQLiteConnection($Data Source{DbPath};Version3;)) { conn.Open(); using (var cmd conn.CreateCommand()) { cmd.CommandText CREATE TABLE IF NOT EXISTS Messages ( Id INTEGER PRIMARY KEY AUTOINCREMENT, UserName TEXT NOT NULL, Message TEXT NOT NULL, Timestamp DATETIME DEFAULT CURRENT_TIMESTAMP );; cmd.ExecuteNonQuery(); } } } // ✅ 插入新消息供 ChatHub.Send() 调用 public static void SaveMessage(string userName, string message) { using (var conn new SQLiteConnection($Data Source{DbPath};Version3;)) { conn.Open(); using (var cmd conn.CreateCommand()) { cmd.CommandText INSERT INTO Messages(UserName, Message) VALUES(user, msg); cmd.Parameters.AddWithValue(user, userName); cmd.Parameters.AddWithValue(msg, message); cmd.ExecuteNonQuery(); } } } // ✅ 查询最近 50 条消息供页面首次加载时拉取历史 public static string[][] GetRecentMessages(int count 50) { var result new Liststring[](); using (var conn new SQLiteConnection($Data Source{DbPath};Version3;)) { conn.Open(); using (var cmd conn.CreateCommand()) { cmd.CommandText SELECT UserName, Message, strftime(%H:%M:%S, Timestamp) FROM Messages ORDER BY Id DESC LIMIT count; cmd.Parameters.AddWithValue(count, count); using (var reader cmd.ExecuteReader()) { while (reader.Read()) { result.Add(new string[] { reader.GetString(0), // UserName reader.GetString(1), // Message reader.GetString(2) // Time }); } } } } return result.ToArray(); } }参数说明App_Data是 ASP.NET 的专用数据目录IIS 默认禁止外部 HTTP 访问该文件夹保障chat.db文件不被直接下载strftime(%H:%M:%S, Timestamp)直接在 SQL 层格式化时间避免 C# 层DateTime.Parse的时区转换风险GetRecentMessages()返回string[][]而非ListMessage减少序列化开销前端 JS 直接JSON.parse()后遍历渲染。4.3 修改 ChatHub在 Send 时同步写库并暴露历史消息获取端点在ChatHub.cs中添加GetHistory方法并修改Send// Hubs/ChatHub.cs续 public class ChatHub : Hub { // ... 前面的字段和方法保持不变 ... // ✅ 新增供客户端首次连接时拉取历史消息 public string[][] GetHistory(int count 50) { return ChatRepository.GetRecentMessages(count); } // ✅ 修改 Send 方法发送消息时同步落库 public void Send(string message, string userName) { var connectionId Context.ConnectionId; var displayName _users.GetOrAdd(connectionId, userName); // ✅ 先落库再广播保证即使广播失败消息也不丢 ChatRepository.SaveMessage(displayName, message); Clients.All.broadcastMessage(displayName, message, DateTime.Now.ToString(HH:mm:ss)); } }4.4 前端首次加载时拉取历史消息在Default.aspx的script块中$.connection.hub.start().done(...)回调内追加// Default.aspx script 块内接在 hub.start().done(...) 后面 $.connection.hub.start().done(function () { console.log(SignalR 连接已建立ConnectionId, $.connection.hub.id); // ✅ 连接成功后立即拉取历史消息 chat.server.getHistory(50).done(function (history) { var $chatWindow $(#chatWindow); history.forEach(function (item) { var isSelf item[0] $(#txtName).val(); var cls isSelf ? self : other; $chatWindow.append(div classmessage ${cls}strong[${item[2]}] ${item[0]}/strong${item[1]}/div); }); $chatWindow.scrollTop($chatWindow[0].scrollHeight); }).fail(function (e) { console.warn(获取历史消息失败可能是首次运行无数据, e); }); $(#txtMsg).focus(); });注意chat.server.getHistory(50)的getHistory方法名必须小写对应ChatHub.GetHistory()返回的history是二维数组item[0]是用户名item[1]是消息内容item[2]是格式化后的时间字符串。5. 部署到 IIS 的避坑指南应用池配置、权限、SignalR 超时与 HTTPS 兼容性5.1 应用池必须设为 .NET CLR 版本 v4.0且“托管管道模式”选“集成”在 IIS 管理器中右键你的网站 → “基本设置” → “应用程序池” → 选择或新建一个应用池配置项推荐值错误值后果.NET CLR 版本v4.0.30319设为 v2.0 → SignalR 2.x 的async方法无法编译500 错误托管管道模式集成设为“经典” →Global.asax中Application_Start不触发MapHubs()/signalr/hubs404启用 32 位应用程序True若服务器为 32 位或引用了 32 位 DLL设为 False → SQLite 的System.Data.SQLite.dll加载失败DllNotFoundException血泪经验某次部署到 Windows Server 2016IIS 默认创建的应用池是“经典”模式查了 3 小时才发现MapHubs()根本没执行Global.asax中的Debug.WriteLine一行日志都没输出。5.2 IIS 必须开启 WebSocket 协议Windows Server 2012 R2SignalR 2.x 默认优先使用 WebSocket 传输若 IIS 未启用会自动降级为 Server-Sent EventsSSE或长轮询Long Polling导致延迟升高、CPU 占用飙升。操作路径服务器管理器 → 添加角色和功能 → “功能” → “WebSocket 协议” → 勾选并安装。安装后重启 IISiisreset。验证方式浏览器打开 F12 控制台 → Network 标签 → 刷新页面 → 查看ws://yoursite/signalr/connect?...连接状态为101 Switching Protocols即 WebSocket 成功握手。5.3 解决 HTTPS 下 SignalR 连接被拦截问题当网站启用 HTTPS 后前端 JS 中$.connection.hub.url默认仍是http://导致混合内容Mixed Content被浏览器阻止。在Default.aspx的script块顶部强制指定 Hub URL// Default.aspx script 块最开头 $(function () { // ✅ 强制 Hub 使用当前页面协议http 或 https $.connection.hub.url window.location.protocol // window.location.host /signalr; // 后续保持不变... var chat $.connection.chatHub; // ... });5.4 SignalR 连接超时与心跳配置防假死SignalR 2.x 默认ConnectionTimeout为 110 秒KeepAlive为 10 秒。在弱网或 NAT 网关环境下连接可能被中间设备静默断开但客户端未感知。在Global.asax.cs的Application_Start中增强配置// Global.asax.cs protected void Application_Start(object sender, EventArgs e) { var config new HubConfiguration { EnableCrossDomain true, EnableJavaScriptProxies true }; // ✅ 缩短超时加快故障发现 config.EnableDetailedErrors false; // 生产环境关闭详细错误 GlobalHost.Configuration.ConnectionTimeout TimeSpan.FromSeconds(30); GlobalHost.Configuration.KeepAlive TimeSpan.FromSeconds(5); RouteTable.Routes.MapHubs(config); }参数说明ConnectionTimeout 30s客户端 30 秒内未收到任何帧主动断开并触发OnDisconnectedKeepAlive 5s服务端每 5 秒发一次空心跳包穿透 NAT 设备保活EnableDetailedErrors false避免将服务器路径、异常堆栈暴露给前端符合安全基线。6. 进阶技巧用 SignalR Groups 实现私聊与房间隔离以及离线消息推送机制6.1 用 Groups 实现“加入房间”和“私聊”两个刚需场景SignalR 的Groups功能允许将 ConnectionId 动态加入命名组比硬编码Clients.Client(id)更灵活。我们扩展ChatHub支持用户创建/加入房间并向房间内广播// Hubs/ChatHub.cs续 public class ChatHub : Hub { // ... 前面字段保持不变 ... // ✅ 加入指定房间房间名由客户端传入如 room1 public void JoinRoom(string roomName) { Groups.Add(Context.ConnectionId, roomName); Clients.Caller.joinedRoom(roomName); // 通知自己已加入 Clients.Group(roomName).userJoined(Context.ConnectionId, roomName); // 通知房间内其他人 } // ✅ 向指定房间发送消息替代全局 Send public void SendToRoom(string message, string userName, string roomName) { var displayName _users.GetOrAdd(Context.ConnectionId, userName); ChatRepository.SaveMessage(displayName, $[{roomName}] {message}); // 存档带房间前缀 Clients.Group(roomName).broadcastMessage(displayName, message, DateTime.Now.ToString(HH:mm:ss)); } // ✅ 私聊只发给指定 ConnectionId需双方都在线 public void SendPrivate(string message, string userName, string targetConnectionId) { var displayName _users.GetOrAdd(Context.ConnectionId, userName); // ✅ 双向记录自己发的 对方收的 ChatRepository.SaveMessage(${displayName} → {targetConnectionId}, message); Clients.Client(targetConnectionId).privateMessage(displayName, message, DateTime.Now.ToString(HH:mm:ss)); Clients.Caller.privateMessage(${displayName}(你), message, DateTime.Now.ToString(HH:mm:ss)); } }前端调用示例在Default.aspx的 JS 中// 加入房间 chat.server.joinRoom(tech-discuss); // 向房间发消息 chat.server.sendToRoom(今天 SignalR 文档写得很清楚, 张工, tech-discuss); // 私聊需提前知道对方 ConnectionId实际项目中可用用户名查表 chat.server.sendPrivate(会议改到下午3点, 张工, f8a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5);6.2 离线消息推送当目标用户不在线时把消息暂存并下次上线时推送SignalR 本身不提供离线消息队列需自行实现。我们利用 SQLite 的原子性新增一张OfflineMessages表// App_Code/ChatRepository.cs续 public static void SaveOfflineMessage(string targetConnectionId, string fromUser, string message) { using (var conn new SQLiteConnection($Data Source{DbPath};Version3;)) { conn.Open(); using (var cmd conn.CreateCommand()) { cmd.CommandText INSERT INTO OfflineMessages(TargetConnectionId, FromUser, Message, CreatedAt) VALUES(target, from, msg, datetime(now)); ; cmd.Parameters.AddWithValue(target, targetConnectionId); cmd.Parameters.AddWithValue(from, fromUser); cmd.Parameters.AddWithValue(msg, message); cmd.ExecuteNonQuery(); } } } public static Liststring[] GetAndDeleteOfflineMessages(string connectionId) { var result new Liststring[](); using (var conn new SQLiteConnection($Data Source{DbPath};Version3;)) { conn.Open(); using (var trans conn.BeginTransaction()) { using (var cmd conn.CreateCommand()) { cmd.Transaction trans; cmd.CommandText SELECT FromUser, Message, strftime(%H:%M:%S, CreatedAt) FROM OfflineMessages WHERE TargetConnectionId id ORDER BY CreatedAt ASC; ; cmd.Parameters.AddWithValue(id, connectionId); using (var reader cmd.ExecuteReader()) { while (reader.Read()) { result.Add(new string[] { reader.GetString(0), reader.GetString(1), reader.GetString(2) }); } } } // ✅ 读完立即删除保证只投递一次 using (var cmd conn.CreateCommand()) { cmd.Transaction trans; cmd.CommandText DELETE FROM OfflineMessages WHERE TargetConnectionId id; cmd.Parameters.AddWithValue(id, connectionId); cmd.ExecuteNonQuery(); } trans.Commit(); } } return result; }然后在ChatHub.OnConnected()中触发离线消息投递public override Task OnConnected() { // ✅ 上线时检查并推送离线消息 var offlineMsgs ChatRepository.GetAndDeleteOfflineMessages(Context.ConnectionId); foreach (var msg in offlineMsgs) { Clients.Caller.privateMessage(msg[0], msg[1], msg[2]); } return base.OnConnected(); }关键设计点使用BEGIN TRANSACTION保证“读-删”原子性避免多实例部署时重复投递ORDER BY CreatedAt ASC确保消息按时间顺序送达符合用户预期privateMessage回调需在前端 JS 中预先定义chat.client.privateMessage function(from, msg, time) { ... };6.3 最后一条血泪经验永远在OnDisconnected中做资源清理而不是依赖Dispose()曾有个项目开发者在ChatHub构造函数中启动了一个Timer每秒检查用户活跃度却在Dispose()中停止它。结果 IIS 应用池回收时Dispose()并不总被调用Timer继续运行疯狂往日志写“用户已掉线”CPU 占用 100%。正确做法是public class ChatHub : Hub { private Timer _heartbeatTimer; public override Task OnConnected() { // 启动定时器如果需要 _heartbeatTimer new Timer(CheckUserActivity, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); return base.OnConnected(); } public override Task OnDisconnected(bool stopCalled) { // ✅ 所有清理逻辑必须放在这里 _heartbeatTimer?.Change(Timeout.Infinite, Timeout.Infinite); _heartbeatTimer?.Dispose(); _heartbeatTimer null; string unused; _users.TryRemove(Context.ConnectionId, out unused); return base.OnDisconnected(stopCalled); } }这不是最佳实践而是生存法则OnConnected/OnDisconnected是 SignalR 生命周期中唯一可靠、必执行的钩子Dispose()是 .NET GC 的礼貌提醒不是契约。希望帮到你。本文还有配套的精品资源点击获取