浏览器里跑 MQTT 这件事,我最早是排斥的。当时做一个设备状态看板,设备侧走的是 MQTT,后端已经有一套完整的 topic 体系和 QoS 策略,我图省事,在前端和后端之间又套了一层自研的 WebSocket 文本协议做转译,后端订阅设备 topic 再转发给页面。跑了两周就崩了:转译层要维护订阅关系、要做消息去重、要处理设备离线时的补发,代码越写越像一个小型 Broker。后来把心一横,让浏览器直接说 MQTT over WebSocket,把转译层整个删掉,页面连上 Broker 直接订阅,代码量少了七成。这次改造让我真正搞明白了 MQTT-WebSocket 连接通信到底在做什么:协议一个字节都没改,改的只是运输方式。下面的内容就是围绕这条链路展开的——握手怎么过、帧怎么拆、参数怎么调、出问题怎么查,都是我在实际项目里反复趟过的部分。
1. 浏览器连不上 1883 端口的根本原因
1.1 浏览器网络栈只开放了三个出口
很多人第一次尝试在前端连 MQTT,会直接写mqtt://192.168.x.x:1883,然后控制台报一个含糊的错误,或者干脆卡在连接中。这不是库的问题,是浏览器的能力边界问题。浏览器里 JavaScript 拿到的网络能力只有三样:HTTP(S) 请求、WebSocket、WebRTC 的数据通道。裸 TCP 连接、UDP 发包,在页面上下文里一律不可用,没有例外。
而 MQTT 协议在设计之初是跑在 TCP 之上的,标准端口 1883(明文)和 8883(TLS)。它假设自己下面有一条可靠的、有序的、双向的字节流。浏览器的 WebSocket 恰好也能提供这么一条字节流——握手完成后,它就是一个全双工的字节通道。所以把 MQTT 报文塞进 WebSocket 帧里传输,在协议层面是完全成立的,这也是 MQTT 规范里明确列出的一种传输绑定方式。
理解这一点之后,很多困惑就解开了:mqtt://这个 scheme 在浏览器环境里根本不存在,前端只能写ws://或wss://。这不是约定俗成,是硬性约束。
1.2 协议没有被改写,只是换了一辆车
我习惯用一个类比来解释给同事听:MQTT 报文就像标准集装箱,1883 端口上的 TCP 是卡车,WebSocket 是火车。集装箱的尺寸、编号规则、装卸流程全都不变,变的只是用什么拉。Broker 收到 WebSocket 帧之后,把里面的字节流按 MQTT 报文格式解析,跟在 TCP 上收到的东西一模一样。
具体到实现,变化集中在三处,我把它们列在下面这张表里,方便对照记忆:
| 维度 | TCP 直连(1883) | WebSocket 承载(8083/8084) |
|---|---|---|
| 地址写法 | mqtt://host:1883 | ws://host:8083/mqtt |
| 建立连接 | TCP 三次握手 | TCP 握手 + HTTP Upgrade 握手 |
| 子协议协商 | 无 | 必须在握手头里声明mqtt |
| 报文边界 | 靠 TCP 流 + MQTT 长度字段 | 靠 WS 帧 + MQTT 长度字段 |
| 常见使用方 | 设备固件、服务端程序 | 浏览器页面、小程序、部分受限环境 |
表里最后一行值得多说一句。WebSocket 承载并不只是给浏览器用的,一些受限的运行环境(比如某些只能发 HTTP 类请求的沙箱)也是靠这条路走通的。所以你在排查问题时,如果发现请求方其实不是浏览器,也不要急着否定 WebSocket 方案。
1.3 什么场景该走这条路,什么场景别硬上
WebSocket 承载是有代价的:多了一次 HTTP 握手、多了 HTTP 头的开销、中间多了一层可能出问题的代理。我在选型时会问自己三个问题。
第一,客户端是不是浏览器或受限环境?如果是,没得选,只能走 WebSocket。如果不是,优先用 TCP 直连,少一层就少一类故障。
第二,消息频率和体积在什么量级?页面上的状态看板、告警推送、指令下发回执,这类场景消息不大、频率也不高,WebSocket 完全撑得住。但如果是每秒几十条的传感器原始数据流,或者单条几百 KB 的图片二进制,就要认真评估了——WebSocket 帧有自己的头开销,浏览器对单帧也有实际限制,中间代理还可能设了缓冲区上限。
第三,能不能接受多一跳延迟?WebSocket 承载本身带来的额外延迟很小,通常可以忽略,但如果中间还有一层反向代理做缓冲,而缓冲策略没配好,延迟会突然变大。这个坑我在第 5 节会详细说。
2. 握手阶段的三个硬性约束:子协议、路径、加密
WebSocket 连接的建立过程是:客户端发一个带Upgrade: websocket的 HTTP 请求,服务端返回 101 状态码,之后这条连接就切换成双向字节流。MQTT over WebSocket 的全部特殊性,几乎都挤在这个握手阶段。
2.1 Sec-WebSocket-Protocol 这个头不能省
这是新手最容易漏掉的一项。客户端在握手请求里,需要带上这样的头:
Sec-WebSocket-Protocol: mqtt服务端(Broker 的 WebSocket listener)会校验这个值。主流 Broker 接受的值通常是mqtt,部分实现还兼容mqttv3.1、mqttv3.1.1这类带版本号的写法。如果客户端没带,或者带了个服务端不认识的值,表现分两种:有的 Broker 直接返回 400,有的会完成握手但在后续立刻断开,让你在日志里看到一条莫名其妙的连接关闭记录。
为什么设计成必须显式声明?因为一个 Broker 的 WebSocket 端口上,理论上可能同时挂载多种子协议,比如 MQTT、另一种消息协议、甚至自定义协议。子协议字段就是路由依据。你不声明,服务端就不知道该用哪套解析器来处理后面的字节流。
用成熟客户端库时这个头一般是自动带的,但如果你在做自定义实现,或者在某些框架里手写握手,这一行必须显式加上。我见过一个案例,某同学用原生WebSocketAPI 自己拼报文,new WebSocket(url)只传了一个参数,结果就是能连上、能发出去、但一个字节都收不回来。加上第二个参数['mqtt']之后立刻正常。
2.2 路径决定了你打到哪个 listener
完整的连接地址长这样:
ws://broker-host:8083/mqtt这里的/mqtt是路径,不是可选的装饰。它由 Broker 的 listener 配置里的挂载点决定。默认配置里通常就是/mqtt,但完全可能被改成/ws、/mqtt/ws或者别的什么。写错了,握手阶段直接返回 404,控制台里表现为 WebSocket 握手失败。
端口同理。常见的 WebSocket 明文端口是 8083,加密的是 8084,但这只是社区习惯,不是规范强制。有的部署会直接复用 80 和 443,通过反向代理按路径分流。这种情况下你的地址里就不带端口号,路径变成唯一的分流依据。
提示:拿不准端口和路径时,别猜。去看 Broker 的监听配置,或者问运维要一份连接信息清单。猜错的成本是半小时起步。
2.3 页面是 https,连接就必须是 wss
这条是浏览器的混合内容策略强制要求的。如果页面本身通过 HTTPS 加载,页内发起的所有 WebSocket 连接必须是wss://,否则浏览器直接拦截,控制台报 mixed content 错误。这个错误很显眼,一般不会被误判,但排查时容易忽略另一层:wss背后走的是 TLS,如果证书链有问题、域名不匹配、或者自签证书没被信任,握手同样会失败,而错误信息通常比较隐晦。
我踩过一次:内网自建服务用自签证书,页面走 HTTPS,wss连接在开发机上怎么都连不上。最后发现是开发机没导入根证书。导入之后一切正常。生产环境里这类问题更常见的形式是证书过期——某天早上所有页面突然连不上,日志里一片 TLS 握手失败,查下来就是证书到期。
2.4 用最笨的办法验证握手是否通过
在写业务代码之前,我强烈建议先手工确认握手能过。最简单的方式是打开浏览器开发者工具,切到 Network 面板,筛选 WS,然后刷新页面。你会看到一条 WebSocket 请求,点进去看:
- 状态码是不是 101
- 请求头里有没有
Sec-WebSocket-Protocol: mqtt - 响应头里有没有
Sec-WebSocket-Protocol: mqtt(服务端确认的回应) - 路径和端口跟你预期的是不是一致
如果是非浏览器环境,可以用一个几十行的 Node 脚本做同样的事,把 WebSocket 打开后立刻看 readyState。握手能过,后面的问题就都是应用层的了,排查范围直接砍掉一半。
3. WebSocket 帧和 MQTT 报文的边界不是一一对应的
这一节是我认为最容易被跳过、但排查问题时最有用的一块。绝大多数人用客户端库,永远不会碰到这个问题,但一旦碰到"消息只收到一半"或者"多条消息粘在一起",不知道这一层就会卡很久。
3.1 一个帧可能装多个报文,一个报文也可能跨多个帧
WebSocket 提供的是一个字节流式的消息通道,它在传输层之上又包了一层帧。而 MQTT 报文本身有自己的结构:一个固定头(包含报文类型和剩余长度字段),加上可变头和载荷。剩余长度字段用的是变长编码,最多四个字节,指示后面还有多少字节。
这两层的边界没有任何约定关系。实际情况中可能是:
- 一个 WebSocket 帧里正好装一个 MQTT 报文(最常见)
- 一个 WebSocket 帧里装了两个小的 MQTT 报文,比如一个 PUBACK 紧跟一个很小的 PUBLISH
- 一个大的 MQTT 报文被拆到多个 WebSocket 帧里,比如一条几百 KB 的载荷
第三种情况在某些 Broker 或中间代理上会以"分片帧"的形式出现,也就是 WebSocket 协议层的 continuation frame。客户端库一般会帮你把分片帧拼回完整消息,然后在应用层交给你一个完整的二进制块,但那个二进制块里仍然可能是多个 MQTT 报文,需要按 MQTT 的长度字段继续切。
3.2 手写解析时的缓冲区写法
如果你在做自定义实现(比如某些嵌入式环境里没有现成的库),正确的处理方式是维护一个累积缓冲区,每次收到数据就往里追加,然后循环尝试解析:
// 简化示意:按 MQTT 剩余长度字段切分累积缓冲区 let buffer = Buffer.alloc(0); function onData(chunk) { buffer = Buffer.concat([buffer, chunk]); while (buffer.length >= 2) { // 解析变长剩余长度 let multiplier = 1; let remaining = 0; let offset = 1; let byte; do { if (offset >= buffer.length) return; // 长度字段还没收全 byte = buffer[offset++]; remaining += (byte & 0x7f) * multiplier; multiplier *= 128; if (multiplier > 128 * 128 * 128) throw new Error('malformed length'); } while ((byte & 0x80) !== 0); const total = offset + remaining; if (buffer.length < total) return; // 报文还没收全,等下一批 const packet = buffer.subarray(0, total); buffer = buffer.subarray(total); handlePacket(packet); // 交给上层处理 } }这段代码的核心思想就一句话:永远不要假设一次收到的数据就是一个完整报文,也永远不要假设一个报文会在一次收到里结束。缓冲区负责吸收这种不确定性。
用现成的客户端库时,这段逻辑库内部已经实现了。但知道它的存在有价值:当你看到"消息内容被截断了一半"这种症状时,第一反应应该是去查帧大小限制和缓冲区配置,而不是怀疑业务代码。
3.3 帧大小的实际上限
WebSocket 协议本身对帧大小没有硬性上限,但实现层面有。浏览器侧,Chrome 对单条消息的实际处理上限在几十 MB 到上百 MB 量级,不同版本有差异,不建议逼近。服务端侧,很多 Broker 或反向代理会配置一个明确的单帧或单消息大小上限,超过就断开连接。
我在一个项目里遇到过:设备上报的固件校验信息里带了一段 Base64 编码的数据,偶尔超过 256 KB,结果那部分消息随机丢失,其他消息正常。查了很久,最后定位到反向代理的缓冲区配置里有一个和单请求体大小相关的限制。把限制调大、同时把业务上的大载荷改成走对象存储链接传递,问题才彻底消失。
这类问题的经验是:消息体积超过 64 KB 就要警惕,超过 256 KB 基本可以认定会踩坑。解决办法不是硬调参数,而是从设计上避免——大文件传引用,不传内容。
4. 客户端落地:连接参数逐个说清楚
4.1 一份能直接用的最小代码
用主流的 JavaScript MQTT 客户端库,浏览器里跑起来大概是这样:
import mqtt from 'mqtt'; const client = mqtt.connect('wss://broker-host:8084/mqtt', { protocolVersion: 4, clientId: `web_${Math.random().toString(16).slice(2, 10)}`, clean: true, keepalive: 30, connectTimeout: 8000, reconnectPeriod: 2000, username: 'device-reader', password: '******', }); client.on('connect', () => { console.log('connected'); client.subscribe('device/+/status', { qos: 1 }, (err, granted) => { if (err) console.error('subscribe failed', err); else console.log('granted', granted); }); }); client.on('message', (topic, payload, packet) => { console.log(topic, packet.qos, payload.toString()); }); client.on('reconnect', () => console.log('reconnecting')); client.on('error', (e) => console.error('mqtt error', e)); client.on('close', () => console.log('closed'));这段代码能跑,但每一个参数背后都有取舍,下面拆开说。
4.2 参数不是随便填的,每个都有明确语义
| 参数 | 建议值 | 为什么这么设 |
|---|---|---|
protocolVersion | 4(对应 3.1.1) | 兼容性最好,多数 Broker 默认支持;填 5 会启用 MQTT 5 特性,但对 Broker 版本有要求 |
clientId | 前缀 + 随机串 | 固定 ID 会被同 ID 的新连接顶掉,页面刷新就会互相踢下线 |
clean | 页面场景用true | 页面是短生命周期,不需要服务端保留会话;设为false会占用服务端会话存储 |
keepalive | 30到60 | 太小浪费流量,太大导致离线发现慢,还要跟中间代理的空闲超时配合 |
connectTimeout | 5000到10000 | 弱网下太短会误判断线,太长导致用户等待 |
reconnectPeriod | 1000到5000 | 太短会在服务端故障时形成重连风暴 |
username/password | 按鉴权方式 | 明文写在页面代码里等于公开,必须配合短期令牌 |
关于clientId这一条我特别想强调。曾经有个页面,为了"方便追踪",clientId用的是当前登录用户 ID。结果同一个账号在两个标签页打开,后打开的会把先打开的顶掉,先打开的页面进入无限重连循环,两边互相踢。用户看到的现象是"页面数据一会儿刷新一会儿不刷新"。改成用户 ID 加随机后缀之后立刻好了。
connectTimeout也有类似的故事。有个项目把它设成了 2000 毫秒,在地铁、电梯这类弱网环境里,页面频繁显示"连接失败",但其实网络只是慢了一点。放到 8000 之后,用户感知良好。这个参数宁可放宽。
4.3 会话恢复和重复订阅的连锁反应
这里有个隐蔽的坑,跟clean和订阅的配合有关。假设你把clean设成了false,也就是让服务端保留会话。第一次连接,订阅了device/+/status,QoS 1。页面刷新或者网络抖动后重连,客户端库在重连成功后会触发connect事件,你的代码又执行了一遍subscribe。
结果是:服务端记录里这个 clientId 对同一个 topic filter 的订阅被覆盖,看起来没什么问题。但如果你的订阅逻辑没处理好,比如每次订阅的话题 filter 略有不同(带着某种时间戳),或者你订阅了多个 filter,服务端就会累积一堆订阅关系,而每次匹配到的消息可能被重复推送多次。
我在一个项目里见过更严重的表现:页面上的计数一直在涨,但实际只有一条消息。原因就是重复订阅导致同一条消息被投递了多次。排查的时候用client.on('message')打日志只能看到"收到多次",看不出为什么,得去服务端看这个 clientId 实际挂了多少个订阅。
解法很简单:clean保持true,每次连接都是全新会话,重连后重新订阅一次,逻辑清晰;或者clean为false时,在订阅前先确认当前会话的订阅状态,避免重复。前者更省心,我基本都用前者。只有当业务真的需要"离线期间消息不错过"时,才考虑保留会话——而页面场景下,用户关掉浏览器之后的消息补发,通常更适合用一次性拉取接口来做,而不是靠 MQTT 会话堆积。
5. 服务端 listener 怎么配:字段语义与反向代理
5.1 listener 配置的三段式结构
不同 Broker 的配置文件语法不一样,但需要表达的语义是同一套。一个可用的 WebSocket listener 至少要描述清楚四件事:监听地址和端口、协议类型是 WebSocket、挂载路径、以及是否启用 TLS。
用一个通用的结构表示大致是这样:
listeners: ws: bind: "0.0.0.0:8083" protocol: websocket mount_point: "/mqtt" websocket: max_frame_size: 1048576 # 单帧上限,1MB idle_timeout: 60 # 空闲超时,秒 wss: bind: "0.0.0.0:8084" protocol: websocket mount_point: "/mqtt" tls: cert_file: "/etc/ssl/server.crt" key_file: "/etc/ssl/server.key"mount_point就是客户端地址里的路径部分,改了它之后所有客户端地址都得跟着改,所以生产环境里我不建议随便动,保持默认/mqtt最省事。max_frame_size和idle_timeout这两个值是排查问题的重点对象,第 7 节会展开。
需要提醒的是,WebSocket listener 只是传输入口,MQTT 的鉴权、ACL、会话管理逻辑跟 TCP listener 是共用一套的。也就是说,你在 TCP 端口上配的用户名密码,在 WebSocket 端口上同样生效。这一点让迁移变得很方便,但也意味着不要指望"换个端口就不用鉴权"。
5.2 反向代理这一层的配置最容易出错
生产环境里,Broker 的 WebSocket 端口通常不直接对外,前面会挂一层反向代理做 TLS 卸载、限流、域名分流。这一层如果配错,症状往往非常像"协议问题",容易把人带偏。
三个关键点。第一,必须正确转发 Upgrade 和 Connection 头,否则代理收到握手请求会当成普通 HTTP 请求处理,返回 200 而客户端拿不到 101。第二,proxy_read_timeout要设置得比 MQTT 的 keepalive 更宽裕,否则连接会在没有数据流转的时候被代理单方面掐断。第三,要把 HTTP/1.1 明确写进配置,老版本的默认值是 1.0,不支持 Upgrade。
一份可用的反向代理片段长这样:
location /mqtt { proxy_pass http://127.0.0.1:8083; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 120s; proxy_send_timeout 120s; proxy_buffering off; }proxy_buffering off这一行很多人会漏。默认开启缓冲的情况下,代理会先攒一批数据再往下发,对普通 HTTP 响应是优化,对 WebSocket 这种实时推送就是延迟放大器。表现是消息发出后过几秒才到,频率越高越明显。
还有一个细节:如果代理层配置了最大请求体限制(比如针对普通 HTTP 接口设的 1 MB),这个大载荷的 WebSocket 消息也可能被拦。排查时需要同时看代理和 Broker 两侧的上限配置,取两者中较小的那个作为实际约束。
5.3 鉴权到底放在握手还是 CONNECT
MQTT 的鉴权信息(用户名、密码、客户端证书)是在 CONNECT 报文里携带的,也就是说,WebSocket 握手阶段理论上还没有业务身份。这带来一个选择:要不要在代理层再做一次鉴权?
我的做法分两种情况。如果只是内网环境的简单隔离,代理层做个 IP 白名单或者基础认证就够了,业务鉴权还是交给 MQTT 的 CONNECT。如果是面向公网的页面,我会在代理层加一道基于短期令牌的校验,把明显不该进来的连接挡在握手外面,减少 Broker 的压力。
需要注意令牌的有效期问题。页面上的连接可能存活很久,令牌却只有几小时。令牌过期后,Broker 不会主动断开已经建立的连接,但重连时会失败。这时候如果客户端库只是无脑重连,就会陷入失败循环。正确做法是监听鉴权失败相关的错误码,触发令牌刷新,然后再重连。这个逻辑我在第 6 节会放到排查链路里再提一次。
6. 从"连上了但收不到消息"往下剥的排查链路
故障排查这部分我想按真实顺序写,不直接给结论,因为排查的价值在于路径本身。
6.1 先按症状分类,别一上来就翻代码
| 症状 | 最可能的层级 | 第一动作 |
|---|---|---|
| 握手直接失败,控制台有明确错误 | 网络 / TLS / 路径 | 查 Network 面板的 101 状态和路径 |
| 握手成功,但几秒后断开 | 子协议 / 鉴权 | 抓握手头,确认 subprotocol 和错误码 |
| 能连上,订阅无响应 | ACL / topic 拼写 | 用另一个已知可用的客户端对比 |
| 收得到消息,但内容不全 | 帧大小 / 缓冲 | 查 Broker 和代理的大小上限 |
| 消息重复到达 | 重复订阅 / QoS 重传 | 查该 clientId 的订阅列表 |
| 隔一段时间必断,重连后恢复 | keepalive / 代理超时 | 对比 keepalive 与 idle_timeout |
这张表不是万能钥匙,但它能把排查范围从"整条链路"缩到"某一层",效率差别很大。
6.2 一次完整的排查过程
去年有个页面反馈"偶尔收不到消息,刷新一下就好"。这个描述极其模糊,我先做了三件事。
第一,复现。我把页面开着不动,等。大约七八分钟后,问题出现了:页面上的状态不再更新,但连接状态显示还是"已连接"。这是个关键线索——连接对象的 readyState 还活着,说明 WebSocket 层没断,但数据不再流动。
第二,对比。我用一个命令行客户端连同一个 Broker、订阅同一个 topic,跑同样的时长。命令行客户端没出问题,一直在收消息。这就排除了 Broker 侧的消息源和 ACL,问题在页面这条链路上。
第三,往中间看。页面和 Broker 之间的差异只有两处:客户端库的配置,和中间的反向代理。我去看了代理的空闲超时配置,是 60 秒。再看客户端的 keepalive,是 120 秒。
原因清楚了:代理在连接空闲 60 秒后单方面关闭,但它关闭的方式对客户端不够"显式",客户端的 WebSocket 层没有立即感知到,于是既没有触发断线事件,也没法再收到数据,卡在一个"假在线"状态。刷新页面等于重新建连,所以问题消失。
修复方案是两个方向同时做,缺一不可:把客户端 keepalive 压到 30 秒,让心跳包在代理超时之前就把连接"焐热";把代理的空闲超时提到 120 秒以上,留出足够余量。另外增加一条兜底:客户端定时检查最后一次收到消息的时间,超过阈值主动断开重连。这条兜底后来还救过几次其他原因导致的假在线。
6.3 三个高频误判
第一个误判是把"假在线"当成"Broker 挂了"。判断依据很简单:如果 Broker 真的挂了,你的重连日志会疯狂刷屏;如果日志安静但数据停了,那就是假在线,问题在中间层。
第二个误判是把消息重复当成"Broker 发了两遍"。QoS 1 的语义是"至少一次",网络抖动导致的重传是正常行为,服务端和客户端都可能重发。如果业务对重复敏感,要么在应用层做幂等(用消息 ID 去重),要么评估是否降到 QoS 0。指望换 Broker 解决问题是不现实的。
第三个误判是把大消息丢失当成"Broker 不稳定"。前面提过,大小上限存在于浏览器、代理、Broker 三处,取最小值。看到"只有特定类型的消息丢",第一反应应该是去看这些消息有什么共同点,通常是体积。
6.4 手上要常备的两个工具
一个能在命令行跑的 MQTT 客户端,用来做对照实验,快速区分"服务端问题"和"客户端问题"。这个工具我几乎每周都要用。
一个能看原始 WebSocket 帧的工具。浏览器开发者工具的消息面板可以看帧,但只能看到文本或者二进制块,看不到 MQTT 报文的结构。如果需要看到报文级别,就得在客户端库的底层挂一个日志钩子,把收发字节打出来,再对照 MQTT 报文格式手动解析。费劲,但偶尔必须这么做。
7. 稳定性调优:心跳、载荷、弱网重连
7.1 keepalive 必须小于链路上最短的空闲超时
这条规则我在前面已经用案例讲过,这里把它提炼成一条可以直接执行的判断:找出整条链路上所有的空闲超时配置——客户端 keepalive、Broker 的 idle_timeout、反向代理的 read_timeout、以及任何中间设备(负载均衡、网关)的连接超时——取其中最小的那个值,把客户端的 keepalive 设成它的二分之一到三分之二之间。
为什么是二分之一左右?因为 keepalive 的机制是客户端承诺"在这个周期内至少发一次报文",实际上客户端会在周期内发 PINGREQ,服务端回 PINGRESP。如果周期设得刚好等于超时值,网络稍有抖动就会踩线。留出接近一半的余量,能吸收掉大部分抖动。
代价是流量和电量。心跳报文很小(两个字节的固定头),频率提高到每 30 秒一次,一天也就不到三千个,完全可接受。但如果客户端是电池供电的设备,就要权衡了,那种场景下通常走 TCP 直连而不是 WebSocket,超时配置也更可控。
7.2 载荷设计比参数调优更有效
我见过很多团队在参数上反复折腾,试图解决消息延迟和丢失问题,但真正的瓶颈在载荷设计。
几个原则。第一,单条消息控制在 1 KB 以内,超过就考虑拆包或者传引用。状态上报、指令下发这类消息,1 KB 足够装下几十个字段。第二,不要在同一条消息里塞无关的信息,topic 的层级本身就是分类手段,善用它。第三,QoS 的选择要跟业务匹配:状态上报用 QoS 0 完全够,丢了下一帧就补上了;指令下发用 QoS 1,保证至少到达一次,配合应用层幂等;QoS 2 的开销在页面场景下基本没必要。
还有一个容易被忽略的点:订阅的时候要用精确的 topic filter。用#订阅所有消息看起来方便,实则是把自己变成了一个全量消息的收集器,流量和解析开销都会成倍增加。页面需要什么就订阅什么,一个页面订阅三五个 filter 是正常量级。
7.3 弱网环境的重连策略要有退避
客户端库自带的重连通常是固定间隔的,reconnectPeriod: 2000就是每两秒重试一次。这个策略在内网环境没问题,但在移动网络下会出问题:网络刚断的时候,客户端立刻重连,失败,两秒后再试,再失败,如此反复,既耗电又可能被服务端限流。
我一般会做一层包装,用指数退避加上限的方式:第一次 1 秒,第二次 2 秒,第三次 4 秒,封顶 30 秒,同时加一点随机抖动避免大量客户端同时重连。实现上可以关掉库自带的重连,自己在close事件里控制重连节奏。
另外两个弱网相关的经验。一是监听浏览器的online和offline事件,网络恢复时立刻主动重连一次,不用等退避计时器。二是页面从后台切回前台时(visibilitychange事件),主动检查连接状态,必要时重建连接。移动端浏览器在后台会挂起定时器和网络活动,切回来的时候连接往往已经失效但状态没更新,主动检查一下能避免用户看到一片空白。
7.4 订阅恢复要在重连回调里做,但要做幂等
重连成功后需要重新订阅,这是必然的。但订阅动作要设计成幂等的:记录当前已经订阅的 filter 列表,重连后遍历这个列表重新订阅。不要在订阅回调里再去订阅新的东西,否则重连几次之后订阅关系就乱了。
我习惯把订阅状态单独抽出来,维护一个Set,加订阅时往里加,取消订阅时往里删,重连时全量重放。这样即使中间有订阅失败,下一次重连也能自动修复,不需要人工干预。
最后分享一个我在实际项目中养成的习惯:连接相关的所有事件——connect、reconnect、close、error、offline——全部打上带时间戳的日志,并且在前端页面上留一个隐藏的诊断面板,能看到最近几十条连接事件。用户反馈"连不上"的时候,让他打开诊断面板截个图,比问十个问题都管用。这个小功能上线之后,我跟用户来回沟通的成本降了一大截,很多时候看一眼日志就知道是网络问题、鉴权问题还是服务端问题,直接省掉了复现这一步。