一、为什么我们需要处理断线重连问题
在现代Web应用中,实时通信已经成为标配功能。比如在线聊天、股票行情推送、多人协作编辑等场景,都需要保持客户端和服务端的长连接。但现实很骨感——网络环境复杂多变,WiFi切换、移动网络抖动、服务端重启等情况都会导致连接中断。这时候如果直接粗暴地断开连接,用户可能会丢失重要数据,体验就像坐过山车时突然停电。
SignalR作为ASP.NET Core的实时通信库,虽然内置了自动重连机制,但在以下场景仍然需要额外处理:
- 断线期间客户端发送的消息如何不丢失?
- 重连成功后如何补发服务端错过的推送?
- 如何避免重复消息导致的数据混乱?
// 示例1:SignalR基础连接监控(C#/.NET 6)
hubConnection.Closed += async (error) => {
// 连接关闭时自动重试,间隔逐渐增加
await Task.Delay(new Random().Next(0,5) * 1000);
await hubConnection.StartAsync();
};
二、客户端离线缓存方案实战
解决数据丢失的核心思路是"写缓存+重发"。我们可以在客户端实现三级保护:
- 内存缓存:使用ConcurrentQueue暂存未确认消息
- 本地存储:通过localStorage持久化重要数据
- 备用通道:当SignalR不可用时降级为HTTP API发送
// 示例2:浏览器端离线缓存实现(JavaScript)
class MessageCache {
constructor() {
this.memoryQueue = []; // 内存队列
this.STORAGE_KEY = 'signalr_offline_msgs';
// 加载历史未发送消息
const saved = localStorage.getItem(this.STORAGE_KEY);
this.diskQueue = saved ? JSON.parse(saved) : [];
}
add(message) {
this.memoryQueue.push(message);
this._syncToDisk();
}
_syncToDisk() {
// 合并内存和磁盘中的消息
const allMessages = [...this.diskQueue, ...this.memoryQueue];
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(allMessages));
}
}
三、服务端消息补发机制
服务端需要三个关键能力:
- 消息暂存:为每个连接维护最近N条广播消息
- 状态标记:记录客户端最后收到的消息ID
- 差异推送:重连时只发送缺失部分
// 示例3:服务端消息历史记录(C#/.NET Core)
public class MessageArchive {
private readonly ConcurrentDictionary<string, CircularBuffer<Message>> _userMessages
= new ConcurrentDictionary<string, CircularBuffer<Message>>();
public void Add(string connectionId, Message msg) {
var buffer = _userMessages.GetOrAdd(connectionId,
_ => new CircularBuffer<Message>(50)); // 每个连接保留50条
buffer.Push(msg);
}
public IEnumerable<Message> GetSince(string connectionId, long lastReceivedId) {
if (_userMessages.TryGetValue(connectionId, out var buffer)) {
return buffer.Where(m => m.Id > lastReceivedId);
}
return Enumerable.Empty<Message>();
}
}
四、完整工作流与异常处理
一个健壮的实现需要处理这些边界情况:
- 网络闪断时避免重复建立连接
- 服务端重启后客户端如何识别"新会话"
- 消息幂等性保证(通过唯一ID+去重表)
// 示例4:带幂等检查的消息处理(C#)
public class ChatHub : Hub {
private readonly MessageArchive _archive;
private readonly IDuplicateChecker _duplicateChecker;
public override async Task OnConnectedAsync() {
// 客户端上报最后收到的消息ID
var lastId = long.Parse(Context.GetHttpContext().Request.Query["lastId"]);
var missed = _archive.GetSince(Context.ConnectionId, lastId);
await Clients.Caller.SendAsync("catchUp", missed);
}
public async Task SendMessage(Message msg) {
if (_duplicateChecker.IsDuplicate(msg.MessageId)) {
return; // 已处理过的消息直接忽略
}
_archive.Add(Context.ConnectionId, msg);
await Clients.All.SendAsync("receiveMessage", msg);
}
}
五、技术选型与性能考量
内存缓存 vs 分布式缓存:
- Redis适合多服务器场景,但增加复杂度
- 内存方案简单但服务器重启会丢失数据
消息ID生成策略:
- Snowflake算法(分布式ID)
- 数据库自增序列(需要集中存储)
// 示例5:混合存储策略实现(C#)
services.AddSingleton<IMessageStore>(provider => {
if (Configuration.UseRedis) {
return new RedisMessageStore(redisConnection);
}
return new InMemoryMessageStore();
});
六、真实场景中的坑与经验
- 心跳超时:移动网络下默认的30秒心跳可能太短,建议动态调整
- 离线检测延迟:浏览器最小化时JS可能被节流,需要配合Visibility API
- 流量控制:重连风暴可能压垮服务端,必须实现退避算法
// 示例6:智能重连策略(JavaScript)
let retryCount = 0;
const maxDelay = 30000; // 最大间隔30秒
async function reconnect() {
try {
await hubConnection.start();
retryCount = 0; // 重置计数器
} catch (err) {
const delay = Math.min(
Math.pow(2, retryCount) * 1000 + Math.random()*1000,
maxDelay
);
setTimeout(reconnect, delay);
retryCount++;
}
}
七、总结与最佳实践
经过多个项目的实战检验,我们提炼出这些黄金法则:
- 客户端必须缓存:内存+磁盘双保险
- 服务端必须记账:至少记录最近5分钟消息
- 连接必须可追踪:使用唯一会话ID贯穿全流程
- 补发必须有序:严格按消息ID顺序处理
最终方案就像给实时通信加了"安全气囊",在网络颠簸时保护数据不丢失,重连后自动回到中断点继续工作。虽然增加了些许复杂度,但换来的是堪比金融级的可靠性保障。
评论