SheepDog1998

博客园 首页 新随笔 联系 订阅 管理

SpringBoot WebSocket 深度技术全解(纯通用无业务、生产落地完整版)

前言

本文脱离任何业务场景、特定框架依赖,只讲解 JSR356 标准 WebSocket 在 SpringBoot 下底层原理、生命周期、生产级通用方案、底层坑点、源码规范、集群改造思路,适用于 IM、消息推送、大屏实时数据、在线协同等所有长连接场景。

一、WebSocket 底层基础原理

1.1 HTTP 轮询方案缺陷(对比突出 WebSocket 优势)

传统实时通信两种实现方式,均存在无法根治的短板:

  1. 短轮询(前端 setInterval 定时请求接口)

    • 客户端每隔固定时间发起 HTTP 请求,无论有无消息都建立连接;

    • 每次请求携带完整 HTTP 请求头、Cookie,带宽消耗巨大;

    • 消息延迟 = 轮询间隔,实时性差;服务端 QPS 随在线人数线性暴涨。

  2. 长轮询(服务端 hold 住请求,有消息再返回)

    • 服务端阻塞 Servlet 线程,无消息时挂起连接;

    • 一旦有消息立刻响应,前端收到后马上发起下一次长轮询;

    • 缺点:大量空闲连接占用 Tomcat 线程池,高并发下线程耗尽服务卡死;网络抖动会频繁断连重建。

1.2 WebSocket 协议标准与握手流程

WebSocket 是基于 TCP 的应用层协议,遵循 RFC6455 规范,Java 统一标准为 JSR-356,Tomcat 7.0+、Undertow、Jetty 全部原生支持。

握手完整流程(一次 HTTP 升级,后续全双工)

  1. 客户端发起标准 GET 请求,携带升级标识头:
GET /ws/connect/{userId} HTTP/1.1
Host: 127.0.0.1:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: 随机Base64字符串
Sec-WebSocket-Version: 13
  1. 服务端校验请求头合法,将 Sec-WebSocket-Key 拼接固定字符串后做 SHA1 加密,返回 101 切换协议响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 加密后的校验串
  1. 状态码 101 代表协议升级成功,TCP 连接不会断开,永久复用,进入 WebSocket 帧通信阶段。

1.3 WebSocket 帧结构(为什么比 HTTP 省流量)

握手完成后所有数据不再携带 HTTP 头,仅传输二进制/文本帧:

  • 帧头部最小仅 2 字节;

  • 分为文本帧、二进制帧、关闭帧、PING/PONG 控制帧;

  • 支持分片传输大文件、掩码加密(浏览器客户端强制掩码)。

1.4 全双工核心定义

  • 全双工:客户端、服务端可任意时刻主动发送数据,互不阻塞;

  • HTTP 是半双工:必须客户端先请求,服务端才能响应,服务端无法主动推送。

二、JSR356 原生 WebSocket 生命周期全注解详解

SpringBoot 中 @ServerEndpoint 是原生标准注解,不由 Spring 管理,由 Web 容器(Tomcat)实例化,五大生命周期回调完整说明:

2.1 @ServerEndpoint(value = "/ws/connect/{uid}")

  • 作用:声明 WebSocket 接入端点路径,支持路径变量 {uid}

  • 特性:每一个客户端连接都会新建一个该类实例,每个连接独享对象,类中成员变量不共享;

  • 限制:无法直接使用 @Autowired@Resource 注入 Spring Bean,后文给出通用解决方案。

2.2 @OnOpen 连接建立回调

触发时机:HTTP 握手成功、协议升级完成,TCP 长连接建立完毕。
可获取参数:

  1. Session:当前连接会话核心对象,所有消息发送、关闭操作依赖它;

  2. @PathParam("uid") Long uid:获取路径上的用户唯一标识;
    业务通用执行逻辑:Token 鉴权、在线会话存入内存Map、用户上线触发离线消息补发。

2.3 @OnMessage 消息接收回调

两种重载:

  1. onMessage(String msg):接收前端文本帧(聊天、心跳JSON通用);

  2. onMessage(byte[] bytes):接收二进制帧(图片、文件流)。
    通用处理场景:

  • 解析 PING 心跳包,返回 PONG;

  • 解析业务JSON消息,调用业务层处理;

  • 过滤空消息、非法格式消息。

2.4 @OnClose 连接关闭回调

触发时机分为两类:

  1. 主动关闭:前端调用 websocket.close()、服务端执行 session.close()

  2. 正常被动关闭:页面关闭、浏览器标签销毁。
    入参:CloseReason 可获取关闭状态码、关闭描述文案。
    通用逻辑:从在线Map移除当前用户Session,更新在线状态。

