Spring Boot WebSocket 实战:从建立连接到稳定运行
在 Spring Boot 中建立一条 WebSocket 连接并不困难。真正困难的是让连接稳定运行:网络切换后如何恢复、如何避免重复连接、如何判断通道是否存活,以及服务部署成多个实例后由谁维护连接。
本文不依赖具体业务,以通用实时通信服务为例,整理一套可以复用的 WebSocket 客户端生命周期设计。
一、为什么一个 connected 字段不够
最简单的实现通常只有一个布尔值:onOpen 时设为 true,onClose 时设为 false。随着功能增加,这个字段很快会失去表达能力。
例如,线程 A 正在建立连接,线程 B 又收到一次启动通知;或者旧连接刚触发关闭回调,新连接已经成功建立。此时两个线程都能修改 connected,最终状态未必代表当前通道。
更可靠的方式是使用有限状态机:
java
public enum SessionState {
IDLE,
CONNECTING,
ACTIVE,
CLOSING,
BACKING_OFF
}
每次状态转换都要经过同一个组件,并检查转换是否合法。例如 IDLE -> CONNECTING -> ACTIVE 是正常路径,ACTIVE -> CONNECTING 则说明系统可能发起了重复连接。
还可以为每次连接生成一个递增的 generation。异步回调执行时必须携带代次,只有当前代次可以修改状态。旧通道迟到的关闭事件不会覆盖新通道的状态。
java
public final class SessionContext {
private final AtomicLong generation = new AtomicLong();
private final AtomicReference<SessionState> state =
new AtomicReference<>(SessionState.IDLE);
public long nextGeneration() {
return generation.incrementAndGet();
}
public boolean isCurrent(long value) {
return generation.get() == value;
}
}
二、把网络回调和耗时任务分开
Netty、Reactor Netty 等网络框架的 I/O 线程负责读写事件循环。如果在回调中直接查询数据库、解析大对象或执行复杂计算,同一个事件循环上的其他连接也会被拖慢。
一个安全的处理方式是:在 I/O 线程中完成帧类型判断和必要校验,然后把业务数据交给独立执行器。
java
public void onTextFrame(String payload) {
lastFrameAt.set(Instant.now());
if ("ping".equals(payload)) {
channel.sendText("pong");
return;
}
messageExecutor.execute(() -> messageHandler.handle(payload));
}
这里有两个细节。第一,心跳应尽快响应,不必绕过完整处理链。第二,交给执行器前应限制消息大小并完成基础格式检查,避免异常数据无限占用内存。
Java 21 虚拟线程适合大量包含阻塞 I/O 的独立任务,但它并不会取消数据库连接数、下游吞吐量等限制。即使使用虚拟线程,也要为队列长度和外部资源设置边界。
三、心跳用于发现半开连接
TCP 连接可能处于"本机认为已连接,远端实际上已经不可达"的半开状态。例如路由设备丢失会话、移动网络切换或系统休眠,都不一定立即触发 onClose。
心跳策略通常有两种:
- 使用 WebSocket 协议的 Ping/Pong 控制帧;
- 使用应用层约定的文本或二进制心跳。
应该选择服务端明确支持的方式,而不是叠加多套心跳。心跳太频繁会增加网络和调度开销,太稀疏则延长故障发现时间。
可以同时记录三个时间点:
java
record SessionHealth(
Instant connectedAt,
Instant lastFrameAt,
Instant lastHeartbeatAckAt) {
}
"长时间没有业务消息"不等于连接失活;只有心跳超过阈值未得到响应,或底层通道已经 inactive,才应进入关闭与恢复流程。
定时任务也不要为每条连接创建一个独立线程。可以使用一个调度器批量检查到期会话,或者使用时间轮降低大量定时任务的管理成本。
四、重连必须包含退避和抖动
如果远端服务短暂不可用,立即无限重连会持续施加压力。大量客户端在同一时刻断开时,还会形成"惊群"。
常用策略是指数退避:第一次等待 1 秒,随后等待 2、4、8 秒,直到达到上限。再加入一个随机抖动,使客户端不会在相同时间再次连接。
java
long nextDelayMillis(int attempt) {
int capped = Math.min(attempt, 6);
long base = Math.min(60_000L, 1_000L << capped);
long jitter = ThreadLocalRandom.current().nextLong(200, 1_200);
return base + jitter;
}
连接恢复后要把失败次数归零。手动关闭、程序退出和认证拒绝等确定性错误,则不应继续自动重连。
同一会话还需要"单航班"保护:任何时刻最多只有一个连接任务或重连任务。可以使用 AtomicReference<ScheduledFuture<?>> 保存待执行任务,调度前通过 CAS 注册,连接成功或主动停止时取消。
五、多实例部署需要连接所有权
单机中的 ConcurrentHashMap 只能避免一个 JVM 内重复创建。部署多个应用实例后,每个实例都有自己的内存,仍可能同时维护同一个逻辑会话。
可以使用 Redis 租约实现轻量所有权:
text
ws:lease:{sessionId} -> instanceId
实例通过 SET key value NX PX ttl 获取租约,并在租约过期前续期。续期脚本必须比较 value,防止旧实例误续新实例的租约。释放租约也必须先校验持有者,不能直接 DEL。
租约并不是永久锁。实例崩溃后不需要执行清理,TTL 到期即可由其他实例接管。为了避免网络分区期间出现两个持有者,发送关键操作时还可以携带 fencing token:每次获得租约时递增序号,下游只接受更大的序号。
六、优雅停机不能只调用 close()
应用发布时,如果进程直接退出,远端可能需要等待心跳超时才能发现连接断开。优雅停机应按顺序执行:
- 停止接受新的连接任务;
- 标记会话为
CLOSING; - 等待正在处理的消息完成或达到截止时间;
- 发送 WebSocket Close 帧;
- 释放租约和线程资源。
Spring Boot 可以通过 SmartLifecycle 或关闭事件组织这一过程。要为停机设置总超时,否则某条异常连接会阻塞整个容器退出。
七、用指标而不是感觉判断稳定性
长连接故障通常具有偶发性,仅靠错误日志很难复盘。至少应该采集:
- 当前各状态会话数量;
- 每分钟新建、关闭和重连次数;
- 连接建立耗时;
- 心跳往返耗时;
- 重连成功率;
- 消息处理队列长度。
日志中应包含 sessionId、instanceId、连接代次、关闭原因、重连次数和下一次重连时间。认证令牌及完整消息正文不应直接写入日志。
测试时不要只验证正常连接。还应主动模拟断网、DNS 失败、服务端拒绝、进程重启、Redis 短暂不可用以及多个实例同时争抢租约。
总结
稳定的 WebSocket 客户端不是一个 connect() 方法,而是一套完整的生命周期管理机制。状态机解决并发转换,连接代次隔离迟到回调,心跳发现半开连接,指数退避避免重连风暴,租约保证多实例所有权,优雅停机则让发布过程可控。
这些能力与具体业务无关。无论实时协作、行情推送、设备网关还是在线通知系统,都可以在同一套基础模型上继续扩展。