稍候片刻,月出文自明。
let lastHeartbeat = Date.now();
sub.ctx.interval(() => {
if (Date.now() - lastHeartbeat > 80000) {
clean();
conn.terminate();
}
if (Date.now() - lastHeartbeat > 30000) conn.send('ping');
}, 40000);
conn.on('pong', () => { lastHeartbeat = Date.now(); });ws.onmessage = (event) => {
if (event.data === 'ping') {
ws.send('pong');
return;
}
if (event.data === 'pong') return;
// ...
};ws.onclose = () => {
if (wsRef.current === ws) wsRef.current = null;
handlersRef.current.onClose?.();
scheduleReconnect();
};
ws.onerror = (err) => {
handlersRef.current.onError?.(err);
ws.close();
};if (wsRef.current?.readyState === WebSocket.CONNECTING || wsRef.current?.readyState === WebSocket.OPEN) return;useWebSocket({
url: socketUrl,
onMessage: handleMessage,
autoReconnect: false, // ← 关键
reconnectInterval: 5000,
enabled: !!rid,
});if (![WAITING, JUDGING, COMPILING, FETCHED].includes(rdoc.status)) {
this.disconnectTimeout = setTimeout(() => this.close(4001, 'Ended'), 30000);
}const send = useCallback((data: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
return;
}
pendingMessages.current.push(data);
}, []);disable={!connected}if (probeAt !== null) {
if (delayed) probe(socket, now);
else if (now - probeAt >= probeTimeout) fail();
} else if (now - lastReceived >= idleTimeout || (resumed && networkInterrupted)) {
probe(socket, now);
}ws.onmessage = (event) => {
if (!current(ws)) return;
// Heartbeats and non-JSON frames are also evidence of a live connection.
lastReceived = Date.now();
probeAt = null;
if (event.data === 'ping') {
try { ws.send('pong'); } catch { fail(); }
return;
}
if (event.data === 'pong') return;
// ...
};
const retire = () => {
const socket = wsRef.current;
wsRef.current = null;
probeAt = null;
if (!socket) return;
// Abandon half-open sockets without waiting for their close event to arrive.
socket.onopen = null;
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
socket.close();
};
// Transport-level 1000 can also be a proxy/server restart; only explicit business/policy stops are terminal.
const TERMINAL_CLOSE_CODES = new Set([1008, 4000, 4001]);const offline = () => { networkInterrupted = true; };if (!socket) {
if (resumed && autoReconnect) {
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
connect();
}
return;
}if (now < lastCheck) {
// A system clock correction must not postpone detection for hours.
lastReceived = now;
connectingAt = now;
if (probeAt !== null) probeAt = now;
}// A delayed timer after sleep must grant a new probe window, not immediately expire it.
const delayed = now - lastCheck > 30_000;const fail = (event?: CloseEvent) => {
if (disposed || stopped) return;
stopped = !autoReconnect || Boolean(event && TERMINAL_CLOSE_CODES.has(event.code));
retire();
if (stopped) {
window.clearInterval(watchdog);
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
canQueue.current = false;
pendingMessages.current = [];
}
try {
handlersRef.current.onClose?.(event);
} finally {
scheduleReconnect();
}
};const scheduleReconnect = () => {
if (disposed || stopped || !autoReconnect || reconnectTimer !== undefined) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = undefined;
connect();
}, duration(reconnectInterval, 3000));
};ws.onopen = () => {
if (!current(ws)) return;
lastReceived = Date.now();
probeAt = null;
networkInterrupted = false;
const sendNow = (data: any) => {
if (!current(ws) || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify(data));
} catch {
fail();
}
};
for (const message of pendingMessages.current.splice(0)) sendNow(message);
// Each business restores its subscriptions/current activity here.
if (current(ws)) handlersRef.current.onOpen?.(sendNow);
};
const send = useCallback((data: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify(data));
} catch {
recoverRef.current();
}
return;
}
if (canQueue.current) pendingMessages.current.push(data);
}, []);watchdog = window.setInterval(() => check(), Math.max(1000, Math.min(10_000, probeTimeout)));async prepare() {
this.checkPriv(PRIV.PRIV_USER_PROFILE);
// ...
this.ctx.on('contest/announcement', (recipients) => this.announcement(recipients));
await this.refresh();
}this.queue = this.queue.catch(() => {}).then(async () => {
if (this.closed) return;
if (id) await announcements.acknowledge(this.user._id, id);
const pending = await announcements.pending(this.user._id);
if (!this.closed) this.send({ announcements: pending });
});this.rid = rid.toString();
this.onRecordChange(rdoc);
useWebSocket({
url: socketUrl,
onMessage: handleMessage,
// A reconnect obtains the current snapshot; terminal 4001/Ended stays closed.
autoReconnect: true,
reconnectInterval: 5000,
enabled: !!rid,
});
const handleSocketOpen = useCallback((send: (payload: any) => void) => {
send({ rids: initialRids });
}, [initialRids]);
async message({ rids }) {
this.watchedRids = rids;
// load + replay onRecordChange
}onOpen: (sendNow) => {
connected.current = true;
unsynced();
sendNow({ active: active() });
},export const ACTIVITY_LEASE_MS = 30_000;const elapsed = previous && at - previous.at <= ACTIVITY_LEASE_MS
? Math.max(0, Math.min(at, bounds.end) - Math.max(previous.at, bounds.begin)) : 0;
const status = await activity.heartbeat(domainId, tdoc, this.user._id, this.token, pid, false);
this.send({ ready: true, elapsed: status.problemTimes?.[pid] || 0 });
问题关联项目
问题背景
我把 HydroNext 套了一层腾讯 EdgeOne,比赛公告、提交记录、评测进度这些与客户端相连的内容都要依赖 WebSocket 从服务端推送。
介于 OJ 比赛的独特性,使得在比赛过程中这些 Service 需要保持连接,其中公告是最典型的:管理员在比赛中间发一条,所有报了名的选手页面上应该立刻弹出来。而一场比赛开一两个小时是常态,长的比赛甚至超过十个小时,而 EdgeOne 对 WebSocket 的空闲回收阈值是 300 秒,这就需要我们的系统在保持长连接的时确保心跳正常,同时拥有故障恢复的能力。
原本系统里已经有心跳了,farmworker 每 40 秒检查一次,30 秒没收到帧就发 ping,80 秒还没收到就把连接 terminate 掉。两边的帧间隔通常远小于 300 秒,保活是绰绰有余的。
然而实际我在接入 EdgeOne 时立刻就遇到了 WebSocket 断连的问题,所有需要与客户端保持连接的内容都无法即时推送。
问题既然问题不在「心跳」那在哪呢?链路上有流量,中间设备就不会回收连接,但这证明不了这条连接现在还能用,如果断在中间层,浏览器收不到 close 也收不到 error,readyState 将会一直保持 true 状态,使得服务端认为连接对象还在,但消息就会排在队列里,一直无法收到。只要一场十小时的比赛里出一次,那台电脑后面的公告就全丢了,直到刷新后重新开放了连接,但这在路由页面禁止刷新的页面中是致命的,它会一直阻塞。
这次要写的就是「心跳」之外的那部分:怎么知道一条连接已经「死了」,又该怎么换掉。
面向客户端的五条长连接
HydroNext 的所有实时功能均使用了 WebSocket 技术。
例如:判题行为本身是异步的,但一次提交要进行编译、检查测试点、逐个传回结果,使用 WS 长连接一定比异步的轮询要好。
经过设计,面向客户端就存在这样五条长连接:
场景
建立时间
端点
缘由
全局比赛公告
登录后进入任意页面建立公告长连接
/contest-announcements-conn
收已报名比赛的公告,回传已读确认
提交记录列表
打开 /record 或者域内提交列表
/record-conn?domainId=…&…
实时更新状态、分数、新提交
提交记录详情
打开 /record/{rid}
/record-detail-conn?rid=…&domainId=…
编译信息、评测进度、测试点结果
编辑器自测
点了“自测”按钮、有记录 ID 之后
同上,用自测记录的 ID
自测状态、输出、结果
比赛做题计时
已报名的用户以比赛模式进入题目
/contest/{tid}/activity-conn?pid=…
前/后台状态,累计每题和总用时
这几类连接有一些共同点,后面的设计都绕不开:
过去的代码它假设连接断开的时候,会有一个事件来通知它,但其实因意外断连就不会存在这个事件。
接入 EdgeOne 之后
EdgeOne 对 WebSocket 的策略默认是不开启的,它最大回收策略时间是空闲 300 秒就回收,这导致对于大部分超长静默时间,但要长效保持的长连接是致命的威胁。
五条连接使用的是同一段链路,也就一起受同一个回收策略影响。浏览器和 CDN 之间是一条连接,CDN 和服务端之间又是一条,其中任意一段断开连接,浏览器都只能是“没有新消息”。
过去的心跳机制做了什么
看到 idle timeout,我第一反应也是加心跳。动手之前先去框架层翻了一下,发现心跳早就有了:
这个定时器每 40 秒醒一次。超过 30 秒没收到帧就发 ping,超过 80 秒还什么都没收到,就直接 terminate。客户端收到 ping 回 pong:
正常链路下,两个方向的帧间隔都在 40 秒以内,离 300 秒差得很远,所以在 CDN 看来这条连接一直是活跃的。
也就是说 在 CDN 关闭了连接的情况下,再加个心跳继续去保持 这个方法是不可行的,这说明问题根本就不在心跳上,那么问题出在哪里了呢?
保活与活性检测
心跳这个词其实涵盖了两件事情。
其中一件是保活。CDN 如果看到链路上一直有流量,就不会把这条连接回收掉。
而另外一件是这次改进的关键,那就是活性检测。活性检测要做的就是确认这条连接现在还能不能收发数据,如果不能那就 drop,再重新建立一条新的通讯。
而在 Hydro 中只实现了保活机制,服务端发 ping,客户端回 pong。客户端不主动探测,不记录最后一次收到消息的时间,也没有连接超时,它判断连接死亡的唯一依据是 onclose。
服务端和客户端的能力在此时并不对等:
同时,像手机在 Wi-Fi 和蜂窝网络之间切换、浏览器进后台挂起、笔记本合盖、服务端重启但客户端连接对象仍旧保持、CDN切换节点 等等情况都有可能导致断连但无法被发现最终导致断连无法发送信息。
这些情况下 TCP 连接是半开的:浏览器认为 OPEN,对端早就不存在了。没有 FIN,没有 RST,什么事件都没有。close 可能几分钟后来,也可能永远不来,而 Hydro 的恢复逻辑完全依赖这个事件,而它可能永远都不会恢复。
Hydro 实现的七个问题
把改动之前的 use-websocket.ts 拿出来分析,会发现问题比较集中。
1. 重连只写在 onclose 里
onclose 不来,这里就不会重连。半开连接下,这条问题影响最大。
2. onerror 里只是调了 close()
这段代码看起来是在处理错误,实际上只是调了一下 ws.close(),重连仍然依赖 onclose。链路已经坏掉的时候,close() 一样等不到回调。
3. 握手没有超时
这一句是用来防止重复连接的,问题在于它没有超时。握手卡在 CONNECTING 的时候(弱网、代理劫持、CDN 节点异常都有可能),这个 socket 会一直占着位置,后面的每次重连尝试都会被它挡回去。
4. 详情页和自测关闭了自动重连
详情页显式把自动重连关掉了。打开一个正在评测的记录页,连接只要断一次,页面就不会再更新。自测用的是同一个 hook(components/editor/scratchpad.tsx:139),症状一样,输出停在断开的那一刻。
当时这样写也有理由:详情页的数据是快照式的,用户刷新一下就能拿到。但在比赛期间使用了监考客户端,它无法让用户去刷新页面,整个系统都依赖的是路由。
5. 不区分关闭原因
服务端在记录进入终态 30 秒后会主动关连接:
旧客户端不看关闭码,只要开了自动重连,收到任何 close 都会在 3 秒后重建。
每次重连都会重新执行一遍 prepare()。如果失败的原因一直存在,比如权限不够、参数错了、来源校验不通过,就会变成死循环:连上、prepare 报错、服务端发 4000、三秒后重连,再来一次。
4001 那边情况反过来了,它只出现在详情页,而详情页刚好关了自动重连,所以不会循环,代价是连正常断线也不恢复。
这两个问题的根源是同一个:客户端不知道这次关闭该不该重连。
6. 没有处理浏览器挂起和时钟回拨
浏览器挂起的时候,setInterval 会被节流甚至暂停。笔记本合盖一整晚,醒来以后 Date.now() 已经过去很久。
Hydro 的代码中没有超时逻辑,所以这个问题没暴露。但一旦加上超时,醒来的第一件事就是误杀一条健康连接。
7. 发送队列没有上下文
连接不可用的时候,消息会被无条件塞进队列。如果这条连接其实已经死了,消息就一直躺在队列里,用户以为发出去了,实际上没有,界面上也不会有任何提示。
小结
七条写下来有点长,先缩成一张表:
维度
Hydro 旧实现
改进方式
死亡判定
Only onclose
时钟加探测 lastReceived / probeAt / connectingAt
心跳方向
被动回 pong
主动发 ping
空闲连接
永不检测
120s 空闲就探测,10s 无帧判死
握手卡住
CONNECTING 永久占位
30s 超时进 fail()
关闭语义
一律重连
白名单 {1008, 4000, 4001},1000 可恢复
半开 socket
等 onclose
retire() 摘掉 handler、置空引用、主动 close()
发送失败
无处理
try/catch 转进 fail()
消息队列
永久累积
终止、卸载、切域时清空,onOpen 前统一冲刷
浏览器挂起
无处理
延迟 tick 重开探测窗口,时钟回拨重置基准
页面事件
无
online / focus / pageshow / visibilitychange
上下文隔离
无
current(ws) 引用检查,domainId 路径隔离
详情页和自测
autoReconnect: false
自动重连,服务端推快照
表格里看不出时间感,换成时间轴。假设中间链路在 t = 300s 那一刻彻底断了:
Hydro 中旧实现那条链上,断点之后没有任何动作,直到用户自己刷新,而在 HydroNext 的新实现中会在空闲满 120s 时发出探测,10s 内没有响应就判定失败,然后重连。
这七条问题中,要改动的地方其实只有三处,分别是怎么判断连接已失效、哪些情况不应重连、重连后怎么处理旧连接合旧消息。
排查难点
排查方向定下来后,我们再回头看这条路,好像蛮顺利的。但当真正花时间确认问题到底出在哪一步时,就出现了一些困难。
没有任何错误
整条连接没有抛出任何异常错误,控制台中甚至没有 console.error 也没有失败的请求。浏览器没有 WS 失效的提示,因为在浏览器的视角中 WS 的连接一直是 OPEN
readyState 不代表连接可用
WebSocket.OPEN 的标识只能说明这个对象处于打开状态,但在半开连接下,读写都不会立即报错,它一定要等到 TCP 超时才会报错,而这个超时时间通常超过了数分钟。
本地无法复现
对,没错,这个问题本地无法复现,因为本地没有 CDN,代理也不会主动回收空闲连接,它挂机一整天都是好的,问题只在经过CDN的同时长时间空闲,并在特定的网络路径的组合下才会出现。
时间长
等到测试线上环境时,复现一次要 120s、300s 甚至更长时间,手动的操作成本太高了。而且等到了和没等到都说明不了什么问题。
界面状态也会错
公告弹窗使用 connected 控制按钮的
而 connected 只由 onOpen 和 onClose 驱动。半开连接下 onClose是不会发送的,这个时候 connected 就一直是 true 界面上就会显示已连接,按钮也会处于可用状态,但点下去 noting happen
CDN 看不到内部状态
回收策略、节点切换、会不会发 FIN 这些都完全不可控。我只能看到结果,就像 vibe coding 一样。
能用的线索
实际能依靠的差不多就这几些:
这几样里面,临时探针给出的判断最直接。安静一段时间之后,如果 readyState 始终是 1,距离上次收到帧的时间却一直在增加,那这条连接基本就可以认定已经死了。
不过这些手段都只能确认连接确实已经失效,既没法复现,也没法证明修好了。所以后来换了个做法:把失败模式写进测试里,让测试去等它出现。
加心跳?这不对呀!
排查到一半,最容易得出的结论是连接被 CDN 掐了,加个心跳就行,但越修越不对劲:“我靠,这个心跳也会出现同样的问题呀!”
改造方案
所有失败路径都汇到 fail() 里:握手超时、send 抛异常、探测超时、onclose、onerror
1. 先探测,再判断
最简单的做法是超过 N 秒没有消息就重连,但公告这个场景用不了:几个小时内没有业务消息本来就是正常的。
所以改成了两步:先发一帧探测,等不到回应再判定失败。
第一阶段只发一个 ping,不立刻判死,10 秒内收到任何帧都算正常。任何入站帧都算,不只是业务 JSON:服务端还在给我发心跳,这本身就能说明链路是通的。如果只认业务消息,公告连接会被反复误杀。
这个探测之所以能成立,靠的是服务端已有的对称处理:框架收到客户端的 'ping' 会马上回 'pong'(framework/framework/server.ts:697-698)。相当于发一条一定会被应答的帧,拿应答来当证据。
但为什么定 120 秒?服务端的心跳周期是 40 秒,120 秒刚好是连续错过三次。正常连接不会走到这个分支,走到了就说明入站方向至少已经断了两分钟。再短的话会和正常心跳的节奏撞在一起,正常连接也会频繁进入探测状态。
客户端自己不再按 40 秒发一次心跳,是因为它要等的正是“服务端的心跳停了”这个信号。按同样的频率探测,只会多出一堆无谓的流量和状态抖动。
2. 不等 close,主动丢弃旧 socket
既然 close 事件可能不会来,那就不等它。判定失败之后,直接旧 socket 放掉。
这里的顺序是有讲究的。先清掉 wsRef.current,让旧 socket 的迟到事件过不了 current(ws) 检查;再摘掉 handler,避免它的 onclose 又触发一次失败处理;最后才调用 close(),这一次调用只是尽力而为的清理。
这是重连逻辑里最常见的竞态:
如果没有 current(ws) 这一层检查,最后那个迟到的 onclose 会再 fail(),把刚建好的连接 retire 掉。这样一来每条连接都活不过一个 RTT,页面会一直停在重连状态。
3. 用关闭码区分该不该重连
服务端关连接有不同的理由,客户端得能分:
这三种情况下重连没有意义,有时反而有害:
1000 是特意排除在外的。按直觉,1000 表示正常关闭,应该停下来;但在真实网络里,1000 也可能来自代理重启、服务端优雅重启或者 CDN 节点下线。传输层是正常关闭,业务却不一定结束。
4. 页面事件作为恢复触发
除了定时器,还挂了几个浏览器事件:
事件
处理
online
立刻探测,可以跳过正在等待的 3 秒退避
offline
只记一个标记,不判死
focus
探测
pageshow
探测
visibilitychange
页面变可见时探测
offline 只做提示:
navigator.onLine 为 false 的时候连接大概率不可用,为 true 也不代表 OJ 可达,DNS、代理、CDN、服务端任何一个出问题都算。所以 offline 只记标记,online 触发一次探测,探测失败才重建。
online 还顺手解决了一个小问题:可以跳过正在等待的退避。
网络刚恢复的时候,没必要再等 3 秒。
5. 浏览器挂起和时钟回拨
这两个问题在写的时候都容易漏掉,但线上确实都会遇到。
时钟回拨:
用户手动改时间,或者 NTP 把时钟往回拨,now - lastReceived 会变成负数。不处理的话,检测逻辑可能停摆几个小时。
挂起后的延迟 tick:
浏览器回到前台时,setInterval 可能只补一次 tick,而 Date.now() 已经过去了半小时。如果直接按探测超时处理,刚醒来的健康连接会被立刻误杀。这里的做法是:发现 tick 被推迟了,就重新发一次 ping,再给一个新的 10 秒窗口。
完整链路
把上面这些拼起来之后,所有失败路径最后都收敛到一个函数里:
这个函数保证了三件事:旧连接一定会被 retire;终止码会拦住后续的重连,并且清掉队列和定时器;onClose 回调一定会执行,而重连放在 finally 里,即使回调抛错也不影响恢复。
重连本身做了去重:
reconnectTimer !== undefined 保证同一时间只有一个待执行的重连任务。
连接建立后统一重置状态:
队列是先冲刷排队中挤压的消息,再调用 onOpen。业务在 onOpen 里恢复订阅,这样处理不会和旧消息的顺序打架。
send 也补上了错误处理:
canQueue 在终止、卸载和切换域的时候都会被置为 false,并清空队列。队列不能跨连接上下文存活,否则 A 域没发出去的消息,可能在 B 域的连接上被发出去。
巡检的周期与探测窗口同步:
默认探测超时时间是 10s,巡检时间也是 10s,把业务窗口时间调小时,巡检也会变密,同时拥有 1s 的下限,防止主线程被占满。
服务端需要配合
自动重连有一个前提:重连之后,客户端要能获取到它错过的状态。服务端如果只是重新接受连接但什么都不推,那客户端连上了也什么都不知道,所以这次改动是联合后端一起做的。
公告
每次连接都会执行 prepare(),末尾是 refresh(),把未读公告重新推一遍:
客户端不需要补位,重连本身也是一次同步。
而确认操作也是幂等的:
这样的话,重连导致同一条确认发两次也不会出问题。
记录详情
每次建立连接,服务端都需要把当前的记录推送一次,而客户端只需要允许重连即可。
记录列表
列表连接是无状态的,所以客户端需要告诉服务端自己在看哪些记录
服务端在收到 rids 后,会把当前的状态重放一遍
onOpen 在这里顺便承担了重订阅的职责。
比赛计时
计时在这个业务里存在一个 30s 的租约约束。
服务端结算时,只有上一次上报到这一次上报的间隔不超过 30 秒,这段时间才计入:
客户端每 10s 上报一次,正常都在租约范围内,断开连接超过 30s 时,租约国企,这段空白不会追溯计入。
所以计时对重连的延迟特别敏感。3 秒的重连间隔基本不会丢时间;而在 Hydro 的旧实现里,一个挂着的半开连接会一直丢到用户自己刷新页面为止,中间几十分钟甚至几个小时都不会计入。
客户端把状态标记成未同步,重连后马上重报当前是否在前台。服务端在 prepare() 里校验报名信息,顺便把已累计时间发回来:
结尾
至此,所有的改动就结束了。
其实这次排查与改动并没有引入什么新的机制,心跳、重连在 Hydro 时代就已经拥有了,但我在 HydroNext 中引入了“已死判断”,让系统更加健壮,并且更好的支持了 EdgeOneCDN。没错,仔细回想一下,这些问题只是因为引入一个 CDN 导致的,其实这侧面反映出了系统在设计之初就没有对面向客户端的 WS 连接做更健壮的处理方式,好在引入了这一系列的判断与处理,最终弥补了这一问题。