2.1 连接管理:Socket 与心跳保活
接入层的第一件事是把长连接建稳:连接怎么建、怎么喂、怎么发现静默掉线。白板最大的隐患是「假在线」——连接其实早被网关掐了,前端却以为还连着,画了没人看、撤了别人不知道。本篇讲 Socket 建连与心跳保活的完整做法。
连接生命周期
| 阶段 | 客户端要做 | 服务端要做 |
|---|---|---|
| 建连 | 发起 WebSocket 升级 | 校验 token、分配连接 |
| 心跳 | 定期 ping | 回 pong、计时 |
| 正常 | 收发事件 | 路由、广播 |
| 掉线 | 超时触发重连 | 清理会话 |
核心是双向都确认活着:单靠 TCP 的超时不可靠,必须应用层心跳。
心跳怎么喂
| 参数 | 建议 | 说明 |
|---|---|---|
| 心跳间隔 | 30~45s | 小于网关空闲超时 |
| 超时判定 | 2~3 个周期无 pong | 判死,触发重连 |
| 心跳内容 | ping 带时间戳 | 顺带测 RTT |
| 重连 | 指数退避 | 避免雪崩 |
要点是「客户端主动、服务端应答、双方都判超时」。只发不收,连接其实死了也发现不了;必须等 pong,等不到才判断。
静默掉线怎么破
长连接静默掉线有三类:① 网关空闲超时(最常见);② 客户端切后台 / 锁屏,浏览器把定时器冻住;③ 弱网丢包。对策都要落在「检测 + 重连」上: | 现象 | 根因 | 对策 | |---|---|---| | 定时批量掉线 | 网关空闲超时 | 心跳喂住 + 全链路超对齐 | | 切回来不更新 | 后台冻结定时器 | 前台可见事件重连 | | 偶尔丢事件 | 弱网丢包 | 序号 + 补拉 |
踩坑提示
切后台重连是移动端和桌面最坑的:浏览器锁屏时 JS 定时器被挂起,心跳停了,但前端没感知,等用户切回来发现界面卡在「别人早画完的版本」。必须在 visibilitychange 回前台时主动重连,并拉一次「断线期间的增量」补齐。再一个坑:重连要用指数退避,一千房间同时断网同时重试会瞬间打爆服务。
建连握手:不只是升级 WebSocket
很多人以为建连就是发起 WebSocket 升级一连了事,但协作白板的连接从握手那一刻起就要携带业务语义:这个连接属于哪个用户、要进哪个房间、权限够不够。推荐的握手流程是把鉴权放在 HTTP 升级请求的查询参数里,服务端在升级完成前做完校验,校验不过直接拒绝,连接根本建不起来。
| 握手要素 | 放哪 | 校验什么 |
|---|---|---|
| token | 查询参数 | 登录态、有效期 |
| roomId | 查询参数 | 房间存在、是否加密 |
| 权限 | 服务端查库 | 只读 / 可编辑 |
| 客户端版本 | 协议头字段 | 协议是否兼容 |
服务端拿到 token 后要做两件事:把 userId 绑定到 socket 上(后续广播认人全靠它),同时把 socket 注册到「用户 → 连接」表里。一个用户多端登录时,每端各占一条连接,互不干扰,广播时按端各自推一遍即可。
// 服务端握手:校验并绑定会话
wss.on('connection', (socket, req) => {
const { token, roomId } = parseQuery(req.url);
const user = verifyToken(token); // 失败直接关闭
if (!user) return socket.close(4001, 'bad token');
socket.user = user;
socket.roomId = roomId;
joinRoom(roomId, socket); // 详见 2.2 房间模型
socket.send(welcome(user.lastSeq)); // 顺带回房间最新 seq
});
握手阶段还有一个容易忽略的点:回一个 welcome 事件,把房间当前最新 seq 告诉客户端。客户端拿它和本地 seq 对比,就能立刻判断要不要先补一轮缺口,这为后面的断线重连打下了基础。
心跳的工程实现
心跳逻辑双方都要写,而且都要「带判超时」。下面是一段可直接套用的客户端心跳,要点是 ping 带时间戳、等 pong、超时判死:
// 客户端心跳:主动发、等应答、判超时
let pingTimer = null, loss = 0;
function startHeartbeat(ws) {
clearInterval(pingTimer);
pingTimer = setInterval(() => {
if (loss >= 3) { ws.close(); reconnect(); return; }
ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
loss += 1;
}, 30 * 1000);
}
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'pong') { loss = 0; rtt = Date.now() - msg.ts; }
};
服务端只需被动应答,但要顺手做两件事:记录每个连接的 lastPong 时间;开个扫描器定期回收 pong 超时的僵尸连接。客户端定时器可能被系统冻结,服务端不能跟着一起睡,否则假在线永远清不干净。
| 角色 | 心跳职责 | 判超时方式 |
|---|---|---|
| 客户端 | 发 ping、等 pong | 连续 3 次未收到 pong 判死 |
| 服务端 | 回 pong、记时间 | 扫描 lastPong 超过 90s 则踢 |
全链路超时对齐
静默掉线的头号元凶是「中间网关的空闲超时」比心跳间隔短。部署在云上时,连接要穿过负载均衡、Nginx、CDN 多层代理,任何一层的空闲超时都会先于你的应用把连接掐掉,而两端都毫不知情。必须逐层排查、全部对齐:
| 链路环节 | 常见默认空闲超时 | 建议值 |
|---|---|---|
| 云负载均衡 | 60s | 调到 300s 以上 |
| Nginx 代理 | 60s | 读超时设 300s |
| CDN 回源 | 60~120s | 与心跳取最小公约 |
| 应用层 | 自定义 | 心跳 30~45s 判死 90s |
一个取巧的做法:心跳间隔取所有环节超时值的三分之一以内,留够余量。若某层超实在改不动(比如共享的 Serverless 网关),就把心跳压到 20s,宁可多点流量,也不能让连接假死。打算把白板部署到 Serverless 边缘节点的同学,可以先看 云函数 Node 部署指南 里关于网关超时的章节,能少踩不少坑。
连接监控与告警
接入层的稳定性最后要落到监控上。三个核心指标必须配齐告警:并发连接数(看容量)、pong 延迟分位值(看网络质量)、异常关闭码分布(看断线根因)。WebSocket 的 close code 是免费的排障线索:
| close code | 含义 | 处理 |
|---|---|---|
| 1000 | 正常关闭 | 不重连 |
| 1006 | 异常断开(未握手完成) | 立即重连 |
| 4001 | 鉴权失败 | 停止重连,弹登录 |
| 4002 | 被踢(异地登录) | 停止重连,提示用户 |
告警阈值建议:单节点连接数超过容量的 70% 就扩容;pong 中位数 RTT 超过 500ms 说明链路在劣化;1006 占比小时级超过 5% 就要查网关。监控指标的具体落地方案可以参考 连接管理详解,按层拆指标、按阈值配告警。连接数本身也要做容量规划:单节点扛两万连接是常见的经验上限,超过就横向扩,别迷信单机性能。
小结
连接稳定靠「应用层心跳 + 双向判超时」;静默掉线靠「全链路超时对齐 + 回前台重连 + 指数退避」。把假在线彻底排掉,接入层才算站住。
