在传统的 HTTP 请求响应模式下,客户端想获取服务端的新消息,只能靠轮询或者长轮询,这种方式不仅浪费带宽,实时性也很难保证。WebSocket 协议通过一次 HTTP 握手升级为全双工长连接,服务端可以随时主动推送消息,天然适合聊天、通知推送这类场景。Spring Boot 对 WebSocket 提供了开箱即用的支持,本文将基于原生 WebSocket API(而不是 STOMP 子协议),完整实现点对点私聊和群聊广播两个核心功能,并讨论生产环境中的注意事项。

一、搭建基础环境与 WebSocket 配置
首先创建一个 Spring Boot 项目,在 pom.xml 中引入 WebSocket starter 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
这个 starter 会自动引入 spring-websocket 和容器相关的支持包。接下来需要编写一个配置类,向容器注册 ServerEndpointExporter,它的作用是把标注了 @ServerEndpoint 注解的类注册为 WebSocket 端点。需要特别注意:如果项目将来部署到外置 Tomcat(打 war 包),这个 Bean 就不要注册了,否则会报重复端点的错误,因为外置容器会自己完成端点扫描和发布。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
这里采用的是 JSR-356 标准的注解式开发方式,优点是代码直观、不需要额外封装消息处理器。另一种方案是使用 Spring 封装的 WebSocketHandler 体系,通过继承 TextWebSocketHandler 来处理消息,配合 WebSocketConfigurer 注册路径。两种方式功能等价,本文选择注解方式,因为它的会话管理更直接。
二、会话管理:点对点通信的核心
点对点私聊的前提是服务端知道每个在线用户对应的 WebSocket 会话。最常用的做法是用一个线程安全的 Map 来维护用户标识和会话的映射关系。这里有几个细节容易踩坑:第一,必须使用 ConcurrentHashMap 而不是普通 HashMap,因为 WebSocket 的连接、断开、收发消息是由不同线程触发的,普通 HashMap 在并发写入时可能出现数据丢失甚至死循环;第二,连接地址建议带上用户名参数,例如 ws://localhost:8080/ws/chat?userId=zhangsan,服务端在连接建立时取出参数作为 Map 的 key。
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ServerEndpoint("/ws/chat/{userId}")
@Component
public class ChatWebSocketServer {
// 全局在线用户会话表,静态保证所有实例共享
private static final Map<String, Session> ONLINE_SESSIONS = new ConcurrentHashMap<gt();;
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
ONLINE_SESSIONS.put(userId, session);
System.out.println("用户上线:" + userId + ",当前在线人数:" + ONLINE_SESSIONS.size());
}
@OnClose
public void onClose(@PathParam("userId") String userId) {
ONLINE_SESSIONS.remove(userId);
System.out.println("用户下线:" + userId);
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
try {
session.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
需要提醒的是,@ServerEndpoint 标注的类默认由 WebSocket 容器管理生命周期,每次连接都会创建一个新实例,所以它不能像普通 Spring Bean 那样直接注入 Service。如果需要在 WebSocket 类中使用业务层的 Bean,要通过 SpringUtils.getContext().getBean(xxx.class) 这种工具类手动获取,或者实现一个静态注入的 Handler。这是新手最常遇到的问题之一。
接下来实现私聊逻辑。客户端发送的消息约定为 JSON 格式,包含发送者、接收者和内容三个字段,服务端解析后从 Map 中查找目标用户的 Session,找到就推送,找不到就返回对方不在线的提示:
@OnMessage
public void onMessage(String message, Session session) throws IOException {
// 实际项目中建议用 Jackson 解析,这里为了直观用简单拆分
ChatMessage msg = JSON.parseObject(message, ChatMessage.class);
Session targetSession = ONLINE_SESSIONS.get(msg.getToUserId());
if (targetSession != null && targetSession.isOpen()) {
// 同步推送给接收方
targetSession.getBasicRemote().sendText(message);
// 回显给发送方,表示发送成功
session.getBasicRemote().sendText(message);
} else {
session.getBasicRemote().sendText("{\"type\":\"system\",\"content\":\"对方不在线\"}");
}
}
发送消息时有同步和异步两种选择:getBasicRemote().sendText() 是同步阻塞的,发送完成才返回;getAsyncRemote().sendText() 则立即返回,适合高并发场景。如果业务对消息顺序有要求,建议用同步方式,或者给每个会话加发送锁,避免异步发送时消息乱序。
三、群聊广播与消息模型设计
群聊的实现比私聊更简单,本质就是遍历在线用户表,把消息逐一推送出去。但遍历时的并发问题必须重视:如果一边遍历一边有用户上下线,直接 for-each 遍历 Map 可能抛出 ConcurrentModificationException。虽然 ConcurrentHashMap 的迭代器是弱一致性的,不会抛这个异常,但更稳妥的做法是先复制一份快照再遍历:
public void broadcast(String message) {
ONLINE_SESSIONS.forEach((userId, session) -> {
try {
if (session.isOpen()) {
session.getBasicRemote().sendText(message);
}
} catch (IOException e) {
// 单个用户发送失败不应影响其他人
System.err.println("推送给 " + userId + " 失败:" + e.getMessage());
}
});
}
消息格式建议统一定义一个 DTO,用 type 字段区分消息类型,前端根据类型做不同的渲染。一个典型的消息结构如下:
public class ChatMessage {
private String type; // chat 私聊 / group 群聊 / system 系统提示
private String fromUserId;
private String toUserId; // 群聊时可留空
private String content;
private Long timestamp;
// 省略 getter 和 setter
}
把系统提示也纳入统一消息模型有好处,比如用户上下线时广播一条 type 为 system 的消息,前端就能在聊天室里显示某某加入了房间。这样所有消息走同一条通道,前端解析逻辑只需要一份。
四、生产环境必须考虑的几个问题
1. 多实例部署与会话共享。上面方案最大的局限在于会话表是单机内存态的。一旦服务部署两个以上实例,经过 Nginx 负载均衡后,用户 A 的连接可能落在节点一,用户 B 落在节点二,点对点消息就找不到目标会话了。解决方案有两种:一是负载均衡层开启 IP Hash 或者基于用户 ID 的粘性会话,保证同一用户始终连到同一节点;二是引入消息中间件(Redis 发布订阅、RabbitMQ 或 Kafka),每个节点把消息发到中间件,再由各节点在本地会话表中查找并推送。后者是主流做法,扩展性更好。
2. 心跳与僵尸连接清理。网络异常断开时,服务端可能收不到 onClose 事件,Map 里会残留已经失效的会话。建议客户端定时(比如 30 秒)发送一个心跳消息,服务端记录每个会话的最后活跃时间,再用一个定时任务每分钟扫描一次,把超过两分钟没有心跳的会话强制关闭并移除,同时向对方推送离线状态。
3. 安全认证。千万不要直接把用户 ID 暴露在 URL 上,任何人改个参数就能冒充别人。正确做法是连接时携带 Token(放在 URL 参数或首次消息中),服务端在 onOpen 阶段校验 Token 合法性,解析出真实用户 ID 后再写入会话表,校验失败直接调用 session.close() 拒绝连接。
4. 前端连接示例。最后给出浏览器的连接代码,帮助读者快速验证:
const userId = "zhangsan";
const ws = new WebSocket("ws://localhost:8080/ws/chat/" + userId);
ws.onopen = () => console.log("连接已建立");
ws.onmessage = (event) => console.log("收到消息:", event.data);
ws.onclose = () => console.log("连接已关闭");
// 发送私聊消息
ws.send(JSON.stringify({
type: "chat",
fromUserId: "zhangsan",
toUserId: "lisi",
content: "你好,在吗?"
}));
整体来看,Spring Boot 整合原生 WebSocket 的开发成本并不高,核心就是围绕 Session 的生命周期做好管理。单机场景下本文方案可以直接落地,分布式场景则要在消息路由上多下功夫。如果项目对消息可靠性、离线消息、已读回执有更高要求,可以考虑在 WebSocket 之上引入 STOMP 协议配合消息代理,那会是另一个更完整的架构方案。
Spring BootWebSocket点对点通信修改时间:2026-09-06 09:34:39