连接断了你发现了吗:WebSocket 心跳与断线重连

SailTrack
2026-08-27
点 赞
0
热 度
4
评 论
0
  1. 首页
  2. 后端开发
  3. 连接断了你发现了吗:WebSocket 心跳与断线重连

写在前面

IM 系列写到这儿,其实一直回避了一个问题:连接本身是会"悄悄死掉"的。

之前写过 WebSocket 的基础架构,也写过用 outbox 模式保证消息投递。但测试的时候有个场景让我很尴尬:页面切到后台十几分钟,切回来发消息——界面显示消息发出去了,别人回我的我却收不到,直到手动刷新。连接早就断了,但前端不知道。

这篇记录一下我怎么处理"连接的生死"这件事:服务端怎么管理一堆连接、心跳怎么做、断了怎么重连、重连之后丢掉的消息怎么补。

一、服务端怎么知道往哪儿推:会话注册表

推送的前提是服务端知道"这个人现在有哪些连接"。我的做法很直接:一张按用户 ID 组织的内存表。

@Component
public class ImWebSocketSessionRegistry {

    private final ConcurrentHashMap<Long, CopyOnWriteArraySet<WebSocketSession>> sessionsByUserId
        = new ConcurrentHashMap<>();

    public void register(Long userId, WebSocketSession session) {
        sessionsByUserId.computeIfAbsent(userId, ignored -> new CopyOnWriteArraySet<>()).add(session);
    }

    public void unregister(Long userId, WebSocketSession session) {
        CopyOnWriteArraySet<WebSocketSession> sessions = sessionsByUserId.get(userId);
        if (sessions == null) return;
        sessions.remove(session);
        if (sessions.isEmpty()) {
            sessionsByUserId.remove(userId, sessions);
        }
    }
}

两个设计点:

值是 Set 不是单个 session。 用户开两个标签页、或者电脑加手机同时登录,就是两条连接。消息要推给这个人的每一条连接,漏了一条,那个窗口就不会响。

key 是 userId 不是 sessionId。 业务语义是"推给某个人",不是"推给某条连接"。按人组织,推送的代码才能写成 sendToUsers(userIds, payload) 而不用关心连接细节。

注册和注销的时机都挂在 WebSocket 生命周期上:连接建立时 register,正常关闭和传输异常时 unregister。整个推送链路长这样:

SessionRegistry:按用户维护多端会话,推送时逐条投递并清理失效连接

看起来闭环了,但接下来就是这篇文章的主题——这套闭环漏了一种情况:连接断了,但两端都还没发现。

二、悄无声息的死连接

TCP 连接的死亡不总是有声明的。正常关闭会有 close 帧,但更多时候是"非正常死亡":手机锁屏进了飞行模式、笔记本合盖睡眠、Wi-Fi 无声切换——对端 TCP 栈根本没机会发送关闭包,这条连接就在服务端"半开着"。

更麻烦的是中间设备。运营商网关和公司防火墙对空闲连接有天然的清理冲动,NAT 映射表里的条目几十秒到几分钟没有流量就可能被回收。回收之后这条连接的所有后续包都被静默丢弃——连接死了,但双方还以为它活着。

那 TCP 自带的 Keep-Alive 为什么不救场?看一眼数字就明白了:

TCP Keep-Alive 与应用层心跳对比

Linux 默认的 TCP Keep-Alive 探测间隔是 2 小时——它设计的目标是清理"死了很久"的连接,而不是保活。等它探测出来,NAT 表早清空两小时了。

所以应用层心跳不是可选项,是必须项:

// 前端:每 30 秒发一次 PING
nextSocket.onopen = () => {
  if (heartbeatTimer) window.clearInterval(heartbeatTimer);
  heartbeatTimer = window.setInterval(() => {
    if (nextSocket.readyState === WebSocket.OPEN) nextSocket.send('PING');
  }, 30000);
};
// 服务端:回 PONG,顺带兼容两种格式
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    String payload = message.getPayload();
    if ("PING".equalsIgnoreCase(payload) || payload.contains("\"PING\"")) {
        session.sendMessage(new TextMessage("""
            {"eventType":"PONG","occurredAt":"%s"}
            """.formatted(LocalDateTime.now())));
    }
}

服务端兼容纯文本 PING 和 JSON 格式是个历史遗留的小妥协:早期版本发的是 JSON 包装的心跳,后来改成裸文本省流量,但线上可能有旧页面还挂着。判断条件宽一点,省一次灰度。

30 秒的间隔是拍脑袋加经验的结果:太短浪费流量,太长则"发现死亡"的延迟跟着变长。30 秒意味着最坏情况下,一条死连接也会在半分钟内暴露——因为发不出去 PING 的客户端,onclose 很快就会触发,重连流程立刻接管。

三、推送失败的连接,当场清理

注册表里总会混进死连接——心跳只是降低概率,不能根除。所以每次推送时我都要处理它:

