2023 年我给一个后台系统加实时通知功能,选了 WebSocket。Demo 阶段体验丝滑:服务端一推,前端立刻弹出通知,我对着产品经理演示,他很满意。上线后却陆续收到反馈:有用户说"通知有时候收不到",有用户说"页面挂了一会儿,通知就再也不来了,刷新一下才恢复"。我本地怎么都复现不出来——开发环境里 WebSocket 稳得很。后来才定位到根因:用户的网络中间往往隔着 NAT 网关、公司代理、负载均衡,这些设备会把"一段时间没有数据往来"的连接静默地关掉。关键就在"静默"二字:连接被中间设备掐断了,但客户端和服务端谁都没收到通知,双方还以为连接好好的——客户端在一条已经死掉的连接上痴痴地等消息,服务端往一条已经死掉的连接上推消息,消息就这么凭空蒸发了。这就是所谓的"半开连接"。我原来的认知是"WebSocket 建立了长连接,连上了就一直连着,不用我操心",而真相恰恰相反:一条长连接是需要你主动地、持续地去维护它"还活着"这件事的——你不去探活,它就会在某个你看不见的地方悄悄烂掉。那次之后我才认真把 WebSocket 这套东西从头搞明白。这篇文章就把它梳理一遍:WebSocket 到底是什么、连接怎么建立、为什么会悄悄断、心跳和重连怎么做,以及把实时通信真正做稳要避开的那些坑。
问题背景
先把那次事故的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:用 WebSocket 做实时通知,本地和 Demo 一切正常;上线后用户偶发性地"收不到通知",或者页面空闲一段时间后通知彻底不再来,刷新页面才恢复。服务端日志里没有任何报错。
我当时的错误认知:"WebSocket 是长连接,一次连上就会一直保持着,中间不用我做任何事,服务端发了客户端就一定能收到。"
真相:一条 WebSocket 长连接,会被它途经的 NAT、代理、负载均衡因为"空闲太久"而静默关闭——而且这种关闭不一定会通知到连接的两端。于是出现"半开连接":一端(甚至两端)以为连接还在,实际上数据根本传不过去。长连接的"活着"不是一个一劳永逸的状态,而是需要靠心跳持续探测、靠重连及时恢复的、动态维持的结果。
要把实时通信做稳,需要几块认知:
- WebSocket 和 HTTP 的关系,它凭什么能做到全双工长连接;
- 一次连接是怎么从 HTTP 握手"升级"上来的;
- 连接为什么会"悄悄"断开,半开连接是怎么回事;
- 心跳机制怎么探活,断线后怎么自动重连;
- 消息可靠性、连接管理、服务端重启这些工程坑怎么处理。
一、WebSocket 是什么:HTTP 之外的全双工长连接
要理解 WebSocket,最好的办法是把它和我们最熟的 HTTP 对照着看。
HTTP 是"一问一答"的。客户端发一个请求,服务端回一个响应,然后这次交互就结束了。它是单向发起的——只能客户端问、服务端答,服务端没办法主动找客户端说话。早年要做"实时",只能靠轮询(客户端每隔几秒问一次"有新消息吗"),既浪费又不及时。
WebSocket 是"全双工长连接"。全双工,意味着连接建立后,客户端和服务端任意一方都可以随时主动地向对方发数据,不需要对方先开口。长连接,意味着这条连接一次建立后会一直保持着,不像 HTTP 那样一次交互就关。这两点加起来,服务端才终于能做到"有新通知,立刻推给客户端"。
WebSocket 和 HTTP 不是对立关系——它其实借用 HTTP 来完成最开始的握手,握手成功后,再把这条 TCP 连接"升级"成 WebSocket 协议,后续的数据就以 WebSocket 数据帧的形式收发,不再是 HTTP 报文了。下面先看浏览器端建立一个连接长什么样:
// 浏览器端:建立一个 WebSocket 连接(wss 是加密版,类比 https)
const ws = new WebSocket("wss://api.example.com/notify");
// 连接建立成功
ws.onopen = () => {
console.log("连接已建立");
};
// 收到服务端"主动推"过来的消息 —— 这是 HTTP 做不到的
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("收到通知:", data);
};
// 连接关闭 —— 注意:正常关闭和异常断开,都会走这个回调
ws.onclose = (event) => {
console.log("连接关闭, code =", event.code);
};
ws.onerror = (err) => {
console.error("连接出错", err);
};
再看服务端。下面用 Node.js 的 ws 库起一个最简单的 WebSocket 服务,接收消息、也主动推送消息:
const { WebSocketServer } = require("ws");
// 服务端:监听 WebSocket 连接
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket, request) => {
console.log("新连接来自", request.socket.remoteAddress);
// 收到这个客户端发来的消息
socket.on("message", (raw) => {
const msg = JSON.parse(raw);
console.log("收到客户端消息:", msg);
});
// 服务端可以随时主动向这个客户端推消息
socket.send(JSON.stringify({ type: "welcome", text: "连接成功" }));
socket.on("close", () => console.log("一个连接已断开"));
});
二、连接怎么建立:从 HTTP 握手"升级"上来
上一节说 WebSocket "借 HTTP 握手",这一节把这个过程讲细。理解握手,后面排查"连不上""被网关拦截"这类问题才有抓手。
整个过程是这样的:客户端先发出一个特殊的 HTTP GET 请求,这个请求带着几个关键的头部——Upgrade: websocket 和 Connection: Upgrade,意思是"我想把这条连接升级成 WebSocket";还带一个 Sec-WebSocket-Key,是个随机值,用来做校验。服务端如果同意,就回一个状态码 101 Switching Protocols(注意不是 200),并在 Sec-WebSocket-Accept 里回一个根据上面那个 key 算出来的值。客户端校验通过,握手就完成了——从这一刻起,这条底层的 TCP 连接就不再说 HTTP,改说 WebSocket 协议了。
下面是一次握手的完整报文,左边请求、右边响应,看清楚那几个关键头部:
# 客户端发出的握手请求 —— 本质是一个带特殊头部的 HTTP GET GET /notify HTTP/1.1 Host: api.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 # 服务端同意升级,返回 101(而不是 200)—— 之后这条连接归 WebSocket 用 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
这里有个排查时的实用知识点:因为握手是标准 HTTP,所以它会受到所有 HTTP 中间层的影响。如果你的 WebSocket 连不上,很可能是某个反向代理(比如 Nginx)没有配置转发 Upgrade 和 Connection 这两个头——代理默认不会透传它们,握手就升级不成功。另外,生产环境务必用 wss://(基于 TLS 的加密连接),它不仅安全,还能让连接更不容易被中间设备干扰和篡改。
三、连接为什么会"悄悄"断开:半开连接的真相
连接建立起来了,真正的难题才开始:它会断,而且常常是悄无声息地断。开头那场事故的核心,就在这里。
第一个原因是中间设备的空闲超时。一条 WebSocket 连接,从客户端到服务端,中间会穿过家用路由器的 NAT、运营商网关、公司代理、云负载均衡器等等一长串设备。这些设备为了节省资源,普遍有一个策略:一条连接如果在一段时间内(可能只有几十秒到几分钟)没有任何数据往来,就把它当成废弃连接回收掉。而实时通知这种场景,恰恰经常是长时间没消息——结果连接就在这种"安静"中被中间某个设备掐断了。
第二个、也是最要命的原因,是这种断开不保证通知到两端。理想情况下,连接关闭时双方都会收到一个关闭信号。但当连接是被中间设备粗暴丢弃、或者某一端的网络突然中断时,另一端往往什么信号都收不到。它会一直以为连接是好的:服务端继续往这条"连接"上 send,数据发进了一个黑洞;客户端继续在这条"连接"上等 onmessage,永远等不到。这就是半开连接(half-open connection)——TCP 层面连接的一端已死,另一端却毫不知情。
你可能会问:TCP 自己不是有 keepalive 机制吗?有,但默认它要等大约 2 小时才发第一个探测包,这个间隔对实时业务毫无意义。所以结论很明确:你不能依赖底层自动帮你发现连接死亡,必须在应用层自己做一套探活机制——这就是下一节的心跳。下面这段代码先展示"天真的服务端"会怎么栽在半开连接上:
// 反面教材:一个没有任何探活机制的"天真"推送
function naivePush(socket, payload) {
// 问题:即使这条连接早已是半开状态(对端已死),
// socket.readyState 可能仍然显示 OPEN,send 也不会报错 ——
// 数据被发进了黑洞,你却以为推送成功了。
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(payload));
console.log("以为推送成功了……但对端可能根本收不到");
}
}
// 真相:readyState === OPEN 只代表"本端没主动关过这条连接",
// 它完全不能证明"对端还活着、链路还通"。
// 想知道连接是否真的可用,唯一办法是主动探测 —— 心跳。
四、心跳机制:主动探活,揪出半开连接
既然不能等底层来告诉你连接死没死,那就主动去问。这就是心跳(heartbeat)机制——周期性地给对端发一个小小的探测,看它还应不应。
WebSocket 协议本身就内置了一对专门用于心跳的控制帧:ping 和 pong。一端发 ping,另一端收到后会(由协议栈自动)回一个 pong。浏览器端尤其方便——它对收到的 ping 会自动回 pong,不需要你写任何 JavaScript。所以最常见、最省事的方案是:服务端主导心跳。服务端定期给每个连接发 ping,并记录这个连接"最近一次回 pong 是什么时候";如果一个连接连续几个周期都没回过 pong,就判定它已经是半开连接,服务端主动把它关掉、清理掉。
下面是服务端心跳的完整实现——给每个连接打一个"存活标记",每个周期检查并重置:
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
// 给每个连接一个"存活标记",新连接默认存活
socket.isAlive = true;
// 收到客户端自动回的 pong,就把标记重新置为存活
socket.on("pong", () => { socket.isAlive = true; });
});
// 每 30 秒一轮:检查所有连接是否在上一轮之后回过 pong
const timer = setInterval(() => {
wss.clients.forEach((socket) => {
if (socket.isAlive === false) {
// 上一轮 ping 之后一直没回 pong —— 判定为半开连接,强制关闭
console.log("检测到半开连接,主动终止");
return socket.terminate();
}
// 先把标记置为 false,再发 ping;若对端活着,会回 pong 把它改回 true
socket.isAlive = false;
socket.ping();
});
}, 30000);
wss.on("close", () => clearInterval(timer));
服务端的 ping/pong 能帮服务端发现"客户端死了"。但反过来,客户端也需要知道"服务端死了"或"链路断了"。浏览器的 WebSocket API 不允许 JS 主动发协议层 ping,所以客户端的探活通常做在应用层:客户端定时发一条自定义的心跳消息,并期待服务端在一定时间内回应;如果迟迟收不到回应,就判定连接已坏,主动关闭并触发重连。下面是客户端侧的应用层心跳:
function setupClientHeartbeat(ws) {
let pongTimer = null;
// 每 25 秒发一条应用层心跳
const pingTimer = setInterval(() => {
if (ws.readyState !== ws.OPEN) return;
ws.send(JSON.stringify({ type: "ping" }));
// 发出后启动一个超时:10 秒内没等到服务端的 pong,就认定连接已死
pongTimer = setTimeout(() => {
console.warn("心跳超时,连接已不可用,主动关闭以触发重连");
ws.close(); // 主动关闭 -> 触发 onclose -> 进入重连流程
}, 10000);
}, 25000);
ws.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
// 收到服务端的心跳回应,取消那个"超时判死"的定时器
if (msg.type === "pong") clearTimeout(pongTimer);
});
ws.addEventListener("close", () => {
clearInterval(pingTimer);
clearTimeout(pongTimer);
});
}
服务端和客户端的两套心跳合在一起,才算把"探活"这件事做完整:服务端的 ping/pong 帮服务端及时清掉死连接、不再往黑洞里推消息;客户端的应用层心跳帮客户端及时发现链路坏了,从而触发下一步——重连。
五、断线重连:连接一定会断,关键是断了之后
心跳的作用是"发现连接死了",而发现之后,要让用户无感地恢复,就得有"重连"。这里要先扭转一个心态:不要把断线当成异常,要把它当成常态。用户从 Wi-Fi 切到 4G、电梯里信号中断、笔记本合盖休眠又唤醒——这些都会断连,而且会频繁发生。一个健壮的实时应用,设计时就该假设"连接随时会断",把自动重连做成默认能力。
重连有一个最容易踩的坑:断了就立刻重连、连不上就马上再连。设想服务端刚重启,几万个客户端在同一秒全部断线,如果它们都"立即重连",这几万个连接请求会在同一瞬间砸向刚起来的服务端,把它再次冲垮——这就是"重连风暴"(惊群效应)。
正确的做法是指数退避(exponential backoff):第一次重连等 1 秒,失败了等 2 秒,再失败等 4 秒、8 秒……间隔逐次翻倍,并设一个上限(比如 30 秒)。再叠加一个关键细节——随机抖动(jitter):在退避时间上加一个随机值,让原本会"齐步走"的几万个客户端把重连时间打散开,服务端的压力就被摊平了。下面是一个带指数退避和抖动的重连封装:
function createResilientWS(url, { onMessage }) {
let ws = null;
let retries = 0; // 连续重连失败的次数
let manualClose = false; // 是否是调用方主动关闭(主动关就不该重连)
function connect() {
ws = new WebSocket(url);
ws.onopen = () => {
retries = 0; // 连上了,退避计数清零
console.log("连接已建立");
};
ws.onmessage = (event) => onMessage(JSON.parse(event.data));
ws.onclose = () => {
if (manualClose) return; // 主动关闭的,不触发重连
// 指数退避:1s, 2s, 4s, 8s... 封顶 30s
const base = Math.min(30000, 1000 * 2 ** retries);
// 关键:加一段随机抖动,把大量客户端的重连时间打散开
const delay = base + Math.random() * 1000;
retries++;
console.warn(`连接断开,${Math.round(delay)}ms 后第 ${retries} 次重连`);
setTimeout(connect, delay);
};
}
connect();
return {
send: (data) => ws?.readyState === ws.OPEN && ws.send(JSON.stringify(data)),
close: () => { manualClose = true; ws?.close(); }, // 主动关闭
};
}
重连还有一个比"连上"更深的问题:连上之后,状态怎么对齐?断线的那几秒、几十秒里,服务端可能产生了新消息;重连成功只是恢复了"管道",并不会自动把断线期间的消息补回来。所以重连成功后,客户端通常要做一次状态同步:比如带上"我最后收到的消息 ID",请求服务端把这之后的消息补发过来。把这一步漏掉,就会出现"断线期间的通知永久丢失"——这又是开头那个"通知收不到"的一种成因。
六、工程坑:消息可靠、连接管理、优雅重启、握手鉴权
心跳和重连解决了"连接活着"的问题,但要把实时通信真正用进生产,还有几个绕不开的工程坑。
坑 1:WebSocket 不保证消息送达,需要应用层 ack。很多人以为"send 出去了对方就一定收到"。并非如此——消息可能在连接断开的瞬间发出,卡在半路丢掉;send 本身不会报错。要做可靠投递,得在应用层加一套确认机制:每条消息带一个唯一 ID,发出后进入"待确认队列",收到对端回的 ack 才移除;超时没收到 ack 就重发。下面是发送端的可靠投递:
// 发送端:每条消息带唯一 id,发出后进"待确认队列",
// 收到 ack 才移除;超时未确认就重发(重连后也靠它补发积压消息)。
const pending = new Map(); // msgId -> { payload, timer }
function sendReliable(ws, payload) {
const id = crypto.randomUUID();
const msg = { id, type: "data", payload };
function deliver() {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
// 3 秒没收到 ack 就再发一次
const timer = setTimeout(deliver, 3000);
pending.set(id, { payload, timer });
}
deliver();
}
// 收到对端回的 ack:把对应消息从待确认队列里清掉
function onAck(msgId) {
const entry = pending.get(msgId);
if (entry) {
clearTimeout(entry.timer);
pending.delete(msgId);
}
}
这里有个连带的点:既然会"重发",接收端就必须按消息 ID 去重(幂等处理),否则一条通知可能被展示两次。"重发 + 去重"是一对,缺一不可。
坑 2:服务端必须管理连接,才能定向推送。WebSocket 服务端手里是一堆 socket,但业务要的是"给用户 A 推一条消息"。这中间需要一张注册表:把"用户 ID → 连接"的映射维护起来。要注意:一个用户可能多端登录(手机 + 网页),所以一个 userId 要对应一个连接集合;还有连接关闭时务必从注册表里删掉,否则注册表会越积越大、还会往死连接上推消息。
// 服务端:维护"用户 -> 连接集合"的注册表,才能定向推送和广播。
const registry = new Map(); // userId -> Set<socket>
function register(userId, socket) {
if (!registry.has(userId)) registry.set(userId, new Set());
registry.get(userId).add(socket);
// 连接一关闭,必须从注册表移除,否则会泄漏 + 往死连接推消息
socket.on("close", () => {
const set = registry.get(userId);
set?.delete(socket);
if (set && set.size === 0) registry.delete(userId);
});
}
// 给某个用户的所有在线设备推送(一个用户可能多端登录)
function pushToUser(userId, payload) {
const set = registry.get(userId);
if (!set) return; // 用户不在线 —— 可转存为离线消息
for (const socket of set) {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
}
还要补一句:这张注册表是存在单台服务器内存里的。一旦服务端要多实例部署,用户 A 连在实例 1、要给他推消息的业务跑在实例 2,实例 2 的注册表里根本没有 A——这时就需要借助 Redis 发布订阅或专门的消息网关在实例间转发。单机注册表是起点,不是终点。
坑 3:服务端重启要优雅,别直接 kill。直接杀进程,所有连接会以异常码 1006 断开,客户端会立刻无脑重连,正好制造一次重连风暴。优雅的做法是:重启前先主动给每个客户端发一个"我要重启了"的信号,并用一个表示"计划内重启"的关闭码(如 1012 Service Restart)关闭连接,让客户端知道这是预期内的、可以错开时间再重连。
// 服务端优雅下线:先通知客户端,再关闭,别让它们一窝蜂立即重连。
function gracefulShutdown(wss) {
console.log("收到重启信号,开始优雅下线");
for (const socket of wss.clients) {
if (socket.readyState === socket.OPEN) {
// 先发一条业务层通知,客户端可据此错开重连时间
socket.send(JSON.stringify({ type: "server_restarting" }));
// 用 1012(Service Restart)而不是任由它变成 1006 异常关闭
socket.close(1012, "server restart");
}
}
// 给在途消息留一点收尾时间,再真正关掉监听
setTimeout(() => wss.close(() => process.exit(0)), 3000);
}
process.on("SIGTERM", () => gracefulShutdown(wss));
坑 4:鉴权要放在握手阶段做。WebSocket 连接一旦升级成功就长期存在,不像 HTTP 那样每个请求都自然带鉴权。所以必须在握手那一下就完成鉴权——把 token 放在握手请求的 URL 参数或头部里,服务端在升级成 WebSocket 之前校验,不合法直接拒绝,根本不让它升级成功。把鉴权拖到"连上之后再用第一条消息验证"是不安全的,那意味着未鉴权的连接已经建立了。
const { WebSocketServer } = require("ws");
// noServer 模式:由我们自己接管 HTTP 的 upgrade 事件
const wss = new WebSocketServer({ noServer: true });
httpServer.on("upgrade", (req, socket, head) => {
const token = new URL(req.url, "http://x").searchParams.get("token");
const user = verifyToken(token); // 你自己的鉴权逻辑
if (!user) {
// 握手阶段就拒绝:回 401,根本不升级成 WebSocket
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
// 鉴权通过,才完成协议升级,并把用户身份挂到连接上
wss.handleUpgrade(req, socket, head, (ws) => {
ws.userId = user.id; // 后续推送、注册表都靠它
wss.emit("connection", ws, req);
});
});
下面这张图把一条 WebSocket 连接从建立、维持到断开重连的完整生命周期串起来:
关键概念速查
| 概念 / 机制 | 说明 |
|---|---|
| WebSocket | 全双工长连接,建立后服务端可随时主动向客户端推送 |
| 协议升级 | 客户端发带 Upgrade 头的 HTTP GET,服务端回 101 Switching Protocols |
| 空闲超时 | NAT、代理、负载均衡会把长时间无数据的连接静默回收 |
| 半开连接 | 连接一端已死,另一端毫不知情,数据发进黑洞 |
| 心跳 ping/pong | 周期性主动探测对端是否存活,揪出半开连接 |
| readyState | 只代表本端没关过连接,不能证明对端活着、链路还通 |
| 指数退避 | 重连间隔逐次翻倍并封顶,加随机抖动避免重连风暴 |
| 消息 ack | 消息带唯一 ID,收到确认才算送达,超时则重发 |
| 连接注册表 | 用户 ID 到连接集合的映射,支撑定向推送与广播 |
| 握手鉴权 | 在升级成 WebSocket 之前校验 token,非法连接直接拒绝 |
避坑清单
- 长连接的"活着"不是一劳永逸的状态,要靠心跳持续探测、靠重连及时恢复来动态维持。
- NAT、代理、负载均衡会因"空闲太久"静默关闭连接,且不保证通知到两端,于是产生半开连接。
- readyState === OPEN 只说明本端没关过连接,完全不能证明对端还活着、链路还通。
- 别依赖 TCP keepalive 发现连接死亡,它默认要等约 2 小时,必须在应用层自己做心跳。
- 服务端主导 ping/pong 心跳,连续几个周期没回 pong 的连接判为半开,主动 terminate 清理。
- 浏览器不能用 JS 发协议层 ping,客户端探活要做在应用层:定时发心跳消息并设回应超时。
- 把断线当常态而非异常,自动重连必须用指数退避 + 随机抖动,否则会引发重连风暴。
- 重连只恢复管道,不恢复消息;重连成功后要带"最后收到的消息 ID"做一次状态同步补发。
- WebSocket 不保证消息送达,可靠投递要靠应用层 ack + 超时重发,接收端按 ID 去重。
- 鉴权放在 HTTP 握手阶段做;服务端重启要优雅通知客户端;生产环境一律用 wss://。
总结
回头看那次"通知偶发丢失、页面挂一会儿就再也收不到"的事故,最该记住的不是某个心跳函数,而是我上线前那个想当然的认知——"WebSocket 是长连接,连上了就一直连着,不用我操心"。这句话错在哪?它把"长连接"理解成了一种静态的、自动维持的状态。而真相是:一条穿过 NAT、代理、负载均衡的长连接,会在你看不见的地方被悄悄掐断,断了还不一定通知你。所谓"连接活着",从来不是一个你建立完就拥有的状态,而是一个需要你用心跳持续探测、用重连不断修复的动态结果。
所以做实时通信,工程重心根本不在"怎么建立连接"——那行 new WebSocket() 谁都会写,Demo 阶段它也确实丝滑。真正的重心在连接建立之后:你怎么知道它还活着(心跳)、它死了你怎么发现(探活超时)、发现了怎么恢复(指数退避重连)、恢复后怎么把断线期间漏掉的消息补回来(状态同步)、消息发出去到底有没有到(应用层 ack)。这篇文章的几节,其实就是顺着一条连接的生命周期展开的:先认清 WebSocket 是什么、怎么从 HTTP 握手升级上来;再理解它为什么会"悄悄"断、什么是半开连接;然后是发现问题的心跳、恢复问题的重连;最后是消息可靠性、连接管理、优雅重启、握手鉴权这些把它真正做稳的工程细节。
你会发现,这套思路和我们处理任何"不可靠资源"的工程经验是相通的。我们不相信网络一定通,所以有重试;不相信请求一定成功,所以有超时和降级;不相信下游一直健康,所以有熔断和探活。WebSocket 只是把这种"不可靠"以一种更持久、更隐蔽的形式摆在你面前——它不是一次请求的失败,而是一条长期存在的连接在某个时刻悄悄烂掉。你那套"假设它会坏,然后设计如何发现和恢复"的老经验,在这里一条都不浪费。
最后想说,实时通信这件事,做没做这些"连接维护",差距永远不会在 Demo 里暴露——Demo 的连接存活几分钟,网络又干净,怎么写都丝滑。它只在真实用户、弱网环境、长时间挂机、服务端重启之后才显形。所以别等用户来报"通知怎么又没收到",在你写下第一行 new WebSocket() 的时候就该想清楚:这条连接一定会断,我打算靠什么发现它断了、又靠什么把它和它承载的消息一起救回来。想清楚了,你的实时通信才不只是 Demo 里那一下漂亮的推送,而是一个能在地铁里、电梯里、信号忽好忽坏的真实世界中扛住的系统。
—— 别看了 · 2026