小程序里的 WebSocket 总掉线:心跳、重连与后台保活的实战方案
上个月我们小程序的客服消息模块上线第三天,用户群里的反馈很一致:锁屏几分钟再回来,消息就收不到了,得杀掉小程序重进才能恢复。后端日志里对应的是大量"客户端已断但服务端还没踢"的半开连接。这个模块一天掉线告警 400 多次,我们花了一周把连接层重写了一遍,掉线告警降到每天个位数。这篇文章把过程里踩的坑和最终的 reconnect 管理类完整写出来。
先搞清楚你的连接为什么会断
WebSocket 长连接断掉的原因,比大多数人以为的要杂。我们整理过一次线上日志,把断线归类成五类:

| 断线类型 | 典型触发场景 | 小程序端的特征 |
|---|---|---|
| 网关空闲超时 | Nginx proxy_read_timeout 默认 60 秒,期间没有任何数据往来 |
连接静默几分钟后被服务端主动关 |
| 客户端切后台 | 小程序 onHide 后微信可能挂起页面并断开 socket | onClose 触发,code 常见 1000 或 1006 |
| 网络切换 | Wi-Fi 切 4G、进电梯、地铁过隧道 | onSocketError 先来,随后 onClose |
| 服务端发布重启 | 后端发版,连接被批量踢 | 某个时间点集中出现大量 1006 |
| token 过期后鉴权拒绝 | 连接建立后服务端校验 token 失败 | 服务端回错误帧后关闭,重连也连不上 |
关键认知是:很多"掉线"其实是预期行为。小程序切后台,微信框架本来就可能挂起你的页面;服务端网关有读写超时;运营商网络切换。你没法阻止断开,只能让"断开→恢复"这个过程对用户无感。搞清楚这一点之后,整个方案的思路就清晰了——心跳用来撑住网关超时窗口,重连用来兜住各种意外断开,切后台的重置逻辑用来省掉无意义的重连尝试,消息补偿用来堵住断线期间丢的数据。
还有一类问题特别隐蔽:并发连接数限制。微信官方文档写得很明确,同一个小程序(同 appid)同时最多存在 5 个 WebSocket 连接。之前有同事不知道这条,聊天页开一个、行情推送开一个、另一个页面又偷偷开了一个,用户多跳几次页面之后,第 6 个 socket 直接创建失败,报 exceed max websocket connection count 5。这个问题在开发时不容易暴露,因为开发者工具里页面跳转少。
心跳包:间隔要和服务端超时窗口匹配
心跳的目的不是"保持活跃"这个玄学,而是非常具体的一件事:让中间链路上的每一台设备(Nginx、LB、运营商 NAT)都不因为静默而掐掉这条连接。
设计心跳前必须先确认服务端的超时窗口。以 Nginx 反代为例:
# 依赖:Nginx 1.20+;环境:网关层 WebSocket 反代配置
# WebSocket 升级必须带 Upgrade/Connection 头
# 超时参数是客户端心跳设计的直接依据,先定它们再定心跳间隔
location /ws/ {
# 只对 /ws/ 前缀的请求做升级,其他路径不受影响
proxy_pass http://backend;
# WebSocket 握手依赖 HTTP/1.1,默认 1.0 会导致升级失败
proxy_http_version 1.1;
# 这两个头是 WebSocket 升级的必需项,漏了握手直接失败
proxy_set_header Upgrade $http_upgrade;
# Connection 头的值固定写 upgrade,不要用变量
proxy_set_header Connection "upgrade";
# 读超时:60 秒内后端没有任何数据发往客户端就断开
# 这个值就是客户端心跳间隔的设计依据
proxy_read_timeout 60s;
# 写超时:客户端 60 秒没有发数据过来就断开
# 心跳间隔必须小于它,留一半以上余量
proxy_send_timeout 60s;
}
如果网关是 60 秒超时,客户端心跳间隔就不能大于 60 秒,还要给网络抖动留余量。我们实测后的选择是 25 秒发一次 ping,理由是:
- 小于网关超时的一半,即使某一次心跳在网络里晚到 10 秒,下一次心跳也赶在超时前到达;
- 也不能太密,25 秒一次对电量和流量几乎无感,5 秒一次在低端机上就有点费电了;
- 服务端回复 pong 的超时设 10 秒,两个周期没收到 pong 就判定连接已死,主动触发重连。
心跳的探测逻辑经常被忽略:发 ping 不够,还要等 pong。只管发不管收,遇到"TCP 连接还在但服务端进程已经假死"的情况,心跳会一直发得出去(写缓冲不报错),你以为连接健康,其实早就是一条僵尸连接。所以我们的判定是连续 2 个周期没有收到 pong,直接 closeSocket 然后走重连。
断线重连:指数退避 + 最大重试上限
重连最朴素的写法是 onClose 里直接 connectSocket,这个写法在服务端宕机时就是灾难——几千个客户端同时疯狂重连,服务端重启起来瞬间又被冲垮。标准解法是指数退避(Exponential Backoff):每次重试的间隔翻倍,加一个随机抖动(jitter),并设最大重试次数。
flowchart TD
A[onClose / onSocketError] --> B{是预期性断开吗?}
B -- 是: 切后台主动close --> C[标记 disconnected<br/>不重连, 等onShow]
B -- 否 --> D{未超最大重试次数?}
D -- 否 --> E[停止重试<br/>提示用户检查网络]
D -- 是 --> F[计算退避间隔<br/>base * 2^retry + 随机抖动]
F --> G[延时后 connectSocket]
G --> H{onOpen 成功?}
H -- 是 --> I[重置计数<br/>恢复心跳, 拉离线消息]
H -- 否 --> J[retry + 1]
J --> F
几个实践中必须处理的细节:
1. 间隔上限封顶。 2 的指数增长很快,第 7 次就是 64 秒了。一般封顶到 30~60 秒,否则用户等两分钟才重试一次,体验太差。我们的公式是 Math.min(30000, 1000 * 2 ** retry) + Math.random() * 3000。
2. 最大重试次数。 我们定 8 次,也就是从 2 秒开始大约 3 分钟内还连不上就放弃,转为提示状态。用户网络真断了的话,重试再多也没用,等 onShow 或网络状态变化事件再重置。
3. 防止重连风暴叠加。 onClose 和 onSocketError 可能连续触发多次,每次都起一个重连定时器就乱了。管理类里必须有一个 connecting 状态标记,同一时间只允许存在一个重连流程。
4. 网络恢复立即重试。 监听 wx.onNetworkStatusChange,网络从 none 恢复到 wifi/4g 时把重试计数清零立刻重连,不用傻等退避计时器走完。这一条对地铁场景特别有效——出了隧道信号一恢复,两秒内连接就回来了。
onClose 和 onSocketError 的分类处理
很多人把 onClose 里的 code 原样打条日志就完了,其实 code 是分类处理的关键输入。我们在管理类里是这样分的:
| 关闭 code | 含义 | 处理策略 |
|---|---|---|
| 1000 | 正常关闭 | 如果是自己主动 close 就不重连;服务端发的 1000 按需重连 |
| 1006 | 异常关闭(最常见的"掉线") | 走完整重连流程,指数退避 |
| 1005 | 无状态码关闭 | 记录日志,按 1006 处理 |
| 连不上 | 未成功 onOpen 就失败 | retry + 1,退避后重试 |
onSocketError 触发时通常后面会跟着 onClose,所以错误回调里只做两件事:记录错误信息(errno/msg 对排障很有用),以及标记当前连接不可用。真正的重连决策统一放在 onClose 里做,避免两个回调各自触发一套重连逻辑。
有个坑值得单独说:切后台主动断开时,onClose 也会触发。如果不在 close 之前打上"预期性断开"标记,onClose 回调会老老实实发起重连——用户明明已经离开页面,你还在后台烧电重连,等用户回来时重试次数已经耗尽,反而显示"连接已断开"。这就是下一节要解决的问题。
切后台与回前台:onHide/onShow 的连接保活
小程序的生命周期里,页面 onHide 之后微信随时可能挂起页面、回收资源,WebSocket 大概率会被系统断开。这里的正确姿势不是死扛着保持连接,而是配合平台行为:
- onHide:主动 closeSocket,并标记"预期性断开"。这样 onClose 触发时不会走重连。断开前把本地最新的消息 seq 存进 storage。
- onShow:先检查 socket 状态,如果已断开,立即重连(重试计数清零),成功后带上 seq 拉离线消息。如果在后台时间很短、连接居然还活着(部分机型不会立刻杀),发一个心跳确认连通性,收不到 pong 就重连。
这里有一个连接生命周期的时序,可以对照着理解 onHide/onShow 两端的行为差异:
sequenceDiagram
participant MP as 小程序前端
participant GW as 网关(Nginx)
participant SV as 业务服务端
MP->>GW: connectSocket(wss://...)
GW->>SV: 代理握手
SV-->>MP: onOpen(携带当前最大消息seq)
loop 每25秒
MP->>SV: {type:ping}
SV-->>MP: {type:pong}
end
Note over MP: onHide: 主动close并标记预期断开
MP->>SV: close(code=1000)
Note over SV: 服务端等待超时或收到close清理会话
Note over MP: 用户回前台 onShow
MP->>GW: connectSocket(带token)
GW->>SV: 握手
SV-->>MP: onOpen
MP->>SV: {type:sync, since:本地seq}
SV-->>MP: 离线消息批量下发
Note over MP: 心跳与重连计数全部重置
另外要区分两个容易混的 API:wx.closeSocket 是全局函数,如果你有多个连接需要传 socket 的 code 之外的标识——实际上小程序的多连接管理要靠 SocketTask。强烈建议从一开始就用 wx.connectSocket 返回的 SocketTask 实例来操作,它的 send、close、onMessage 都是实例级别的,多连接场景下不会串台。我们管理类里就只持有 SocketTask,不碰全局的 wx.onMessage。
回前台的时序还有一个细节:onShow 里别急着直接 connect,先调 wx.getNetworkType 确认网络可用。用户从后台回来时网络可能还在恢复中,直接连大概率失败还白白消耗一次重试计数。
域名、wss 与连接数限制的合规清单
长连接能跑起来之前,有几道合规门槛必须过:
1. 域名备案 + wss。 小程序的 socket 域名必须在微信公众平台配置 socket合法域名,协议必须是 wss://(生产环境不支持 ws),端口只允许 443。域名要求 ICP 备案,证书要是受信任 CA 签发的,自签证书在真机上必挂。开发阶段可以在开发者工具里勾选"不校验合法域名"临时绕过,但真机预览照样会校验,别抱侥幸。
2. 并发 5 个连接的上限。 同一 appid 最多 5 条 WebSocket。架构上建议收敛成单一长连接 + 消息类型分发:聊天、通知、状态推送全走一条连接,前端按消息 type 分发给不同的业务模块。这样既绕开了连接数上限,也把心跳和重连逻辑收拢到一处。真有多业务域隔离的需求,用进程内的事件总线拆,不要在 socket 层面拆。
3. 域名不要带 IP 和中文。 合法域名不支持 IP 地址,也不支持端口自定义(443 之外不行),这些在配置时就会报错,属于低级但常见的坑。
token 过期后的重连鉴权
长连接跑一天,登录 token 大概率会过期。这时候断线重连会用旧 token 握手,服务端直接拒绝,然后客户端进入无限重连循环——这是线上特别典型的一种死循环,日志表现为"重连 8 次全部 401"。
我们的处理办法是分层的:
- 握手 URL 里不塞 token,改为连接建立后第一帧发鉴权消息。这样重连时可以拿到最新 token。
- 连接前检查 token 剩余有效期,不足 60 秒就先静默刷新(小程序侧一般用 refresh_token 换新),再发起连接。
- 收到服务端回的鉴权失败帧(比如
{type:auth_fail})时,停止重连,先走一次 token 刷新,拿到新 token 后再重置重连计数重新连。避免拿同一个过期 token 撞墙 8 次。
// 依赖:微信小程序基础库 2.x;环境:小程序前端,无第三方依赖
// reconnect.js — 可复用的长连接重连管理类
class ReconnectSocket {
constructor(options) {
// 服务端 wss 地址,如 wss://api.example.com/ws/
this.url = options.url;
// 心跳间隔毫秒数,须小于网关超时窗口(本文按 25s 配)
this.heartbeatInterval = options.heartbeatInterval || 25000;
// pong 超时时间:连续两个周期收不到 pong 判定假死
this.pongTimeout = options.pongTimeout || 10000;
// 最大重试次数,超过后停手等用户操作或网络恢复
this.maxRetry = options.maxRetry || 8;
// 取 token 的函数由业务方注入,管理类不关心登录实现
this.getToken = options.getToken;
// 业务消息回调,收到非心跳帧时透传给页面
this.onMessage = options.onMessage;
// 连接状态变化回调:connected / idle / failed / auth_expired
this.onStateChange = options.onStateChange;
// SocketTask 实例,所有读写都走它而不是全局 API
this.task = null;
// 当前重试次数,成功连接后清零
this.retryCount = 0;
// 心跳周期定时器和 pong 超时定时器
this.heartbeatTimer = null;
this.pongTimer = null;
// 重连退避定时器,同一时间只允许存在一个
this.reconnectTimer = null;
// 防止 onClose 多次触发导致重复重连
this.connecting = false;
// 预期性断开标记(切后台主动 close 时置 true)
this.expectedClose = false;
// 本地已收到的最大消息序号,用于离线补偿
this.lastSeq = 0;
}
connect() {
// 正在连接或已连上时直接返回,防止重复发起
if (this.connecting || (this.task && this.task.readyState === 1)) return;
this.connecting = true;
this.expectedClose = false;
// 连接前取一次 token,业务方可在函数内做静默刷新
const token = this.getToken();
wx.connectSocket({
url: this.url,
header: { Authorization: 'Bearer ' + token },
}).then((task) => {
// 只用 SocketTask 实例操作,多连接场景下不会和其他连接串台
this.task = task;
this.bindTask(task);
}).catch(() => {
// 创建失败也走统一的失败处理,交给重连调度
this.connecting = false;
this.scheduleReconnect();
});
}
bindTask(task) {
task.onOpen(() => {
this.connecting = false;
// 连上了就清零,下次断开从 2 秒重新退避
this.retryCount = 0;
// 连接健康,恢复心跳周期
this.startHeartbeat();
// 重连成功后先拉离线消息,堵住断线期间丢的数据
this.send({ type: 'sync', since: this.lastSeq });
this.onStateChange && this.onStateChange('connected');
});
task.onMessage((res) => {
// 约定所有帧都是 JSON 文本帧
const msg = JSON.parse(res.data);
// 收到 pong 说明连接是活的,清掉 pong 超时计时器
if (msg.type === 'pong') {
clearTimeout(this.pongTimer);
this.pongTimer = null;
return;
}
// 鉴权失败帧:token 过期的统一入口
if (msg.type === 'auth_fail') {
// 停止当前重连,业务方刷新 token 后再调 connect
this.expectedClose = true;
this.close();
this.onStateChange && this.onStateChange('auth_expired');
return;
}
// 业务消息按 seq 单调递增,记下最大值供下次补偿用
if (msg.seq && msg.seq > this.lastSeq) this.lastSeq = msg.seq;
this.onMessage && this.onMessage(msg);
});
task.onError((err) => {
// onError 后通常紧跟 onClose,这里只记日志
// 重连决策统一收口在 onClose,避免两套逻辑打架
console.warn('[ws] error:', err.errMsg);
});
task.onClose((res) => {
// 断开的第一件事:停掉心跳,别往死连接上发数据
this.stopHeartbeat();
this.task = null;
this.connecting = false;
// 预期性断开(切后台主动 close)不重连,等 onShow 指令
if (this.expectedClose) {
this.expectedClose = false;
this.onStateChange && this.onStateChange('idle');
return;
}
// 意外断开:进入指数退避重连调度
this.scheduleReconnect();
});
}
scheduleReconnect() {
// 达到上限:停手,等待用户操作或网络恢复事件再重置
if (this.retryCount >= this.maxRetry) {
this.onStateChange && this.onStateChange('failed');
return;
}
// 已有重连排队就不叠加,防止 onClose 连续触发产生多个定时器
if (this.reconnectTimer) return;
// 指数退避 + 随机抖动,避免服务端重启后客户端集体同时重连
const base = Math.min(30000, 1000 * Math.pow(2, this.retryCount));
const delay = base + Math.random() * 3000;
this.retryCount++;
this.reconnectTimer = setTimeout(() => {
// 定时器只消费一次,消费完立刻置空
this.reconnectTimer = null;
this.connect();
}, delay);
}
startHeartbeat() {
// 起新心跳前先清旧定时器,防止重连后出现双份心跳
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
// 没有可用连接时这一轮心跳直接跳过
if (!this.task) return;
this.send({ type: 'ping' });
// 发出 ping 后起一个 pong 超时计时器
this.pongTimer = setTimeout(() => {
// 没等到 pong:连接疑似假死,主动断掉触发重连
this.close();
}, this.pongTimeout);
}, this.heartbeatInterval);
}
stopHeartbeat() {
// 两个定时器都要清,漏一个就会误判假死
clearInterval(this.heartbeatTimer);
clearTimeout(this.pongTimer);
this.heartbeatTimer = null;
this.pongTimer = null;
}
send(data) {
// readyState 不是 1 时静默丢弃
// 消息可靠性由 seq 补偿机制兜底,不在这里排队
if (!this.task || this.task.readyState !== 1) return;
this.task.send({ data: JSON.stringify(data) });
}
// 切后台时调用:预期性断开,省电也不触发重连
park() {
this.expectedClose = true;
this.retryCount = 0;
// 有连接就主动关,没有就只清退避定时器
if (this.task) this.close();
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// 回前台时调用:立即重连(跳过退避)
resume() {
wx.getNetworkType({
success: (res) => {
// 网络还没恢复时不白耗重试,等网络变化事件再来
if (res.networkType === 'none') return;
// 后台这段时间不算失败,重试计数清零立刻连
this.retryCount = 0;
this.connect();
},
});
}
close() {
// 没有实例就不做任何事,防止空指针
if (!this.task) return;
// 正常关闭用 1000,服务端据此清理会话
this.task.close({ code: 1000, reason: 'client close' });
}
}
module.exports = { ReconnectSocket };
// 用法:const { ReconnectSocket } = require('./reconnect.js')
// 页面 onShow 里 connect/resume,onHide 里 park,其余交给管理类自己处理
在页面里的接法大致是:
// 依赖:上文 reconnect.js;环境:小程序页面逻辑
const { ReconnectSocket } = require('../../utils/reconnect.js');
// 页面级持有单例,onShow/onHide 反复进出不会重复创建
let ws = null;
Page({
onShow() {
// 首次进入:创建管理类并建立连接
if (!ws) {
ws = new ReconnectSocket({
url: 'wss://api.example.com/ws/',
// token 从全局取,过期由 auth_fail 分支兜底
getToken: () => getApp().globalData.token,
onMessage: (msg) => this.handleMessage(msg),
onStateChange: (s) => this.setData({ connState: s }),
});
ws.connect();
} else {
// 从后台回来:立即重连并补偿离线消息
ws.resume();
}
},
onHide() {
// 主动挂起连接,避免后台空转重连
if (ws) ws.park();
},
});
消息补偿机制:断线期间的消息一个都不能丢
心跳和重连解决的是"连接在不在"的问题,消息补偿解决的是"断线期间发生了什么"。补偿的原理很直接:服务端给每条下行消息一个单调递增的 seq,客户端每次 onMessage 记录最大 seq;重连成功的 onOpen 里,第一帧发送 {type:'sync', since: lastSeq},服务端把大于该 seq 的消息批量下发。整个机制只有三个角色:seq 生成器在服务端、seq 游标在客户端、sync 协议连接两端。
有几个实现要点:
- seq 由服务端生成、按会话单调递增,客户端只读不写。
- 补偿下发要注意批量大小,一次塞几千条消息到 WebSocket 帧里可能把低端机卡死,建议服务端分页下发,每页 100 条左右,客户端收完一页回 ack 再拉下一页。
- 本地 seq 持久化到 storage(onHide 时写一次),小程序冷启动后也能接着补偿。这里别只存内存,杀进程重启的场景很常见。
这套机制上线后我们验证过一个极端 case:用户在地铁里断了 20 分钟,出站重连,3 秒内 46 条积压消息全部补齐,顺序正确、无重复。靠的就是 seq 比对——重复消息靠客户端按 seq 去重兜底。
收尾的几句实话
这套方案跑了几个月,目前还会偶发的掉线只剩运营商级 NAT 超时一类(部分省份 4G 网络对长连接的静默断开更激进),我们的对策是把心跳做成可配置的,服务端下发建议间隔,极端地区的用户调到 20 秒。常见误区是觉得"心跳越勤越稳"——间隔小于 15 秒收益极小,电量和流量成本反而上来了;反过来,超过网关超时窗口的一半就是裸奔。另一个趋势值得一提:微信基础库对小程序后台网络的政策一直在收紧,指望"框架帮我保活"不现实,把断线重连当默认假设来设计,才是这类功能长期稳定的前提。
你在 wx.connectSocket 上踩过什么坑,评论区聊聊。
参考与延伸
- wx.connectSocket 官方文档 —— SocketTask 用法、并发 5 连接限制的官方说明
- WebSocket 常见问题(微信开放社区文档) —— 合法域名与 wss 要求
- MDN WebSocket API —— 关闭码 1000/1005/1006 的标准定义
- RFC 6455 - The WebSocket Protocol —— 协议层心跳 Ping/Pong 帧规范
关键词:微信小程序、WebSocket、心跳包、断线重连、长连接、SocketTask、消息补偿、wss 域名