在现在的后端项目里,WebSocket 已经不算什么新概念了。但我翻过不少项目的代码,真正把 WebSocket 当成"通信基础设施"来做的确实不多。大多数场景下,大家习惯哪里需要就在哪里 new 一个客户端,onMessage 里直接写业务,断线没人管,服务端一重启,客户端就再也连不上了。尤其是涉及实时行情推送、物联网设备上行数据、在线协作这类高并发场景时,简单写法几乎撑不过一个晚上就会出现大面积断连。这篇文章我打算从实际项目出发,完整拆解一个企业级 WebSocket 客户端的封装思路,包含心跳检测、智能重连、二进制数据处理三个核心模块,并给出脱敏后的可运行源码片段。适合正在用 Spring Boot 集成第三方 WebSocket 服务,或者准备自建实时推送通道的后端同学参考。
1. 整体设计:为什么不能直接抱着 OkHttp 的 WebSocket 裸用
1.1 自研封装到底要解决什么问题
先说结论:OkHttp 自带的 WebSocket 客户端已经非常成熟了,握手、帧解析、连接管理这些底层能力基本不用你操心。但直接裸用会有几个绕不开的问题。
第一,连接生命周期没人管。WebSocket 连接本质上是一条 TCP 长连接,任何一步网络抖动、服务端重启、空闲超时,都可能让这条连接在应用层毫无感知的情况下消失。裸用 OkHttp 的 WebSocketListener,你只能在 onFailure 里打印一行日志,然后连接就真的死了。这显然不是生产环境能接受的。
第二,应用层需要的心跳机制要自己建。TCP 协议本身有 KeepAlive,但默认间隔通常是 2 小时,而且它的主要目的是清理死连接,不是探测对方进程是否存活。你可以理解为 TCP KeepAlive 只能告诉你"这条 TCP 链路还通不通",但没办法告诉你"对端的应用进程是否已经卡死"。真正的业务级心跳得在 WebSocket 之上自己实现。
第三,业务数据往往是二进制的。实时行情、设备上报数据、音视频信令这类场景,用 JSON 文本传输也可以,但带宽和解析性能都很吃亏。WebSocket 原生支持二进制帧(Opcode=0x2),但 OkHttp 只负责把二进制帧以 ByteString 或 byte[] 的形式交给你,后面怎么拆包、怎么解析、怎么做半包处理,完全要靠你自己设计。
所以自研封装的核心目标不是"重新造一个 WebSocket 实现",而是在 OkHttp 这层稳定的基础上,补上三段胶水代码:连接状态机、心跳引擎、重连策略,再加一套适合业务的二进制协议解析层。
1.2 三个模块的分工与边界
我把这个封装拆成三个相对独立的模块,目的是让每个模块能单独测试、单独调参。
连接管理模块负责对外暴露 connect、send、close 三个最基础的接口,内部维护当前连接状态(已连接、连接中、已断开、手动关闭)。它不关心心跳怎么发,也不关心重连要不要退避,只负责把字节流正确地交给底层 WebSocket 对象,并且保证线程安全。
心跳引擎模块负责周期性发送 Ping 帧,同时监听 Pong 帧的返回。一旦发现连续几个周期没有收到 Pong,就判定当前连接已经不可用,主动触发一次"连接失效"事件。这里的关键点是:主动关闭旧连接,让上层重连逻辑立刻介入,而不是等 TCP 底层超时报错。
重连策略模块负责在连接被动断开时,按照指数退避加随机抖动的算法,计算下一次重连的时间点。它要区分"正常手动关闭"和"意外断开"——用户主动 close 之后,绝对不能自动重连,这是最常见的坑。
这个拆分的思路其实和 Dubbo、gRPC 这类成熟框架的长连接管理是类似的。连接状态机是骨架,心跳和重连是围绕状态机的两个轮子,业务层只关心数据收发,不感知底层连接是否换过一条。
1.3 线程模型设计:回调线程不能干重活
还有一个很容易被忽略的问题,就是线程模型。OkHttp 的 WebSocket 回调(onMessage、onFailure、onClosed)默认是在 OkHttp 自己内部线程池里触发的。如果在这些回调里直接做耗时的业务处理,比如解析 protobuf 后落库、调用第三方接口、加分布式锁,会把这个回调线程卡住。一旦阻塞时间过长,OkHttp 的整个连接池都会受影响,其他连接的读写也会被拖累。
正确的做法是:在 onMessage 里拿到原始数据后,立刻投递到自己的业务线程池里处理,回调线程只做转换和分发。心跳检测的定时任务则使用独立的 Scheduler,避免和业务线程池互相干扰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 心跳检测:让服务端和 NAT 设备都认为你活着
2.1 为什么 TCP KeepAlive 不够用
在引入业务心跳之前,先说说为什么中间网络设备会"杀死"你的连接。
很多云厂商的负载均衡、Nginx 反向代理、家庭路由器的 NAT 表,都有空闲连接超时机制。比如 Nginx 的 proxy_read_timeout 默认是 60 秒,如果你在这 60 秒内没有任何数据从服务端传到客户端,Nginx 就可能主动把这条连接断开。Linux 系统的 net.ipv4.tcp_keepalive_time 默认是 7200 秒,也就是 2 小时,这意味着 TCP 自带的探测机制根本赶不上中间设备的超时节奏。
所以应用层必须主动周期性地发送心跳消息,让整条链路上的设备都能感知到"这个连接还在用"。我常用的一句类比是:TCP KeepAlive 是半年才做一次体检,而业务心跳是每 30 秒测一次血压,中间设备看到你血压正常,就不会把你的号码从名单里划掉。
WebSocket 协议本身提供了 Ping 和 Pong 两种控制帧,这就是应用层心跳的基础设施。客户端发 Ping,服务端收到后必须回 Pong,这是协议层面的强制规定。所以只要用 Ping/Pong,我们就能得到一个非常可靠的存活判定依据。
2.2 心跳间隔和超时判定:不能拍脑袋
既然要用 Ping/Pong 做心跳,间隔设多少合适?我在几个线上项目里的实践是:心跳间隔应该小于等于整个链路中最短的空闲超时时间的二分之一。
举个例子,如果 Nginx 的 proxy_read_timeout 配置成了 60 秒,那你的心跳间隔建议不要超过 30 秒。这样即使某一帧心跳在网络中抖动了一下,也还有足够的余量让后续心跳续上连接。如果你心跳间隔设成 45 秒,一旦某次心跳恰好被网络延迟拖到 50 秒,Nginx 很可能就在这中间把连接关了。当然,心跳间隔也不是越短越好,每 5 秒一次 Ping 会白白增加服务端压力,同时让 NAT 表项一直处于活跃状态,反而不利于网关做连接复用。
超时判定这里,我强烈建议不要"一票否决"。也就是说,不要发一次 Ping 后 5 秒内没收到 Pong,就立刻判定连接死亡。网络瞬断很常见,一次丢包不代表链路不可用。我的做法是:连续丢失 3 次 Pong 响应才判定连接失效。具体实现时,每次发出 Ping 后更新 lastPingSentTime,收到任何 Pong 时更新 lastPongReceivedTime,心跳任务每次执行时检查 lastPongReceivedTime 是否在 3 个心跳周期内没有变化。一旦超时,就主动关闭当前 WebSocket,触发重连流程。
还有一个实践技巧:心跳任务里不要只发 Ping,还要顺带把本地的连接健康度指标打出来,比如当前连接状态、最近一次 Pong 距离现在多久。这对后面排查问题非常有帮助。
3. 智能重连:指数退避加随机抖动,防止重连风暴
3.1 被动断开与主动断开:状态必须分开
重连逻辑最容易踩的坑就是"不该重连的时候乱重连"。用户手动调用 close() 关闭连接时,如果重连逻辑也跟着触发,就会出现一个非常诡异的现象:你明明想关掉客户端,它却一直尝试连回去,怎么都关不掉。
解决思路很简单:用一个 volatile boolean manuallyClosed 标志位。手动关闭时,置为 true,并清空底层 WebSocket 引用;被动断开时,保持 false,然后才允许进入重连流程。有一点要注意,这个标志位必须是线程安全的,因为在 OkHttp 的 onFailure 回调线程里要读取它,在业务线程里 write 它。用 volatile 就能保证可见性,不需要加锁。
另外,重连之前一定要先做清理。每次新的连接尝试开始之前,必须把上一次残留的 WebSocket 引用置空,避免多个连接对象同时存在。如果底层 WebSocket 没有正确关闭,旧连接的心跳任务可能还在跑,最后出现"一个客户端发了 N 个连接都在服务端挂着"的资源泄漏。
3.2 退避公式和抖动设计
重连间隔绝对不能用固定值。假设你设成 5 秒重试一次,如果是单客户端问题不大,但如果是服务端重启导致的批量断连,几百个客户端同时每隔 5 秒发起重连,服务端一恢复就会被请求洪峰打满,这就是典型的"重连风暴"。
业内通用的做法是指数退避加随机抖动。公式我习惯这样写:
java复制public long nextDelayMs(int attempt) {
long baseDelay = Math.min(maxDelayMs, initialDelayMs * (1L << Math.min(attempt, 16)));
long jitter = ThreadLocalRandom.current().nextLong(0, baseDelay / 4);
return baseDelay + jitter;
}
参数含义:
- initialDelayMs:第一次重连前的等待时间,我通常设 1000 毫秒。
- maxDelayMs:退避的上限,避免重试间隔无限增大,我通常设 30000 毫秒。
- attempt:当前是第几次重连,每次失败加一。当连接成功后重置为 0。
- jitter:在 baseDelay 的基础上增加 0 到 25% 的随机扰动。
为什么要加扰动?想象一个场景:服务端凌晨做发布,所有客户端几乎同时断开,然后同时开始退避。如果大家都严格按照同一个退避公式执行,那么在第 1 秒、第 2 秒、第 4 秒……这些时间点上,客户端们还是会"整齐划一"地发起重连请求,服务端依然会被打爆。加上随机抖动后,重连请求会被打散到一个时间窗口里,这个窗口内的请求密度就均匀多了。这也是我反复跟团队强调的一点:指数退避解决的是"重试频率爆炸"的问题,随机抖动解决的是"重试同步爆炸"的问题。
还有一个需要处理好的细节:重连成功后,需要做一次状态重置。这包括把 attempt 归零、重置心跳计数、清理上一次连接过期的一些本地状态(比如未发送完的消息缓冲队列)。如果你不重置 attempt,可能会出现在某次网络恢复后,后续所有重连判断都带着很大的 baseDelay 基数,导致本来 1 秒就能连上,结果等了 30 秒才重连。
4. 二进制数据处理:从 JSON 到自定义协议
4.1 什么时候必须用二进制
WebSocket 的文本帧(Opcode=0x1)传 JSON 很方便,开发效率极高,但有两个让我很难受的缺点:
-
体积大。一条行情数据用 JSON 大概是
{"symbol":"BTC-USDT","price":100.25,"vol":12345,"ts":1699999999999},接近 60 个字节。同样的信息用二进制结构体只需要 4(symbolId)+ 8(price double)+ 8(vol long)+ 8(ts long)= 28 个字节,体积直接减半以上。在每秒推送几万条数据的行情场景里,这个差距就是 50% 的带宽成本。 -
解析慢。JSON 解析涉及字符串处理、内存分配、字符集转换,在单机百万级消息量下会成为明显的 CPU 瓶颈。二进制协议按固定偏移读取数据,解析成本极低。
那是不是所有场景都应该用二进制?也不是。如果你对服务端有完全的控制权,数据量也不大,JSON 完全够用,而且调试方便得多。我的判断标准是:单条消息超过 50 字节且 QPS 超过 5000,或者对消息体积敏感(比如 IoT 设备走流量卡),才值得引入二进制协议。
另外还要澄清一个误区:就算你用了二进制数据,WebSocket 协议本身也会自动处理好分片和重组,也就是说一个完整的 WebSocket 消息在回调里拿到的就是一个完整的 byte[],不会出现 TCP 层那种粘包半包问题。
但你仍然需要在自己的业务协议里设计帧结构。为什么呢?因为一个 WebSocket 消息可能包含多条业务数据,或者一条业务数据被拆成了多个消息。为了准确区分业务边界,需要自定义一个简单的二进制帧格式。
4.2 一个极简的帧格式与解析器
我常用的一个极简帧格式只有三部分:魔数、长度、载荷。
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 2 字节 | 固定值 0xAA55,用于快速判断帧头是否合法 |
| version | 1 字节 | 协议版本号,方便做兼容 |
| type | 1 字节 | 消息类型,如 0x01 表示行情,0x02 表示心跳 |
| length | 4 字节 | 载荷长度(大端序) |
| payload | 变长 | 实际的业务数据 |
解析器核心逻辑很简单:读入 byte[],先检查长度是否足够,再校验 magic,然后取出 type 和 payload 传给上层。
java复制public class BinaryFrameParser {
private static final short MAGIC = (short) 0xAA55;
public BinaryFrame parse(byte[] data) {
if (data.length < 8) {
throw new IllegalArgumentException("invalid frame, length < 8");
}
ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN);
short magic = buf.getShort();
if (magic != MAGIC) {
throw new IllegalArgumentException("invalid magic: " + Integer.toHexString(magic));
}
byte version = buf.get();
byte type = buf.get();
int payloadLength = buf.getInt();
if (buf.remaining() < payloadLength) {
throw new IllegalArgumentException("payload length mismatch, need "
+ payloadLength + ", remaining " + buf.remaining());
}
byte[] payload = new byte[payloadLength];
buf.get(payload);
return new BinaryFrame(version, type, payload);
}
}
这里有几个细节值得说明:
- 用 ByteBuffer 而不是手写 byte[] 位移,是因为 ByteBuffer 自带字节序控制,你只要统一约定好大端还是小端,解析时一行代码指定即可,不容易出 bug。
- payloadLength 解析出来后必须校验当前缓冲区剩余长度,防止恶意数据或半包数据导致数组越界。虽然 WebSocket 帧保证完整,但业务帧长度不可信,该做的校验不能省。
- version 字段在一开始就预留好,是因为二进制协议一旦上线,后续加字段、改类型都容易破坏兼容性。有了 version,服务端和客户端就可以在握手阶段协商好协议版本。
对于真正的场景化业务数据,比如一个 tick 数据,在拿到 payload 后再做一次反序列化:
java复制public class TickDecoder {
public Tick decode(byte[] payload) {
ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
if (buf.remaining() < 28) {
throw new IllegalArgumentException("tick payload too short: " + buf.remaining());
}
Tick tick = new Tick();
tick.setSymbolId(buf.getInt());
tick.setLastPrice(buf.getDouble());
tick.setVolume(buf.getLong());
tick.setTimestamp(buf.getLong());
return tick;
}
}
报文大小和字段顺序是服务端定死的,客户端只需要严格遵守约定。如果后续要增加字段,我的习惯是在帧的尾部追加,而不是在中间插入,这样老版本客户端即使忽略尾部的额外字节,也能正确解析前面的核心字段。
5. 核心源码与配置:脱敏后的可直接运行版本
5.1 依赖与基础配置
我用 OkHttp 4.x 和 Spring Boot 2.x 做演示。这里选 OkHttp 而不是 javax.websocket 或者 Spring 自带的 WebSocketClient,主要原因是 OkHttp 的连接池、超时、TLS 握手都处理得很成熟,而且它的 WebSocket 客户端 API 非常简洁,回调模型也足够稳定。Spring 自带的 WebSocketClient 底层走 JSR-356,配置起来繁琐,而且在某些 Servlet 容器里还有兼容性坑。
xml复制<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
配置项我统一放在 application.yml 里,方便不同环境切换:
yaml复制realtime:
ws:
url: wss://your-server.example.com/gateway # 生产环境替换成真实地址
token: ${WS_TOKEN} # 敏感信息走环境变量,不硬编码
heartbeat-interval-ms: 30000
heartbeat-timeout-count: 3
reconnect-initial-delay-ms: 1000
reconnect-max-delay-ms: 30000
binary-frame-enabled: true
这里的 token 一定不要写死在配置仓库里,走环境变量或者配置中心是基本要求。脱敏代码里用 ${WS_TOKEN} 占位,实际接入时你就知道该把真实值放在哪里。
5.2 客户端封装主干
下面这段是客户端封装的主干,我简化了部分 getter 和日志,保留了最核心的连接、发送、关闭逻辑。
java复制public class RealtimeWebSocketClient {
private final OkHttpClient httpClient;
private final String url;
private final String token;
private final HeartbeatManager heartbeatManager;
private final ReconnectStrategy reconnectStrategy;
private final BinaryFrameParser frameParser;
private volatile WebSocket webSocket;
private volatile boolean manuallyClosed = false;
private final AtomicInteger reconnectAttempt = new AtomicInteger(0);
public RealtimeWebSocketClient(RealtimeWsProperties props) {
this.url = props.getUrl();
this.token = props.getToken();
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // WebSocket 场景 readTimeout 设 0
.pingInterval(0, TimeUnit.SECONDS) // 我们用自己的心跳,不用 okhttp 的
.build();
this.heartbeatManager = new HeartbeatManager(props.getHeartbeatIntervalMs(),
props.getHeartbeatTimeoutCount());
this.reconnectStrategy = new ReconnectStrategy(props.getReconnectInitialDelayMs(),
props.getReconnectMaxDelayMs());
this.frameParser = new BinaryFrameParser();
}
public void connect() {
manuallyClosed = false;
doConnect();
}
private void doConnect() {
Request request = new Request.Builder()
.url(url)
.header("Authorization", "Bearer " + token)
.build();
webSocket = httpClient.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket ws, Response response) {
log.info("ws connected");
reconnectAttempt.set(0);
heartbeatManager.start(ws);
}
@Override
public void onMessage(WebSocket ws, ByteString bytes) {
heartbeatManager.onAnyMessageReceived();
dispatchBinaryMessage(bytes.toByteArray());
}
@Override
public void onFailure(WebSocket ws, Throwable t, Response response) {
log.warn("ws failure", t);
heartbeatManager.stop();
scheduleReconnect();
}
@Override
public void onClosed(WebSocket ws, int code, String reason) {
log.info("ws closed: code={}, reason={}", code, reason);
heartbeatManager.stop();
if (!manuallyClosed) {
scheduleReconnect();
}
}
});
}
private void scheduleReconnect() {
if (manuallyClosed) {
return;
}
int attempt = reconnectAttempt.getAndIncrement();
long delayMs = reconnectStrategy.nextDelayMs(attempt);
log.info("schedule reconnect in {} ms, attempt={}", delayMs, attempt);
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.execute(this::doConnect);
}
public void send(byte[] data) {
WebSocket ws = webSocket;
if (ws == null || !isConnected()) {
throw new IllegalStateException("websocket not connected");
}
ws.send(ByteString.of(data));
}
public void close() {
manuallyClosed = true;
heartbeatManager.stop();
WebSocket ws = webSocket;
if (ws != null) {
ws.close(1000, "client closed");
}
}
private boolean isConnected() {
WebSocket ws = webSocket;
return ws != null;
}
}
几个关键点:
- readTimeout 设为 0。WebSocket 连接和普通 HTTP 不同,它不像一次请求响应那样有明确的读超时,如果设一个正数,超过时间没有任何数据就会触发 onFailure,导致连接被误杀。这条我踩过坑,所以直接写 0。
- pingInterval 也设成 0,意思是让 OkHttp 不自己发 Ping,而是由我们的 HeartbeatManager 统一管理。否则两套心跳都发,不仅浪费流量,还在排查问题时容易混淆。
- onMessage 里收到二进制帧后,我没有直接把消息塞给业务线程池,而是先交给 frameParser 做一层帧边界解析,再把完整的业务帧投递出去。这样上层业务拿到的永远是一条语义完整的消息。
5.3 心跳模块和重连模块的实现细节
心跳模块是独立的一个类,单独用 ScheduledExecutorService 调度:
java复制public class HeartbeatManager {
private final ScheduledExecutorService scheduler;
private final long intervalMs;
private final int timeoutCount;
private volatile long lastPongTime;
private volatile boolean stopped;
private ScheduledFuture<?> pingTask;
public HeartbeatManager(long intervalMs, int timeoutCount) {
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ws-heartbeat");
t.setDaemon(true);
return t;
});
this.intervalMs = intervalMs;
this.timeoutCount = timeoutCount;
this.lastPongTime = System.currentTimeMillis();
}
public void start(WebSocket ws) {
stopped = false;
pingTask = scheduler.scheduleAtFixedRate(() -> {
if (stopped) return;
long lastDelayMs = System.currentTimeMillis() - lastPongTime;
if (lastDelayMs > intervalMs * timeoutCount) {
log.warn("heartbeat timeout, lastDelayMs={}", lastDelayMs);
ws.close(4000, "heartbeat timeout");
return;
}
ws.send(ByteString.of(new byte[]{0x02}));
}, intervalMs, intervalMs, TimeUnit.MILLISECONDS);
}
public void onAnyMessageReceived() {
lastPongTime = System.currentTimeMillis();
}
public void stop() {
stopped = true;
if (pingTask != null) {
pingTask.cancel(false);
}
}
}
这里我定义的心跳数据是 new byte[]{0x02},表示这是一个心跳类型帧。实际项目中你可以把它换成前面帧格式里的 type=2 的完整帧结构。关键在于:只要服务端在协议层回了 Pong,或者业务层回了一个合法消息,onAnyMessageReceived 就会刷新 lastPongTime。
重连模块相对简洁:
java复制public class ReconnectStrategy {
private final long initialDelayMs;
private final long maxDelayMs;
public long nextDelayMs(int attempt) {
long baseDelay = Math.min(maxDelayMs, initialDelayMs * (1L << Math.min(attempt, 16)));
long jitter = ThreadLocalRandom.current().nextLong(0, baseDelay / 4 + 1);
return baseDelay + jitter;
}
}
如果你觉得这种默认的退避幅度还不够平滑,也可以改成乘 1.5 的系数,或者用随机化 seed 的方式。核心原则始终是不变:退避一定要有上限,抖动一定要存在。
5.4 Nginx 代理配置:企业环境绕不开的一环
大多数生产环境,WebSocket 服务前面都挂着一层 Nginx。如果 Nginx 配置不对,客户端连上来几秒钟就会被掐掉。下面是我在实际项目里验证过的配置模板:
nginx复制map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream ws_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
server_name ws.example.com;
location /gateway {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
几个要点:
- Upgrade 和 Connection 这两个请求头必须透传。如果是浏览器发起的 WebSocket 握手,这两个头一定会带上;但如果是普通 HTTP 请求没有 Upgrade 头,map 会把 Connection 头设置为 close,保证普通请求不受影响。
- proxy_read_timeout 必须大于你客户端的心跳间隔。默认 60 秒如果你心跳是 30 秒,勉强够用;但如果改成 5 分钟甚至更长的心跳策略,这里必须同步调整,否则 Nginx 会提前断开连接。
- keepalive 32 是对 upstream 的长连接配置,能够减少 Nginx 和后端服务之间的握手次数,对高并发场景有明显帮助。
6. 常见问题与排查实录
6.1 服务端在响应完成前断开连接
我遇到过一条非常典型的报错:stream disconnected before completion: websocket closed by server before response。意思是在一次请求还没有正常完成时,连接就被服务端主动关闭了。
排查这类问题,我的路径是这样的:
- 先确认连接建立后有没有正常完成鉴权。很多服务端在收到未经鉴权的数据后会直接断开连接。
- 确认心跳是否已经生效。如果服务端有一个"5 分钟无数据自动断开"的配置,而你的客户端心跳间隔恰好是 5 分钟,那就非常容易触发超时断连。
- 去服务端看日志,确认关闭 code 和 reason 是什么。比如 close code 1008 表示策略违规,1001 表示服务端正在重启,这些信息会直接告诉你下一步该查什么。
我曾经遇到一次诡异的问题:客户端设置了 readTimeout 为 30 秒,结果每次连接稳定运行 30 秒后准时断开。查了半天发现是 OkHttp 在 WebSocket 模式下也会应用 readTimeout 配置,一旦超过 30 秒没有任何数据从服务端过来,就触发了超时。解决方式就是我前面强调的 readTimeout=0。
6.2 用 Charles 和 wscat 检查心跳与帧
排查 WebSocket 问题,工具很重要。Charles 可以监听 WebSocket 流量,能看到文本帧和二进制帧的收发情况。我通常用它来验证客户端是否每 30 秒发一个 Ping,以及服务端是否回了 Pong。
命令行调试则更轻量,用 wscat 这个 Node 工具:
bash复制wscat -c wss://your-server.example.com/gateway -H "Authorization: Bearer your-token"
连上之后手动发一条 Ping,观察服务端响应。如果 wscat 能正常收发而你的封装连不上,问题大概率出在你代码里的订阅、鉴权或者自定义帧协议上,而不是网络链路。
6.3 回调线程里做耗时操作导致整条链路卡死
这个坑听起来简单,但在压测环境下特别容易暴露。有人会在 onMessage 里直接写业务逻辑,比如调用一个同步的数据库操作,一旦这个操作慢,OkHttp 的回调线程就被占住。OkHttp 内部处理 WebSocket 消息的线程数量是有限的,回调线程一旦耗尽,其他连接的 WebSocket 消息就全部在等待队列里排队,表现为整体延迟飙升。
我的建议很直接:所有消息在 onMessage 里只做解析和分发,投递到独立的业务线程池之后立即返回。线程池大小按消息量和处理耗时估算,如果用自定义帧协议,解析已经很轻量,实际上可以做到 OnMessage 回调几乎是瞬时返回的。
6.4 重连风暴的另一面:服务端连接数被打满
前面讲了抖动可以防止客户端同步重试,但还有一个实际问题:如果重连确实很快成功,比如服务端 5 秒就恢复了,几百个客户端在 5 秒内全部重连成功,这时候服务端的瞬时连接数会很高。
应对方案不复杂,一是设置 maxDelayMs 和合理的抖动区间,二是给连接加上一个"最小重连间隔"的约束。比如我要求重连间隔不能小于 1 秒,这样即使服务端立即恢复,客户端也会至少等 1 秒后再发起连接,避免瞬时并发过高。还可以在服务端做连接数限流,比如单 IP 每秒最多接受 N 个新连接,但这部分属于服务端治理,不在客户端封装的范畴了。
一些实操体会
写到这里,我回忆了一下过去几次做实时通信系统的经历。最深刻的一个教训是:心跳和重连不是"锦上添花"的功能,而是实时通信系统的生命线。没有它们,任何 WebSocket 客户端都只是"演示项目"。另一个想提醒大家的是,封装一定要做好观测性。我见过太多项目把心跳、重连写进去,但没有任何日志和指标,出了问题只能盲猜。建议你在每一次连接建立、每一次心跳超时、每一次重连调度时都打印结构化日志,带上 attempt 次数和延迟时间。这样等真正出问题的时候,你是看着完整的证据链在排查,而不是对着代码发愁。等到这套封装在项目里稳定运行一段时间后,你会明显感觉到,实时消息这块再也不是"哪个模块用就哪里写两下"的临时方案了,而是一个可以被多个业务复用的基础设施。