2.5 @OnError 连接异常回调

触发场景:网络断开、IO 异常、并发写通道冲突、前端强制断网。
特殊区分:EOFException 属于正常通道关闭,无需打印异常堆栈;其余网络、并发异常需要记录错误日志。
通用逻辑:清理失效Session,防止僵尸连接残留。

三、生产环境七大核心通用技术方案(无业务耦合)

3.1 方案一:握手阶段URL携带Token鉴权(通用安全标准)

3.1.1 原生WebSocket鉴权痛点

JSR356 没有 SpringMVC 拦截器、过滤器上下文,无法从 Header 中直接读取Token(浏览器JS WebSocket不支持自定义请求头),主流两种兼容方案:

  1. URL Query 参数携带 Token(兼容性最好,所有前端框架通用);

  2. 先HTTP登录获取Ticket,通过路径传递短凭证。

3.1.2 完整鉴权执行流程

  1. 前端连接地址拼接Token:ws://127.0.0.1:8080/ws/connect/10001?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

  2. @OnOpen 内解析Query字符串,提取token值;

  3. 通用JWT校验逻辑:读取密钥、解析Claims、校验过期时间、校验签名;

  4. 校验失败:调用 session.close(CloseCodes.CANNOT_ACCEPT, "Token无效") 主动断开,拒绝非法连接;

  5. 校验通过:存入在线会话Map,允许后续消息收发。

3.1.3 URL参数解析通用工具方法细节

  • 兼容Token内部包含 =& 等特殊字符;

  • 使用 URLDecoder.decode 解码,解决前端URL编码后参数乱码;

  • 判空拦截空Token、空白Token,杜绝匿名连接。

3.2 方案二:在线会话线程安全管理 + 多端互踢机制

3.2.1 存储容器选型:ConcurrentHashMap

不能使用普通 HashMap:上线、下线、多线程推送会并发读写,触发 ConcurrentModificationException
容器定义标准:

// Key:全局唯一用户ID,Value:当前用户有效WebSocket会话
private static final Map<Long, Session> ONLINE_SESSION_MAP = new ConcurrentHashMap<>();

3.2.2 多端互踢实现逻辑

同一用户新设备发起连接时,先执行 ONLINE_SESSION_MAP.remove(userId)

  1. 如果旧Session存在,自动失效;

  2. 新Session放入Map,保证一个用户同一时间仅存在一条有效长连接
    适用场景:账号单点登录、PC端/移动端互踢。

3.2.3 Session 对象生命周期注意

每个连接独立Session,仅存于内存,服务重启全部丢失;单机部署有效,集群环境失效(集群改造见文末)。

3.3 方案三:PING/PONG 心跳保活机制(解决僵尸连接)

3.3.1 僵尸连接产生原因

  1. 用户手机锁屏、浏览器后台休眠,路由器/防火墙自动切断空闲TCP连接;

  2. 用户直接断网、关闭电脑,不会触发 onClose 回调;

  3. 服务端Map中残留失效Session,推送消息时频繁报错,占用内存。

3.3.2 标准心跳交互规范

  1. 前端定时任务(20~30s)发送纯文本字符串 PING

  2. 服务端 @OnMessage 拦截PING消息,立刻回复 PONG

  3. 扩展增强方案(生产推荐):

    • 内存维护 Map<Long, Long> userLastHeartbeat,记录每个用户最后心跳时间戳;

    • 启动定时任务(30s执行一次),遍历对比当前时间,超过90s无心跳则主动关闭Session、清理Map。

3.3.3 心跳包设计优势

  • 轻量无序列化开销,仅简单字符串;

  • 双向验证连通性,前端收不到PONG自动触发重连逻辑。

3.4 方案四:消息推送线程安全锁(解决并发写入异常)

3.4.1 报错根源

JSR356 规范明确:Session 会话对象非线程安全
多个异步线程同时调用 session.getBasicRemote().sendText() 发送消息,会抛出固定异常:

java.lang.IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]

场景举例:同时给同一个用户推送系统通知、聊天消息、离线消息,多线程并发写入通道冲突。

3.4.2 通用标准解决方案

单个Session对象加synchronized同步锁,串行执行消息发送:

private void safeSendText(Session session, String json) throws IOException {
    synchronized (session) {
        // 发送前必须判断连接是否开启,避免关闭后写入报错
        if (session.isOpen()) {
            session.getBasicRemote().sendText(json);
        }
    }
}

锁粒度仅绑定单个会话,不同用户之间不会互相阻塞,无性能损耗。

