弱网与重连
弱网下最难的不是“重连”,而是重连后状态一致:消息不丢、不重复、顺序可解释、游标能推进。本页给出协议层的确定性参数、客户端必须实现的状态机、可复现的弱网测试矩阵与常见事故。
协议参数(来自冻结契约)
| 参数 | 值 | 含义 |
|---|---|---|
| 客户端 ping 间隔 | 30000ms | 需要主动保活;服务端依赖活动判定 |
| 服务端空闲超时 | 90000ms | 超过即断开连接 |
| 要求 pong | 是 | 未回 pong 视为链路异常 |
| 连续未 pong 重连阈值 | 60000ms | 达到后客户端主动重连 |
| 重连策略 | exponential-backoff-with-jitter | 指数退避 + 抖动 |
| 退避区间 | 1000ms → 30000ms | 避免同时重连造成风暴 |
| 最大尝试次数 | 8 | 超过后进入挂起,等网络/前台事件恢复 |
| 离线补发 | hello 后自动补发 | 重连成功即开始补收 |
| 设备级补收(rev25) | WS query 可选 deviceId(SDK 自动生成/持久化) | 按设备游标补发;A 设备 ack 不影响 B;无 deviceId 走 legacy 用户级游标 |
| 补收完成信号 | sync_done | 客户端应以 sync_done 作为“历史已对齐”的时刻 |
| 投递语义 | at-least-once(客户端按 messageId 去重) | 客户端必须按 messageId 去重 |
| 顺序 | 同一会话内 seq 单调递增;跨会话无全局序 | 同一会话内 seq 单调;跨会话无全局序 |
| 发送幂等 | 同用户 clientMsgId 10 分钟内幂等 | 重试必须复用 clientMsgId |
没有 sync_done 就宣布“同步完成”是错的
重连成功(CONNECTED)只代表长连接建立,离线消息还在补发。UI 应显示“同步中”,直到收到 sync_done 或重放结束;否则首屏可能缺消息。
客户端状态机
| 状态 | 进入条件 | 应该做什么 |
|---|---|---|
| CONNECTING | 调用 connect / 自动重连 | 显示连接中;暂停非必要请求;不要重复 connect |
| CONNECTED | 握手成功、hello 到达 | 恢复订阅;开始接收离线补发;发送待发队列 |
| SUSPEND | 网络切换/系统挂起/心跳异常 | 保留待发队列;按退避重连;不要清空本地消息 |
| DISCONNECT | 主动断开或超过重试上限 | 停止自动重连;等待用户操作或网络恢复事件 |
| KICKED | 服务端踢下线(封禁/多端策略) | 停止重连、清理登录态、提示原因码 |
JavaScript
import { createClient, Events } from '@ni-paas-im-web';
const client = createClient({ appkey: 'YOUR_APP_KEY', serverUrl: 'http://127.0.0.1:8080' });
let syncing = true;
client.addEventListener(Events.CONNECTING, () => setStatus('连接中'));
client.addEventListener(Events.CONNECTED, () => { setStatus('同步中'); syncing = true; });
client.addEventListener(Events.SUSPEND, () => setStatus('网络不稳定'));
client.addEventListener(Events.DISCONNECT, ({ reason }) => setStatus(`已断开:${reason}`));
client.addEventListener(Events.KICKED, ({ code, reason }) => {
stopReconnect();
logout(`被踢下线:${code} ${reason}`);
});
// 离线补发通过 MESSAGES 事件到达;收到 sync_done 后再标记同步完成
client.addEventListener(Events.MESSAGES, ({ messages, syncDone }) => {
for (const message of messages) {
if (!seen.has(message.messageId)) { // 按 messageId 去重(at-least-once)
seen.add(message.messageId);
store(message);
}
}
if (syncDone || messages.some((m) => m.syncDone)) { syncing = false; setStatus('已同步'); }
});发送侧:重试与去重
| 问题 | 正确做法 | 错误做法 |
|---|---|---|
| 发送超时 | 用同一 clientMsgId 重发(10 分钟幂等) | 生成新 clientMsgId 重发 → 重复消息 |
| 断线期间点发送 | 进入本地待发队列,连接恢复后按序发送 | 直接丢弃或无限弹错 |
| 收到重复消息 | 按 messageId 去重后再入库/渲染 | 按 clientMsgId 去重(服务端可能没有该字段回传) |
| 顺序错乱 | 同一会话按 seq 排序渲染 | 按本地到达时间排序 |
| 重连风暴 | 使用 SDK 内置退避,不要自己并发重连 | 页面每次失败都 new client |
弱网测试矩阵
| 场景 | 注入方式 | 通过标准 |
|---|---|---|
| 高延迟 | 单向 200ms | 消息最终送达;p95 时延可解释;UI 无假死 |
| 丢包 | 5% 丢包 + TCP 重传 | 0 丢失 / 0 重复(客户端去重后) |
| 硬切断 | 直接断 socket | 重连 + 离线补收;sync_done 后消息完整 |
| 网络切换 | Wi-Fi ↔ 移动网络 | SUSPEND → CONNECTING → CONNECTED,自动补 join 聊天室 |
| 弱网 + 大量离线 | 断线期间写入 500 条 | 补收不中断 UI;分页/批量入库 |
| 长时间抖动 | 30 分钟随机丢包/延迟 | 内存无持续增长;无 false-dead(假死连接) |
常见事故
| 现象 | 根因 | 修复 |
|---|---|---|
| 重连后聊天室收不到消息 | 只恢复了 WS,没有补 join 或 join 超时 3002 | 确认 SDK 自动补 join;失败时提示并重试 |
| 首屏少消息 | CONNECTED 就渲染完成、没等 sync_done | 用 syncing 状态门控 |
| 重复消息 | 重试换了 clientMsgId / 没按 messageId 去重 | 去重表 + 固定 clientMsgId |
| 在线状态长期错误 | 在线状态是快照查询,不订阅 | 按需查询或订阅存在回调;不要缓存过久 |
| 服务端判死连接 | 客户端未按 30s ping / 90s 空闲 | 使用 SDK 心跳,不要自己发业务 ping 代替 |