前阵子接了个需求:用户在一款上门服务类应用里下单后,服务端需要在瞬间把订单数据推送到对应的工作人员端,同时用户端还得实时看到接单、开始服务、完成服务这些状态变化。最开始我先考虑HTTP轮询,但仔细算了一下就放弃了——如果在线用户有几千人,每隔几秒打一次接口,服务器压力不小,而且数据延迟也没法真正控制在毫秒级。后来把方案定成了WebSocket,用Spring Boot做服务端,配合Nginx做代理,前前后后跑通了整套实时通信链路,也踩了不少坑。这篇文章就是把这次实战的完整过程拆开讲一遍:WebSocket到底解决了什么问题、Spring Boot里怎么落地、前端怎么对接、部署时有哪些隐藏的坑,以及最常见的报错该怎么排查。内容适合刚接触WebSocket的后端开发,也适合那些接口能跑通、但一上线就连接不稳、频繁断线的人参考。
1. 为什么实时通信场景绕不开WebSocket
1.1 HTTP轮询的痛:你的接口在“空转”
很多人第一反应就是轮询,毕竟HTTP协议大家都熟,前端定时调接口也能“假装实时”。但轮询的本质是客户端主动去问,服务端被动回答,这个模式下有几个痛点。
第一个痛点是浪费资源。假设一个订单状态机包含下单、接单、开始服务、完成、评价五个状态,如果最近一个状态变化要2秒内让用户看到,前端就得每2秒请求一次,哪怕服务端数据一点没变,请求照样要打过来。每一个轮询请求都意味着一次完整的HTTP握手、业务处理、响应返回,高峰期接口QPS会莫名其妙高出一大截,数据库和带宽都被无效请求白占。
第二个痛点是延迟不可控。轮询间隔设短了服务器扛不住,设长了用户感知就是“卡”。业务高峰期服务端响应变慢,轮询周期还会被拉长,实时性更差。而WebSocket不一样,它是客户端和服务端之间建立一条长连接,连接建立之后,服务端任何时刻都可以主动把数据推给客户端,延迟在毫秒级,而且没有数据的时候连接基本不产生额外流量,这是轮询做不到的。
第三个痛点是代码层面不好维护。为了模拟“服务端主动推送”,轮询方案里往往还要配合乐观锁、版本号、时间戳去判断数据有没有变化,逻辑越写越绕。WebSocket方案里服务端直接下发完整消息,客户端按类型处理就行,业务逻辑清晰得多。
1.2 WebSocket一次握手,双向跑数据
WebSocket本质上是一条建立在TCP之上的长连接,复用HTTP的80/443端口完成“握手”。握手阶段客户端发一个带Upgrade: websocket头的HTTP请求,服务端确认后返回101状态码,之后这条连接就升级成了WebSocket,双方可以互相发数据帧,不再受HTTP“一来一回”的限制。
这里可以拿打电话类比。HTTP请求就像寄快递,每次都要写地址、填单号、对方签收,一来一回都是独立事件;而WebSocket就像拨通电话后一直占着线,两边随时可以说话,不用每次开口都先通话。打电话当然要比寄快递多占一条线路,但如果你是在做实时沟通,这条线路带来的效率提升是质变的。
从协议层面看,WebSocket的数据帧非常轻量,一条消息只需要几个字节的控制头,相比HTTP请求里那一大堆Header,传输效率高很多。对于频繁推送的业务场景,这种差距在流量账单上看得很明显。另外WebSocket原生支持二进制帧,传文件、传图片流也很方便,不像HTTP轮询得考虑怎么封装二进制数据。
有个点是很多人容易忽略的:WebSocket虽然和HTTP共用握手端口,但它一旦升级成功,就不再受HTTP请求响应模型的限制,所以Nginx、网关这些中间件必须特殊配置才能正确转发。这也是很多后端写完代码本地一切正常、一到服务器就疯狂断连的直接原因,后面会详细讲。
1.3 Spring Boot集成WebSocket的三种路线,怎么选
Spring Boot集成WebSocket有几种常见方案,我实际用下来各有侧重,选不对后面会走弯路。
第一种是原生注解@ServerEndpoint,配合ServerEndpointExporter注册。这是最接近Java WebSocket规范(JSR-356)的方式,代码简洁、直白,一个类搞定连接管理和消息收发。对于大多数点对点推送、群发广播的场景,这个方案完全够用,而且不引入额外的协议层,调试也方便。我这次项目用的就是这种。
第二种是Spring框架封装的WebSocketHandler,实现TextWebSocketHandler接口,配合HandshakeInterceptor拦截握手。这种方式和Spring MVC结合更紧密,可以利用Spring的拦截器链做鉴权、参数解析,适合需要精细化控制握手过程的项目。但代码量会多一些,连接管理也要自己维护。
第三种是STOMP协议,基于WebSocket封装了一套类似消息队列的语义,支持订阅、点对点、广播这些概念,配合Spring的@MessageMapping注解,写起来像写Controller。这个方案适合业务逻辑比较复杂、消息类型很多、要按主题分发的大项目,但它对初学者来说协议层比较重,出了问题排查困难。
我的建议很直接:如果只是做“服务端推消息给客户端”这类常规实时通信,@ServerEndpoint是最快的路径,也是这篇文章后面要展开的重点。STOMP适合团队对消息模型有清晰规划、需要长期演进的大型系统,别为了“架构先进”一上来就上STOMP,简单场景复杂化会给自己找麻烦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务端代码怎么写:从连接管理到主动推送
2.1 项目依赖拉起来只要十秒
Spring Boot里引入WebSocket非常简单,在pom.xml里加一个依赖就够了:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
我用的是Spring Boot 2.7.x版本,这个版本依赖里自带Tomcat 9.0.x,对WebSocket的支持已经很成熟。如果你的项目是Spring Boot 3.x,本质逻辑不变,只是底层的Jakarta命名空间不同,代码里基本不用改。加完依赖后还需要一个配置类来注册WebSocket端点,因为Spring Boot默认不会自动扫描@ServerEndpoint:
java复制@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
这段配置的作用是把所有带@ServerEndpoint的类注册到WebSocket容器里,让它们对外提供服务。有个细节要记住:如果你用的是Spring Boot内置Tomcat跑项目,这个Bean必须有;但如果你把项目打成war包部署到外部Tomcat,这个Bean反而会导致冲突,部署时要删掉或注释掉。这个问题后面部署章节还会重点说。
2.2 @ServerEndpoint核心代码拆解
下面是我们项目的核心类,我先贴完整代码,再逐段拆解:
java复制@Component
@Slf4j
@ServerEndpoint("/ws/{userId}")
public class WebSocketServer {
private static final Map<String, Session> SESSION_MAP = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
SESSION_MAP.put(userId, session);
log.info("用户连接成功:
