news 2026/10/16 10:10:44

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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.com");
  • EnableJavaScriptProxies = 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 ConcurrentDictionary<string, string> _users = new ConcurrentDictionary<string, 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替代Dictionary<string,string>:OnConnected和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 Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="ChatRoomWebForms.Default" %> <!DOCTYPE html> <html> <head runat="server"> <title>ASP.NET 聊天室</title> <script src="Scripts/jquery-3.6.0.min.js"></script> <script src="Scripts/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 id="form1" runat="server"> <div> <h2>在线聊天室(ASP.NET SignalR 2.4.3)</h2> <div> <label>昵称:<input type="text" id="txtName" value="游客" /></label> <label>消息:<input type="text" id="txtMsg" placeholder="按回车发送" /></label> <button type="button" id="btnSend">发送</button> </div> <div id="chatWindow"></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 class="message ${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/hubs;
  • chat.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时,按顺序排查:

  1. 检查Global.asax.cs中MapHubs()是否执行:在Application_Start开头加System.Diagnostics.Debug.WriteLine("SignalR registered");,启动调试时看输出窗口是否有该日志;
  2. 确认web.config中system.webServer节点包含handlers配置(VS 新建项目通常已包含,但手动创建项目可能缺失):
    <system.webServer> <handlers> <add name="SignalR" path="signalr" verb="*" type="Microsoft.AspNet.SignalR.Hosting.AspNetHandler" preCondition="integratedMode" /> </handlers> </system.webServer>
  3. 禁用浏览器扩展干扰:某些广告拦截插件(如 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};Version=3;")) { 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};Version=3;")) { 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 List<string[]>(); using (var conn = new SQLiteConnection($"Data Source={DbPath};Version=3;")) { 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[][]而非List<Message>:减少序列化开销,前端 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 class="message ${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 2016,IIS 默认创建的应用池是“经典”模式,查了 3 小时才发现MapHubs()根本没执行,Global.asax中的Debug.WriteLine一行日志都没输出。

5.2 IIS 必须开启 WebSocket 协议(Windows Server 2012 R2+)

SignalR 2.x 默认优先使用 WebSocket 传输,若 IIS 未启用,会自动降级为 Server-Sent Events(SSE)或长轮询(Long Polling),导致延迟升高、CPU 占用飙升。

操作路径:服务器管理器 → 添加角色和功能 → “功能” → “WebSocket 协议” → 勾选并安装。安装后重启 IIS(iisreset)。

验证方式:浏览器打开 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 秒内未收到任何帧,主动断开并触发OnDisconnected;
  • KeepAlive = 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};Version=3;")) { 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 List<string[]> GetAndDeleteOfflineMessages(string connectionId) { var result = new List<string[]>(); using (var conn = new SQLiteConnection($"Data Source={DbPath};Version=3;")) { 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 的礼貌提醒,不是契约。

希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 10:10:26

Linux性能调优实战:从监控工具到内核参数与容量规划

最近在准备 RHCA II 的最后一门 EX442&#xff08;性能调优与容量规划&#xff09;&#xff0c;相关笔记已经写到第 13 篇。说句实话&#xff0c;刚看这个标题时我也觉得“调优”离我很远&#xff0c;但真正动手做下来&#xff0c;发现它比想象中落地得多——不是在系统里随便改…

作者头像 李华
网站建设 2026/10/16 10:10:06

九个原生浏览器API,替代JS库实现前端性能优化与代码瘦身

先说一个我最近越来越强烈的感觉&#xff1a;很多前端团队还在手里揣着“老黄历”写代码。我说得直接一点&#xff0c;现在的 HTML 已经不是当年那个只能渲染静态标签的 HTML 了&#xff0c;它底层挂了一堆系统级接口。过去你得靠各种 JS 库才能实现的效果&#xff0c;现在浏览…

作者头像 李华
网站建设 2026/10/16 10:10:05

Gradle 7.2-all.zip 全量包详解:Android 构建兼容性与离线部署实战

简介&#xff1a;本资源为Gradle 7.2版本的完整离线发行包&#xff08;gradle-7.2-all.zip&#xff09;&#xff0c;专为Android开发者、Java工程构建人员及Android Studio用户设计&#xff0c;旨在解决国内网络环境下Gradle在线下载缓慢、超时或失败导致的项目编译卡顿问题。压…

作者头像 李华
网站建设 2026/10/16 10:10:05

从0到1搭建智慧机械管理平台:设备数据采集、报警与健康度落地方案

我们做设备管理平台的时候&#xff0c;第一反应往往是想把“可视化大屏”和“数据中台”先立起来&#xff0c;但真正从0到1把一个智慧机械管理平台跑通之后&#xff0c;我的真实感受是&#xff1a;最核心的竞争力根本不在那些漂亮的页面里&#xff0c;而在你如何理解车间里那一…

作者头像 李华
网站建设 2026/10/16 10:09:40

端侧AI Agent重构实战:从1.2GB到280MB的移动端落地之旅

如果你最近在关注端侧 AI&#xff0c;大概率听说过那套被圈内俗称“大龙虾”的开源 Agent 框架——功能真叫一个全&#xff1a;意图识别、任务规划、工具调用、记忆管理、云端模型网关&#xff0c;几乎把 Agent 全家桶都塞进了一个进程&#xff0c;钳子够大、壳也够厚。前阵子我…

作者头像 李华
网站建设 2026/10/16 10:09:01

造价人深夜死磕文档到底在磕什么?一份实战工作法

凌晨一点&#xff0c;办公室只剩空调的低鸣。屏幕上计价软件的界面定格在“暂列金额”这一行&#xff0c;旁边的Excel里交错着好几张表&#xff1a;清单工程量、材料价格、取费汇总&#xff0c;某一处合计还在闪烁黄色高亮&#xff0c;提醒我自己那里还有一处没核实完。窗外是小…

作者头像 李华