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 分钟前
前后端分离的核心优势
前端·后端
IT_陈寒20 分钟前
Vue的响应式让我加班到凌晨,问题竟出在这个不起眼的地方
前端·人工智能·后端
乱码三千32 分钟前
使用 Docker-compose 搭建SRS直播中转服务端
后端·架构·github
y1su32 分钟前
【Leetcode】1477. 找两个和为目标值且不重叠的子数组
数据结构·后端·算法·leetcode·职场和发展
愛芳芳1 小时前
Swagger2 多环境动态配置实战指南
java·后端·maven·springboot·swagger
JaguarJack2 小时前
PHP 8.6 只是个小版本,三十项弃用却已在为 PHP 9.0 铺路
后端·php·服务端
BingoGo2 小时前
PHP 8.6 只是个小版本,三十项弃用却已在为 PHP 9.0 铺路
后端·php
Bs_MoneyMagnet5 小时前
基于springboot+vue的滑雪场票务与装备租赁系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
AlienZHOU9 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
狗头大军之江苏分军11 小时前
《潮水漫过十七岁》开学了
后端