1. 从一个实战场景说起:实时推送到底难在哪
做Java后端的朋友,多半会遇到这样一种需求:页面上的数据要实时更新,比如用户下单后后台立刻弹出提示、大屏看板的数据每隔几秒自动变动、或者做一个群聊页面,消息发出去对方马上就能收到。
早期做法是前端轮询。前端隔两三秒向后台发一个普通HTTP请求,问一句“有没有新数据”。接口还没返回几条数据,连接先被问了几百次,服务器压力大不说,数据更新还有一两秒的延迟,体验很鸡肋。
想做得更好,就得让服务器主动把消息推给浏览器。这个需求落到技术方案上,基本就是WebSocket。WebSocket服务端并不是一个很高深的东西,它走的是一条独立的TCP长连接通道,一次握手成功后,客户端和服务端都能随时往通道里写数据,双向、低成本、实时。
这篇内容就是给Java技术栈的朋友讲清楚WebSocket服务端的基本使用方法。我会抛开官方文档里那些繁琐的术语,从拿到一个需求开始,到把服务端代码跑起来,再到上线后常遇到的问题,一步步过一遍。适合刚接触WebSocket的新手,也适合需要快速在Java项目里落地WebSocket功能、但不想走太多弯路的同学。
HTTP协议本身是先请求后应答,服务器不能主动找客户端。WebSocket出现之前,所有“主动推送”都是靠前端反复发请求来伪装的。现在Java服务端实现WebSocket的方式已经很成熟,最常用的是JSR 356标准注解,也就是@ServerEndpoint。理解了这个标准,再去看Spring封装的那套,也会轻松很多。
2. 动手前的准备:选型、依赖与三种实现姿势
2.1 Java WebSocket标准的来龙去脉:JSR 356与容器支持
Java里做WebSocket服务端,有一个规范叫JSR 356,它定义了WebSocket协议在Java体系中的API标准,发布在Java EE规范里。也就是说,只要你的运行环境遵循Java EE或者Jakarta EE标准,就不需要额外引入重量级框架,直接用标准API就能开发WebSocket端点。
规范里提了两类开发模式:一类是注解式开发,通过@ServerEndpoint把一个普通Java类标记为WebSocket服务端点,用@OnOpen、@OnMessage这些注解声明生命周期方法;另一类是继承式开发,写一个类继承javax.websocket.Endpoint,再通过ServerContainer注册。实际项目里90%以上都用注解式,简洁直观。
有人会有疑问:Tomcat、Jetty、Undertow这些容器兼容JSR 356吗?兼容。它们都实现了JSR 356,所以你能在Spring Boot的内置Tomcat里直接用@ServerEndpoint,也能把WAR包扔进外部Tomcat里跑。需要注意的只是不同容器对规范细节的微调,这一点后文排障部分会提到。
2.2 Maven依赖怎么加、用哪个版本
如果你是普通的Java Web项目,用的是外部Tomcat 9或10,那么javax.websocket-api或者jakarta.websocket-api通常已经由容器提供,Maven依赖里只需要配一个provided范围的坐标就行。举个例子,Tomcat 9对应的是javax命名空间:
<dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency>这里用provided是因为容器里已经带了API实现,如果打个WAR包进去还带一份API,反而容易引起类加载冲突。Spring Boot项目如果在内嵌Tomcat里跑,同样不需要把API打进去,写代码时只需要有这个依赖即可编译。
如果你用的是Jakarta命名空间,也就是Tomcat 10.1或者Spring Boot 3.x,把包名换成jakarta.websocket就行:
<dependency> <groupId>jakarta.websocket</groupId> <artifactId>jakarta.websocket-api</artifactId> <version>2.1.0</version> <scope>provided</scope> </dependency>注意一个坑:Spring Boot 2.x加WebSocket功能,很多人习惯引入spring-boot-starter-websocket,这个包里其实带的是Spring MVC对WebSocket封装所需的依赖,还会拉来Tomcat等容器。如果你只是想在Spring Boot里用标准@ServerEndpoint,那么引入这个starter不会自动注册你的端点,后面要单独配置一个ServerEndpointExporter,不然@ServerEndpoint的类会出现“定义了却找不到路径”的情况。
2.3 三种实现方式,怎么选:注解式、编程式、Spring封装
先说结论:新项目、普通需求,优先用注解式。注解式代码量最少,一个类就是一个端点,生命周期方法一目了然,团队协作时上手成本低。
编程式实现,也就是继承Endpoint并注册到ServerContainer,这种方式更灵活,适合在运行时动态决定端点路径、或者需要拿到更多容器控制能力的场景。但写起来啰嗦,需要自己管理Session和消息处理线程,日常业务开发用不到。
Spring封装的那套,比如TextWebSocketHandler和WebSocketHandlerRegistry,面向的是WebSocket协议之上的Spring抽象,适合需要和SpringMVC的拦截器、权限体系深度集成的项目。它的好处是可以和Spring Security无缝整合,坏处是学习成本高了一点,你要理解Handler、HandshakeInterceptor、WebSocketSession这一整套体系。
个人经验建议:如果是纯后端服务,没有复杂的登录鉴权整合需求,标准JSR 356注解式完全够用;如果在Spring生态里做人脸识别推送、在线课堂这类复杂应用,且要复用Spring Security做登录态校验,那就用Spring的WebSocketHandler。不要两套混用,否则排查问题时每一次握手都会面临“又是谁拦截了”的讨论。
3. 核心实操:写一个能用的WebSocket服务端
3.1 注解式开发:@ServerEndpoint与第一个可运行的端点
先来一个最基础的服务端例子,作用是接收客户端发来的文本消息,并且原样返回。这相当于WebSocket版的“你好,世界”。
import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; @ServerEndpoint("/ws/echo") public class EchoEndpoint { @OnOpen public void onOpen(Session session) { System.out.println("连接打开:" + session.getId()); } @OnMessage public void onMessage(String message, Session session) throws IOException { System.out.println("收到消息:" + message); session.getBasicRemote().sendText("服务端已收到:" + message); } @OnClose public void onClose(Session session) { System.out.println("连接关闭:" + session.getId()); } @OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }这段代码放到项目里,启动Web容器后,客户端就可以通过ws://ip:port/项目上下文/ws/echo连接了。/ws/echo前的项目上下文取决于你部署的WAR包名或者Spring Boot的context-path,如果上下文为空,那就是ws://ip:port/ws/echo。
很多新手不知道@ServerEndpoint里的路径能不能写变量。是可以的,比如@ServerEndpoint("/ws/chat/{roomId}"),这个roomId就能作为参数传给后续方法,后面会单独说。
3.2 生命周期回调:@OnOpen、@OnMessage、@OnClose、@OnError
这四个注解对应WebSocket连接从生到死的四个阶段,理解它们比背代码重要。
@OnOpen是握手成功后的回调,在这里可以初始化连接相关状态。比如把当前Session存到一个全局Map里,或者给Session放一个用户ID属性。有一点容易被忽略:@OnOpen方法可以接收Session参数,也可以接收EndpointConfig和路径参数。它虽然叫打开,但这时TCP连接已经建立,只是WebSocket Protocol层刚刚完成握手。
@OnMessage是接收消息的核心方法。它能接收String文本、byte[]二进制、PongMessage,也能接收Reader或InputStream,甚至可以通过自定义实现Decoder来接收一个Java对象。容器收到一个完整消息时,会调用这个方法。这里要记住:默认情况下,一个连接上同一时刻只能处理一条消息,如果你在@OnMessage里做了阻塞操作,会拖住该连接后续消息的接收。后面聊并发时再展开。
@OnClose是关闭连接时的回调。无论是客户端主动断开,还是服务端调用session.close(),都会触发它。在这个方法里一定要清理全局Session列表,不然在线人数统计会失真,之后尝试给已断开的Session发消息还会抛异常。
@OnError处理异常。实际开发中这里至少要把异常日志打出来,别留一个空方法。断网、协议错误、消息解析失败都会走到这里,空实现会让线上问题排查变得极其困难。
3.3 会话管理:Session并不是线程安全的,你要如何维护在线列表
javax.websocket.Session对象代表一条WebSocket连接。客户端连接进来后,服务端手里的根对象就是这个Session。发送消息、关闭连接、获取连接参数都靠它。
但有三个残酷现实:第一,Session不是线程安全的,多线程同时调用它发消息,可能出现消息交错或者连接状态异常;第二,Session的对象生命周期受容器管理,你可能在某次重连后发现拿到的还是旧对象;第三,Session如果已经关闭,调用sendText会抛IllegalStateException。
所以在线列表怎么维护?最常用的做法是用一个线程安全的Map,键为Session的ID,值为Session对象,或者键为自定义用户ID,值为Session。我的建议是同时维护两层映射:一层Map<String, Session>按会话ID存,便于拿到原始会话;一层Map<String, String>保存业务用户ID与会话ID的对应关系,便于按用户推送。
private static final Map<String, Session> SESSION_MAP = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session) { SESSION_MAP.put(session.getId(), session); } @OnClose public void onClose(Session session) { SESSION_MAP.remove(session.getId()); }注意,在线列表字段推荐用static final ConcurrentHashMap。原因有两点:一是@ServerEndpoint端点类默认不是单例,每个连接都会创建一个实例,字段不带static就会出现每个连接各自一份Map,互相看不到;二是普通的HashMap在多线程操作时会出并发问题,必须用线程安全版本。
关于发送消息前判活,我习惯写一个小的工具方法:
private void safeSend(Session session, String text) { if (session != null && session.isOpen()) { session.getBasicRemote().sendText(text); } }session.isOpen()只能作为发消息前的即时检查,它并没有办法保证你调用sendText的瞬间连接是开着的,所以异常捕获依然要写。
3.4 消息推送:点对点、广播与异步发送,别把消息发丢了
WebSocket服务端不只是被动接收消息,更多时候是要主动往客户端推数据。比如用户A下单成功后,服务端要把订单状态推给用户B。这就是服务端主动推送。
推送分三种常见形式:点对点、广播、分组推送。点对点就是拿目标用户的Session,调用getBasicRemote().sendText();广播就是遍历所有在线Session逐个发送;分组推送就是在在线列表基础上维护一个组ID和Session列表的映射。
发送消息时有两个Remote可选:getBasicRemote()是同步阻塞发送,getAsyncRemote()是异步非阻塞发送。同步发送的好处是简单,但如果某客户端网络慢,可能会导致发送线程阻塞,影响其他用户的消息;异步发送可以避免阻塞,但需要关注异步回调里的失败处理。
关于消息丢失问题,WebSocket协议本身不保证应用层消息一定被完整送达,尤其是在中间有代理服务器、网络抖动的情况下。你调用sendText只是把消息交给操作系统Socket缓冲区,不代表对方已经收到。业务上很重要的消息,我通常会在消息体里加一个自增序号,客户端收到后回一个ACK,服务端超时未收到ACK再触发重推。这已经属于可靠性方案的范畴了,功能可以不做得太重,但消息体的设计最好从一开始就预留一个messageId字段。
4. 进阶:让服务端更健壮、更实用
4.1 连接参数与路径参数:@PathParam、查询参数、配置参数
真实场景里,客户端连接时通常要带一些身份信息。常见的做法有三种:
第一种,路径参数,把参数放进URL路径,在注解方法上用@PathParam接收:
@ServerEndpoint("/ws/chat/{roomId}/{userId}") @OnOpen public void onOpen(Session session, @PathParam("roomId") String roomId, @PathParam("userId") String userId) { // 加入房间映射 }第二种,查询参数,也就是ws://host/ws?token=xxx这种形式。查询参数可以在@OnOpen里通过session.getRequestParameterMap()拿到,这是一个Map<String, List<String>>。为什么值是List?因为HTTP查询参数允许重复键,WebSocket握手协议沿用这个规则,所以别用get("token")直接返回一个String,要先取get("token").get(0)。
第三种,请求头参数,通过session.getRequestParameterMap()并不能取请求头。可以用握手阶段的配置,在编程式开发时通过HandshakeRequest获取;在注解式开发时如果要读取自定义Header,需要自己实现Configurator,重写modifyHandshake方法,然后把请求头数据塞进EndpointConfig.getUserProperties(),再在@OnOpen方法里通过EndpointConfig取出。这个操作相对少见,但做登录鉴权时很实用。
还有一个容易踩坑的点:Session的getUserProperties()可以用来存放每个连接的自有业务数据。这个方法返回的是Map<String, Object>,且该Map是连接级别的,比你在类的静态字段里放数据要安全得多。我常用的模式是登录成功后,把userId塞进session.getUserProperties().put("userId", userId),后续发送消息时直接从Session里取,避免“用连接ID再反查用户ID”的二次查询。
4.2 JSON消息处理:自定义Decoder/Encoder
@OnMessage直接接收String没问题,但业务消息通常不是一段裸文本,而是JSON结构。如果你在每个消息处理方法里手写JSONObject.parseObject,代码会越来越臃肿,也难维护。JSR 356提供了Decoder解决了这个问题。
解码器负责把客户端发来的文本消息转换成Java对象,编码器负责把Java对象转成文本消息发送。拿处理JSON举个例子。
先定义消息类:
public class ChatMessage { private String type; private String content; private String sender; // getter / setter 省略 }再写一个解码器:
import javax.websocket.DecodeException; import javax.websocket.Decoder; import javax.websocket.EndpointConfig; public class ChatMessageDecoder implements Decoder.Text<ChatMessage> { @Override public ChatMessage decode(String s) throws DecodeException { // 使用自己项目里的JSON工具,这里示意 return JsonUtil.fromJson(s, ChatMessage.class); } @Override public boolean willDecode(String s) { return s != null && !s.isEmpty(); } @Override public void init(EndpointConfig config) {} @Override public void destroy() {} }在@ServerEndpoint注解里声明解码器:
@ServerEndpoint(value = "/ws/chat", decoders = {ChatMessageDecoder.class})之后@OnMessage方法直接接收ChatMessage对象:
@OnMessage public void onMessage(ChatMessage msg, Session session) { // 直接处理对象,解析逻辑已经封装 }编码器类似,实现Encoder.Text<T>,重写encode(T object)方法。配置到encoders属性后,服务端调用session.getBasicRemote().sendObject(object)即可自动转JSON发送。
我个人的建议是:小项目里消息类型不超过三五种,直接String+手动解析就行;一旦消息类型多了,一定要上Decoder/Encoder。它不复杂,但能让代码结构清爽很多,也方便后续加版本字段、兼容旧客户端。
4.3 心跳、超时与断线重连:服务端怎么配合客户端
WebSocket连接虽然比HTTP长命,但中间有路由器、防火墙、Nginx等代理设备,它们通常对空闲的连接有一个超时回收机制。如果连接长时间没有数据流动,可能被中间设备悄悄断开,而两端还浑然不知。
这个问题要从两端同时入手。客户端一般设置定时器,每30秒或50秒发送一个Ping帧或一个自定义心跳消息。WebSocket协议本身有Ping和Pong控制帧,浏览器端JavaScript的WebSocket API不开放主动发送Ping帧的能力,所以前端项目常常发自定义消息如{"type":"ping"}。服务端收到后,要么原样回一个{"type":"pong"},要么直接忽略,但要把该Session的最后活跃时间更新一下。
服务端这边也可以主动控制:给Session设置最大空闲时间。如果你用的是Tomcat作为WebSocket容器,连接空闲时间默认受容器配置影响。JSR 356里没有直接暴露一个API方法去设置超时秒数,但你可以通过ServerContainer的setDefaultMaxSessionIdleTimeout来设置全局默认值,或者通过@OnOpen里的session.setMaxIdleTimeout(long)单独设置该连接的闲置超时时间。
@OnOpen public void onOpen(Session session) { session.setMaxIdleTimeout(60000); // 60秒没有收发消息就关闭 }这个设置是把双刃剑。设得太短,用户刷个牙回来连接就被断了,体验差;设得太长,中间设备又会比你先动手。经验值一般是60到120秒之间。如果你设置了Nginx层的proxy_read_timeout,那就把服务端超时设得比Nginx小一点,保证是服务端主动关连接,而不是Nginx静默断开。关连接时客户端收到的是1001或1006状态码,下一步触发重连,逻辑才清晰。
服务端做心跳时还需要清理僵尸连接。客户端异常断电时,断开检测往往不及时,在线列表里会残留很多不可用的Session。我的做法是起一个后台定时任务,每隔一分钟遍历一次在线Session,通过session.isOpen()过滤掉明显关闭的,再结合最后活跃时间,超过阈值但一直没有心跳的就主动关闭。
5. 常见问题与排错实录:这些年踩过的坑
5.1 握手总是404/405/500,问题出在哪
WebSocket连接失败最常见的表现是:前端报WebSocket connection to 'ws://xxx' failed,然后浏览器控制台显示HTTP状态码404。
404的第一反应是路径不对。很多人写了@ServerEndpoint("/ws/chat"),但前端连的是/ws/chat/,多了一个斜杠,路径就不匹配了。第二反应是上下文路径没写对。你部署的是ROOT.war,访问路径是ws://ip:port/ws/chat;如果WAR包名是wsdemo.war,访问路径就变成ws://ip:port/wsdemo/ws/chat。我见过连续三次排错都是这种低级原因,所以改动路径前,先确认上下文。
405通常出现在容器不支持WebSocket协议的情况。比如你用了一个非常老的Servlet容器,或者没有开启对应的WebSocket支持。Tomcat 7.0.47以上才支持JSR 356,Tomcat 8直接支持,Tomcat 9也支持,如果版本过旧,换容器版本基本能解决。Tomcat 10需要注意命名空间是jakarta。
500错误多半是服务端点类初始化异常。@ServerEndpoint标注的类必须是public,并且有public无参构造器。如果写成内部类,或者构造器不是public,容器实例化时就会报错。另外,端点的生命周期方法是public的,返回值要求是void,参数列表也不能随便写,不符合规范会在部署阶段报错。
如果请求能走到服务端但就是握不上手,看一下日志里的@OnError是不是抛出HandshakeException。有一种常见情况是跨域问题,但WebSocket的跨域限制和HTTP不完全一样。浏览器会带上Origin头,服务端如果想限制来源,需要写ServerEndpoint里配置Configurator检查Origin,如果不匹配就返回403,前端就会看到握手失败。默认情况下,JSR 356是不做跨域限制的,所有来源都能连。生产环境如果你没写任何校验,那就要小心别人直接拿着你的地址来连接。
5.2 多实例和负载均衡下的Session共享问题
WebSocket是长连接,和HTTP短连接的一个关键区别是:连接一旦建立,就会固定在某一台服务器实例上。客户端如果通过Nginx负载均衡轮询到A机器建立了WebSocket,后续消息一直由A机器处理,但如果连接断开重连,可能被路由到B机器。此时如果A机器的Session列表里还保存着这个用户,B机器上也存了一份,就出现了“用户状态分片”的问题。
解决思路分几层:简单做法是Nginx上开启ip_hash或者使用hash $http_upgrade来保证同一个客户端的升级请求路由到同一台后端实例;更高层做法是把WebSocket的在线会话信息放到Redis里,服务端发送消息时先通过Redis找到用户所在节点,再由那个节点推送。后者会引入一套跨节点下发机制,工程复杂度高不少。
如果你的项目是多实例部署,但当前只想先把功能跑起来,我的建议是先把在线列表和消息广播设计成可插拔接口,不要硬编码到本地Map里。等真的需要扩展节点时,替换Redis实现会平滑很多。这个改动看着小,后期价值很大,因为许多Java项目是从单机开始,最后被多实例问题绊倒。
5.3 并发写消息,结构体被冲掉怎么办
之前提到过Session不是线程安全的。什么叫“结构体被冲掉”?举个例子,两个线程同时调用同一个Session的getBasicRemote().sendText(),一个发一段长文本,另一个也发一段长文本,在底层Socket缓冲区里可能交错,客户端最终收到的内容会乱掉。
要保证同一个Session上的消息有序发送,有几个办法。
最简单的方法是给每个Session配一个独立的发送队列和单线程发送器。每个Session对应一个队列,发送消息时把任务丢进队列,由单个线程串行消费,确保顺序。另一个常见办法是用ConcurrentHashMap配合锁机制,发送前对Session加锁,整个发送过程串行化。
private final Lock lock = new ReentrantLock(); private void sendTextWithLock(Session session, String text) throws IOException { lock.lock(); try { if (session.isOpen()) { session.getBasicRemote().sendText(text); } } finally { lock.unlock(); } }我进一步建议:在高频推送场景下,尽量用getAsyncRemote().sendObject或者sendText的异步版本,再在回调里处理失败。同步阻塞发送会让业务线程长期卡住,整个推送服务吞吐量下降。如果用异步发送,同一个Session上的异步消息本身也未必保证有序,所以还是要加队列或锁。
5.4 开发调试工具推荐与验证方法
写完服务端最重要的是能验证。我常用的调试方式有几种:
浏览器控制台直接写JS是最快的,打开开发者工具,输入:
const ws = new WebSocket("ws://localhost:8080/ws/echo"); ws.onopen = function() { ws.send("hello"); }; ws.onmessage = function(event) { console.log("收到:" + event.data); };这样基本能验证连接、收发消息是否正常。
需要压力测试的时候,可以用命令行工具websocat或者编程方式模拟大量客户端。但正式线上环境建议先小流量,别一上来就压测,因为WebSocket连接数比HTTP连接更昂贵。一台2核4G的机器,开几千个长连接通常问题不大,如果每个连接还要维护内存级的会话状态,那内存消耗就要重新估量。
排查网络层问题时,抓包最有效。Chrome的开发者工具也能看到WS帧的收发,但要想看TCP层的断开原因,还是tcpdump抓包加Wireshark分析更直观。我遇到过前端不断重连,服务端也不断打印@OnOpen日志,最后抓包才发现是客户端把连接关掉了,只是服务端没有立刻收到关闭帧,白白维持了很多半开连接。这种场景下,服务端心跳清理就非常必要。
最后再提一个高频坑:@ServerEndpoint的类里如果注入了Spring的Service,直接用@Autowired大概率会失败。因为端点实例是WebSocket容器创建的,不是Spring容器创建的,Spring的依赖注入不会自动帮你去处理这个对象。常见的解决办法是把端点类标记为@Component,然后通过一个静态工具类从Spring容器里获取Service,或者在@OnOpen里通过WebApplicationContextUtils手动获取Spring的Context。虽然听起来绕,但这是很多用Spring Boot开发WebSocket的人都会撞上的墙。
我在实际项目中,一般流程是先把纯文本echo跑通,再加上JSON格式和业务映射,最后才考虑心跳、鉴权和分布式。WebSocket服务端的“基本使用方法”其实就这么多,剩余的复杂度基本都来自业务本身。如果你照着上面的代码动手走一遍,很快就能把服务端立起来。之后再遇到那些“服务端主动推送”“聊天室”“实时在线状态”需求,你也不至于一上来就发愁。