做后端的同学应该都有过类似的经历:页面需要实时刷新数据,最开始就是让前端隔几秒调一次接口。数据少的时候还能忍,数据一多、用户一多,服务器压力肉眼可见地涨。后来接触 WebSocket 之后才反应过来,很多场景其实从一开始就该走长连接,而不是用轮询硬扛。
这篇笔记是我自己学习 WebSocket 过程中的一次系统梳理。从协议层到底层原理,从单机接入到集群广播,从握手细节到生产环境那些容易踩的坑,尽量把一条线讲完整。适合刚接触 WebSocket 的后端同学,也适合写过几个 Demo 但在生产环境碰过壁的人对照自查。
1. 从一次“消息怎么还不来”开始:WebSocket 到底解决了什么
1.1 HTTP 的请求-响应模型,天生不适合“服务器主动说话”
很多后端业务写了一两年,对网络协议的理解还停留在“客户端发请求,服务器返回响应”这个层面。这种模型下,服务器永远是被动的——它只能对请求作出应答,没办法在没有请求的情况下主动把数据推给客户端。
这种设计在 Web 早期没什么问题,因为那时候页面都是用户点了链接才加载。但后来出现了很多需要“服务器主动通知”的场景:网页聊天、订单状态变更、股价刷新、协同编辑。拿订单状态来说,用户下单之后前端想知道支付结果,最原始的办法是每隔几秒钟调一次“查订单状态”的接口。这就是轮询(Polling)。
轮询最大的问题有两个:一是大量请求是无效的,因为大多数时候订单状态根本没变过,这些请求纯粹是在浪费服务器资源和带宽;二是实时性上限很低,假设轮询间隔是 5 秒,那用户看到状态更新的延迟平均就是 2.5 秒,想再提高实时性就只能缩小轮询间隔,代价是服务器压力成倍增长。
我见过一个实际项目,一万人在线的后台管理系统,用轮询刷任务状态,数据库每秒要扛几千次无效查询。后来改成 WebSocket 推送,数据库压力直接降了一个量级。这就是协议模型带来的本质差距。
1.2 轮询、长轮询到全双工的演进逻辑
解决“服务器主动说话”的问题,中间还有一个过渡方案叫长轮询(Long Polling):客户端发一个请求,服务器收到后不立即返回,而是把请求挂住,等有数据了或者超时了再返回;客户端收到响应后立刻再发下一个请求。
长轮询相比普通轮询减少了大量空响应,但它本质上还是“一问一答”,只是把答案延迟了。一条 TCP 连接上同一时刻只能挂一个请求,消息返回之后连接就释放了,下次推送还得重新建立连接。而且长轮询在高并发下对服务器的连接资源消耗非常严重,因为每个挂起的请求都占着一个线程或协程。
WebSocket 的做法完全不同:它是建立一个真正的全双工通道,客户端和服务器都可以在任何时间主动发送数据,不需要等对方请求。连接建立之后,数据帧可以双向随时流动。这就从协议层面解决了实时性的问题。
我用一个很简单的类比来理解这件事:HTTP 轮询像你反复打客服电话问“货到了没”,每问一次都要重新拨号;长轮询是把电话挂在那里等客服主动告诉你;WebSocket 则是客服直接加了你微信,有消息直接发你,你也可以随时发消息过去。
三种方式对比下来,结论很清楚:
| 方案 | 实时性 | 服务器压力 | 实现复杂度 |
|---|---|---|---|
| 普通轮询 | 低,受轮询间隔限制 | 高,大量无效请求 | 最低 |
| 长轮询 | 中,取决于消息频率 | 中高,占用连接资源 | 低 |
| WebSocket | 高,全双工即时推送 | 低,单条长连接复用 | 中 |
这里要澄清一个认识:WebSocket 并不是来替代 HTTP 的,它解决的是 HTTP 协议在“双向实时通信”上的能力缺失。日常的增删改查接口该用 HTTP 还是用 HTTP,只有到了“需要服务器主动推送数据”的场景,WebSocket 才真正发挥价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 协议细节:握手、帧、掩码,别被“新协议”三个字吓住
2.1 一次握手:从 HTTP Upgrade 到 101 Switching Protocols
我第一次接触 WebSocket 时的一个困惑是:它叫 WebSocket,但为什么握手的请求长得很像 HTTP?
实际上 WebSocket 的握手就是一次 HTTP GET 请求,只是带了几个特殊的头部字段。客户端发起握手时,会带上 Upgrade: websocket 和 Connection: Upgrade,同时生成一个随机的 Sec-WebSocket-Key。服务器收到后,把这个 Key 拼接上一个固定的 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做一次 SHA-1 哈希,然后 Base64 编码,作为 Sec-WebSocket-Accept 返回。客户端校验这个值,握手成功,连接从 HTTP 升级为 WebSocket,状态码是 101 Switching Protocols。
我用实际数据走一遍这个过程。假设客户端发来的 Key 是:
code复制dGhlIHNhbXBsZSBub25jZQ==
服务端把它拼上固定 GUID:
code复制dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
然后做 SHA-1 哈希,结果 Base64 编码得到:
code复制s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
这就是返回给客户端的 Sec-WebSocket-Accept。在命令行里可以直接验证:
bash复制echo -n "dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | openssl sha1 -binary | base64
这个 Key 的设计只是为了确认服务器真的支持 WebSocket 协议,防止某些代理服务器误把后续的 WebSocket 数据帧当成普通 HTTP 请求转发,它本身没有任何鉴权能力。鉴权得自己做,这点后面详细讲。
2.2 帧格式与掩码:为什么客户端发数据必须“化妆”
握手完成之后,客户端和服务器之间传输的不再是 HTTP 报文,而是一个个 WebSocket 帧(Frame)。每一帧的结构里,有这么几个关键字段:
FIN:标志这个数据包是不是某一帧消息的最后一帧,一个消息可以被拆成多个帧发送;opcode:数据类型,0x1是文本帧,0x2是二进制帧,0x8是关闭帧,0x9是 Ping,0xA是 Pong;MASK:掩码标志,客户端发送的帧必须置 1,服务端发送的帧必须置 0;payload len:负载长度,7 位、16 位、64 位三种表示方式,超过 125 字节的载荷要用扩展长度字段。
这里最反直觉的一点是掩码机制。为什么客户端发出的数据要额外做一次掩码处理?这是为了防一种叫缓存污染攻击的问题。简单来说,如果客户端不掩码,攻击者可以构造一些特殊的字节序列,让数据在中间经过某些代理服务器时,恰好被解释成 HTTP 请求,从而污染代理缓存,影响其他用户。加了掩码之后,客户端实际发送到网络上的字节与真实负载不同,这个攻击路径就被堵死了。
对后端来说,理解这些帧格式的实用价值在于:遇到问题时你能判断问题出在哪一层。比如客户端发了一个超大文本消息,超出了服务端配置的缓冲区大小,连接突然被断开,你抓包看到的是帧的负载长度异常,而不是业务代码出错。再比如某些 WebSocket 库在收到 Ping 帧时没有自动回 Pong,导致连接被对端判定为不健康后断开,这种问题排查很久都未必能定位到帧层面。
2.3 连接何时算“断开”:网络层的残酷真相
用 WebSocket 做长连接后,你很快就会遇到一个新的追问:怎么知道一个连接还在不在?
TCP 层面,如果一端断开连接,正常情况下另一端会收到 FIN 包,这时能感知到连接关闭。但现实是很多情况根本收不到 FIN:比如用户直接拔了网线,比如笔记本合盖休眠,比如手机从 Wi-Fi 切到 4G 导致网络断了。这些情况下 TCP 连接对服务端来说可能一直处于“半开”状态,表面上连接对象还在,实际已经死透了。
我之前在排查一个在线状态不准的问题时,就发现服务端维护的连接数远远大于实际在线的用户数。原因就是大量客户端异常断网后,服务端毫不知情,连接对象一直占着内存和文件描述符。真正的解法只有一个:心跳检测。这部分在后面的生产坑里细说。
3. 后端接入的姿势:框架 API 与原生 API 的取舍
3.1 Spring WebSocket:最省力的接入方式
国内后端最常见的组合是 Spring Boot,所以先看 Spring 生态里怎么接 WebSocket。
Spring 提供了一个 TextWebSocketHandler 抽象类,继承了它之后,主要关注四个回调方法:
afterConnectionEstablished:连接建立后触发,通常在这里把 Session 保存起来;handleTextMessage:收到文本消息时触发,业务处理的主入口;handleTransportError:传输错误回调,连接异常断开多半从这里能发现线索;afterConnectionClosed:连接关闭后触发,务必在这里清理 Session。
一个最简的 Handler 大概长这样:
java复制@Component
public class OrderNotifyHandler extends TextWebSocketHandler {
private final Map<String, WebSocketSession> clientSessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String userId = (String) session.getAttributes().get("userId");
clientSessions.put(userId, session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 收到客户端消息,按业务处理
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String userId = (String) session.getAttributes().get("userId");
clientSessions.remove(userId);
}
}
注册这个 Handler 也很简单:
java复制@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final OrderNotifyHandler orderNotifyHandler;
public WebSocketConfig(OrderNotifyHandler orderNotifyHandler) {
this.orderNotifyHandler = orderNotifyHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(orderNotifyHandler, "/ws/order/notify")
.addInterceptors(new AuthHandshakeInterceptor())
.setAllowedOrigins("*");
}
}
Spring WebSocket 还支持 STOMP 子协议,它本质上是在 WebSocket 之上定义了一套类似消息队列的语义,提供 subscribe、send、destination 这些概念。如果你的业务里有明确的“主题订阅”需求,比如不同用户订阅不同频道,STOMP 确实更方便,它帮你把“消息路由到哪个连接”这个问题在协议层解决了一部分。
3.2 原生 API:什么时候该“不偷懒”
Spring 封装省事,但有时框架抽象反而挡住了一些东西。比如你想精确定制握手阶段的逻辑、想自己控制会话管理、想用 Netty 自己维护一大批长连接,这时候往往要抛弃 Spring 的高级封装,回到更底层的 API。
Java 生态里还有一种常见做法是使用 @ServerEndpoint 注解(JSR 356 标准):
java复制@ServerEndpoint("/ws/order/notify")
@Component
public class OrderNotifyEndpoint {
@OnOpen
public void onOpen(Session session) {
// 连接建立
}
@OnMessage
public void onMessage(String message, Session session) {
// 收到消息
}
@OnClose
public void onClose(Session session) {
// 连接关闭
}
@OnError
public void onError(Session session, Throwable error) {
// 异常处理
}
}
这种方式和框架耦得更小,但 Session 管理、线程安全、心跳这些事情都得自己来。对于大流量、高并发的场景,这反而是主流选择,因为你能完全掌控资源的使用方式。
3.3 为什么选型比写代码更重要
我在实际项目中总结的选型经验是:看你的核心诉求是“集成速度”还是“控制粒度”。
如果是内部系统的看板实时刷新、通知推送,用户量几千到几万,Spring WebSocket 或者 Spring 封装的 STOMP 完全够用,开发速度快,维护成本低。但如果是面向 C 端的消息服务,比如客服系统、直播弹幕、行情推送,用户量几十万上百万,那一定要考虑自己基于 Netty 或者更底层的框架来做连接管理、背压控制、集群广播,因为这时候框架帮你做的“轻松”反而会成为瓶颈。
另外还要考虑团队的技术积累。一个小团队如果没人深度研究过 STOMP 和消息代理,出问题的时候查起来很痛苦;相反,直接用一个大家都能看懂的 Handler + ConcurrentHashMap,反而更容易维护。技术选型没有绝对的对错,关键是要跟团队能力和业务阶段匹配。
4. 生产环境必踩的四个大坑
4.1 跨域和鉴权:你以为是 CORS,其实 WebSocket 的“跨域”是另一套
WebSocket 握手阶段是一个 HTTP GET 请求,所以很多人下意识以为跨域问题能靠传统的 CORS 配置解决。这是一个很容易产生误区的点。
浏览器对于 WebSocket 握手请求的跨域检查,不是走 CORS 的预检机制,而是靠 Origin 请求头。也就是说,即使服务端在 HTTP 层面配置了 Access-Control-Allow-Origin,WebSocket 握手时浏览器也不会执行常规 CORS 检查,服务端必须在握手逻辑里自己校验 Origin。
最稳妥的做法是在握手拦截器里校验来源:
java复制public class OriginCheckInterceptor implements HandshakeInterceptor {
private static final Set<String> ALLOWED_ORIGINS = Set.of("https://your-frontend.com");
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
String origin = request.getHeaders().getOrigin();
if (origin == null || !ALLOWED_ORIGINS.contains(origin)) {
return false; // 拒绝握手
}
return true;
}
}
同时还有一个很常见的需求:WebSocket 连接怎么鉴权?握手阶段是一次 HTTP GET,常见的做法是让前端在 URL 上带 token 参数,或者在握手请求里带 Authorization 头,在后端拦截器里校验 token,解析出用户身份后放进 attributes。注意 Spring 的 HandshakeInterceptor 里的 attributes 是可以传给 WebSocketSession.getAttributes() 的,这是连接生命周期里传递用户信息的关键通道:
java复制public class AuthHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
String token = request.getHeaders().getFirst("Authorization");
Long userId = AuthService.verifyToken(token);
if (userId == null) {
return false;
}
attributes.put("userId", userId);
return true;
}
}
之后在 afterConnectionEstablished 里,通过 session.getAttributes().get("userId") 就能拿到这个用户 ID。这样连接就跟业务用户绑定起来了,后面按用户推送才有依据。
4.2 心跳机制:不要把断线发现的时机交给 TCP
前面说过,客户端异常断网时服务端可能长时间感知不到。要解决这个问题,业界最通用的方案是应用层心跳。
核心设计是两件事:
- 服务端定期向客户端发送 Ping 帧(或者自定义的心跳消息);
- 客户端收到 Ping 后自动回复 Pong 帧;
- 服务端如果在规定时间内没收到客户端的任何数据(包括 Pong),就判定这条连接失活,主动关闭。
Spring 的 WebSocketHandler 里可以自己实现心跳逻辑。基于常见的实践做法,用一个 ScheduledExecutorService 定期遍历所有 Session,检查最后活动时间,超时未收到数据的就主动 close:
java复制@Component
public class HeartbeatService {
private final Map<String, WebSocketSession> sessions;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public HeartbeatService(Map<String, WebSocketSession> sessions) {
this.sessions = sessions;
}
@PostConstruct
public void start() {
scheduler.scheduleAtFixedRate(this::checkAndCloseIdle, 0, 30, TimeUnit.SECONDS);
}
private void checkAndCloseIdle() {
long threshold = System.currentTimeMillis() - 90_000;
sessions.entrySet().removeIf(entry -> {
WebSocketSession session = entry.getValue();
if (session.getLastActiveTime() == null
|| session.getLastActiveTime().getTime() < threshold) {
try {
session.close(CloseStatus.SESSION_NOT_RELIABLE);
} catch (IOException ignored) {
}
return true;
}
return false;
});
}
}
这里两个关键参数是 心跳间隔 和 超时阈值。常见的经验值是间隔 30 秒发一次 Ping,90 秒内没收到任何数据就判定失活。间隔太短会浪费带宽,太长则断线发现不及时,需要结合业务容忍度来调。
另外要注意:Ping/Pong 帧是 WebSocket 协议层的控制帧,前端如果用浏览器原生 WebSocket 对象,Pong 的回复是浏览器自动处理的,不需要前端写额外代码。但如果你们用的是自定义心跳消息(比如发送 {"type":"ping"} 这种业务数据),那前端就必须显式回一个 {"type":"pong"},这时候要跟客户端开发对齐好。
4.3 代理层的超时:Nginx 是一个“隐形杀手”
后端 WebSocket 服务通常不会让客户端直连,前面大概率要挂一层 Nginx。Nginx 默认对 HTTP 请求的读取超时是 60 秒,如果 60 秒内没收到任何数据,连接就会被切断。这就是一个非常隐蔽的生产故障:你明明配了心跳,连接还是断,因为流量先经过了 Nginx,而你只在新连接建立时发了一些数据,之后 60 秒内没有双向数据传输,直接被 Nginx 断了。
要让 Nginx 正确代理 WebSocket,至少需要显式配置这几个指令:
nginx复制map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream websocket_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
server_name your-domain.com;
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
解释一下关键点:
proxy_http_version 1.1必须设置,HTTP/1.0 不支持 Upgrade 语义;Upgrade和Connection两个头是 WebSocket 协议升级的前提;proxy_read_timeout和proxy_send_timeout要大于你的心跳间隔,经验上至少留出两三倍的余量。
map 那块的作用是动态设置 Connection 头:当客户端请求带了 Upgrade: websocket 时,$connection_upgrade 的值就是 upgrade,转发时保留升级头;普通 HTTP 请求则设为 close,避免影响常规接口。
另外,如果用了云厂商的负载均衡(SLB/CLB),也要确认它支持 WebSocket 长连接,并且空闲超时时间设置合理。很多云 LB 默认空闲超时只有 15 秒到 60 秒,这个比 Nginx 更容易被忽略。
4.4 集群广播:连接不在同一台机器上
单机环境下,所有 WebSocket 连接都存在一台服务器进程里,推送给谁直接查内存里的 Session Map 就行。但一旦上了多节点部署,问题就出现了:用户 A 的连接在节点 1,业务服务在节点 2 收到一条“给用户 A 推送消息”的请求,节点 2 的内存里根本没有用户 A 的 Session,这条消息就丢了。
解决这个问题的基本思路是引入消息中间层,让所有节点都能感知到所有消息。最简单的是 Redis Pub/Sub。
具体实现思路:
- 每个节点启动时,订阅固定的频道,比如
websocket:notify; - 任何节点需要给某个用户推送消息时,把消息发布到这个频道;
- 每个节点都从频道收到消息后,检查自己的本地 Session 里有没有目标用户,有就推送,没有就忽略。
代码骨架大概是:
java复制@Component
public class RedisWsNotifier {
private final RedisTemplate<String, String> redisTemplate;
private final UserSessionManager sessionManager;
public void notifyUser(Long userId, String payload) {
// 发布到所有节点共用的频道
redisTemplate.convertAndSend("websocket:notify", userId + ":" + payload);
}
@EventListener(ApplicationReadyEvent.class)
public void subscribe() {
// 伪代码:订阅频道并监听消息
// 收到消息后解析 userId,用 sessionManager 判断本地是否有该用户
// 有则 session.sendMessage(new TextMessage(payload))
}
}
这里用 Redis Pub/Sub 的取舍是:它足够轻量,适合大部分业务场景,但发布的消息不做持久化,如果某个节点短暂不可用,就会丢失那段时间的推送消息。如果业务要求消息不能丢,就要升级为消息队列(RabbitMQ、Kafka 之类)搭配消费重试来做。
还有一点得提醒:不要用数据库轮询来模拟广播。有人可能觉得“简单点,定时去数据库查一下有没有新消息要推”,这在连接数少的时候还能用,一旦连接数上千,数据库就是灾难。换个思路想一下,推送是实时性要求很高的事情,而数据库轮询天生是批处理节奏,两者根本不在一个频道上。要让所有节点共享连接状态,用 Redis 或消息队列才是对的方向。
5. 从“连上”到“推送”:消息流转的关键链路
5.1 谁保留连接:Session 的生命周期管理
连接建立之后,服务端必须把 WebSocketSession 这个对象保存下来,否则连接建立完就丢了,后续没法推送。最基础的做法是一个 ConcurrentHashMap,Key 是用户 ID,Value 是 Session 对象。
但这里有很多细节问题:
- 一个用户多个连接:用户可能在 PC 和手机上同时打开页面,会产生两个连接。如果直接用 userId 做 Key,后建立的连接会覆盖先前的,先前那个就变成了“孤儿连接”,永远收不到推送了。合理的做法是 userId 映射到一组 Session,可以用
CopyOnWriteArraySet或ConcurrentHashMap.newKeySet()来保存同一个用户的多个连接。 - 多实例问题:单机内存 Map 只能保存当前进程的连接。集群环境下需要有一个“连接注册中心”,要么用 Redis 记录用户和节点之间的映射关系,要么每次推送走广播。两种方案各有取舍,上一节已经讲了。
- 资源清理:
afterConnectionClosed里必须从 Map 中移除 Session。漏了这一步就是连接泄漏,时间一长内存、文件描述符都被耗尽。而且心跳线程在判定超时要close连接时,也要同步清理 Map。
一个稍微完整的连接管理组件是这样:
java复制@Component
public class UserSessionManager {
private final Map<Long, Set<WebSocketSession>> userSessions = new ConcurrentHashMap<>();
public void addSession(Long userId, WebSocketSession session) {
userSessions.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session);
}
public void removeSession(Long userId, WebSocketSession session) {
Set<WebSocketSession> sessions = userSessions.get(userId);
if (sessions != null) {
sessions.remove(session);
if (sessions.isEmpty()) {
userSessions.remove(userId);
}
}
}
public Set<WebSocketSession> getSessions(Long userId) {
return userSessions.getOrDefault(userId, Collections.emptySet());
}
}
5.2 服务端如何知道“该发给谁”
WebSocket 连接建立时是不知道业务身份的,必须通过握手阶段的鉴权来绑定。绑定完成之后,服务端就有了“userId → Session”的映射关系。
具体到业务推送,又有两种典型模式:
- 定向推送(单播):比如用户下单成功后,后端在业务代码里判断出“该订单属于 userId=123 的用户”,然后调用
sessionManager.getSessions(123L),遍历每个 Session 发消息。聊天里的私聊消息也是这个模型。 - 广播(多播):比如系统公告、全局通知,所有在线用户都要收到。实现方式是遍历所有用户的 Session,逐个发送。这在连接数少的时候没问题,但如果连接数很多,就要考虑批量发送或者借助消息中间件来做扇出。
还有一种是按主题订阅,这在 STOMP 协议里是原生的:客户端 subscribe 一个频道,服务端往这个频道发消息时所有订阅者都能收到。如果用的是原始 WebSocket,就得自己在服务端维护“频道 → 用户集合”的映射。
5.3 推送的可靠性:丢了怎么办
WebSocket 协议本身没有类似 HTTP 状态码那样的“送达确认”。发送成功只代表数据写入了 TCP 缓冲区,不代表对端业务真的处理了。
对可靠性要求高的业务(比如支付结果通知),不能默认“发出去就完事”。常见的补充方案有两种:
- 业务 ACK:客户端收到推送后,调用一个 HTTP 接口回执确认。服务端如果一段时间内没收到 ACK,就把消息重新放入发送队列,走重试逻辑。
- 消息持久化 + 拉取兜底:服务端把推送记录存到 Redis 或者数据库,客户端在连接断开重连之后,先主动拉取自己离线期间的消息列表。这个方案也解决了“离线消息”的问题。
我在实际项目中的取舍是:实时推送负责“快”,离线拉取兜底负责“准”。两者配合使用,而不是只依赖其中一种。
6. 什么场景用 WebSocket,什么场景不该用
6.1 WebSocket 真正擅长的场景
聊到这里,可以系统盘一下哪些场景适合 WebSocket:
- 聊天/IM:消息需要双向流动,且延迟要求高,这是 WebSocket 最经典的使用场景;
- 实时看板/监控:任务状态、系统指标需要秒级刷新,与其让前端疯狂刷接口,不如后端主动推送变更;
- 协同编辑:多端同时编辑同一个文档,状态同步必须双向实时进行;
- 游戏/互动直播:低延迟交互,WebSocket 是基础能力;
- 股票行情/竞拍:价格变化以毫秒级推送到客户端,轮询根本无法满足。
这些场景的共同点是:数据变化频繁、实时性要求高、服务端需要主动发起通信。
6.2 SSE 和 WebSocket 怎么选
SSE(Server-Sent Events)是另一个经常和 WebSocket 摆在一起讨论的技术。它是基于 HTTP 的单向推送方案,由服务器向客户端持续推送数据,客户端用浏览器原生的 EventSource API 就能接收,不需要额外的协议升级。
对比下来:
| 维度 | WebSocket | SSE |
|---|---|---|
| 方向 | 全双工,双向 | 单向,仅服务端到客户端 |
| 协议 | 独立的全双工协议 | 基于 HTTP |
| 自动重连 | 需自己实现 | 浏览器原生支持 |
| 传输类型 | 文本+二进制 | 仅文本 |
| 复杂度 | 较高 | 低 |
SSE 最大的优势是简单:不需要额外协议,不需要心跳,断线自动重连是浏览器内置行为。如果你的业务只需要服务端单方面推送,不涉及客户端发消息,SSE 往往比 WebSocket 更合适。举几个例子:监控面板数据推送、通知栏消息提醒、AI 对话令牌流式输出,这些场景用 SSE 都很顺畅。
但需要双向通信(比如用户要往服务端发指令,同时也要接收推送)、传输二进制数据(比如实时音频流)、或者对连接数量和控制力要求极高时,WebSocket 仍然是更通用的方案。选型原则我总结成一句话:能用 SSE 解决的不盲目上 WebSocket,需要双向实时交互的果断选 WebSocket。
7. 生产环境还要留心的三个细节
7.1 并发写:Session.sendMessage 不是线程安全的
WebSocketSession 的 sendMessage 方法在多个线程同时调用时可能出问题,因为它底层直接操作的是 TCP 数据流,多线程并发写会导致帧数据交错,客户端解析直接报错。
解决的办法是在每个 Session 外层加一个发送锁,或者用一个队列把发送操作串行化。如果你用的是 Netty 这类框架,它的 Channel 写操作本身是线程安全的,直接用即可。如果用的是 Spring WebSocket,建议做一个发送工具类,把 sendMessage 统一收口:
java复制@Component
public class WsSender {
public void sendText(WebSocketSession session, String payload) {
if (session != null && session.isOpen()) {
synchronized (session) {
try {
session.sendMessage(new TextMessage(payload));
} catch (IOException e) {
// 处理发送失败,关闭连接或记录日志
}
}
}
}
}
7.2 推送频率:小心 I/O 放大效应
每一条 WebSocket 消息在网络上传输时都有额外的帧开销,虽然很小,但消息频率一高就很可观。假设在线用户 1 万,你要给所有人推一条 1KB 的消息,那就是 10MB 的带宽消耗。如果每秒推 10 条,就是 100MB/s,这个数字对一台普通服务器来说已经是很大的压力。
实际项目里要注意合并消息:把多个小消息合并成一个大消息推送,对高频变化的数据做节流或降频,客户端需要看实时行情时才高频推送,否则用低频快照加增量更新的组合。
7.3 连接数上限:别等打爆了才想起监控
每一条 WebSocket 连接都会占用一个文件描述符和一部分内存。Linux 默认的文件描述符上限(ulimit -n)通常是 1024,如果不调大,服务器只能同时支撑 1024 条连接,这在生产环境里几分钟就被打满了。部署时要记得调大这个限制。
同时还要在监控面板上加入连接数指标:当前连接总数、新增连接速率、连接断开速率。连接数突然飙升往往是异常流量,而连接数持续下降则说明可能有网络故障或者客户端崩溃。这两个指标能在问题扩大之前给你预警,比接到用户投诉再排查好用得多。
我自己的经验是:连接管理这件事,比业务代码更容易出问题。每一个连接背后的 Session 生命周期、心跳、重连、鉴权,任何一个环节断了,用户看到的就是“页面卡死”或者“消息收不到”。但这些坑基本都有成熟的解决方案,只要你愿意把协议层的东西搞明白,写代码的时候就会踏实很多。这套学习笔记是给未来的自己留的一份备忘,也希望对你排查 WebSocket 线上问题有些参考价值。
