【006】常见 WebSocket 场景与后端 session/鉴权的关系

前五篇把 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(和普通请求一样)。
  • 浏览器原生 EventSource API,自动重连。
  • 比 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 面板

  1. 找到状态码 101 的请求 → 点开看 Messages 标签页。
  2. 绿色箭头 = 客户端发的,红色箭头 = 服务端发的。
  3. 如果看不到 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、并发编程打基础。

相关推荐
砚凝霜32 分钟前
【软考信息安全】第二十一章 网络设备安全概述与安全机制
网络·安全
Escalating_xu1 小时前
【Linux 网络编程】网络基础概念(下):IP、端口、Socket 与网络字节序,写代码前先打好基础
linux·网络·tcp/ip
网络小江1 小时前
上网第五十二课:加密隧道与拨号专网——安全接入的通俗解释
网络协议
zbtlink2 小时前
网络卡在哪儿:空口时间、会话表容量与接入速率的可复现定位方法
网络·智能路由器
CCblandy2 小时前
Airllm 大模型本地化部署与高效推理应用指南
大数据·网络·人工智能
睡一觉就好了。3 小时前
序列化与非序列化
linux·网络
m0_715674433 小时前
金融数据流转监测排行:从看见到拦截的距离
大数据·网络·安全·网络安全·金融
盛世宏博智慧档案4 小时前
TCP以太网温湿度传感器:从RS485痛点突围到工业组网实战
网络·网络协议·tcp/ip
富洋金锐4 小时前
windows-winmtr与pathping命令-网络丢包排查
网络·windows