【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 需要显式传递,否则握手会失败(通常表现为 400502)。

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)。
  • UpgradeConnection:必须传,否则后端收不到升级请求。
  • 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 反代必须传 Upgradeproxy_read_timeout 要调大,否则空闲连接会被 Nginx 断掉。
  • 多实例广播需要引入 Redis Pub/Sub 或外部消息 Broker,单机内存里的 session Map 只能管本实例的连接。
  • 心跳 + 断线重连是生产必备。前端用指数退避重连,重连后补拉断线期间的消息。
  • 监控连接数,设告警。前端 bug 导致的重连风暴能把服务端打垮。

下一篇(007)预告 🧵:进程与线程------操作系统层面的进程/线程模型,和 Java 线程、线程池的对应关系,为后面理解 Tomcat 线程池、@Async、并发编程打基础。

相关推荐
CDN36011 小时前
高防切换后网站打不开?DNS 解析与回源路径故障排查
前端·网络·数据库
西西弟11 小时前
网络编程基础之TCP循环服务器
运维·服务器·网络·网络协议·tcp/ip
Oll Correct11 小时前
实验十六:路由环路问题
网络·笔记
@insist12311 小时前
网络工程师-虚拟专用网技术(一):核心精讲
网络·网络工程师·软考·软件水平考试
没头脑的男大11 小时前
宇树的自己电脑的适配
linux·服务器·网络
guygg8811 小时前
OPC UA Helper: 连接PLC获取变量值
服务器·网络·c#
ytdbc11 小时前
hclp第三次
网络
2601_9495394511 小时前
15万级家用混动SUV电池与续航技术入门科普
运维·网络
呱呱巨基11 小时前
网络基础概念
linux·网络·c++·笔记·学习