断网那一瞬间,最尴尬的往往不是消息发不出去,而是重连回来之后:别人发的消息插到了自己发的消息前面,同一条消息出现了两遍,中间还莫名其妙少了几条 😅。WebSocket 聊天室的”断线重连 + 消息顺序”问题,本质上不是网络问题,而是应用层缺少一套可校验的顺序与去重机制。

断线那一秒,到底发生了什么
WebSocket 建立在 TCP 之上,单条连接内部的数据帧天然有序、不丢、不重。但”断线重连”意味着旧连接关闭、新连接建立,两条连接之间没有任何顺序约定:
-
服务端在旧连接上推送的消息,客户端可能一条都没收到;
-
客户端以为发出去的消息,可能卡在内核缓冲区里随连接一起消失;
-
重连成功后,服务端积压的历史消息、其他用户的新消息、本地补发的消息,三股数据流几乎同时到达 ⚡。
只要不做处理,乱序、重复、丢失就是必然结果,而不是偶发 bug。
思路全局 Seq + 客户端游标 + 幂等 ID
一套能扛住断线的方案,靠的是三个东西配合 🧭:
-
房间级单调递增 seq:服务端给每条成功入库的消息分配一个房间内严格递增的序号(Redis
INCR或数据库自增 ID),这是唯一的顺序真相。 -
客户端游标 lastSeq:本地记录”已经连续收到的最大 seq”,重连时把它带上去,作为补发起点。
-
clientMsgId 幂等 ID:每条消息在客户端生成时带一个 UUID,服务端按它去重,重复投递也不会变成两条。
有了 seq,顺序就有据可依;有了 lastSeq,补发才有的放矢;有了 clientMsgId,重发才不会刷屏。
发送本地 outbox 队列 + ACK 确认 🔁
消息发出去不等于送达。可靠的做法是维护一个本地待确认队列:
const outbox = new Map(); // clientMsgId -> { msg, status, timer }
function send(msg) {
const clientMsgId = crypto.randomUUID();
const item = { msg: { ...msg, clientMsgId }, status: 'pending' };
outbox.set(clientMsgId, item);
flush(clientMsgId);
item.timer = setTimeout(() => flush(clientMsgId), 3000); // 超时重投
}
function flush(clientMsgId) {
const item = outbox.get(clientMsgId);
if (!item || ws.readyState !== WebSocket.OPEN) return;
item.status = 'sending';
ws.send(JSON.stringify({ type: 'send', data: item.msg }));
}
// 服务端回执才真正落定
function onServerAck({ clientMsgId, seq, serverTs }) {
const item = outbox.get(clientMsgId);
if (!item) return; // 去重:重复 ack 直接忽略
clearTimeout(item.timer);
outbox.delete(clientMsgId);
applyLocalSeq(item.msg, seq, serverTs);
}
重连成功后遍历 outbox,把所有 pending / sending 的消息按原始顺序重新投递。服务端收到 clientMsgId 已存在的消息时,直接返回已有 seq,不重复入库 ✅。这里有个细节容易被忽略:展示顺序以服务端返回的 seq 为准,而不是本地发送时间,否则自己发的消息很容易”飘”到别人消息上面。
接收Gap 检测、排序缓冲与增量补发 📡
重连握手的正确姿势是把游标交给服务端:
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'sync', roomId, lastSeq })); // 携带断点
};
服务端返回 seq > lastSeq 的增量消息。客户端落地时做三件事:
const buffer = new Map();
function onMessage(m) {
if (buffer.has(m.seq)) return; // seq 去重
buffer.set(m.seq, m);
if (m.seq === lastSeq + 1) { // 连续,立即上屏
while (buffer.has(lastSeq + 1)) {
lastSeq++;
render(buffer.get(lastSeq));
buffer.delete(lastSeq);
}
} else if (m.seq > lastSeq + 1) { // 出现空洞
waitGapFill(); // 等 300ms,仍缺则主动拉区间
}
}
几个经验值:空洞等待窗口 200–400ms 比较合适;单次补发上限建议 200 条,超出直接拉最近 N 条快照,避免重连瞬间被历史消息淹没;lastSeq 持久化在 localStorage,刷新页面也能续上。
服务端补发逻辑其实很短:
// 伪代码:按游标补发 + 幂等写入
socket.on('sync', ({ roomId, lastSeq }) => {
const list = msgRepo.listAfter(roomId, lastSeq, 200);
socket.emit('sync', { list, snapshot: list.length === 200 });
});
socket.on('send', async ({ clientMsgId, roomId, content }) => {
const exist = await msgRepo.findByClientMsgId(clientMsgId);
if (exist) return socket.emit('ack', { clientMsgId, seq: exist.seq }); // 幂等
const seq = await redis.incr(`seq:room:${roomId}`);
await msgRepo.insert({ seq, roomId, clientMsgId, content, serverTs: Date.now() });
socket.emit('ack', { clientMsgId, seq, serverTs: Date.now() });
io.to(roomId).emit('message', { seq, clientMsgId, content, serverTs: Date.now() });
});
重连策略心跳先感知,退避再重连 ⚠️
浏览器无法主动发送 WebSocket ping 帧,所以心跳得在应用层做:客户端每 15s 发一次 {"type":"ping"},服务端回 pong,连续 2 次没回应就判定连接已死,主动 close() 后进入重连。服务端侧同样要有 idle 超时,及时回收半开连接。
重连间隔用指数退避加随机抖动,避免服务端重启瞬间被海量连接打爆:
function reconnect() {
const delay = Math.min(30000, 1000 * 2 ** retry) * (0.7 + Math.random() * 0.6);
setTimeout(connect, delay);
retry++;
}
window.addEventListener('online', () => { retry = 0; connect(); }); // 网络恢复立刻重试
retry 在连接稳定 30s 后归零,否则一次长时间断网会把退避拉到最大,恢复后反而慢半拍。
时间戳、多端登录与展示顺序的坑 🧾
-
别信客户端时钟:手机系统时间可能差几分钟,排序一律用服务端
serverTs,本地时间只做兜底。 -
多标签页要按 msgId 去重:同一账号开了三个页面,服务端会推三份,UI 层用
clientMsgId / seq做一次幂等渲染。 -
并发写要串行:
onopen之前调用send()会抛InvalidStateError,所有发送先入队,等连接就绪再统一 flush。 -
图片、语音等大消息:先传文件拿到 URL,再发文本消息走 seq 通道,避免大包阻塞顺序。
完整流程串起来就是:心跳感知断线 → 退避重连 → 携带 lastSeq 握手 → 服务端增量补发 → 客户端 gap 缓冲排序上屏 → 本地 outbox 按序重投 → 服务端幂等去重 → ACK 落定。每一步都不复杂,合在一起才稳 🚀。
WebSocket 断线重连这件事,拼的不是重连代码写得多花哨,而是有没有把”顺序”和”去重”当成服务端契约来做——seq 是顺序的唯一真相,clientMsgId 是幂等的最后一道防线,lastSeq 是重连后不丢消息的抓手。这三样补齐之后,弱网、切后台、跨基站切换这些场景基本都能平滑过渡,聊天室也不会再出现”消息倒着排队”的尴尬场面。
