一、为什么我们需要处理断线重连问题

在现代Web应用中,实时通信已经成为标配功能。比如在线聊天、股票行情推送、多人协作编辑等场景,都需要保持客户端和服务端的长连接。但现实很骨感——网络环境复杂多变,WiFi切换、移动网络抖动、服务端重启等情况都会导致连接中断。这时候如果直接粗暴地断开连接,用户可能会丢失重要数据,体验就像坐过山车时突然停电。

SignalR作为ASP.NET Core的实时通信库,虽然内置了自动重连机制,但在以下场景仍然需要额外处理:

  1. 断线期间客户端发送的消息如何不丢失?
  2. 重连成功后如何补发服务端错过的推送?
  3. 如何避免重复消息导致的数据混乱?
// 示例1:SignalR基础连接监控(C#/.NET 6)
hubConnection.Closed += async (error) => {
    // 连接关闭时自动重试,间隔逐渐增加
    await Task.Delay(new Random().Next(0,5) * 1000);
    await hubConnection.StartAsync();
};

二、客户端离线缓存方案实战

解决数据丢失的核心思路是"写缓存+重发"。我们可以在客户端实现三级保护:

  1. 内存缓存:使用ConcurrentQueue暂存未确认消息
  2. 本地存储:通过localStorage持久化重要数据
  3. 备用通道:当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));
    }
}

三、服务端消息补发机制

服务端需要三个关键能力:

  1. 消息暂存:为每个连接维护最近N条广播消息
  2. 状态标记:记录客户端最后收到的消息ID
  3. 差异推送:重连时只发送缺失部分
// 示例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();
});

六、真实场景中的坑与经验

  1. 心跳超时:移动网络下默认的30秒心跳可能太短,建议动态调整
  2. 离线检测延迟:浏览器最小化时JS可能被节流,需要配合Visibility API
  3. 流量控制:重连风暴可能压垮服务端,必须实现退避算法
// 示例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++;
    }
}

七、总结与最佳实践

经过多个项目的实战检验,我们提炼出这些黄金法则:

  1. 客户端必须缓存:内存+磁盘双保险
  2. 服务端必须记账:至少记录最近5分钟消息
  3. 连接必须可追踪:使用唯一会话ID贯穿全流程
  4. 补发必须有序:严格按消息ID顺序处理

最终方案就像给实时通信加了"安全气囊",在网络颠簸时保护数据不丢失,重连后自动回到中断点继续工作。虽然增加了些许复杂度,但换来的是堪比金融级的可靠性保障。