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% 就要查网关。监控指标的具体落地方案可以参考 连接管理详解,按层拆指标、按阈值配告警。连接数本身也要做容量规划:单节点扛两万连接是常见的经验上限,超过就横向扩,别迷信单机性能。

小结

连接稳定靠「应用层心跳 + 双向判超时」;静默掉线靠「全链路超时对齐 + 回前台重连 + 指数退避」。把假在线彻底排掉,接入层才算站住。

延伸阅读