前五篇把 HTTP 请求-响应模型从报文到分层到接口设计捋了一遍。但有一类需求,HTTP 的「客户端问、服务端答」模式天然不擅长:服务端想主动给客户端推消息------聊天、实时通知、协同编辑、行情推送、在线状态。你当然可以让前端每隔几秒轮询一次,但这既浪费带宽又不够实时。
WebSocket 就是为这个场景生的:一次 HTTP 握手升级后,客户端和服务端之间保持一条全双工的长连接,双方随时可以互发消息,不用再反复建连。听起来很美,但引入 WebSocket 也带来了新问题:鉴权怎么做(HTTP Header 升级后就没了)、Nginx 怎么配、多实例怎么广播、什么时候其实用 SSE 就够了。
这篇想帮你建立一个「该不该用 WebSocket、用了怎么和现有鉴权体系对接」的决策框架。下面我按「为什么需要推送 → 四种方案对比 → WebSocket 协议直觉 → 鉴权怎么做 → Spring Boot 两条路线 → 网关与多实例 → 前端对接 → 什么时候别用」的顺序往下聊。
1. 服务端推送:为什么 HTTP 请求-响应不够用 📡
HTTP 的基本模型是客户端发起、服务端响应。服务端不能凭空给客户端发消息------除非客户端先问。
但很多业务场景需要「服务端有新数据时,立刻告诉客户端」:
| 场景 | 推送内容 | 实时性要求 |
|---|---|---|
| IM / 聊天 | 新消息 | 毫秒级 |
| 通知中心 | 审批结果、系统公告 | 秒级 |
| 协同编辑 | 其他人的编辑操作 | 毫秒级 |
| 行情 / 大盘 | 价格变动 | 毫秒级 |
| 在线状态 | 谁在线、谁正在输入 | 秒级 |
| 日志 / 监控面板 | 实时日志流、指标 | 秒级 |
| 扫码登录 | 手机扫码后通知 PC 端 | 秒级 |
如果用普通 HTTP,前端只能轮询------每隔几秒发一次 GET 问「有新消息吗」。消息少的时候,99% 的请求都是白跑;消息密的时候,轮询间隔又不够快。
2. 四种方案对比:轮询、长轮询、SSE、WebSocket 🔄
2.1 对比表
| 方案 | 原理 | 方向 | 实时性 | 连接开销 | 复杂度 |
|---|---|---|---|---|---|
| 短轮询 | 前端定时 GET | 客户端 → 服务端 | 取决于间隔(通常秒级) | 高(频繁建连) | 低 |
| 长轮询 | 前端 GET,服务端 hold 住直到有数据或超时 | 客户端 → 服务端 | 较好(有数据立即返回) | 中(连接被 hold) | 中 |
| SSE | 服务端单向推流(text/event-stream) |
服务端 → 客户端 | 好 | 低(一条 HTTP 长连接) | 低 |
| WebSocket | 全双工长连接 | 双向 | 好 | 低(一条 TCP 连接) | 高 |
2.2 短轮询
javascript
// 前端:每 3 秒问一次
setInterval(async () => {
const res = await fetch('/api/notifications/unread');
// 更新 UI
}, 3000);
优点 :实现最简单,后端就是普通 REST 接口。
缺点:浪费带宽和服务端资源;间隔短了压力大,间隔长了不够实时。
适合:对实时性要求不高、消息频率低的场景(如每分钟刷一次未读数)。
2.3 长轮询
text
客户端 GET /api/messages/poll?since=1712000000
→ 服务端 hold 住(最多 30 秒)
→ 有新消息:立即返回 200 + 数据
→ 超时无消息:返回 204,客户端立即重新发起
优点 :比短轮询实时,有数据立即推。
缺点:每次返回后要重新建连;服务端要 hold 大量连接(Tomcat 线程被占,除非用异步);实现比短轮询复杂。
适合:兼容性要求高(老浏览器)、不想引入 WebSocket 的中等实时场景。
2.4 SSE(Server-Sent Events)
java
// Spring Boot 端
@GetMapping(value = "/api/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<NotificationDto>> stream() {
return notificationService.subscribe()
.map(n -> ServerSentEvent.<NotificationDto>builder()
.id(String.valueOf(n.id()))
.event("notification")
.data(n)
.build());
}
javascript
// 前端
const es = new EventSource('/api/notifications/stream');
es.addEventListener('notification', (e) => {
const data = JSON.parse(e.data);
// 更新 UI
});
优点:
- 基于 HTTP,天然支持 Cookie / Authorization(和普通请求一样)。
- 浏览器原生
EventSourceAPI,自动重连。 - 比 WebSocket 简单得多。
缺点:
- 单向:只能服务端推给客户端。客户端要发消息还是走普通 HTTP。
- HTTP/1.1 下浏览器对同域并发连接有限制(通常 6 个),SSE 占一个。HTTP/2 下多路复用,问题缓解。
- IE 不支持(2025 年了,基本可以忽略)。
适合 :通知推送、日志流、行情展示------只需要服务端 → 客户端单向推的场景。很多时候 SSE 就够了,不需要上 WebSocket。
2.5 WebSocket
text
客户端 ──HTTP Upgrade──▶ 服务端
◀──101 Switching──
◀═══ 全双工消息 ═══▶
优点:
- 全双工:双方随时互发。
- 头部开销小(握手后不再有 HTTP 头)。
- 实时性最好。
缺点:
- 实现复杂(鉴权、心跳、断线重连、多实例广播)。
- 网关/代理需要额外配置。
- 不走 HTTP 语义,监控和日志工具不如 REST 方便。
适合:IM 聊天、协同编辑、游戏、双向交互频繁的场景。
2.6 决策流程图
text
需要服务端主动推送?
├─ 否 → 普通 REST 就行
└─ 是 → 需要客户端也频繁发消息?
├─ 否 → SSE(优先考虑,简单够用)
└─ 是 → WebSocket
└─ 消息频率很低?→ 长轮询也行(兼容性好)
一句话:能用 SSE 就别上 WebSocket,能用 REST 就别上 SSE。复杂度是有成本的。
3. WebSocket 协议直觉:握手、帧、心跳 🔌
3.1 握手升级(和【001】4.14 衔接)
WebSocket 连接从一次普通 HTTP 请求开始:
http
GET /ws/chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://www.example.com
服务端同意升级:
http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
之后 ,这条 TCP 连接不再走 HTTP,改用 WebSocket 帧协议 。浏览器 Network 面板里会看到一条状态码 101 的请求,之后的消息在 Messages 标签页里。
关键点:
- 握手阶段仍然是 HTTP------所以 Cookie、Authorization 头在这一步是可以带的。
- 握手完成后,HTTP 头就没了------后续消息里没有 Header 的概念,只有帧。
Origin头可以用来做简单的来源校验(类似 CORS,但不是 CORS 机制)。
3.2 帧(Frame)
握手后,数据以帧为单位传输:
| 帧类型 | 用途 |
|---|---|
| Text | 文本消息(通常是 JSON) |
| Binary | 二进制消息(图片、音频、protobuf) |
| Ping | 心跳探测(客户端或服务端发) |
| Pong | 心跳响应(收到 Ping 后自动回) |
| Close | 关闭连接 |
和 HTTP 的区别 :没有方法、没有状态码、没有 Header。每条消息就是一段数据(文本或二进制),消息的「类型」「路由」需要你自己在消息体里约定(比如 JSON 里加 type 字段)。
3.3 心跳与断线重连 💓
TCP 连接可能因为网络切换、NAT 超时、中间设备清理等原因静默断开------双方都不知道对方已经不在了。
心跳:定期发 Ping/Pong,确认连接还活着。
text
客户端 ──Ping──▶ 服务端
客户端 ◀──Pong── 服务端
如果一定时间内没收到 Pong,认为连接已断,主动关闭并重连。
断线重连 :前端需要自己实现(WebSocket API 没有自动重连):
javascript
function connect() {
const ws = new WebSocket('wss://api.example.com/ws/chat?token=xxx');
ws.onclose = () => {
console.log('连接断开,3 秒后重连');
setTimeout(connect, 3000);
};
ws.onerror = () => {
ws.close(); // 触发 onclose → 重连
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// 处理消息
};
}
connect();
重连策略建议:
- 用指数退避(1s → 2s → 4s → 8s → 最大 30s),别固定间隔疯狂重连。
- 重连成功后,可能需要补拉 断线期间的消息(通过 REST 接口拉取
since某个时间戳之后的消息)。 - 如果用 SockJS 或 Socket.IO,它们内置了重连逻辑。
3.4 ws:// 与 wss://
| 协议 | 对应 | 端口 |
|---|---|---|
ws:// |
HTTP(明文) | 80 |
wss:// |
HTTPS(TLS 加密) | 443 |
生产必须用 wss:// ------和 HTTPS 一样的道理(【002】)。混合内容(HTTPS 页面里连 ws://)会被浏览器拦截。
4. 鉴权:WebSocket 下最容易踩坑的地方 🔐
这是本篇的重头戏。REST 接口的鉴权很直接------每个请求都带 Authorization: Bearer <token>,服务端每次校验。但 WebSocket 不一样。
4.1 问题在哪
text
HTTP 请求:每次都带 Header → 每次都能校验 Token
WebSocket:握手时带一次 Header → 之后的消息没有 Header → 怎么持续校验?
而且,浏览器的 new WebSocket(url) API 不支持自定义 Header ------你没法像 Axios 那样加 Authorization。
4.2 三种常见方案
方案 A:Token 放 URL query 参数(最常用)
javascript
const ws = new WebSocket('wss://api.example.com/ws/chat?token=eyJhbGci...');
服务端在握手阶段从 query 里取 token 校验:
java
@Override
public void afterConnectionEstablished(WebSocketSession session) {
String token = extractTokenFromUri(session.getUri());
UserInfo user = authService.validate(token);
if (user == null) {
session.close(CloseStatus.NOT_ACCEPTABLE);
return;
}
session.getAttributes().put("user", user);
}
优点 :简单,前端原生 WebSocket API 就能用。
缺点 :Token 出现在 URL 里,可能被日志、Referer、代理记录。生产环境用 wss://(TLS 加密 URL),并确保服务端访问日志不记录 query 参数。
方案 B:Cookie(Session 方案)
如果你的项目用 Cookie 会话(不是 JWT),浏览器建 WebSocket 时会自动带 Cookie(同域情况下):
javascript
// 前端不需要手动传 token,Cookie 自动带
const ws = new WebSocket('wss://api.example.com/ws/chat');
服务端从 HttpSession 里取用户信息:
java
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest servletRequest) {
HttpSession session = servletRequest.getServletRequest().getSession(false);
if (session != null) {
attributes.put("user", session.getAttribute("currentUser"));
return true;
}
}
return false; // 拒绝握手
}
优点 :不用在 URL 里暴露 token。
缺点 :跨域时 Cookie 受限(需要 withCredentials + CORS 配置,和【001】6.1 一样的问题);前后端分离 + 不同域时不太方便。
方案 C:先 HTTP 换 ticket,再用 ticket 连 WebSocket
text
① 前端 POST /api/ws/ticket (带 Authorization Header)
→ 服务端返回一次性 ticket(UUID,有效期 30 秒)
② 前端 new WebSocket('wss://api.example.com/ws/chat?ticket=abc-123')
→ 服务端校验 ticket(用完即删)→ 握手成功
优点 :Token 不出现在 WebSocket URL 里;ticket 一次性,泄露风险低。
缺点:多一次 HTTP 请求;实现稍复杂。
适合:安全要求较高的场景(金融、企业内部)。
4.3 连接建立后的持续鉴权
握手时校验了 Token,但 Token 可能在连接存续期间过期。怎么办?
| 策略 | 做法 |
|---|---|
| 不管(简单场景) | 连接建立时校验一次,之后信任。适合 Token 有效期长、连接生命周期短的场景 |
| 定期校验 | 服务端定时检查 session 里的 Token 是否过期,过期则主动关闭连接 |
| 客户端刷新 | 客户端在 Token 快过期时,通过 WebSocket 消息发送新 Token;或断开重连(带新 Token) |
| 消息级校验 | 每条消息都带 Token(开销大,通常不必要) |
推荐 :握手时校验 + 服务端定期检查(比如每 5 分钟)。Token 过期时服务端发一条 {"type":"AUTH_EXPIRED"} 消息,前端收到后刷新 Token 并重连。
4.4 和 Spring Security 的关系
Spring Security 的过滤器链默认作用于 HTTP 请求 。WebSocket 握手是 HTTP 请求,所以握手阶段会经过 Security 过滤器------如果你配了 JWT 过滤器,握手请求的 Header 里有 Token 就能被拦截校验。
但问题是:浏览器 new WebSocket() 不能自定义 Header。所以要么:
- 用 Cookie 方案(Security 自动从 Cookie 取 session)。
- 用 query 参数方案,在
HandshakeInterceptor里手动校验(绕过 Security 的 Header 校验)。 - WebSocket 端点在 Security 配置里放行,自己在
HandshakeInterceptor里做鉴权。
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/ws/**").permitAll() // WebSocket 端点放行
.anyRequest().authenticated()
)
// ...
.build();
}
}
然后在 HandshakeInterceptor 里自己校验 token(见 4.2 方案 A)。
5. Spring Boot 里的两条路线 ☕
Spring Boot 提供两种 WebSocket 支持,选哪条取决于你的场景复杂度。
5.1 路线 A:原生 WebSocketHandler(轻量)
适合:简单的点对点推送、通知、少量连接。
java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler(), "/ws/chat")
.addInterceptors(new AuthHandshakeInterceptor())
.setAllowedOrigins("https://www.example.com");
}
@Bean
public WebSocketHandler chatHandler() {
return new ChatWebSocketHandler();
}
}
java
public class ChatWebSocketHandler extends TextWebSocketHandler {
// 在线连接管理(简化版,生产要考虑线程安全)
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
String userId = (String) session.getAttributes().get("userId");
sessions.put(userId, session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
// 收到客户端消息,解析 JSON,按 type 分发
String payload = message.getPayload();
// ... 业务处理
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String userId = (String) session.getAttributes().get("userId");
sessions.remove(userId);
}
// 主动推送给某个用户
public void sendToUser(String userId, String message) throws IOException {
WebSocketSession session = sessions.get(userId);
if (session != null && session.isOpen()) {
session.sendMessage(new TextMessage(message));
}
}
}
java
public class AuthHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
// 从 query 参数取 token
String token = UriComponentsBuilder.fromUri(request.getURI())
.build().getQueryParams().getFirst("token");
if (token == null) return false;
UserInfo user = authService.validate(token);
if (user == null) return false;
attributes.put("userId", user.getId());
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {}
}
优点 :直接、透明,你完全控制消息格式和路由。
缺点:消息路由、房间/频道、广播都要自己写。
5.2 路线 B:STOMP over WebSocket(重量级)
适合:聊天室、多频道订阅、需要消息路由的复杂场景。
STOMP (Simple Text Oriented Messaging Protocol)是一个简单的消息协议,跑在 WebSocket 之上,提供了发布/订阅 、点对点 、消息路由等能力------类似一个轻量级的消息队列。
java
@Configuration
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue"); // 订阅前缀
config.setApplicationDestinationPrefixes("/app"); // 发送前缀
config.setUserDestinationPrefix("/user"); // 点对点前缀
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("https://www.example.com")
.withSockJS(); // 降级支持(长轮询等)
}
}
java
@Controller
public class ChatController {
// 客户端发到 /app/chat.send → 广播到 /topic/chat
@MessageMapping("/chat.send")
@SendTo("/topic/chat")
public ChatMessage send(ChatMessage message) {
return message;
}
// 点对点:发给特定用户
@MessageMapping("/chat.private")
public void sendPrivate(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) {
String targetUser = message.getTo();
messagingTemplate.convertAndSendToUser(
targetUser, "/queue/private", message);
}
}
前端(用 @stomp/stompjs):
javascript
const client = new StompJs.Client({
brokerURL: 'wss://api.example.com/ws',
onConnect: () => {
// 订阅广播频道
client.subscribe('/topic/chat', (msg) => {
const data = JSON.parse(msg.body);
// 更新 UI
});
// 订阅个人消息
client.subscribe('/user/queue/private', (msg) => {
// ...
});
// 发送消息
client.publish({
destination: '/app/chat.send',
body: JSON.stringify({ content: 'Hello', sender: 'zhang' })
});
}
});
client.activate();
优点 :消息路由、频道订阅、点对点都内置了;withSockJS() 提供降级方案。
缺点:概念多(destination、broker、subscribe);调试不如原生 WebSocket 直观;SockJS 增加了复杂度。
5.3 怎么选
| 场景 | 推荐 |
|---|---|
| 简单通知推送(审批结果、系统消息) | 原生 WebSocketHandler,甚至 SSE |
| 扫码登录(等一个事件就断) | 原生 WebSocketHandler 或长轮询 |
| 聊天室、多频道订阅 | STOMP |
| 协同编辑 | 原生 WebSocket + 自定义协议(OT/CRDT) |
| 行情推送(只推不收) | SSE 优先 |
6. Nginx 反代 WebSocket:必须额外配置 🔧
Nginx 默认代理的是 HTTP 请求-响应。WebSocket 的 Upgrade 需要显式传递,否则握手会失败(通常表现为 400 或 502)。
6.1 Nginx 配置
nginx
location /ws/ {
proxy_pass http://spring_app;
proxy_http_version 1.1;
# 关键:传递 Upgrade 头
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 空闲超时(默认 60s,长连接要调大)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
要点:
proxy_http_version 1.1:WebSocket 升级需要 HTTP/1.1(不是 1.0)。Upgrade和Connection:必须传,否则后端收不到升级请求。proxy_read_timeout:默认 60 秒,WebSocket 空闲超过这个时间 Nginx 会断连。设成 1 小时或更长,配合心跳保活。
6.2 常见问题
| 现象 | 原因 |
|---|---|
| 握手返回 400 | Nginx 没传 Upgrade 头 |
| 连接 60 秒后自动断开 | proxy_read_timeout 太短 |
| 握手返回 502 | 后端 WebSocket 端点没配对、或后端没启动 |
wss:// 连不上 |
Nginx 的 listen 443 ssl 没配,或证书问题(【002】) |
6.3 Spring Cloud Gateway
如果用 Spring Cloud Gateway 做网关,WebSocket 路由需要用 lb:ws:// 前缀:
yaml
spring:
cloud:
gateway:
routes:
- id: websocket-route
uri: lb:ws://chat-service
predicates:
- Path=/ws/**
Gateway 基于 Netty,对 WebSocket 的支持比 Nginx 更原生,但配置方式不同。
7. 多实例下的广播问题 📢
单实例时,所有 WebSocket 连接都在同一个 JVM 里,sessions.values().forEach(s -> s.sendMessage(...)) 就能广播。但生产环境通常是多实例(K8s 多 Pod、多台机器),用户 A 连在实例 1,用户 B 连在实例 2------实例 1 的内存里没有用户 B 的 session。
7.1 问题示意
text
用户 A ──ws──▶ 实例 1(有 A 的 session)
用户 B ──ws──▶ 实例 2(有 B 的 session)
A 发消息给 B:
实例 1 收到 → 在自己的 sessions 里找 B → 找不到 → 消息丢了
7.2 解决思路:引入消息中间件
text
实例 1 收到 A 的消息 → 发到 Redis Pub/Sub(或 MQ)
所有实例订阅同一个 channel → 每个实例检查自己有没有目标用户的 session → 有就推
Redis Pub/Sub 示意(概念,详细实现在 P4 阶段):
java
// 发送端:把消息发到 Redis channel
redisTemplate.convertAndSend("ws:broadcast", messageJson);
// 接收端:每个实例都监听
@Component
public class WebSocketMessageListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody());
// 在本实例的 sessions 里找目标用户,找到就推
chatHandler.deliverToLocalSessions(payload);
}
}
STOMP + 外部 Broker :如果用 STOMP 路线,可以把 enableSimpleBroker 换成 外部 RabbitMQ/ActiveMQ 作为 broker,天然支持多实例:
java
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq.internal")
.setRelayPort(61613);
}
7.3 连接亲和性(Sticky Session)
另一种思路:让同一个用户的 WebSocket 连接始终打到同一个实例(通过网关的 session affinity / IP hash)。
优点 :不需要跨实例广播(点对点场景)。
缺点:广播仍然需要中间件;实例下线时连接全断,重连可能打到别的实例。
实际项目里通常两者结合:sticky session 减少跨实例通信 + Redis/MQ 兜底广播。
8. 消息格式约定:WebSocket 里的「接口契约」 📋
WebSocket 消息没有 HTTP 的方法/状态码/Header,所有语义都要自己在消息体里定义。建议一开始就约定好格式,和【005】的 REST 契约一样重要。
8.1 推荐的 JSON 消息结构
json
{
"type": "CHAT_MESSAGE",
"payload": {
"content": "你好",
"to": "user-42"
},
"requestId": "req-abc-123",
"timestamp": "2025-04-13T10:30:00"
}
| 字段 | 说明 |
|---|---|
type |
消息类型,用于路由和处理(类似 HTTP 方法 + 路径) |
payload |
消息体(类似 HTTP body) |
requestId |
可选,用于请求-响应配对(客户端发请求,服务端回复带同一个 requestId) |
timestamp |
可选,消息时间戳 |
8.2 常见消息类型
| type | 方向 | 说明 |
|---|---|---|
CHAT_MESSAGE |
客户端 → 服务端 | 发送聊天消息 |
CHAT_MESSAGE_ACK |
服务端 → 客户端 | 消息已收到确认 |
NEW_MESSAGE |
服务端 → 客户端 | 推送新消息 |
TYPING |
双向 | 正在输入状态 |
NOTIFICATION |
服务端 → 客户端 | 系统通知 |
AUTH_EXPIRED |
服务端 → 客户端 | Token 过期,请重连 |
ERROR |
服务端 → 客户端 | 错误信息 |
PING / PONG |
双向 | 应用层心跳(如果不用协议层 Ping/Pong) |
8.3 错误消息
json
{
"type": "ERROR",
"payload": {
"code": "FORBIDDEN",
"message": "你没有权限发送消息到这个频道"
},
"requestId": "req-abc-123"
}
和 REST 的 ErrorResponse 保持风格一致,前端处理逻辑可以复用。
9. 前端对接:原生 WebSocket vs SockJS vs Socket.IO 🖥️
9.1 三者对比
| 方案 | 协议 | 降级 | 自动重连 | 生态 |
|---|---|---|---|---|
原生 WebSocket |
WebSocket | 无 | 无(自己写) | 浏览器原生 |
| SockJS | WebSocket → 长轮询 → 流式等 | 有 | 有 | Spring 生态常用 |
| Socket.IO | 自定义协议(基于 WS + 轮询) | 有 | 有 | Node.js 生态,Java 端需额外库 |
9.2 选择建议
- Spring Boot 后端 + STOMP :用 SockJS(
withSockJS()),前端用@stomp/stompjs+sockjs-client。 - Spring Boot 后端 + 原生 Handler :前端用原生
WebSocket,自己写重连。 - Node.js 后端 :Socket.IO 是主流。不要在 Spring Boot 后端用 Socket.IO------协议不兼容,需要额外的 Java Socket.IO 库,维护成本高。
9.3 SockJS 的降级机制
text
SockJS 客户端尝试连接:
① 先试 WebSocket → 成功就用
② WebSocket 不通(代理拦截、老浏览器)→ 降级到 XHR-streaming
③ 再不行 → 降级到 XHR-polling(长轮询)
好处 :在企业内网(某些代理不支持 WebSocket)或老旧环境下仍能工作。
代价 :SockJS 的 URL 格式和原生 WebSocket 不同(/ws/info、/ws/{server}/{session}/websocket),Nginx 配置要注意路径匹配。
10. 资源与性能:WebSocket 连接不是免费的 📊
10.1 每条连接的成本
一条 WebSocket 连接 = 一条 TCP 连接 = 服务端要维护:
- 一个 Socket 文件描述符(Linux 默认
ulimit -n可能只有 1024) - 一块内存(接收/发送缓冲区、session 对象)
- 如果用 Tomcat(BIO/NIO),还占一个线程或 selector 注册
Tomcat 的 WebSocket 并发 :默认 max-connections=8192,但实际能撑多少取决于内存和业务逻辑。纯推送(消息小、频率低)万级连接没问题;聊天(消息频繁、要解析 JSON、要查库)几千就要注意了。
10.2 和 Tomcat 线程池的关系
好消息 :WebSocket 连接建立后,不占 Tomcat 工作线程 (NIO 模式下)。线程只在有消息到达时被短暂使用。所以 WebSocket 连接数可以远大于 threads.max。
坏消息 :如果你在 handleTextMessage 里做了阻塞操作(查数据库、调下游 HTTP),那处理消息时还是会占线程。高并发消息场景下,考虑异步处理或用 WebFlux。
10.3 连接数监控
text
# 看当前 WebSocket 连接数(如果你在 sessions Map 里维护了)
GET /actuator/metrics/custom.websocket.connections
# 看 Tomcat 连接数
GET /actuator/metrics/tomcat.connections.current
建议:把在线连接数作为监控指标暴露出来,设告警阈值。连接数突然暴涨可能是前端重连风暴(bug 导致无限重连)。
11. 什么时候别用 WebSocket 🚫
| 场景 | 为什么不该用 WebSocket | 更好的方案 |
|---|---|---|
| 通知红点(每分钟刷一次) | 频率低,轮询就够 | 短轮询 / SSE |
| 单向推送(日志流、行情) | 不需要客户端发消息 | SSE |
| 文件上传进度 | HTTP 本身有进度事件 | XMLHttpRequest.upload.onprogress |
| 低频操作确认(支付结果) | 一次性等待 | 长轮询 / SSE |
| 移动端弱网环境 | WebSocket 断线重连体验差 | 长轮询(更抗网络抖动) |
| 需要 CDN 缓存的内容 | WebSocket 不走 HTTP 缓存 | REST + CDN |
过度使用 WebSocket 的代价:
- 运维复杂度上升(Nginx 配置、多实例广播、连接数监控)。
- 排障困难(没有 HTTP 状态码、没有标准日志格式)。
- 移动端省电策略可能杀后台 WebSocket 连接。
- 某些企业代理/防火墙会拦截 WebSocket 升级。
12. 排障备忘 🔧
12.1 浏览器 Network 面板
- 找到状态码 101 的请求 → 点开看 Messages 标签页。
- 绿色箭头 = 客户端发的,红色箭头 = 服务端发的。
- 如果看不到 101 → 握手失败,看响应状态码和 body。
12.2 常见问题速查
| 现象 | 排查方向 |
|---|---|
| 握手 403 | Spring Security 拦截了 WebSocket 端点 |
| 握手 404 | 端点路径没配对、或 Nginx location 没匹配 |
| 握手 400 | Nginx 没传 Upgrade 头 |
| 连接成功但收不到消息 | 消息路由错误(STOMP destination 不对)、或多实例问题 |
| 60 秒后自动断开 | Nginx proxy_read_timeout 太短、或没有心跳 |
| 频繁断线重连 | 网络不稳、心跳间隔太长、或服务端主动关闭(检查日志) |
wss:// 连不上但 ws:// 可以 |
TLS 证书问题(【002】) |
前端报 WebSocket is closed before the connection is established |
握手阶段就被拒绝了,看服务端日志 |
12.3 curl 测试 WebSocket 握手
bash
# 只测握手是否成功(看能否拿到 101)
curl -v -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"http://127.0.0.1:8080/ws/chat?token=test"
看到 101 Switching Protocols 就说明握手成功。之后 curl 会挂住(因为进入了 WebSocket 帧模式),Ctrl+C 退出即可。
更好的工具 :websocat(命令行 WebSocket 客户端),可以交互式发消息:
bash
websocat "ws://127.0.0.1:8080/ws/chat?token=test"
小结 💡
- 先问「需不需要服务端主动推」,再问「需不需要双向」。能用 REST 就别上 SSE,能用 SSE 就别上 WebSocket------复杂度是有成本的。
- WebSocket 握手是 HTTP (101 Switching Protocols),握手后变成全双工帧协议,没有 Header、没有状态码,消息格式全靠自己约定。
- 鉴权是最大的坑 :浏览器
new WebSocket()不能自定义 Header。常用方案是 Token 放 query 参数(简单)、Cookie(同域方便)、或先换一次性 ticket(安全)。握手后 Token 过期的问题需要额外处理。 - Spring Boot 两条路线 :原生
WebSocketHandler(轻量、自己管路由)和 STOMP over WebSocket(内置发布订阅、适合聊天室)。简单推送用前者,复杂消息路由用后者。 - Nginx 反代必须传
Upgrade头 ,proxy_read_timeout要调大,否则空闲连接会被 Nginx 断掉。 - 多实例广播需要引入 Redis Pub/Sub 或外部消息 Broker,单机内存里的 session Map 只能管本实例的连接。
- 心跳 + 断线重连是生产必备。前端用指数退避重连,重连后补拉断线期间的消息。
- 监控连接数,设告警。前端 bug 导致的重连风暴能把服务端打垮。
下一篇(007)预告 🧵:进程与线程------操作系统层面的进程/线程模型,和 Java 线程、线程池的对应关系,为后面理解 Tomcat 线程池、@Async、并发编程打基础。