WebSocket 从建立连接到稳定运行

Spring Boot WebSocket 实战:从建立连接到稳定运行

在 Spring Boot 中建立一条 WebSocket 连接并不困难。真正困难的是让连接稳定运行:网络切换后如何恢复、如何避免重复连接、如何判断通道是否存活,以及服务部署成多个实例后由谁维护连接。

本文不依赖具体业务,以通用实时通信服务为例,整理一套可以复用的 WebSocket 客户端生命周期设计。

一、为什么一个 connected 字段不够

最简单的实现通常只有一个布尔值:onOpen 时设为 trueonClose 时设为 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()

应用发布时,如果进程直接退出,远端可能需要等待心跳超时才能发现连接断开。优雅停机应按顺序执行:

  1. 停止接受新的连接任务;
  2. 标记会话为 CLOSING
  3. 等待正在处理的消息完成或达到截止时间;
  4. 发送 WebSocket Close 帧;
  5. 释放租约和线程资源。

Spring Boot 可以通过 SmartLifecycle 或关闭事件组织这一过程。要为停机设置总超时,否则某条异常连接会阻塞整个容器退出。

七、用指标而不是感觉判断稳定性

长连接故障通常具有偶发性,仅靠错误日志很难复盘。至少应该采集:

  • 当前各状态会话数量;
  • 每分钟新建、关闭和重连次数;
  • 连接建立耗时;
  • 心跳往返耗时;
  • 重连成功率;
  • 消息处理队列长度。

日志中应包含 sessionIdinstanceId、连接代次、关闭原因、重连次数和下一次重连时间。认证令牌及完整消息正文不应直接写入日志。

测试时不要只验证正常连接。还应主动模拟断网、DNS 失败、服务端拒绝、进程重启、Redis 短暂不可用以及多个实例同时争抢租约。

总结

稳定的 WebSocket 客户端不是一个 connect() 方法,而是一套完整的生命周期管理机制。状态机解决并发转换,连接代次隔离迟到回调,心跳发现半开连接,指数退避避免重连风暴,租约保证多实例所有权,优雅停机则让发布过程可控。

这些能力与具体业务无关。无论实时协作、行情推送、设备网关还是在线通知系统,都可以在同一套基础模型上继续扩展。

相关推荐
你为她披上外套时我正站在窗外1 小时前
subtitler v2.0 的零拷贝设计:从 2000 次分配到 0 次
后端
IT_陈寒2 小时前
Vite的静态资源路径搞得我头秃,原来这样配才对
前端·人工智能·后端
小刘数字化2 小时前
告别PPT:AR数据呈现如何重塑商业汇报体验
后端·restful
程序员爱钓鱼2 小时前
Rust match 模式匹配详解:比 if 更强大的条件分支
后端·rust
Java面试题总结2 小时前
Vue3流式调用大模型接口完整实践
后端·python
宠友信息3 小时前
从重复请求到订单同步看内容社区源码的处理思路
java·spring boot·redis·后端·mysql·spring
MobotStone3 小时前
五个AI使用习惯让你的效率翻倍
后端
pixcarp3 小时前
GMP调度模型和GC垃圾回收机制解析
后端·学习·golang·内存管理·gc·gmp
万少9 小时前
用腻了 WorkBuddy 的默认界面?这个开源小工具给它换上了毛玻璃
前端·javascript·后端