3.4.3 全局统一推送静态方法

封装静态工具 sendToUser(Long uid, Object data),业务层、定时任务、离线消息统一调用,统一处理空Session、连接关闭、序列化异常,消除重复代码。

3.5 方案五:离线消息通用持久化补发模型

适用于私信、IM聊天、站内通知通用,不绑定任何业务表结构:

  1. 离线存储阶段
    用户不在线时,消息持久化到数据库,标记字段 read_status = 0(未读),关联接收人ID、会话ID;

  2. 上线拉取阶段
    用户WebSocket握手成功后,自动查询当前用户所有未读数据;

  3. 批量推送阶段
    循环遍历未读消息,调用安全推送方法逐条下发前端;

  4. 状态更新防重阶段
    全部推送完成后,批量UPDATE数据库将消息改为 read_status=1,同时清空会话未读计数;
    核心目的:用户频繁断线重连时,不会重复推送历史离线消息,从数据层兜底防重复。

3.6 方案六:数据库事务与WebSocket推送解耦(数据一致性规范)

3.6.1 错误写法(推送放入事务内)

@Transactional(rollbackFor = Exception.class)
public void sendMsg(){
    // 1.数据库插入消息
    insertMsg();
    // 2.事务内执行WebSocket推送
    ChatWebSocketServer.sendToUser(uid, dto);
}

缺陷:

  • WebSocket推送是IO阻塞操作,网络延迟会长期占用数据库连接;

  • 推送抛出异常会触发事务回滚,导致消息丢失,数据不一致;

  • 数据库连接池快速耗尽,高并发服务卡死。

3.6.2 标准正确分层写法

public void saveAndPush(Long senderId, MsgDTO dto) {
    // 1.事务方法:仅做数据库持久化,无任何推送逻辑
    saveInTransaction(senderId, dto);
    // 2.事务提交完成后,外部执行推送,推送失败不影响数据
    try {
        ChatWebSocketServer.sendToUser(dto.getReceiverId(), dto);
    } catch (Exception e) {
        log.error("消息推送失败,可前端轮询兜底拉取", e);
    }
}

@Transactional(rollbackFor = Exception.class)
public void saveInTransaction(Long senderId, MsgDTO dto) {
    // 插入消息、更新会话、未读数自增
}

规范总结:事务只负责数据落库,推送作为容错旁路,即使推送失败,前端可通过HTTP接口轮询加载历史消息兜底。

3.7 方案七:WebSocket 获取Spring Bean通用解决方案

3.7.1 问题底层原因

@ServerEndpoint 标注的类由 Tomcat Web容器实例化,不在Spring IOC容器管理范围内,构造函数、成员变量无法使用 @Autowired 注入Service、Mapper。

3.7.2 通用无框架依赖方案

通过Spring上下文工具类静态获取Bean,标准工具逻辑:

import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;

public class SpringContextUtil {
    private static ApplicationContext context;

    public static void setContext(ApplicationContext applicationContext) {
        context = applicationContext;
    }

    // 根据Class获取Bean
    public static <T> T getBean(Class<T> clazz) {
        return context.getBean(clazz);
    }

    // 获取配置文件环境变量(如JWT密钥)
    public static Environment getEnv() {
        return context.getBean(Environment.class);
    }
}

在项目启动类 main 方法注入上下文:

@SpringBootApplication
public class App {
    public static void main(String[] args) {
        ApplicationContext context = SpringApplication.run(App.class, args);
        SpringContextUtil.setContext(context);
    }
}

WebSocket中使用:

// 静态获取业务Service,无注入依赖
private static MsgService getMsgService() {
    return SpringContextUtil.getBean(MsgService.class);
}

四、通用生产级完整WebSocket模板(无业务、可直接复制)

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.net.URLDecoder;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 通用生产级WebSocket模板
 * 内置:URL Token鉴权、多端互踢、PING/PONG心跳、线程安全推送、异常清理
 * 完全剥离业务逻辑,适配所有SpringBoot项目
 */
@Slf4j
@Component
@ServerEndpoint("/ws/connect/{userId}")
public class StandardWebSocketServer {

    // 在线用户会话池:线程安全
    private static final Map<Long, Session> ONLINE_SESSION_MAP = new ConcurrentHashMap<>();

