WebSocket / STOMP 与 Actuator + springdoc:把服务推出去、把状态看出来
前面十三章讲的都是"客户端来问、服务端来答"。这一章要解决两个新问题:服务端怎么主动把消息推给客户端(WebSocket / STOMP),以及线上服务怎么被别人看见(Actuator 暴露健康与指标,springdoc 暴露接口文档)。这两件事看着不相关,但都属于"服务对外的那张脸",配置思路也很像:默认什么都不暴露,你要什么就精确地开什么。
WebSocket 握手:一次 HTTP 请求如何"升格"成双向通道
HTTP 是"请求—响应"模型:客户端不问,服务端不能说。轮询能解决"服务端有消息要通知"的问题,但代价太大。WebSocket 的出现就是为了在同一个 TCP 连接上做全双工通信。
它的聪明之处在于:复用 HTTP 的握手流程,靠协议升级(Upgrade)切换协议。这样防火墙、代理、负载均衡都不用改,因为前三个报文长得就是普通 HTTP。
| 步 | 报文 / 动作 | 说明 |
|---|---|---|
| 1 | 客户端 GET /ws,带 Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key | 看起来是个普通 GET 请求,所以能穿过大部分中间设备。 |
| 2 | 服务端返回 101 Switching Protocols,带 Sec-WebSocket-Accept | Sec-WebSocket-Accept = Base64(SHA1(key + 固定 GUID))。这一步只证明服务端"同意升级",不是安全认证。 |
| 3 | 此后同一个 TCP 连接上跑 WebSocket 帧,不再有 HTTP 头 | 双方可以随时发消息,服务端终于能主动推了。 |
| 4 | 任一方发 Ping / Pong 控制帧;Close 帧关闭连接 | 心跳就靠这个。没有心跳的 WebSocket 在 NAT/网关超时后会被静默掐断,双方都以为还连着。 |
论关于握手,你必须知道的四件事
① 认证信息要放在握手请求里:因为握手就是一次 HTTP 请求,所以 Cookie、Authorization 头、URL 查询参数都能带。但浏览器原生 WebSocket API 不支持自定义请求头——这是个大坑。想传 token 只能:① 靠 Cookie(会被 CSRF 影响);② 放在 URL 参数(会进日志,不推荐);③ 握手时带一个短寿命的一次性 ticket;④ 改用 STOMP + connectHeaders(STOMP 层的头,不受浏览器限制)。最后一种最干净,所以生产项目大多上 STOMP。
② 中间设备可能掐断长连接:Nginx 默认 proxy_read_timeout 60s,超过了就断。所以要在服务端配置心跳(如 10~25 秒一次),并让 Nginx 的超时大于心跳间隔。"联调正常、上线 60 秒就断"是 WebSocket 最经典的故障。
③ 反向代理必须显式开启升级支持:Nginx 要写 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";,否则代理只转发 HTTP 头,升级失败,客户端报 Unexpected response code: 200。
④ 同源策略不适用于 WebSocket:WebSocket 没有同源限制,任何站点的 JS 都能连你的 wss:// 服务并自动带上用户的 Cookie。所以必须自己在握手阶段校验 Origin 头,否则就是 WebSocket 版的 CSRF。Spring 通过 setAllowedOrigins("*") 来配——默认只允许同源,别随手改成 *。
和 SSE、长轮询比:什么时候不该用 WebSocket
WebSocket 很强,但不是所有"推送"都要用它。用错技术会让架构无谓地复杂化。
| 方案 | 方向 | 协议 | 适用与代价 |
|---|---|---|---|
| 短轮询 | 客户端拉 | HTTP | 最土但最稳。实现零成本、兼容一切代理。代价:实时性差、大量空请求浪费。只有"更新频率很低(分钟级)"时才选它。 |
| 长轮询 | 客户端拉(挂起) | HTTP | 服务端 hold 住请求直到有数据或超时(如 30 秒)。兼容性好、能穿过所有代理。代价:每个连接占一个线程(除非用异步 Servlet / 虚拟线程),并发量大时资源消耗高。 |
| SSE Server-Sent Events | 服务端单向推 | HTTP(text/event-stream) | 只需服务端推、不需要客户端发的场景首选:AI 流式回答、日志实时输出、进度条、股票报价。优点:就是普通 HTTP,自动重连、自动带 Last-Event-ID 断点续传、不用改任何中间件。缺点:只能单向;HTTP/1.1 下浏览器对同域连接数有限制(通常 6 个)。 |
| WebSocket | 双向 | ws / wss | 需要双向、高频、低延迟时用:聊天、协同编辑、游戏、实时交易撮合。代价:需要处理心跳、重连、集群会话、代理配置、权限校验,运维复杂度明显更高。 |
论选择顺序:先问方向,再问频率
① 只推不收 → 直接用 SSE:AI 对话的流式输出是典型例子。用 SSE 你什么都不用配,用 WebSocket 你得操心心跳和重连。Spring MVC 里返回 SseEmitter,或者用 WebFlux 返回 Flux<ServerSentEvent>,几十行就搞定。
② 要双向 → 才上 WebSocket:判断标准是"客户端是否需要主动、频繁地发消息"。聊天需要,进度条不需要。
③ 频率极低 → 老实轮询:后台任务的完成通知、每小时更新的报表,用轮询反而更简单可靠。"为了实时而实时"是过度设计的主要来源之一。
④ 一个容易忽略的细节:HTTP/1.1 下浏览器对同一域名的并发连接有 6 个左右的限制,而 SSE 会长期占用一个。如果你页面上同时开了 3 个 SSE,再发普通 AJAX 就可能被排队卡住。HTTP/2 多路复用没有这个问题,这也是上 HTTP/2 的一个实际收益。
原生 WebSocket 端点:@EnableWebSocket 与 WebSocketHandler
Spring 提供了两层 WebSocket 支持:底层的原生 API(自己处理 TextMessage、BinaryMessage)和上层的 STOMP 支持(有消息语义、有订阅发布)。先用原生 API 把原理跑通。
原生端点:三个类搞定
① WebSocketSession 并发发送会炸:同一个 session 上并发调用 sendMessage 会抛 IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]。因为一个 session 只允许一个发送在途。解法:给每个 session 配一个发送队列(或用 ConcurrentWebSocketSessionDecorator 包装),发送串行化。
② @EnableWebSocket 忘了加:Handler 注册不上,浏览器连接直接被拒,日志里只有一句笼统的 404。
③ setAllowedOrigins("*"):等于把任何网站都允许连你的服务并自动带上用户 Cookie。必须写具体域名,多个用逗号分隔。
④ 没限消息大小:默认上限很大,一个恶意客户端持续发大帧就能把服务端内存打满。上面那段 setMaxTextMessageBufferSize 不是可选项。
STOMP + SockJS:从"裸 socket"到"消息协议"
原生 WebSocket 只提供了"一个能双向发字符串的通道",它没有消息语义:没有"订阅"、没有"目的地"、没有"路由"。你只能自己约定 JSON 格式再手写 if-else 分发。STOMP 就是给 WebSocket 套上这层语义的简单文本协议。
STOMP 的核心概念只有四个,理解它们就理解了一切:
| 概念 | 类比 | 说明 |
|---|---|---|
| 帧 Frame | HTTP 的请求行 | 文本格式:命令 + 头 + 空行 + 体。命令有 CONNECT、SUBSCRIBE、SEND、MESSAGE、DISCONNECT。 |
| 目的地 Destination | 消息队列的 topic | 以 / 开头的字符串,如 /topic/orders。以 /topic 开头是广播,/queue 通常按点对点理解(但 Spring 的 SimpleBroker 里两者都当作广播,语义差别要自己维护)。 |
@MessageMapping | @RequestMapping | 服务端接收消息的入口,路径前缀由 setApplicationDestinationPrefixes 决定(默认 /app)。 |
SimpMessagingTemplate | 服务端的"广播喇叭" | 在任意地方(包括非 WebSocket 线程,如定时任务、MQ 消费者)往某个目的地推消息。 |
STOMP 配置:Broker + 端点 + 前缀
服务端:接收消息与主动推送
① 心跳配了但不生效:setHeartbeatValue 必须配 setTaskScheduler,而且 SimpleBroker 的心跳还要 withSockJS().setHeartbeatTime() 配合。只写一处等于没写。
② 集群下"只推给连到本机的用户":SimpleBroker 是单机内存 broker,用户 A 连到实例 1,你的定时任务在实例 2 上执行 convertAndSend,A 收不到消息。解法有两条:① 用外部 broker(RabbitMQ / ActiveMQ 做 STOMP relay,registry.enableStompBrokerRelay(...));② 保持 SimpleBroker 但在集群间用 Redis Pub/Sub 或 MQ 转发,由每个实例收到后推给本机连接的用户。这是"本地测试全对、上线丢消息"的头号原因。
③ @MessageMapping 的路径写错前缀:客户端要发到 /app/chat.send(带 setApplicationDestinationPrefixes 配的前缀),而不是 /chat.send。少写前缀会 404 且没有明显报错。
④ 广播粒度太粗:全都塞进 /topic/all,然后靠前端过滤,等于把服务端该干的过滤搬到了浏览器,还泄露了别人的数据。按业务维度拆目的地:房间号、用户 ID、租户 ID 都该进目的地路径。
⑤ 忘记给 STOMP 也做鉴权:很多人以为配了 SecurityFilterChain 就安全了——但 WebSocket 的 CONNECT 帧不是 HTTP 请求,需要单独配置 ChannelInterceptor 校验 CONNECT 帧里的令牌,否则任何拿到端点地址的人都能订阅任意目的地。这是安全审计里的高频问题。
心跳与断线重连:让"连着"这件事可被观测
WebSocket 连接最反直觉的性质是:它断了,你不一定知道。TCP 连接在网络中断(比如地铁里切网)时不会立刻报错,双方都要等到超时才发现。所以生产环境里,心跳和重连不是可选功能。
按职责划分,两端要做的事各有不同:
| 环节 | 服务端要做 | 客户端要做 |
|---|---|---|
| 心跳 | 配置 setHeartbeatValue({10000,10000}),超时未收到 Pong 就主动关闭会话。 | STOMP.js 会自动处理 Ping/Pong,但要在 connect() 时声明 heartbeatIncoming/Outgoing,否则协商后心跳为 0(关闭)。 |
| 重连 | 无需处理。要注意的是重连会产生新会话,旧会话的订阅关系全丢,服务端不能把状态挂在会话上。 | 必须做指数退避重连(1s、2s、4s、8s,上限 30s),并在重连成功后重新订阅所有目的地。 |
| 消息补偿 | 为每个订阅维护一个递增序号,客户端重连时带 lastSeq,服务端补发缺失消息(可类比 SSE 的 Last-Event-ID)。 | 记录已处理的序号,重连时上报。也要接受"可能重复",做幂等。 |
| 观测 | 暴露在线连接数、每会话消息量、异常关闭次数——这些指标直接接进 Actuator/Micrometer。 | 上报连接状态,UI 上给用户明确提示("连接已断开,正在重连"),别让用户对着不动的界面发呆。 |
论集群与容灾:三件事必须提前想清楚
① 会话粘性(sticky session):负载均衡必须把同一个客户端的握手和后续帧都路由到同一个实例。Nginx 可以用 ip_hash,云 LB 一般有"会话保持"开关。但粘性只是让你"能连上",解决不了"消息要跨实例广播"的问题——粘性不能替代 broker 广播。
② 用 Redis 广播补齐跨实例推送:每个实例订阅一个 Redis 频道,业务代码推消息时同时写 Redis 频道,各实例收到后调 SimpMessagingTemplate 推给本机连接的用户。这样无论用户在哪个实例,都能收到。这就是 Spring Cloud 那章讲的"消息总线的简化版"。
③ 优雅停机要主动通知客户端:实例下线时如果直接断开,客户端只感知到"异常断开",重连可能还会打回这个即将消失的实例。做法:在 shutdown 钩子里往所有会话推一条"服务将重启,请重连"的业务消息,并把 LB 上的该实例摘除(先摘流、再停机)。这也是下面 Actuator 那节的 server.shutdown=graceful 要配合做的事。
④ 容量规划要做最坏假设:一个 WebSocket 连接占一个(或半个)线程/协程加一块内存。1 万个在线用户对传统"一连接一线程"模型就是压力。要么用虚拟线程(Java 21 的 spring.threads.virtual.enabled=true),要么用 WebFlux 的响应式模型,要么把连接数控制在可控范围并做好横向扩容。
Actuator:把服务状态变成可查询的端点
服务上线后,"它活着吗、它健康吗、它现在什么指标"必须有统一答案。Actuator 就是 Spring Boot 给的标准答案:加依赖就有一批 HTTP 端点,直接返回 JSON。
引入与暴露:默认只开两个端点
| 端点 | 返回什么 | 暴露建议 |
|---|---|---|
/health | 整体健康状态 + 各组件明细(DB、Redis、磁盘、自定义检查) | 可以暴露 给 LB / K8s 探针用,但不要 show-details: always,那会泄露内网地址与依赖清单。 |
/info | 应用元信息:版本号、构建时间、Git 提交号 | 可以暴露。配合 git-commit-id-maven-plugin 能自动带上 commit 与分支,排查"线上到底是哪个版本"极有用。 |
/metrics / /prometheus | JVM、HTTP、数据库连接池、自定义指标 | 限内网。给 Prometheus 抓取用,必须走内网或加认证。 |
/loggers | 查询与动态修改日志级别 | 限内网。线上排障神器(不改包重启就能开 DEBUG),但也是"能改变运行行为"的写操作。 |
/env / /configprops | 所有配置项与属性源 | 不要暴露。会泄露数据库密码、密钥、第三方 AK/SK(虽然有脱敏,但不完全可靠)。 |
/heapdump / /threaddump | 堆转储、线程快照 | 绝对不要暴露。heapdump 里能捞到内存中的明文密码与 token。 |
/shutdown | 关闭应用(POST) | 默认禁用且不要开启。要优雅停机用 server.shutdown=graceful,不要开这个远程关机按钮。 |
自定义 HealthIndicator:把"业务健康"也纳入探针
论探针不是"越全越好":三个必须克制的理由
① 探针决定流量,误判会造成雪崩:如果健康检查里包含一个"非关键"的下游服务,那个服务一抖,所有实例全部变成 DOWN,LB 把全部实例摘掉,整个集群从"部分功能降级"变成"完全不可用"。业务健康检查要用 OUT_OF_SERVICE 表达降级,把 DOWN 留给"真的不能服务"。
② 探针会被高频调用:K8s 的 livenessProbe 可能每 10 秒调一次,100 个实例就是每秒 10 次。如果每次探针都去查库、调下游,你就自己制造了一个稳定的 DDoS。所以探针要轻、要快、要有超时和缓存。
③ liveness 和 readiness 要分开:liveness 失败会被重启(回答"进程是不是卡死了"),readiness 失败只是摘流量(回答"现在能不能接受请求")。把 liveness 也配上 DB 检查是经典事故:数据库一抖,K8s 把所有实例重启一遍,重启后连不上库又重启,陷入死循环。Actuator 已经提供了 /actuator/health/liveness 与 /actuator/health/readiness,用它们。
springdoc-openapi:让接口文档跟着代码走
接口文档最大的问题是"会过期"。springdoc-openapi 的做法是从代码里生成文档:扫描你的 Controller,解析注解,实时产出 OpenAPI 3 的描述,再用 Swagger UI 渲染成可交互的页面。代码改了文档自动跟着改——这是它唯一但足够重要的价值。
先澄清一个常见误解:Spring Boot 3 里不要再引 springfox。springfox 早已停止维护,不兼容 Spring Boot 3 的 PathPatternParser 与 jakarta.* 命名空间,硬上会启动失败。正确选择是 springdoc-openapi-starter-webmvc-ui。
依赖与基础配置
注解:给接口补上"机器猜不出来"的信息
把 JWT 认证配进 Swagger UI,实现"文档里直接调通"
① 生产环境暴露 Swagger UI:等于把你的接口清单、参数结构、错误码全送给攻击者,连枚举都不用做。整改方式:springdoc.swagger-ui.enabled=false(默认值在 2.6 后视版本而定,务必显式写),或者只在 dev profile 打开,或者把 /swagger-ui/** 与 /v3/api-docs/** 挂到管理端口只在内网可达。
② Actuator 的 include: "*":一行配置换来 heapdump 与 env 公网可访问,是最常见的高危配置。审查清单里这一条必须逐项确认。
③ /actuator/health 返回 500 让 LB 摘掉全部实例:自定义 HealthIndicator 里抛了未捕获异常。虽然 Spring 会兜底成 DOWN,但整机被判死。Indicator 里一定要 try-catch,异常时返回降级状态而不是抛出去。
④ @Schema 和 DTO 不一致:example 手写的值和真实校验规则冲突(例如 example 写了 25 位 ID 但字段限制了长度),联调时被前端当成真相。example 一定要是能真正通过校验的值。
⑤ 管理端点和业务端点共用端口却忘了鉴权:把 /actuator/** 直接 permitAll 图省事。正确做法是把管理端点放到独立端口(management.server.port),或用 Spring Security 单独一条 SecurityFilterChain 保护。
① WebSocket 靠 HTTP 握手升级(101),中间要靠心跳续命;Nginx 必须配 Upgrade 头,超时要大于心跳。
② WebSocket 没有同源限制,必须自己校验 Origin;鉴权放在 HandshakeInterceptor 里做。
③ 只推不收用 SSE,双向高频才用 WebSocket,低频用轮询。别为了实时而实时。
④ STOMP 给 WebSocket 加上了订阅与目的地语义;SimpleBroker 是单机的,集群要么换外部 broker,要么用 Redis 广播转发。
⑤ Actuator 默认只开 health/info,要什么显式开什么;env、heapdump、shutdown 永远别开。
⑥ 探针要轻、要快、liveness 与 readiness 要分开。把非关键依赖放进 liveness 是自毁式配置。
⑦ Boot 3 里文档用 springdoc,不要用 springfox;Swagger UI 生产必须关,JWT 用 SecurityScheme 配进去就能在页面里直接调。
1.(原理题)WebSocket 握手过程是怎样的?为什么它能穿过防火墙和反向代理?
查看答案
答案:客户端发一个带 Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key 的普通 HTTP GET 请求(端口和报文格式与普通 HTTP 完全一致,所以防火墙不会拦);服务端同意则返回 101 Switching Protocols 并带 Sec-WebSocket-Accept(值为 Base64(SHA1(key + 固定 GUID)));之后同一个 TCP 连接上开始跑 WebSocket 帧,不再有 HTTP 头。解析:关键点是"复用 HTTP 通道做协议升级",这也是它能不带额外端口开放就上线的根本原因。
2.(排错题)本地联调 WebSocket 一切正常,部署到测试环境后客户端每隔 60 秒断一次,日志里看到 CloseStatus: 1006。最可能的原因是什么?怎么排查?
查看答案
答案:几乎可以确定是中间代理的空闲超时。Nginx 默认 proxy_read_timeout 60s,60 秒内连接上没有数据传输就主动关闭,客户端收到的是异常关闭(1006 表示"无正常关闭帧")。排查:① 看 Nginx 配置里的 proxy_read_timeout 与 proxy_send_timeout;② 确认服务端心跳已开启(setHeartbeatValue + setTaskScheduler);③ 确认 proxy_set_header Upgrade $http_upgrade 和 Connection "upgrade" 都配了。修法:把代理超时调到大于心跳间隔(如心跳 10 秒、超时 300 秒)。解析:"本地好、线上断"的定时断开,十次有九次是代理超时。
3.(选择题)AI 对话的流式输出(只需要服务端往客户端推 token)最适合用哪个方案?A. WebSocket B. SSE C. 短轮询
查看答案
答案:B,SSE。理由:① 只需要单向推送,WebSocket 的双向能力用不上;② SSE 就是普通 HTTP,不用配代理升级、不用管心跳协商,还自带断线重连和 Last-Event-ID 断点续传;③ Spring MVC 里返回 SseEmitter 即可,实现成本极低。解析:选型先问方向、再问频率。双向才需要 WebSocket。
4.(场景题)用 SimpleBroker 部署了 3 个实例,用户 A 连到实例 1。一个定时任务跑在实例 2 上、调用 convertAndSend 推消息,A 收不到。为什么?两种解法是什么?
查看答案
原因:SimpleBroker 是进程内内存 broker,它只知道连到本实例的会话。实例 2 的 broker 里根本没有用户 A 的会话,消息推了个寂寞。解法一:换成外部 broker,用 registry.enableStompBrokerRelay("/topic", "/queue") 指向 RabbitMQ 或 ActiveMQ 的 STOMP 支持,所有实例共享同一个 broker。解法二:保持 SimpleBroker,但用 Redis Pub/Sub 做实例间广播——定时任务把消息发到 Redis 频道,每个实例订阅频道,收到后调 SimpMessagingTemplate 推给本机连接的用户。解析:这跟"本地测试全对、上线丢消息"是同一个根因,也是 WebSocket 集群化的必修课。
5.(安全题)为什么 management.endpoints.web.exposure.include="*" 很危险?如果运维坚持要全开怎么办?
查看答案
危险点:* 会连 /actuator/env(配置项与属性源,可能含明文密钥)、/actuator/configprops、/actuator/heapdump(堆转储里能捞出内存中的密码和 token)、/actuator/threaddump、/actuator/shutdown(远程关机)一起暴露。heapdump 尤其致命:它会把 JVM 内存完整落盘,脱敏在此时完全没有意义。折中方案:① 管理端点走独立端口 management.server.port 且 management.server.address=127.0.0.1,或绑定内网网卡;② 用 Spring Security 单独一条 SecurityFilterChain(securityMatcher("/actuator/**"))要求运维角色认证;③ 通过网关屏蔽外部对管理端口的访问;④ 真的需要排查时用 SSH 隧道或临时开启,用完立刻关。解析:面试官想听的是"你能在安全和可用性之间给出可执行的折中方案",而不是简单说"不行"。