写在前面
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。整个推送链路长这样:

看起来闭环了,但接下来就是这篇文章的主题——这套闭环漏了一种情况:连接断了,但两端都还没发现。
二、悄无声息的死连接
TCP 连接的死亡不总是有声明的。正常关闭会有 close 帧,但更多时候是"非正常死亡":手机锁屏进了飞行模式、笔记本合盖睡眠、Wi-Fi 无声切换——对端 TCP 栈根本没机会发送关闭包,这条连接就在服务端"半开着"。
更麻烦的是中间设备。运营商网关和公司防火墙对空闲连接有天然的清理冲动,NAT 映射表里的条目几十秒到几分钟没有流量就可能被回收。回收之后这条连接的所有后续包都被静默丢弃——连接死了,但双方还以为它活着。
那 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 的可靠性,别看它"在线时收消息快不快",要看它"断线重连后消息少不少"。
六、小结
三点体会:
- 连接的死亡是常态,不是异常。 锁屏、切网、NAT 超时,任何一条都在悄悄杀连接。心跳不是锦上添花,是让"死连接"显形的基本手段。
- 退避重连是在替服务端挡压力。 重连风暴和雪崩只有一线之隔,两段式退避(快速几拍 + 长周期探测)是成本最低的分界线。
- 推送永远要配一条拉取的退路。 再勤的心跳也防不住断线窗口里的消息丢失,重连后的补偿拉取才是"消息不丢"的最后一环。
这套机制上线后再没出现过"切个后台回来消息就丢"的情况——连接还是那几百条,但现在每一条的死活,两端都知道。
默认评论
Halo系统提供的评论