    /**
     * 连接建立:鉴权、注册会话、上线补发离线消息
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("userId") Long userId) {
        // 1. 解析URL中的token参数
        String token = getQueryParam(session, "token");
        if (StrUtil.isBlank(token)) {
            closeSession(session, CloseReason.CloseCodes.CANNOT_ACCEPT, "连接失败:未携带登录凭证");
            return;
        }

        // 2. 通用JWT校验(可替换任意项目校验工具)
        boolean tokenValid = verifyJwtToken(token);
        if (!tokenValid) {
            closeSession(session, CloseReason.CloseCodes.CANNOT_ACCEPT, "连接失败:Token过期或非法");
            return;
        }

        // 3. 多端互踢:移除旧连接,存入新会话
        ONLINE_SESSION_MAP.remove(userId);
        ONLINE_SESSION_MAP.put(userId, session);
        log.info("用户{} WebSocket连接建立成功", userId);

        // 4. 上线触发离线消息补发(通过静态工具获取业务Service)
        try {
            MsgService msgService = SpringContextUtil.getBean(MsgService.class);
            msgService.pushOfflineMsg(userId);
        } catch (Exception e) {
            log.error("用户{} 离线消息推送异常", userId, e);
        }
    }

    /**
     * 接收客户端消息:区分心跳、业务消息
     */
    @OnMessage
    public void onMessage(String content, @PathParam("userId") Long userId) {
        if (StrUtil.isBlank(content)) {
            return;
        }
        // 处理PING心跳,回复PONG保活
        if ("PING".equals(content)) {
            Session session = ONLINE_SESSION_MAP.get(userId);
            try {
                safeSendText(session, "PONG");
            } catch (IOException e) {
                log.error("向用户{} 回复心跳PONG失败", userId, e);
            }
            return;
        }
        // 忽略前端收到的PONG响应
        if ("PONG".equals(content)) {
            return;
        }
        // 处理业务JSON消息
        try {
            MsgDTO dto = JSONUtil.toBean(content, MsgDTO.class);
            MsgService service = SpringContextUtil.getBean(MsgService.class);
            service.saveAndSendMsg(userId, dto);
        } catch (Exception e) {
            log.error("用户{} 业务消息解析处理异常", userId, e);
        }
    }

    /**
     * 连接关闭回调,清理在线会话
     */
    @OnClose
    public void onClose(@PathParam("userId") Long userId, CloseReason reason) {
        ONLINE_SESSION_MAP.remove(userId);
        log.info("用户{} 连接关闭,关闭码:{}", userId, reason.getCloseCode());
    }

    /**
     * 连接异常回调,区分正常关闭与异常崩溃
     */
    @OnError
    public void onError(Session session, Throwable throwable, @PathParam("userId") Long userId) {
        // EOFException属于正常通道关闭,不打印堆栈
        if (!(throwable instanceof java.io.EOFException)) {
            log.error("用户{} WebSocket连接异常", userId, throwable);
        }
        ONLINE_SESSION_MAP.remove(userId);
    }

    // ====================== 通用工具方法 ======================
    /**
     * 对外静态推送入口,供业务层全局调用
     */
    public static void sendToUser(Long userId, Object data) {
        Session session = ONLINE_SESSION_MAP.get(userId);
        if (session == null || !session.isOpen()) {
            return;
        }
        try {
            String json = JSONUtil.toJsonStr(data);
            safeSendText(session, json);
        } catch (IOException e) {
            log.error("向用户{} 推送消息失败", userId, e);
        }
    }

    /**
     * 线程安全发送文本,单会话同步锁,解决并发写入冲突
     */
    private static void safeSendText(Session session, String text) throws IOException {
        synchronized (session) {
            if (session.isOpen()) {
                session.getBasicRemote().sendText(text);
            }
        }
    }

    /**
     * 统一关闭会话封装,消除重复try-catch
     */
    private void closeSession(Session session, CloseReason.CloseCodes code, String desc) {
        try {
            session.close(new CloseReason(code, desc));
        } catch (Exception e) {
            log.error("主动关闭会话异常", e);
        }
    }

    /**
     * 解析URL Query参数,兼容带=、&的Token,自动URL解码
     */
    private String getQueryParam(Session session, String paramKey) {
        String queryStr = session.getQueryString();
        if (StrUtil.isBlank(queryStr)) {
            return null;
        }
        String[] paramArr = queryStr.split("&");
        for (String item : paramArr) {
            int eqIndex = item.indexOf("=");
            if (eqIndex == -1) {
                continue;
            }
            String key = item.substring(0, eqIndex);
            String value = item.substring(eqIndex + 1);
            if (paramKey.equals(key)) {
                try {
                    return URLDecoder.decode(value, "UTF-8");
                } catch (Exception e) {
                    log.error("URL参数解码失败", e);
                    return value;
                }
            }
        }
        return null;
    }

