简介:SpringBoot+WebSocket Demo是一套面向SpringBoot开发者的实时通信入门示例,适用于聊天室、在线通知、协作编辑等需要服务端主动推送消息的场景。资源以可运行工程为基础,演示如何引入spring-boot-starter-websocket依赖、实现WebSocketHandler处理器,并借助SockJS搭建简易前端页面完成消息收发与群发广播。压缩包包含106个文件,以xml配置(Maven工程与构建描述)、java源码、class编译产物、html测试页、properties配置及少量js文件为主,整体仅169KB,结构紧凑,便于快速对照学习。已有410人学习下载,适合初涉WebSocket或想在不引入重型框架前提下快速验证实时通信逻辑的开发者。通过该Demo,可以理清端点注册、会话管理、消息转发等核心流程,并在此框架上继续扩展鉴权、消息编码或异常处理等生产级能力。 上周在项目里需要给后台管理系统加一个实时消息通知功能:用户在前台提交了某个申请,后台管理员要立刻看到弹窗提示,不用手动刷新页面。
接到需求后我第一反应就是SpringBoot+WebSocket。这套组合的好处是SpringBoot作为后端框架自带WebSocket支持,不用额外引第三方组件,几行配置就能跑起来。而且WebSocket和SpringBoot的契合度很好,不像Netty那样要自己处理很多底层细节。
这篇博文就基于我实际的开发过程,把从依赖引入、Handler编写、握手拦截器、消息推送改造,到最后部署时遇到的那些坑,完整地梳理一遍。如果你也准备在自己的项目里用WebSocket做实时推送,或者想搞明白这东西到底怎么和SpringBoot结合,这篇应该能帮你少走不少弯路。
1. 为什么后端推送非要选WebSocket
先说说我为什么不用轮询。很多人遇到"需要实时感知新数据"的场景,第一反应是前端定时发Ajax请求,每5秒问一次后端“有没有新消息”。这种方式在小规模内部系统里不是不能用,但问题很明显:用户不操作页面的时候请求白发、服务器压力大、消息到达有延迟。之前我见过一个系统高峰期每分钟几千次无效轮询请求,数据库连接池都被拖垮了。
WebSocket解决的正是这个问题。它和HTTP一样工作在TCP之上,但HTTP是“一问一答”,WebSocket是一条长连接,客户端和服务端可以随时主动往对方塞数据。连接建立之后,服务端想推什么就推什么,不用等前端来问。
从技术角度说,WebSocket和SpringBoot的集成点主要在这几块:
spring-boot-starter-websocket,SpringBoot对WebSocket的自动配置整合,帮我们把底层握手、消息分发的基本工作都包掉了。@ServerEndpoint注解,这是JSR-356标准定义的端点声明方式,SpringBoot也支持,把某个类标记成WebSocket的入口。WebSocketHandler接口,这是Spring专门提供的一套抽象,方便和SpringMVC的组件整合,适合控制力要求更高的场景。
Spring生态里实际有两种写法:一种是直接用Java EE标准的@ServerEndpoint,把WebSocket端点当成一个独立组件来管理;另一种是Spring自己封装的WebSocketHandler接口加上HandshakeInterceptor做握手拦截。两种我都试过,刚开始图省事用的@ServerEndpoint,后来发现要注入Spring容器里的Service时总是动不动就是null,得靠静态工具类绕弯。后面改成Spring的WebSocketHandler方案才顺过来,依赖注入完全通畅,拦截器也比注解配置直观。这篇里我就统一讲Spring原生的这套方案。
一句话总结选型理由:如果你的需求是服务端主动推送、客户端需要保持长连接实时收数据,SpringBoot里用WebSocket就是最标准、最省事的做法。不需要上Netty这种重型框架,也不要用轮询委屈自己。
2. 先搞懂WebSocket握手的几个关键事实
在写代码之前,我觉得有必要把WebSocket的连接建立过程讲清楚,因为后面排查问题全得靠这些知识。
WebSocket的连接并不是直接“嗖”一下建立起来的,它利用了我们已有的HTTP协议。客户端先发一个普通的HTTP请求,请求头长这样:
GET /ws?userId=1001 HTTP/1.1 Host: localhost:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Version: 13这段请求的关键是Upgrade: websocket这个头,它的意思是:“我不打算走普通HTTP流程了,麻烦你把协议切换成WebSocket。”服务端看到这个头,如果同意切换,会返回101状态码:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=Sec-WebSocket-Accept的值是根据请求里的Sec-WebSocket-Key算出来的,具体算法是RFC 6455规定的:把Sec-WebSocket-Key加上GUID字符串,然后做SHA-1哈希,再Base64编码。这个细节有个很实际的作用:我们调试后端时,如果看到连接一直建立不起来,可以先手动算算这个值是否正确,确认是不是被某个中间代理改了头。
握手成功的标志就是状态码101。之后这条TCP连接就升级成了WebSocket长连接,客户端和服务端都可以往里面写数据帧。
WebSocket的数据帧格式里有一个持久保持连接的机制叫心跳。每隔一段时间,客户端或服务端可以发送一个Ping帧,对方必须回一个Pong帧。很多长连接断开的问题,其实不是网络真的断了,而是中间的网络设备(比如Nginx、云厂商的负载均衡)把空闲连接给掐了。有了心跳,连接就会一直被认定为活跃,不会被回收。后面我讲配置和踩坑时都会围绕这个点展开。
所以做WebSocket开发,脑子里要时刻记住两个阶段:握手阶段,走的是HTTP协议语义;连接建立之后,走的是WebSocket自己的帧协议。这两个阶段各有一堆可排查的坑点。
3. SpringBoot里如何写一个能用的WebSocket端点
我先直接上一份完整的、能跑通的代码,然后逐个拆解关键点。
3.1 引入依赖
SpringBoot项目只需要一个starter就够:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>这里有个小提示:spring-boot-starter-websocket会自动把内嵌的Tomcat的WebSocket支持拉进来。你不需要额外引入Tomcat的websocket包,免得版本冲突。之前见过有人画蛇添足手动又加了tomcat-embed-websocket,结果Tomcat版本不一致直接启动报错。
3.2 实现WebSocketHandler
Spring的WebSocketHandler接口定义了四个方法,我们逐个说:
@Component public class MyWebSocketHandler implements WebSocketHandler { private static final Map<String, WebSocketSession> SESSION_POOL = new ConcurrentHashMap<>(); private static final ExecutorService PUSH_EXECUTOR = Executors.newFixedThreadPool(8); @Override public void afterConnectionEstablished(WebSocketSession session) { String userId = (String) session.getAttributes().get("userId"); SESSION_POOL.put(userId, session); // 这里可以记录一条日志:连接建立成功 } @Override public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) { // 生产环境一般是收到客户端的Ping或者业务指令 } @Override public void handleTransportError(WebSocketSession session, Throwable exception) { // 网络异常时触发,需要在这里做清理 String userId = findUserId(session); if (userId != null) { SESSION_POOL.remove(userId); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) { String userId = findUserId(session); if (userId != null) { SESSION_POOL.remove(userId); } } @Override public boolean supportsPartialMessages() { return false; } private String findUserId(WebSocketSession session) { return (String) session.getAttributes().get("userId"); } }afterConnectionEstablished是连接建立成功后的回调,这里适合做在线用户管理。我用了一个ConcurrentHashMap来存“userId -> WebSocketSession”的映射。之所以用userId做key而不是sessionId,是因为业务上我们根本不管底层连接是谁,只关心消息要推给哪个用户,这样在后面做“按用户推送”时会特别顺手。
handleMessage是收到客户端消息时的入口。很多实际的Demo里这里都是空的,因为服务端主动推送场景下,客户端主要用来收,偶尔发个Ping确认连接活着就完事了。不过如果你要做聊天室之类的双向交互,逻辑就写在这里。
注意我用了一个Executors.newFixedThreadPool(8)的线程池,先说明这只是demo级的写法,生产环境建议改为Spring的ThreadPoolTaskExecutor,方便监控和动态调参。为什么会有这个线程池?因为在Spring的WebSocketHandler里,如果我们在处理消息时执行了比较耗时的任务(比如查数据库、调外部接口),会阻塞Tomcat处理WebSocket消息的线程,影响同一条连接上其他消息的处理。把任务丢进线程池,主线程可以立刻返回。
3.3 注册WebSocket端点和握手拦截器
有了Handler,还需要把它暴露到指定的路径上。Spring用WebSocketConfigurer来做这件事:
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Resource private MyWebSocketHandler myWebSocketHandler; @Resource private AuthHandshakeInterceptor authHandshakeInterceptor; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myWebSocketHandler, "/ws") .addInterceptors(authHandshakeInterceptor) .setAllowedOrigins("*"); } }有几个配置容易被忽略。
setAllowedOrigins("*"),如果你的前端页面和后端不在同一个域名或端口,这个配置决定要不要拦截跨域请求。在SpringBoot 2.4之前写setAllowedOrigins("*")就行,SpringBoot 2.4之后这个方法可能在部分版本不生效,报错时会提示用setAllowedOriginPatterns。我碰上过一次:前端页面在8080端口,后端在8081端口,WebSocket连接死活建不起来,浏览器控制台报跨域错误。原因就是这里只设置了setAllowedOrigins,后来换成下面的写法才通过:
registry.addHandler(myWebSocketHandler, "/ws") .addInterceptors(authHandshakeInterceptor) .setAllowedOriginPatterns("*");addInterceptors不是必填项,但很多时候我们必须在握手阶段校验用户是否已登录。HandshakeInterceptor里有beforeHandshake方法,它能拿到当前的HTTP请求,这样就能从请求参数、Header或者Cookie里取出鉴权信息:
@Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { // 从请求参数里取userId,生产环境应该用token去redis里换用户信息 String userId = request.getURI().getQuery() != null ? extractUserIdFromQuery(request.getURI().getQuery()) : null; if (userId == null) { return false; // 拒绝握手 } attributes.put("userId", userId); // 关键:这里的attributes会传给Handler里的session.getAttributes() return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的回调,一般用不上 } }这里有一个非常关键的坑:beforeHandshake方法最后的attributes.put("userId", userId),这个attributesMap最终会被绑定到WebSocketSession的attributes属性上。也就是说,afterConnectionEstablished里通过session.getAttributes().get("userId")拿到的就是这里放进去的值。如果忘记这一步,后面在Handler里想要识别当前连接属于哪个用户就只能靠解析请求URL,又要多写一堆麻烦代码。
3.4 测试一下能不能通
代码写完先用简单方式验证。找一个WebSocket在线测试工具(网上很多,搜websocket在线测试就能找到),连接地址填:
ws://localhost:8080/ws?userId=1001连接成功的话,后端日志会打印afterConnectionEstablished里的记录。如果你用的是SpringBoot内置Tomcat,默认http端口就是WebSocket端口,不需要单独开端口。需要注意URL的scheme是ws,不是http,很多第一次接触的人在这里栽过跟头。
4. 消息推送时踩过的“连接已关闭”坑
连接建立起来了,Handler也注册好了,剩下的核心问题就是怎么把消息主动推给客户端。很多人的代码里会写类似这样的推送:
public void sendToUser(String userId, String message) { WebSocketSession session = SESSION_POOL.get(userId); session.sendMessage(new TextMessage(message)); }如果测试时客户端一直在线,这段代码没任何问题,一切看起来都很顺利。但一旦用户关了浏览器或者手机切了飞行模式,问题就来了:session.sendMessage会抛IllegalStateException,异常信息大概是“The remote endpoint was in state [TEXT_PARTIAL_WRITING]”或者“Connection closed”。如果你没有try-catch,这条推送还会把上层业务逻辑打断。
我真实的踩坑经历是这样的:有一个给管理员推送待办消息的服务,循环遍历所有在线的管理员连接并逐个推送。结果其中一个管理员的网络断了,但服务端还没来得及执行afterConnectionClosed清理,SESSION_POOL里还残留着他的session。推送时抛异常后,后面的管理员全都收不到消息。排查日志时看到一大片异常堆栈,才知道是单个脏连接引发的连锁反应。
修复方案很简单也很必要——推送之前先判断session.isOpen(),发送过程中包一层try-catch,发送失败后立刻从池子里移除:
public void sendToUser(String userId, String message) { WebSocketSession session = SESSION_POOL.get(userId); if (session != null && session.isOpen()) { try { synchronized (session) { session.sendMessage(new TextMessage(message)); } } catch (IOException e) { SESSION_POOL.remove(userId); // 记日志,通知业务方该用户已掉线 } } }加synchronized (session)这个细节是后来才想明白的。Tomcat的WebSocketSession底层并发写是不安全的,如果同时有两个线程往同一个session发送消息,可能出现消息内容互相穿插的脏读。WebSocket官方文档里明确要求“一个连接同时只能有一个线程在写”,所以用synchronized锁住session是最简单的保证方式。
另一个坑是推送线程被阻塞。某个客户端如果接收得很慢,sendMessage会一直阻塞在TCP的写缓冲区上。最开始我没处理这个问题,测试时发现服务端推送一条大消息(几MB)时,整个推送线程卡了好几十秒,其他用户的推送全部排队等待。解决办法有两个方向:一是给sendMessage加超时控制,用session.setBinaryMessageTimeLimit这类API;二是把推送任务丢进独立线程池,不要阻塞业务主流程。我最后采用了线程池方案:
PUSH_EXECUTOR.execute(() -> { sendToUser(userId, message); });线程池隔离的效果很明显:某个慢客户端顶多拖垮池子里几个线程,不会把整个业务线程拖死。但线程池的线程数要合理设置,太小了推送吞吐不够,太大了又浪费资源,8只是demo环境的保守值,生产环境可以根据活跃连接数和推送频率来调。
5. 服务端主动推送的三种玩法
很多人把WebSocket集成进去了,但真正要用的时候反而有点懵:“我Handler里连路径都配好了,怎么从别的类里触发推送啊?”这里我总结了三种实际项目里用得上的玩法。
5.1 静态方法直接推
老项目里常用这种玩法。把Session池和推送方法都写成静态的,需要推送时直接用类名调用:
public class WebSocketSessionPool { private static final Map<String, WebSocketSession> POOL = new ConcurrentHashMap<>(); public static void add(String userId, WebSocketSession session) { POOL.put(userId, session); } public static void remove(String userId) { POOL.remove(userId); } public static void sendToUser(String userId, String message) { WebSocketSession session = POOL.get(userId); if (session != null && session.isOpen()) { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { POOL.remove(userId); } } } }好处是调用方便,哪里都能用。坏处是静态Map在集群环境下变成每个节点的本地数据,消息推送给哪个节点是个问题——这一点我后面单独讲。
5.2 Spring事件驱动推送
在SpringBoot项目里,更符合框架习惯的做法是发内部事件。业务代码Post一个事件,由监听器统一负责推送。这样业务逻辑和WebSocket推送逻辑解耦,将来想换推送渠道(比如再加一个短信通知)也不用动原业务代码:
// 定义事件 public class MessagePushEvent extends ApplicationEvent { public MessagePushEvent(Object source, String userId, String content) { super(source); this.userId = userId; this.content = content; } } // 业务代码里发事件 applicationEventPublisher.publishEvent(new MessagePushEvent(this, userId, content)); // 监听器里做推送 @EventListener public void onMessagePush(MessagePushEvent event) { WebSocketSessionPool.sendToUser(event.getUserId(), event.getContent()); }这种方式在多节点环境里还能搭配消息队列做跨节点广播:业务代码往Redis的Pub/Sub或者RabbitMQ里发一条消息,所有节点的监听器都收到通知,让每个节点只推给本机持有的连接。这就是分布式的常规解法,比直接调用静态方法优雅得多。
5.3 定时任务推心跳,顺便探活
WebSocket长连接的本质是TCP连接,存在被中间设备“静默掐断”的风险。设备本身没给你发TCP RST包,所以你的服务端完全感知不到连接已经失效。这种情况下,定时推一条Ping消息过来能维持链路的存活状态:
// 配合Spring的@Scheduled任务 @Scheduled(fixedRate = 30000) public void heartbeatCheck() { for (String userId : WebSocketSessionPool.getOnlineUserIds()) { WebSocketSession session = WebSocketSessionPool.getSession(userId); if (session != null && session.isOpen()) { try { session.sendMessage(new PingMessage(() -> { ByteBuffer buffer = ByteBuffer.allocate(1); buffer.put((byte) 0); return buffer; })); } catch (IOException e) { // 连接已经失效,清理掉 WebSocketSessionPool.remove(userId); } } } }PingMessage是WebSocket协议里的心跳帧。服务端主动发送Ping,客户端收到后不需要前端做什么特殊处理——浏览器会自动回一个Pong。如果客户端已经断开,发送Ping会抛异常,正好帮我们完成“探活+清理”的双重任务。实测里这是最有效的清理失效连接手段,比单纯依赖afterConnectionClosed回调稳得多,因为有很多断开场景服务端是收不到关闭帧的。
6. 前端配合和后端鉴权中的实用细节
很多人做WebSocket Demo时只关注后端,结果前端连不上就开始怀疑是后端写错了。其实前端也有几个关键点。
6.1 前端WebSocket的写法
如果用原生JavaScript,连接和接收消息大概长这样:
let socket = null; let heartbeatTimer = null; function connectWebSocket(userId) { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; socket = new WebSocket(`${protocol}://${location.host}/ws?userId=${userId}`); socket.onopen = function() { // 连接成功,开始心跳 heartbeatTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send('ping'); } }, 25000); }; socket.onmessage = function(event) { const data = JSON.parse(event.data); handlePushMessage(data); }; socket.onclose = function() { clearInterval(heartbeatTimer); // 自动重连,指数退避 setTimeout(connectWebSocket, 3000, userId); }; socket.onerror = function(error) { // 错误多半会在onclose里触发,这里记日志即可 }; }这里有几个可以直接落地的经验。心跳和重连是必须的:心跳每隔25秒发一次,防止中间设备把空闲连接断开;服务端一般把空闲超时设置为60秒或更长,所以心跳间隔必须小于服务端超时时间。重连要有退避策略,否则服务端重启的瞬间所有客户端一起疯狂重连,连接风暴直接打崩服务器。
如果前端用的是Vue,很多人喜欢配@vueuse/core里的useWebSocket。这个组合式函数把连接状态、自动重连、消息收发都封装好了,比手写原生WebSocket要省事,尤其适合组件里需要响应式管理连接状态的场景。
6.2 握手阶段鉴权的实践经验
WebSocket的握手请求是HTTP请求,所以我们可以在HandshakeInterceptor里做鉴权。但要注意:浏览器WebSocket API没办法自定义请求头。如果我们要在握手时携带token,只能通过以下三种方式之一:
- URL参数:
ws://localhost:8080/ws?token=xxx。简单直接,但token会写在历史记录里,有泄漏风险。 - Cookie:握手时会自动带上同源的Cookie。后端从Cookie里取,安全性和普通HTTP请求一致。前提是前后端同域,或者CORS配置允许携带Cookie。
- 子协议头(
Sec-WebSocket-Protocol):可以用它偷渡token,但会被一些网关和代理过滤,不建议常规使用。
我的习惯是如果做了登录系统,就把token放在Cookie里走第二种方式;临时demo就用URL参数图省事。一定要清楚一点:你在beforeHandshake里返回false或者直接抛异常,客户端看到的不会是HTTP 401,而是WebSocket的onclose事件,错误码通常是1006(异常关闭)。所以生成环境如果发现用户莫名其妙被断开,记得在后端把握手失败的日志打详细一点,不然前端很难排查。
6.3 一个隐蔽问题:分布式部署后的连接漂移
如果你的SpringBoot应用将来要部署多个实例(没有做负载均衡时,两个节点各管各的),那么用户A连的是节点1,管理员B连的是节点2,节点1往B推送时,发现B的session不在自己的池子里,推送失败——这就是分布式场景下的连接漂移问题。
我在真实项目里的处理方式是引入Redis的Pub/Sub。思路不复杂:
- 每个节点把“当前节点持有哪些userId”上报到Redis。
- 推送时先查Redis拿到userId对应的节点编号。
- 如果命中本节点,直接推送;如果命中其他节点,通过Redis的channel发一条消息,让那个节点代为推送。
这样就不需要引入Kafka或RabbitMQ这种重量级中间件,也能在集群环境下把推送消息“投递”到正确的节点。Demo阶段可以先不考虑这个,但它是一个真实项目迟早要面对的问题。如果你打算上云,还要注意负载均衡器需要开启WebSocket支持(通常表现为Upgrade和Connection这两个头的透传),否则连接会在负载均衡层被截断。
7. 关于配置文件和常见报错的小结
SpringBoot的WebSocket基本不需要额外配置,但有几个参数值得知道。
server.websocket.timeout不是SpringBoot官方标准配置项,不同版本的支持程度不一样。更常见的做法是通过实现WebSocketConfigurer时,在底层容器层面做调整。如果你用的是内嵌Tomcat,可以直接这样:
@Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxSessionIdleTimeout(60000L); // 空闲连接60秒超时 container.setMaxTextMessageBufferSize(1024 * 1024); // 单条文本最大1MB container.setMaxBinaryMessageBufferSize(1024 * 1024); return container; }这里有一个很容易踩的坑:setMaxSessionIdleTimeout设的是整个连接的空闲超时,如果我们的业务需要长时间保持连接但又不想一直被心跳打扰,可以把这个值设大一些。但要注意,如果设得太小(比如30秒),而前端心跳间隔是25秒,就很容易出现“刚连上就被断开”的怪问题。我之前就碰过一次:前端心跳发的是文本消息“ping”,但我没有在handleMessage里处理它,导致心跳虽然在传,却被容器判定为“空闲”(因为文本消息不算协议层的心跳帧),结果连接照样被超时清理。后来把前端改成发PingMessage或者在后端handleMessage里做一个收到消息即视为活跃的处理,问题才解决。
顺手列几个常见的异常和对应的解决办法:
| 异常现象 | 可能原因 | 解决办法 |
|---|---|---|
| 连接立即断开,日志无任何记录 | 握手拦截器返回了false | 在beforeHandshake里加日志,确认是否鉴权失败 |
| 前端报“Unexpected response code: 200” | 请求被拦截器或过滤器改写了,或路径没匹配到 | 确认/ws路径是否被权限框架(如Shiro、Spring Security)拦截 |
| 广播时抛IllegalStateException | session已经失效但仍在池里 | 推送前检查isOpen,try-catch后清理 |
| 多个节点各推各的,消息缺失 | 分布式环境连接分散 | 引入Redis Pub/Sub做跨节点转发 |
| 握手跨域失败 | 前后端不同源时未正确配置Origin | 用setAllowedOriginPatterns("*") |
| 启动时Bean创建失败 | 忘了加@EnableWebSocket | 在配置类上补注解 |
其中“前端报200”那个问题,排查思路是这样的:浏览器地址栏输入http://localhost:8080/ws?userId=1001,如果返回的是JSON错误而不是101状态码,说明请求被SpringMVC的拦截器或某个过滤器提前处理了。最常见的是项目里引入了Spring Security,安全过滤器在握手之前就把请求拦截下来,返回302跳转登录页。解决方法是放行WebSocket握手路径,或者给该路径单独配置匿名访问权限。
8. 一些真实项目里的思考
再说说我当时上线这个功能时,除了写代码之外还需要考虑的三件事,这些都不会在常规教程里教你,但对项目的稳定运行挺关键。
第一个是消息可靠性。WebSocket推送本质上是尽力而为,网络抖动、客户端崩溃、服务端重启都会导致消息丢失。如果业务要求“消息必须送达”,那么光靠WebSocket是不够的,需要自己实现补偿机制。比如推送前先把消息落库,客户端收到消息后回一个ack,服务端定期扫库找没有ack的消息重新推送。我当时的做法是在Redis里存一个待推送队列,推送成功才移除,定时任务每5分钟扫一次补推。
第二个是连接数上限。WebSocket连接是长连接,每一条连接都会占用一个文件描述符。默认情况下Linux系统单个进程能打开的文件描述符上限是1024,如果你的在线用户数超过这个数字,必须调整ulimit,否则连接数到顶后新的连接直接失败。这是一个非常隐蔽的运维问题,测试环境怎么都测不出来,一上生产用户一多就出问题。
第三个是优雅停机。用kill -9杀进程对WebSocket服务来说很残忍,因为连接不会正常关闭,客户端必须等到心跳超时才发现服务端已挂。更平滑的做法是配置Spring Boot的优雅停机:
server: shutdown: graceful这样在应用关闭时,Spring会等待已建立的连接处理完当前任务再关闭,WebSocket连接也会收到正常的关闭帧,客户端可以及时发现并重连到其他节点。这个配置对于所有线上应用都值得开,尤其是我们这种长连接服务。
我这套代码跑下来之后,最明显的体感是系统里的“在线状态”变得更加可信了,管理员再也不用靠一遍遍刷新页面来找活干。如果你也准备在SpringBoot项目里接入WebSocket,建议从最简单的单机Demo开始,先把连接建立、消息推送、断开清理这三个环节跑通,再逐步考虑集群、鉴权和消息可靠性的问题。每个阶段踩的坑都有不同解法,但核心思路都是围绕连接状态和消息投递这两个维度来设计的。
本文还有配套的精品资源,点击获取