Azure SignalR限制用户多标签页重复连接异常排查
问题:SignalR多标签页连接管理异常,奇数新标签页无法断开旧连接
我使用SignalR从服务器向特定登录用户发送消息,用户在不同标签页打开同一URL会产生额外Azure SignalR连接,增加成本。于是实现了新标签页打开时关闭旧连接的逻辑,能正常识别用户,但出现奇怪现象:第1、3、5等奇数个新标签页不会断开旧连接,第2、4等偶数个可以。最初用字典存储连接信息,改用MemoryCache后问题依旧。
前端代码
<script> const userIdentifier = '@objectIdClaim';//users unique id const connection = new signalR.HubConnectionBuilder() .withUrl(`/webhub?username=${userIdentifier}`, { accessTokenFactory: () => this.loginToken }) .configureLogging(signalR.LogLevel.Trace) .build(); // Handle server messages connection.on("SendMessageToUser", async (message) => { console.log("Received message from server:", message); }); //handle opening new tab connection.on("ForceDisconnect", () => { connection.stop(); // Stop the SignalR connection showNotification("You have been disconnected, due to the new login"); }); // Stop connection when the user leaves the page window.addEventListener("beforeunload", async () => { await connection.stop(); console.log("SignalR web connection stopped."); }); // Start the connection (async () => { try { await connection.start(); console.log("SignalR connection established."); } catch (e) { console.error("SignalR connection error:", e.toString()); } })(); function showNotification(message) { //pops up modal } </script>
后端WebHub代码
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; using System; using System.Diagnostics; using System.Threading.Tasks; namespace Something { [Authorize] public class Webhub : Hub { // track user connections private readonly IMemoryCache _cache; public Webhub(IMemoryCache cache) { _cache = cache; } public override async Task OnConnectedAsync() { var userId = Context.UserIdentifier; if (userId == null) { // No UserIdentifier available, disconnect this connection Context.Abort(); return; } var connectionId = Context.ConnectionId; // Check if the user already has an active connection if (_cache.TryGetValue(userId, out string existingConnectionId)) { if (existingConnectionId != connectionId) // New connection attempt { Debug.WriteLine($"User {userId} connected BEFORE connection ID {existingConnectionId} connecting NOW {connectionId}"); // Notify and disconnect the old connection await DisconnectOldConnection(existingConnectionId); } } _cache.Set(userId, connectionId, TimeSpan.FromHours(1)); Debug.WriteLine($"User {userId} connected with connection ID {connectionId}."); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { var userId = Context.UserIdentifier; if (userId != null) { // Remove the user's connection when they disconnect _cache.Remove(userId); Debug.WriteLine($"User {userId} disconnected."); } await base.OnDisconnectedAsync(exception); } private async Task DisconnectOldConnection(string connectionId) { // This will disconnect the client with the given connectionId // The client handles disconnection on the client-side await Clients.Client(connectionId).SendAsync("ForceDisconnect"); } } }
异常日志
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID Ii9pOdJti4lRyA8QdG5zWAZq3viAr02. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID Ii9pOdJti4lRyA8QdG5zWAZq3viAr02 connecting NOW fUpzQDu-Itg7yu-z4reQ_QZq3viAr02 User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID fUpzQDu-Itg7yu-z4reQ_QZq3viAr02. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID R_qLkwKtxuE7nLE0TniVbQZq3viAr02. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID R_qLkwKtxuE7nLE0TniVbQZq3viAr02 connecting NOW t3mNN20gUdhOK-UXIH5TQQZq3viAr02 User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID t3mNN20gUdhOK-UXIH5TQQZq3viAr02. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID F9ne7QxXFTH1DZYs3lvVOQZq3viAr02. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID F9ne7QxXFTH1DZYs3lvVOQZq3viAr02 connecting NOW 645t2jvOMbLgeJCVp6Gm5QZq3viAr02 User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID 645t2jvOMbLgeJCVp6Gm5QZq3viAr02. User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected.
Startup.cs配置代码
services.AddSignalR().AddAzureSignalR(Configuration["SignalUrl"]); services.AddSingleton<IUserIdProvider, MyCustomProvider>(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); endpoints.MapRazorPages(); endpoints.MapHub<Webhub>("/webhub"); })
问题原因分析
- 并发竞争冲突:新标签页建立连接时,旧连接收到
ForceDisconnect指令后触发OnDisconnectedAsync,直接删除缓存中该用户的记录。此时新连接刚把自身ID写入缓存,就被旧连接的删除操作清空,导致下一次新连接时缓存无记录,无法触发断开逻辑,形成奇数标签页无法断开的循环。 - 缓存清理逻辑错误:
OnDisconnectedAsync中无条件删除用户缓存的做法不合理,若用户已有新连接活跃,会误删新连接的记录。
修复方案
方案1:调整缓存逻辑,解决并发冲突
修改OnDisconnectedAsync,仅当缓存中的连接ID与当前断开的ID一致时才删除记录,避免误删新连接的缓存:
public override async Task OnDisconnectedAsync(Exception? exception) { var userId = Context.UserIdentifier; if (userId != null) { if (_cache.TryGetValue(userId, out string cachedConnectionId) && cachedConnectionId == Context.ConnectionId) { _cache.Remove(userId); Debug.WriteLine($"User {userId} disconnected, removed cache entry."); } Debug.WriteLine($"User {userId} disconnected."); } await base.OnDisconnectedAsync(exception); }
同时在OnConnectedAsync中,先更新缓存再发送断开指令,缩小并发冲突窗口,必要时加锁:
public override async Task OnConnectedAsync() { var userId = Context.UserIdentifier; if (userId == null) { Context.Abort(); return; } var connectionId = Context.ConnectionId; string? existingConnectionId = null; // 锁操作确保缓存读写原子性,避免并发冲突 lock (_cache) { _cache.TryGetValue(userId, out existingConnectionId); _cache.Set(userId, connectionId, TimeSpan.FromHours(1)); } if (existingConnectionId != null && existingConnectionId != connectionId) { Debug.WriteLine($"User {userId} connected BEFORE connection ID {existingConnectionId} connecting NOW {connectionId}"); await DisconnectOldConnection(existingConnectionId); } Debug.WriteLine($"User {userId} connected with connection ID {connectionId}."); await base.OnConnectedAsync(); }
注:分布式部署场景下,需用Redis等分布式锁替代内存锁。
方案2:改用数据库存储连接信息(多实例场景)
若应用为多实例部署,MemoryCache无法跨实例共享,可使用数据库(如SQL Server、Redis)存储用户最新连接ID:
- 创建
UserConnections表,包含UserId、ConnectionId、LastActiveTime字段。 OnConnectedAsync时,更新该用户的ConnectionId为当前ID,刷新LastActiveTime。OnDisconnectedAsync时,仅当数据库中存储的ConnectionId与当前断开ID一致时,才删除或标记无效。- 发送断开指令时,从数据库读取旧
ConnectionId并发送。
方案3:利用Azure SignalR内置用户连接管理
Azure SignalR支持用户连接分组,可直接获取同一用户的所有连接并处理:
public override async Task OnConnectedAsync() { var userId = Context.UserIdentifier; if (userId == null) { Context.Abort(); return; } // 获取该用户所有活跃连接 var userConnections = await Clients.User(userId).GetConnectionsAsync(); foreach (var connId in userConnections) { if (connId != Context.ConnectionId) { await Clients.Client(connId).SendAsync("ForceDisconnect"); } } await base.OnConnectedAsync(); }
注:需确保IUserIdProvider配置正确,让Azure SignalR能准确识别用户。
内容的提问来源于stack exchange,提问作者Vladimir
相关产品推荐
相关产品推荐