private int sendToUserOrThrow(Long userId, String payload) throws IOException {
    CopyOnWriteArraySet<WebSocketSession> sessions = sessionsByUserId.get(userId);
    if (sessions == null || sessions.isEmpty()) return 0;

    int deliveredCount = 0;
    for (WebSocketSession session : sessions) {
        if (!session.isOpen()) {
            unregister(userId, session);   // 已经半死的,顺手摘掉
            continue;
        }
        sendOrThrow(userId, session, payload);
        deliveredCount++;
    }
    return deliveredCount;
}

private void sendOrThrow(Long userId, WebSocketSession session, String payload) throws IOException {
    try {
        synchronized (session) {
            session.sendMessage(new TextMessage(payload));
        }
    } catch (IOException e) {
        unregister(userId, session);       // 发送失败的,也摘掉
        throw e;
    }
}

两个细节值得展开。

为什么 synchronized (session)? Spring 的 WebSocketSession 不是线程安全的,两个线程同时往一条 session 写消息,帧会交错,轻则消息损坏,重则直接抛异常断连。而推送这件事天然是并发的——一个群消息要推给几十个人,线程池里好几个任务同时在跑。所以锁的粒度是"每条连接",不同用户之间互不影响。

为什么发送失败要 throw? 第一次实现时我把异常吞了,觉得"一条连接失败不该影响别人"。后来发现这会让 deliveredCount 变成假数字——投递失败但统计说成功了,上层基于这个数字做的可靠性判断全是错的。失败要么如实上报,要么记下来,不能假装成功。(完整的"投递失败怎么办"是另一篇 outbox 文章的主题,这里不展开。)

四、重连:不是越快越好

客户端检测到断线(onclose 触发)后要重连。最直觉的实现是失败立刻重试,但这是给自己挖坑:

想象服务器重启的场景。重启需要 30 秒,期间几千个客户端全部断线,如果每个都每秒重试一次,重启完成的那一刻服务端要同时接待几千个握手——断线风暴会把刚恢复的服务再次打死。重连策略本质上是限流。

我的实现是两段式退避:

let reconnectAttempts = 0;
const slowReconnectAfterAttempts = 3;
const slowReconnectDelay = 60000;

function scheduleImSocketReconnect() {
  if (!session.isAuthenticated || reconnectTimer) return;
  const delay = reconnectAttempts >= slowReconnectAfterAttempts
    ? slowReconnectDelay                                    // 3 次以后:60 秒一次
    : Math.min(10000, 800 + reconnectAttempts * 1200);      // 前 3 次:快速重试
  reconnectAttempts += 1;
  reconnectTimer = window.setTimeout(() => {
    reconnectTimer = undefined;
    connectImSocket();
  }, delay);
}

前 3 次用快速递增间隔(800ms、2s、3.2s),覆盖"网线松了一下"这种瞬时抖动——用户几乎无感。3 次之后降频到 60 秒一次,处理"服务器真的挂了"这种长故障,把重试压力降到可以忽略。

连上之后 reconnectAttempts 归零,策略自动回到快速档。这套逻辑的好处是无需配置:网络闪断时它表现得像立即重连,服务器宕机时它自动变成低频探测。

还有一个容易忽略的小点:onerror 里只做 close(),重连一律交给 onclose 触发。因为浏览器在 onerror 之后必定会触发 onclose,如果两处都调度重连,就会出现两条并行的重连定时器,越连越多。

五、重连之后的补偿拉取

心跳保住了连接,重连恢复了连接,但还有最后一环:断线期间的消息,已经永远错过了推送。

所以重连成功的 onopen 里,除了重置心跳,还要做一次全量对账:

nextSocket.onopen = () => {
  wsConnected.value = true;
  reconnectAttempts = 0;
  void refreshImAfterReconnect();   // 重连必拉一次
  // ...启动心跳
};

async function refreshImAfterReconnect() {
  await im.loadConversations();
  shell.mergeBackendConversations(im.conversations);
  if (shell.active.messages) {
    await syncConversationMessages(shell.active.messages);  // 当前打开的会话按 seqNo 补拉
  }
}

思路和 outbox 那篇一脉相承:推送是优化,拉取是兜底。 WebSocket 负责让"在线的人"第一时间看到消息;HTTP 拉取负责让"任何情况下"数据最终是对的。当前会话的补拉是按 seqNo 做增量的,所以这次对账很轻——大多数时候只带回来零条或几条消息。

判断一个 IM 的可靠性,别看它"在线时收消息快不快",要看它"断线重连后消息少不少"。

六、小结

三点体会:

  1. 连接的死亡是常态,不是异常。 锁屏、切网、NAT 超时,任何一条都在悄悄杀连接。心跳不是锦上添花,是让"死连接"显形的基本手段。
  2. 退避重连是在替服务端挡压力。 重连风暴和雪崩只有一线之隔,两段式退避(快速几拍 + 长周期探测)是成本最低的分界线。
  3. 推送永远要配一条拉取的退路。 再勤的心跳也防不住断线窗口里的消息丢失,重连后的补偿拉取才是"消息不丢"的最后一环。

这套机制上线后再没出现过"切个后台回来消息就丢"的情况——连接还是那几百条,但现在每一条的死活,两端都知道。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数