    /**
     * 通用JWT校验方法,可替换任意项目工具类
     */
    private boolean verifyJwtToken(String token) {
        try {
            // 读取配置文件密钥、解析签名、校验过期时间
            String secret = SpringContextUtil.getEnv().getProperty("token.secret");
            // JWT解析逻辑省略
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

五、线上生产高频坑点深度拆解(原理+解决方案)

坑1:并发推送报 TEXT_PARTIAL_WRITING

  • 底层原理:Session底层输出流未写完时,其他线程再次写入,帧数据错乱;

  • 根治方案:发送方法加 synchronized (session) 锁,串行发送;

  • 避坑误区:加全局锁锁类,会导致所有用户消息排队,性能暴跌。

坑2:WebSocket无法注入Service/Mapper

  • 原理:容器托管实例,脱离Spring IOC生命周期;

  • 方案:静态上下文工具类动态获取Bean,无任何侵入;

  • 拓展:不建议使用静态变量持有Service,会引发单例线程安全问题。

坑3:用户断网后服务端仍标记在线,推送全失败

  • 原因:静默断网不会触发onClose,内存Map残留僵尸Session;

  • 双层解决方案:

    1. 前端PING/PONG心跳维持活性;

    2. 后台定时任务清理长时间无心跳连接,主动关闭失效Session。

坑4:用户频繁重连,离线消息重复推送

  • 根源:推送完未更新数据库消息已读状态;

  • 标准流程:推送全部离线消息后,批量UPDATE修改读取状态,从数据层杜绝重复下发。

坑5:裸WebSocket接口无鉴权,安全漏洞

  • 风险:任意客户端拼接ws地址即可建立连接,接收全部推送消息;

  • 强制规范:握手阶段必须校验Token,非法连接直接close,禁止匿名接入。

坑6:推送逻辑写在@Transactional事务内,数据库连接耗尽

  • 底层:IO阻塞占用连接,事务超时回滚导致消息丢失;

  • 分层规范:事务只做CRUD,推送逻辑放在事务外部捕获异常,前端轮询兜底。

坑7:集群部署后,不同服务实例用户无法互通消息

  • 单机局限:Session存储在本机内存,其他服务节点无法访问;

  • 分布式改造通用方案:Redis发布订阅模式

    1. A服务收到消息,发布Msg至Redis Topic;

    2. 所有服务节点订阅Topic,匹配接收用户本地Session并推送;

    3. 实现跨实例全集群消息互通。

六、线上性能与安全扩展优化技术点

6.1 性能优化

  1. 消息序列化优化:统一使用Hutool JSON序列化,避免FastJSON漏洞;高频消息可改用Protobuf二进制传输;

  2. 连接限流:新增拦截器限制单IP、单账号最大并发连接数,防御DOS攻击;

  3. 异步推送线程池:单独创建线程池处理消息推送,不占用Tomcat业务线程;

  4. 消息缓存:高频会话历史消息存入Redis,减少MySQL分页查询压力。

6.2 安全优化

  1. wss加密协议:生产环境强制使用wss://(WebSocket+SSL),明文ws禁止外网访问;

  2. Token短期有效期:JWT设置短过期时间,前端定时刷新Token,重连携带新凭证;

  3. 消息内容过滤:增加敏感词拦截、XSS过滤,防止前端注入恶意脚本;

  4. 关闭超时连接:限制单条连接空闲最大时长,自动回收资源。

6.3 可观测性优化

  1. 埋点监控指标:在线连接总数、新建连接QPS、消息推送成功率、异常连接数;

  2. 分级日志:连接建立/关闭打印INFO,推送失败、鉴权失败打印ERROR;

  3. 告警机制:推送成功率低于阈值、连接数突增突降触发邮件/短信告警。

七、总结(核心技术要点精简梳理)

  1. WebSocket基于TCP全双工长连接,一次HTTP 101握手,解决轮询实时性与资源损耗问题;

  2. JSR356五大生命周期注解控制连接全流程,容器实例化导致无法直接注入Spring Bean;

  3. 生产落地必备四大基础能力:URL Token握手鉴权、ConcurrentHashMap会话管理、PING/PONG心跳、同步锁安全推送;

  4. 架构分层规范:数据库事务仅持久化,推送逻辑外置,离线消息推送后批量更新已读防重复;

  5. 单机内存Session仅适用于单实例,集群环境需Redis发布订阅实现跨节点推送;

  6. 绝大多数线上异常根源来自Session非线程安全、僵尸连接、事务耦合推送、无鉴权四大问题,标准化方案可全部规避。

posted on 2026-07-10 10:26  SheepDog1998  阅读(39)  评论(0)    收藏  举报