资讯动态

Spring Boot实战:WebSocket实时通信从原理到集群部署

发布时间:2026/10/18 13:26:12 来源:尧图企业网站定制
1. WebSocket先弄明白它和HTTP到底差在哪写这篇文章的起因是前段时间我帮一个团队临时救火。他们的“在线客服系统”是用HTTP轮询做的——前端每2秒发一次请求问后端“有没有新消息”。上线第一天人少还行第二天注册用户一多数据库连接直接被打满服务端CPU飙到90%多用户还在群里骂“消息延迟快一分钟”。我接手后第一件事就是把轮询干掉换成了WebSocket。改造完成之后服务器负载降到了原来的十分之一消息延迟从“秒级”降到了“毫秒级”。那个项目做完我最大的感受是很多人不是不想用WebSocket而是对它的理解停留在“听说过、没用过”真到了要动手的时候连Spring Boot里怎么配置都搞不清楚。这篇文章就用一个真实做过的“实时聊天室”项目做主线从协议原理讲到Spring Boot落地再到集群部署和问题排查把WebSocket这套东西完整走一遍。1.1 从一次“点外卖”讲清楚两种通信模式理解WebSocket最简单的方式是拿点外卖做类比。HTTP协议就像你每次点外卖都要重新下单你饿了给商家打个电话说“我要一份炒饭”商家做好送过来通话结束。下次你渴了又得重新拨号重新说一遍“我要一杯水”。这里有两个问题第一每次都要重复沟通和确认信息也就是建立连接第二商家没法主动联系你——如果炒饭卖完了商家只能等你再打电话过来才能告诉你。WebSocket不一样。它更像是“加微信”第一次你添加商家为好友也就是握手建立连接之后这个通道一直保持着你可以随时发消息给商家商家也能随时发消息给你不需要反复建立连接而且是双向的。用技术术语说WebSocket是全双工通信协议底层基于TCP复用HTTP的80/443端口做了一次升级握手之后就以帧Frame为单位自由收发数据。这里有个初学者最容易问的问题WebSocket到底算不算HTTP协议严格说它是一次HTTP请求发起升级后变成的独立协议。客户端发给服务端的握手请求长这样GET /ws/chat HTTP/1.1 Host: demo.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务端如果同意会返回HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo看到101这个状态码就说明握手成功连接升级为WebSocket协议之后就再也不用操心HTTP那套请求-响应模型了。这也是为什么它特别适合聊天、实时推送、在线协作这类“server要主动找client”的场景。1.2 那些年我们用过的“伪实时”方案很多人没接触WebSocket之前项目里做“实时”功能无非就这几种姿势HTTP轮询Polling前端定时发请求比如每2秒一次。实现简单但浪费严重——大量请求其实没有任何新数据纯属空跑。连接数一多服务端压力立刻上来前面说的客服系统就是这么被打垮的。长轮询Long Polling前端发请求后服务端不立即返回而是挂起一段时间有数据了才响应。相比普通轮询省了一些请求但本质上仍然是“一问一答”而且服务端挂起的连接同样消耗资源高并发下还容易碰到连接超时、乱序等问题。SSEServer-Sent Events服务端单向推送给客户端浏览器用EventSource接收。它能做到“服务端主动推送”但它是单向的客户端没法通过同一个通道给服务端发消息而且基于HTTP的流式响应很多网关和代理配置不当时容易缓冲数据导致“假死”。Flash Socket老古董了早年浏览器不支持WebSocket时的临时方案现在基本绝迹不值一提。这几个方案各有各的问题要么延迟高要么浪费资源要么只能单向通信。WebSocket一个通道全部解决。我用下面这个表格总结下它们的区别方便你后面选型时直接参考方案方向实时性资源消耗适用场景HTTP轮询单向请求取决于轮询间隔很高低频、非实时长轮询单向请求较好中等简单推送SSE服务端到客户端单向好较低实时行情、通知推送WebSocket双向实时低聊天、协作、在线游戏现在回到正题直接上代码。我们用Spring Boot来落地这也是Java生态里做WebSocket最主流的方式。2. Spring Boot集成WebSocket的两种姿势在Spring Boot里集成WebSocket大体上有两条路一条是走Spring框架封装的WebSocketHandler纯注解风格或编程风格都成另一条是走Java EE标准的ServerEndpoint属于javax.websocket这套规范。很多刚接触的朋友在这里就开始迷糊我推荐直接走Spring封装的那条路原因后面详细说。2.1 原生Handler vs 标准ServerEndpoint怎么选先给结论除非你有强制的Java EE容器兼容需求否则优先用Spring的WebSocketHandler方案。为什么我最初在项目里用的是ServerEndpoint这套API写起来确实简单几个注解一贴就能跑但它有非常明显的坑它是Java EE规范生命周期由容器管理不经过Spring的IoC容器。这意味着你在ServerEndpoint类里想注入一个Spring的Service要绕一大圈去拿ApplicationContext否则就是NullPointerException。实际项目中这个限制很致命因为聊天室的用户状态、消息记录、离线推送全部要依赖Service层。Spring的WebSocketHandler方案则完全融入Spring生态不管是注入Service还是拦截器、监听器都和写普通Controller没有区别。而且Spring方案天然支持SockJS、STOMP协议这些上层扩展后面做消息代理会省很多事。当然如果你只是做个极简Demo不涉及任何Spring依赖那用ServerEndpoint倒是省事。为了照顾不同的需求我把两种方式都在下面写出来。先创建一个Spring Boot项目Web基础依赖之外还要加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这个starter会自动把WebSocket相关的组件装配好不用额外引入Tomcat的websocket jar包Spring Boot内嵌容器已自动处理。2.2 基于Spring WebSocketHandler的最小可跑示例用Spring方案核心分三步第一步实现WebSocketHandler接口import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; import org.springframework.stereotype.Component; import java.util.concurrent.CopyOnWriteArraySet; Component public class ChatWebSocketHandler extends TextWebSocketHandler { private final CopyOnWriteArraySetWebSocketSession sessions new CopyOnWriteArraySet(); Override public void afterConnectionEstablished(WebSocketSession session) { sessions.add(session); System.out.println(新连接加入: session.getId()); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 收到消息后广播给所有在线客户端 String payload message.getPayload(); for (WebSocketSession s : sessions) { if (s.isOpen()) { s.sendMessage(new TextMessage([ session.getId() ] payload)); } } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session); System.out.println(连接断开: session.getId()); } }这里用CopyOnWriteArraySet保存所有在线会话。为什么用它而不是普通的HashSet因为WebSocket的收发消息是多线程并发的一个线程在遍历所有session发消息另一个线程可能有连接断开在移除session普通集合会抛ConcurrentModificationException。CopyOnWriteArraySet的读操作不上锁、写操作复制一份再改非常适合“读多写少”的场景这也是在线Session列表最常用的数据结构。第二步配置一个WebSocketHandler的注册器Spring Boot提供了一个WebSocketConfigurer接口我们实现它并注册刚才的Handlerimport org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler(), /ws/chat) .setAllowedOrigins(*); } Bean public ChatWebSocketHandler chatWebSocketHandler() { return new ChatWebSocketHandler(); } }注意setAllowedOrigins(*)这一步。默认Spring只允许同源访问如果前端部署在不同端口或域名跨域会被拒绝。开发环境图省事可以放开生产环境务必改成白名单。第三步前端连接const socket new WebSocket(ws://localhost:8080/ws/chat); socket.onopen function() { console.log(连接已建立); socket.send(hello server); }; socket.onmessage function(event) { console.log(收到服务端消息:, event.data); }; socket.onclose function() { console.log(连接已关闭); };这一步跨过去一个最小可跑的WebSocket应用就通了。但别高兴太早这只是“能跑”距离“能上线”还很远。下文才是真正的实战内容。2.3 走STOMP协议为复杂消息设计指明方向如果你只做“全员广播”上面的Handler方案已经够用。可一旦场景复杂起来比如你要区分“聊天消息”“系统通知”“在线状态”甚至要指定发给某个用户Handler方案就得自己写一套路由逻辑。这个场景下Spring推荐的STOMP方案优势就体现出来了。STOMPSimple Text Oriented Messaging Protocol是一个跑在WebSocket之上的轻量级消息协议它定义了SEND、SUBSCRIBE、MESSAGE、ACK等几种简单指令帧。你可以把它理解为“WebSocket通道上的消息路由规则”。客户端可以订阅某个“目的地”Destination服务端可以向某个目的地推消息就像订阅了一个主题的报纸有内容了自动送上门。Spring Boot启用STOMP的配置Configuration EnableWebSocketMessageBroker public class StompConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker(/topic, /queue); config.setApplicationDestinationPrefixes(/app); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws-chat) .setAllowedOriginPatterns(*) .withSockJS(); } }这里的几个前缀含义很关键/topic广播模式所有人都能收到适合公共聊天室。/queue点对点模式只有指定用户能收到适合私聊、一对一通知。/app客户端发送消息的前缀消息走服务端MessageMapping处理逻辑后再决定转发到哪里。服务端处理Controller public class ChatController { Autowired private SimpMessagingTemplate messagingTemplate; MessageMapping(/chat.send) SendTo(/topic/public) public ChatMessage sendMessage(ChatMessage message) { return message; } MessageMapping(/chat.private) public void privateMessage(ChatMessage message) { messagingTemplate.convertAndSendToUser( message.getToUser(), /queue/private, message); } }前端用SockJS stomp.js配合const socket new SockJS(http://localhost:8080/ws-chat); const stompClient Stomp.over(socket); stompClient.connect({}, function(frame) { stompClient.subscribe(/topic/public, function(response) { console.log(JSON.parse(response.body)); }); stompClient.send(/app/chat.send, {}, JSON.stringify({ from: Alice, content: 大家好 })); });我的实际体会是如果项目只做广播用Handler如果消息类型多、要玩点名推送果断上STOMP。STOMP的上限高很多后面切换到RabbitMQ做消息代理时只需要把enableSimpleBroker替换成enableStompBrokerRelay业务代码几乎不用动。3. 实时通信系统的核心细节会话、心跳与断线重连搁这儿只把连接建起来还没法谈“系统”。真正做过实时项目的人都懂上线之后的第一个夜晚往往不是被业务bug干趴下的而是被“连接假死”“幽灵连接”“内存泄漏”这些东西磨得体无完肤。我们从工程视角把会话管理和连接保活的细节一个个抠干净。3.1 在线用户列表与Session状态的优雅管理上一节里我用CopyOnWriteArraySet把所有WebSocketSession堆一起真实项目里这套做法要升级。你至少要记录每个会话对应的userId、连接时间、最后心跳时间、客户端IP这些信息否则出问题连“哪个用户掉线了”都查不到。我的做法是定义一个在线会话的包装类public class OnlineUser { private String userId; private String sessionId; private WebSocketSession session; private String clientIp; private long connectTime; private volatile long lastHeartbeatTime; // getter/setter 省略 }然后通过一个单例的会话管理器维护所有连接Component public class SessionManager { private final ConcurrentHashMapString, OnlineUser userMap new ConcurrentHashMap(); private final ConcurrentHashMapString, String sessionUserMap new ConcurrentHashMap(); public void addSession(OnlineUser user) { userMap.put(user.getUserId(), user); sessionUserMap.put(user.getSessionId(), user.getUserId()); } public void removeSession(String sessionId) { String userId sessionUserMap.remove(sessionId); if (userId ! null) { userMap.remove(userId); } } public OnlineUser getByUserId(String userId) { return userMap.get(userId); } public int onlineCount() { return userMap.size(); } public CollectionOnlineUser allUsers() { return userMap.values(); } }用两个Map分别以userId和sessionId为维度做映射这样不管是“按用户找连接”还是“按连接找用户”都是O(1)操作。多端登录的场景下同一个userId可能会对应多个session那这个数据结构还要升级成MapuserId, MapsessionId, OnlineUser或者MapString, CopyOnWriteArrayListOnlineUser这块按业务取舍不展开。我想强调的是Session管理是WebSocket应用的地基别用全局变量凑合。一旦连接多了你后面排查问题、做强制下线、统计在线人数全都要靠这套结构。3.2 心跳机制与死链检测防止“假在线”WebSocket连接基于TCP但TCP有一种尴尬情况物理链路断了比如手机切换网络、家庭路由器重启、电脑休眠操作系统不会立刻感知连接在应用层看起来还“活着”。如果你不做检测这些僵尸连接会一直占着服务端资源用户明明掉线了还显示在线。解决办法是心跳检测。业界标准做法有两种服务端主动心跳服务端每隔一段时间向客户端发送一个Ping帧如果在规定时间没收到Pong响应就判定连接已死主动关闭。客户端主动心跳客户端每隔一段时间发一个自定义的心跳消息比如{type:heartbeat}服务端收到后更新该用户最后心跳时间戳。服务端隔一段时间扫描一遍把超过阈值没心跳的连接踢掉。我推荐第二种原因很简单服务端Ping帧在部分浏览器和代理环境下会被静默丢弃而业务心跳消息不会。服务端扫描逻辑我通常会结合Spring的定时任务Component public class HeartbeatMonitor { Autowired private SessionManager sessionManager; private static final long HEARTBEAT_TIMEOUT_MILLIS 60_000; Scheduled(fixedRate 30_000) public void checkHeartbeat() { long now System.currentTimeMillis(); for (OnlineUser user : sessionManager.allUsers()) { if (now - user.getLastHeartbeatTime() HEARTBEAT_TIMEOUT_MILLIS) { WebSocketSession session user.getSession(); try { session.close(CloseStatus.SESSION_NOT_RELIABLE); } catch (IOException e) { // 忽略关闭失败也会在下轮被清理 } sessionManager.removeSession(user.getSessionId()); System.out.println(心跳超时,已清理离线用户: user.getUserId()); } } } }这里有两个时间参数需要权衡。心跳发送间隔我常用20秒超时阈值60秒——意味着用户断网后最多60秒就会被清理。如果你的业务对“幽灵在线”容忍度低可以缩短到30秒超时但阈值太小会带来误杀风险比如移动端App切到后台系统限流导致心跳发不出去用户就莫名其妙被踢下线了。生产环境建议阈值不低于心跳间隔的2~3倍。前端的心跳代码也很简单每20秒发一次const HEARTBEAT_INTERVAL 20000; const heartbeatMsg JSON.stringify({type: heartbeat}); setInterval(() { if (socket.readyState WebSocket.OPEN) { socket.send(heartbeatMsg); } }, HEARTBEAT_INTERVAL);你可能会问服务端有了心跳机制还需要这层客户端定时吗需要的。你的心跳消息总需要有一个发送方虽然WebSocket协议本身也有Ping/Pong帧机制但很多浏览器原生API并没有暴露直接发送Ping帧的能力业务心跳是最通用、最兼容所有客户端的方案。3.3 客户端断线重连讲好“组合退避”策略断线重连是WebSocket实战里绕不开的痛点。网络抖动、服务端重启、代理超时任何一次波动都可能断开连接。客户端必须能自动重连并且不能像轰炸机一样疯狂重试——那会直接把服务端拖垮。我见过最粗暴的重连代码就是setInterval(function(){ reconnect(); }, 3000)固定3秒一次。这种方案很危险如果服务端短时间内不可用所有客户端都以固定频率重试重连风暴瞬间打满服务端的线程池。正确姿势是指数退避抖动。指数退避指每次重连间隔翻倍比如1秒、2秒、4秒、8秒、16秒……直到打到上限抖动Jitter指在间隔上叠加一个随机值防止大量客户端在同一时刻发起重连。代码片段大致是这样let reconnectAttempts 0; const MAX_RECONNECT_DELAY 30000; // 最大30秒 function connect() { const socket new WebSocket(ws://localhost:8080/ws/chat); socket.onopen function() { reconnectAttempts 0; // 连上了就重置次数 console.log(连接已建立); }; socket.onclose function() { const baseDelay Math.min( 1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY ); const jitter Math.random() * 1000; const delay baseDelay jitter; reconnectAttempts; console.log(连接关闭, (delay/1000) 秒后重连); setTimeout(connect, delay); }; socket.onerror function() { socket.close(); // 触发 onclose 走统一的重连逻辑 }; } connect();onerror里主动调close是控制重连节奏的技巧有些错误发生后连接并不会自动关闭如果让它挂着重连逻辑就永远不触发所以要主动关闭来落入统一的处理流程。另外补充一个容易忽略的细节断线重连之后前端要重新发送一次“登录校验”或“会话恢复”消息服务端要重新绑定用户身份。如果服务端是session关联用户ID的客户端在onopen后第一时间要上报身份否则重连回来就变成“匿名连接”了。4. 实战从单机走向集群前面的代码在单实例部署下没有任何问题可一旦流量上来你要横向扩容到多个服务实例麻烦就来了。实时通信系统的集群化是实战里最能拉开水平差距的环节也是很多“伪实战教程”避而不谈的部分。4.1 单机版的局限一台机器撑不住的时候怎么办假设你的聊天系统跑在单个Spring Boot实例上用户A连的是实例1用户B连的也是实例1那么A发消息给B实例1内部直接把消息路由过去就行。但你现在升配了变成了两个实例实例1和实例2前面挂一个负载均衡器。用户A连上了实例1用户B连上了实例2。A发消息给B的时候实例1根本不知道B的WebSocket连接在自己这里因为B的session信息在实例2的内存里。消息就丢了。这其实就是有状态服务的水平扩展难题。数据库可以加缓存、加主从但WebSocket的会话状态长在进程内存里跨实例不可见。要解决有两种常见的架构思路。方案一粘性会话Sticky Session让负载均衡器保证同一个用户的请求始终落到同一个实例上。这样可以保证A的WebSocket连接始终在实例1上B的连接始终在实例2上单机逻辑继续有效。这个方案实现成本最低但有一个天生缺陷实例1宕机了落在它上面的用户全断线。粘性会话本质没有解决“状态跨实例共享”的问题只是把问题固定住在容灾上仍然是脆弱的。方案二消息广播外部消息中间件每个实例都订阅一个公共的消息通道当A发消息给B时实例1把消息发到这个公共通道上所有实例包括实例1和实例2都能收到然后每个实例判断“B的session在不在我手上”在就下发不在就丢弃。这个方案无状态化程度高、容灾好是生产环境的主流。实现消息广播的技术栈可选很多Redis的Pub/Sub、Kafka、RabbitMQ的Topic Exchange等。我个人的推荐是中小规模优先用Redis Pub/Sub消息量大或者需要持久化时再上Kafka/RabbitMQ。原因很直白——大多数团队都有现成的Redis引入成本低Pub/Sub模型和WebSocket广播的语义天然契合。4.2 用Redis发布订阅实现跨节点广播假设你已经在项目里引入了Spring Data Redis在服务端加一个广播服务Component public class WebSocketBroadcaster { private static final String WS_TOPIC ws:chat:broadcast; Autowired private StringRedisTemplate redisTemplate; Autowired private SessionManager sessionManager; /** * 发送远程广播消息 */ public void broadcast(String message) { redisTemplate.convertAndSend(WS_TOPIC, message); } /** * 接收来自订阅通道的消息所有实例都会触发 */ EventListener public void onMessage(Message message) { String payload new String(message.getBody(), StandardCharsets.UTF_8); // 在这里自行遍历当前实例的session做下发 for (OnlineUser user : sessionManager.allUsers()) { try { WebSocketSession session user.getSession(); if (session.isOpen()) { synchronized (session) { session.sendMessage(new TextMessage(payload)); } } } catch (IOException e) { // 失败记录日志,交给心跳机制清理 } } } }这里有个细节要特别提醒所有实例都订阅了ws:chat:broadcast当你在实例1上调用broadcast()时Redis会把消息推给包括实例1在内的所有实例。每个实例都遍历自己本地的session列表下发消息。这样B的session在实例2上实例2收到Redis消息后自然就把消息发给B了。需要注意EventListener在Spring Data Redis里监听的是Redis的消息你需要确保在配置里RedisMessageListenerContainer已经正确装配。Spring Boot的自动配置一般都能处理。4.3 多实例下的Session共享与在线人数统计有了广播通道还有一个隐性难题怎么知道用户B当前连的是哪个实例如果你只是广播那没问题所有实例都尝试向B下发消息B所在的实例成功其他实例发现session不存在就忽略逻辑上没毛病。但如果你要做点对点消息只有B能收到其他人不能广播方案效率就很低了。点对点消息的推荐做法是维护一个“用户 - 实例”的映射关系。在用户建立WebSocket连接时把信息登记到Redis的Hash中public void registerUserToInstance(String userId, String instanceId) { stringRedisTemplate.opsForHash().put(ws:user:instance, userId, instanceId); } public void unregisterUser(String userId) { stringRedisTemplate.opsForHash().delete(ws:user:instance, userId); }如果要给某个用户发点对点消息先查Hash拿到他所在的实例ID然后向那个实例发一个专用的Redis通道消息比如ws:private:user:{userId}让那个实例自己去找本地session。在线人数统计也同理。单机版直接读sessionManager.onlineCount()就行集群版就要把各实例的在线数量汇总。做法可以用Redis的HyperLogLog精确度有误差但极其节省内存适用于大用户量统计也可以定期向Redis写当前实例的在线数再聚合展示。我自己的习惯是把每个实例启动时生成一个唯一ID然后定期向Redis写入ws:online:{instanceId}这个键值是当前实例的在线数TTL设成20秒。统计接口汇总所有ws:online:*的键即可。这个方案足够轻量也避免了分布式计数器的复杂度。5. 常见问题与排查技巧实录写到这里已经把核心链路走完了。最后把实际操作中踩过的坑和排查思路整理成速查表每条我都给了定位手段希望能帮你省掉几个深夜排查的秃头时刻。5.1 连接建立后反复断开、连不上这是最高频的问题但原因通常就那么几个。现象可能原因排查手段握手403服务端未放行跨域检查setAllowedOrigins配置握手404路径不对或未走代理核对/ws/chat路径查看Nginx配置连接建立后立刻关闭服务端抛异常或客户端主动关闭看Spring日志中的异常堆栈断断续续断开Nginx代理超时配置设置proxy_read_timeout 3600s无法从HTTPS页面连接页面是HTTPSWebSocket却是ws://改用wss://这里Nginx代理的配置特别值得一提因为网上很多模板只配了proxy_pass漏掉了升级头导致WebSocket握手直接失败。location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }那两行proxy_set_header Upgrade和Connection upgrade是核心缺了任何一个HTTP升级请求在Nginx这一层就会被当成普通请求处理返回404或400。我在生产环境见过太多次了。5.2 服务端如何从请求中拿用户身份WebSocket握手本质是一次HTTP GET请求因此你可以利用HandshakeInterceptor来获取请求参数、Header或者Cookie然后在握手时做身份校验甚至自动登录。Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest (ServletServerHttpRequest) request; String token servletRequest.getServletRequest().getParameter(token); // 校验token if (jwtUtil.verify(token)) { attributes.put(userId, jwtUtil.getUserId(token)); return true; } } response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的扩展点 } }然后在WebSocketConfig里注册这个拦截器在ChatWebSocketHandler的afterConnectionEstablished里就能通过session.getAttributes().get(userId)拿到当前用户。有个容易被忽略的细节放attributes里的数据会跟着session一直存活你可以把userId、用户昵称、头像都塞进去后面推送消息就不用再查库了提升性能的同时也简化了代码。5.3 并发推送时session线程安全与消息乱序这是个偏底层的实战问题。WebSocketSession的sendMessage方法不是线程安全的如果在多个线程同时对同一个session发消息偶发会出现消息交错、内容乱码甚至连接被重置。解决方法是给每个session的发送动作加锁或者为每个session维护一个单线程的消息队列。我在服务端采用的就是简单稳妥的synchronized(session)进行保护private void doSend(WebSocketSession session, String message) throws IOException { synchronized (session) { if (session.isOpen()) { session.sendMessage(new TextMessage(message)); } } }如果并发量特别大synchronized会形成轻微瓶颈但大部分场景下实时消息的发送频率远没到并发瓶颈牺牲的性能换取正确性完全值得。用ConcurrentWebSocketSessionDecorator也是一种方案它内置了缓冲能力和发送限制能解决更复杂的并发控制需求但配置多了反而容易踩坑除非有特殊要求我建议从同步块开始。5.4 千万要提防的“消息积压内存泄漏”还有一个高频事故就是推送速度跟不上生产速度导致消息在内存里堆积。比如一个直播间同时有几万人服务端往每个连接发消息的速度跟不上产生速度未发送的消息会堆积在session的发送缓冲里最终把内存打爆。Spring的WebSocketSession底层是Tomcat的WsSession默认的缓冲区是有上限的。精简策略很简单实时消息要设计丢弃策略而不是无限排队。如果你的会话发送缓冲满了与其堵住后面所有消息不如主动丢掉一些非关键消息。稳定性优先于完整性。聊天室丢一条消息还可以补但整机内存溢出就不可收拾了。所以我会建议在业务层就对消息做削峰和聚合比如两秒内多条消息合并成一条批量推送降低IO频率提高整体吞吐。最后再分享几个我踩过之后的亲身体会项目收尾讲几个从实战里长出来的体会不算总结就是提醒。第一WebSocket别一开始就上STOMP。如果业务只是“服务器广播一个数据”用原生的WebSocketHandler完全够用。STOMP带来的复杂度——客户端要引SockJS库、要处理连接协议细节——在简单场景里是不够划算的。真等消息类型复杂了再迁移也不迟Spring里两套方案的切换成本不大。第二给WebSocket服务加监控越早越好。至少要把“当前连接数”“每秒消息数”“心跳超时数”“推送失败数”这四类指标暴露出来。我在项目里用Actuator的/actuator/metrics配合自定义Metric暴露这些数据当连接数异常升高或者心跳超时激增的时候监控告警能比用户投诉早十分钟发现异常。这十分钟在线上事故处理里就是黄金时间。第三WebSocket不能替代HTTP。很多人一学了WebSocket就恨不得把什么都换成它这是误区。REST API该用还用请求-响应模型在绝大多数场景依然是最合适的只有那些需要“实时、主动、双向”的场景才应该动用WebSocket。我接手的团队里最常见的问题反而是把不需要实时的接口硬改成WebSocket给自己凭空制造了一堆连接管理负担。第四别忽略前端重连的业务逻辑。我之前有次上线只处理了服务端逻辑完全没考虑浏览器断网重连后要不要重新拉取离线消息。结果用户断网又恢复后聊天窗口一直空白。后来加上了“重连成功 - 发送消息同步请求 - 服务端推送离线期间的消息”这个闭环用户体感才恢复正常。实时系统的完整性永远是一整条链路的完整性服务端再健壮客户端缺一环体验照样翻车。实时通信这条技术路线我在不同业务里反复落了好几遍。每次换一个场景都能重新理解一遍“连接管理、消息路由、容灾降级”这些词的重量。希望这篇文章能让你少走几段弯路把Spring Boot WebSocket直接用在真实的业务里而不是停在Demo层。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