小程序里的 WebSocket 总掉线:心跳、重连与后台保活的实战方案

2026-10-01 01:16:42 1 次浏览
微信小程序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 上踩过什么坑,评论区聊聊。

参考与延伸

关键词:微信小程序、WebSocket、心跳包、断线重连、长连接、SocketTask、消息补偿、wss 域名

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。