WebSocket/STOMP:握手、会话、心跳与发送侧背压
WS 连接"建立成功"只是开始:握手能带身份,订阅要校验目的地,发送端遇到慢客户端要在"阻塞、丢帧、断开"之间选。固定基线 Framework 6.1.12 / spring-websocket + spring-messaging / Servlet 容器。请求层分发与异步时间线见Web 请求链路;鉴权链与 CSRF 归安全正文(U29/U30 展开 OAuth2);MQ 排队与确认语义归消息票(U57/U58,MQ 与 Kafka 主题)。
握手:从 HTTP Upgrade 到 WebSocketSession
WebSocket 连接以 HTTP 请求开始(Upgrade: websocket、Sec-WebSocket-Key),容器完成 101 切换后 HttpServletRequest 生命周期结束,后续帧由 WebSocketSession 表达。HandshakeInterceptor 是最后一个"HTTP 世界"的扩展点:可读 Cookie/Header/查询参数做握手期鉴权与属性注入(attributes map 会随会话全程)。
边界三条:握手成功≠消息层已授权(订阅目的地、消息发送人校验在 STOMP 层另行做);HandshakeInterceptor 里不能假设还能写响应体(101 切换在即);容器直连静态资源等非 WS 路由不经过这里。Origin 校验默认按配置进行,跨域 WS 必须显式 allowed origins——浏览器同源策略对 WS 的约束与 HTTP CORS 不是同一套机制(CORS 正文讲 HTTP 侧)。
STOMP 帧、会话与订阅语义
STOMP 在 WS 之上定义帧协议:CONNECT/CONNECTED(会话协商心跳与版本)、SUBSCRIBE(id + destination)、SEND(destination + body)、MESSAGE(投递,带回订阅 id)、ACK/NACK(client 模式下的消费确认)、ERROR/DISCONNECT。
@Configuration @EnableWebSocketMessageBroker
class WsConfig implements WebSocketMessageBrokerConfigurer {
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("https://app.example.com").withSockJS();
}
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic/", "/queue/");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
}
@Controller
class WsController {
@MessageMapping("/chat.{room}") // 客户端 SEND /app/chat.r42
@SendTo("/topic/chat/{room}") // 经 broker 广播给订阅者
public ChatReply handle(@DestinationVariable String room, ChatMsg msg, Principal user) { ... }
}消息权限模型与 HTTP 不同:HTTP 每请求过过滤器链,WS 只在握手过一次;订阅与发送的目的地校验必须在消息层做(@MessageMapping 前的 ChannelInterceptor/AbstractSecurityWebSocketMessageBrokerConfigurer,或订阅级校验),不能依赖"连接已认证=所有目的地可订阅"。user destination(/user/queue/notify)由框架解析到会话,给单个用户投递;会话多开(多标签页)时同用户多条 session 各收一份。
SimpleBrokerMessageHandler 是内置内存 broker:订阅表、心跳、投递都在本进程;enableStompBrokerRelay 转发外部 broker(RabbitMQ 等)——两者的订阅恢复、持久化、容量语义完全不同(外部 broker 归消息票),不能把"SimpleBroker 能用"外推为"集群可用"。
心跳:连接活性与死连接回收
CONNECT 帧协商 heart-beat: cx,cy(客户端能发/收的毫秒数),CONNECTED 回协商结果;双方按对端声明发送周期性换行帧。心跳解决的是死连接检测(TCP 半开、NAT 超时),不是业务超时,也不重传消息——STOMP 1.2 心跳帧无序列号。排障"连接莫名断开"先看三层:中间设备空闲超时(心跳周期应小于它)、容器/代理的 read/write idle 配置、客户端是否真的实现了协商的 beat。@ServerEndpoint/容器级 idle 与 STOMP 心跳是两个机制,混配可能互相打架。
发送侧背压:慢客户端的三种策略
服务器向客户端发送本质是按会话串行的阻塞写:一条消息没写完,后续消息要么阻塞要么缓冲。WebSocketSession.sendMessage 不是线程安全并发调用,标准做法是包一层 ConcurrentWebSocketSessionDecorator,它提供两个上限与两种溢出策略:
private void checkSessionLimits() {
if (!shouldNotSend() && this.closeLock.tryLock()) {
try {
if (getTimeSinceSendStarted() > getSendTimeLimit()) {
String format = "Send time %d (ms) for session '%s' exceeded the allowed limit %d";
String reason = String.format(format, getTimeSinceSendStarted(), getId(), getSendTimeLimit());
limitExceeded(reason);
}
else if (getBufferSize() > getBufferSizeLimit()) {
switch (this.overflowStrategy) {
case TERMINATE -> {
String format = "Buffer size %d bytes for session '%s' exceeds the allowed limit %d";
String reason = String.format(format, getBufferSize(), getId(), getBufferSizeLimit());
limitExceeded(reason);
}
case DROP -> {
int i = 0;
while (getBufferSize() > getBufferSizeLimit()) {
WebSocketMessage<?> message = this.buffer.poll();
if (message == null) {
break;
}
this.bufferSize.addAndGet(-message.getPayloadLength());
i++;
}
if (logger.isDebugEnabled()) {
logger.debug("Dropped " + i + " messages, buffer size: " + getBufferSize());
}
}
default ->
// Should never happen..
throw new IllegalStateException("Unexpected OverflowStrategy: " + this.overflowStrategy);
}sendTimeLimit(单条消息发送毫秒上限)与 bufferSizeLimit(待发缓冲字节上限)超限走 checkSessionLimits:默认 TERMINATE 抛出 SessionLimitExceededException(内含 SESSION_NOT_RELIABLE 1011 关闭状态)——6.1.12 里装饰器只标记并抛出,关闭责任在调用方(框架内部的消息处理链捕获后按该状态关闭会话),慢客户端由此被隔离,代价是用户重连;DROP 丢最旧消息保连接(适合行情类可丢流,不适合指令类)。这层策略是会话级背压,不是全局流控:广播风暴时每会话独立限流,服务端仍要先产生/复制消息;全局入口限流、订阅数上限、广播扇出的合并投递是另一层设计。业务上"每条都必须到"的推送(指令/告警)不能选 DROP——要么 TERMINATE+重连补拉,要么走持久通道(MQ/离线存储)+WS 仅通知。
线程模型与"HTTP 成功≠任务成功"
WS 消息处理默认在容器 WS 线程上执行;@MessageMapping 里的慢 I/O 会占住容器线程(影响同容器的读循环),应把重活交给 @SendToUser+异步或专门 executor,并注意 Principal/attributes 传播不是自动的(对比异步上下文)。
convertAndSend 返回成功只代表消息进入出站通道(SimpleBroker 下即入内存队列),不代表对端已收到、已处理——它与"HTTP 200 ≠ 后台任务完成"同构:需要业务确认的流程要应用层 ACK(STOMP client ACK 模式或业务回执+幂等),不是帧协议自动给的。
错误输入与排障链
| 症状 | 层 | 验证 | 处置 |
|---|---|---|---|
| 握手 403/404 | Origin 不允许/端点未注册 | 服务端日志+浏览器控制台 | 配 allowed origins/核对路径 |
| 连上但收不到消息 | 订阅未生效/目的地前缀不符 | 抓 CONNECTED/SUBSCRIBE 帧 | 核对 prefix 与 @SendTo |
| 越权收到他人消息 | 消息层未做目的地校验 | 用低权限账号订阅敏感 topic | ChannelInterceptor 校验订阅/发送 |
| 连接周期性断开 | 心跳与中间设备空闲超时不匹配 | 双方 beat 协商+断开时间间隔 | 调心跳周期/检查代理 idle |
| 大量 1011 关闭 | 慢客户端触发 sendTimeLimit/buffer TERMINATE | 关闭原因字符串(本文摘录的格式) | 调上限/改 DROP(可丢流)/分流 |
| 消息丢失无感知 | DROP 策略或客户端断线窗口 | 序列号对账 | 业务 ACK+补拉机制 |
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| SockJS 回退(xhr-streaming 等) | 非 WS 传输下会话/心跳语义变化;客户端库行为 |
| 外部 broker relay | 订阅恢复、持久化、集群语义归消息票(U57/U58) |
| Reactive 栈(Reactor Netty WS) | 线程模型与背压本质不同(U26/U27) |
| 网关/代理(Nginx)缓冲与 idle | Upgrade 头透传、proxy_read_timeout 与心跳关系 |
| Security 集成(CSRF for WS) | 握手期 token 传递方案(U29/U30) |
实验:输入、步骤、预期与边界
实验目录:labs/spring-senior(ClientLab.java,run.sh client 运行;stomp/ws 模式用真实 spring-websocket/messaging 类:StompEncoder/Decoder、ConcurrentWebSocketSessionDecorator(阻塞 mock 底层会话)、SimpleBrokerMessageHandler 直连通道——无真实容器握手)。
| 场景 | 输入 | 预期 |
|---|---|---|
| STOMP 帧编解码 | 编码 CONNECT/SUBSCRIBE/SEND 后解码 | 命令/头/体逐项一致(含 destination、heart-beat 头) |
| 发送超时终止 | mock 底层 sendMessage 阻塞 250ms,sendTimeLimit=100ms | 后续发送抛 SessionLimitExceededException(SESSION_NOT_RELIABLE);调用方按其状态关闭后底层会话收到 1011 关闭 |
| 缓冲溢出 DROP | bufferSizeLimit 小、底层阻塞,OverflowStrategy.DROP 连发 | 缓冲降到限内、会话不关闭、最终送达数<发送数 |
| SimpleBroker 投递 | CONNECT→SUBSCRIBE(/topic/a)→SEND(/topic/a) | 订阅者收到 MESSAGE 帧且带原订阅 id;无订阅目的地不投递 |
本机已运行(2026-10-25,JDK 17.0.19,Framework 6.1.12)。未覆盖:真实容器握手/升级、SockJS 回退、外部 broker、浏览器客户端、网络断连。
追问链
第一层:为什么 HTTP 过滤器链的授权对 WS 不够?
过滤器在握手请求上运行一次;WS 生命周期内的 SUBSCRIBE/SEND 不再经过它。消息层必须独立做目的地鉴权,否则"连上即全通"。
第二层:TERMINATE 和 DROP 怎么选?
按消息语义:可丢流(行情/进度)选 DROP 保连接;指令/告警类不可丢,选 TERMINATE+重连补拉或持久通道。也要看慢客户端比例:个别慢导致的整批断开与重建成本。
第三层:广播风暴时这层背压够吗?
会话级装饰器只保护每条连接的发送线程;风暴源头(一次事件扇出百万订阅)仍会消耗服务端产生/复制消息的资源。需要入口限流、订阅数控制、合并投递(delta/节流)甚至业务上降级为"通知+拉取"。
技术案例
- 站内通知:用户 user destination 单播 + 未读数持久化;WS 只做即时提醒,重连后按持久存储补拉。
- 协作白板:DROP+序号,客户端检测缺口后全量拉一帧;慢参与者自动降帧率。
- 交易行情:订阅级节流(服务端按订阅合并 100ms 窗口),比会话级丢弃更省服务端资源。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Spring Framework Reference: WebSocket / STOMP | 6.1 | 端点/拦截器/broker/注解官方语义 |
| RFC 6455 The WebSocket Protocol | 2011 | 握手升级、帧、关闭码 |
| STOMP 1.2 | 2012 | 帧语法、心跳协商、ACK 模式 |
| ConcurrentWebSocketSessionDecorator.java | v6.1.12 | 发送上限与溢出策略(L203–L235) |
本地 ClientLab | Framework 6.1.12 / JDK 17.0.19 | 帧编解码/装饰器策略/broker 投递实测 |
访问日期:2026-10-25。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。