楼层: 首页/ 软件技术/ Spring 核心框架/ WebSocket / STOMP 与 Actuator + springdoc:把服务推出去、把状态看出来
14

WebSocket / STOMP 与 Actuator + springdoc:把服务推出去、把状态看出来

WebSocket · STOMP · Actuator · OpenAPI

前面十三章讲的都是"客户端来问、服务端来答"。这一章要解决两个新问题:服务端怎么主动把消息推给客户端(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-AcceptSec-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 把原理跑通。

原生端点:三个类搞定

// 1) Handler:每个连接对应一个 WebSocketSession,自己处理消息 @Component public class EchoHandler extends TextWebSocketHandler { // 用 CopyOnWriteArraySet 存会话:WebSocketSession 不是线程安全的 private static final Set<WebSocketSession> SESSIONS = new CopyOnWriteArraySet<>(); @Override public void afterConnectionEstablished(WebSocketSession session) { SESSIONS.add(session); // 从握手阶段的 attributes 里取用户信息(需要在 HandshakeInterceptor 里放进去) Object userId = session.getAttributes().get("userId"); log.info("连接建立 userId={}, 当前在线 {}", userId, SESSIONS.size()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // 单发:session.sendMessage 需要传一个 WebSocketMessage session.sendMessage(new TextMessage("echo: " + payload)); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { SESSIONS.remove(session); // 必须移除,否则内存泄漏 } // 群发:遍历所有连接推送(集群下这么做只能推到本机,见下一节) public void broadcast(String text) { TextMessage msg = new TextMessage(text); for (WebSocketSession s : SESSIONS) { if (s.isOpen()) { try { s.sendMessage(msg); } catch (IOException e) { SESSIONS.remove(s); } } } } }
// 2) 握手拦截器:在这里做鉴权(WebSocket 没别的鉴权时机) @Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { // 1) 校验 Origin,防 WebSocket 版的 CSRF(浏览器不会自动帮你防) String origin = request.getHeaders().getOrigin(); if (origin == null || !origin.endsWith("example.com")) { response.setStatusCode(HttpStatus.FORBIDDEN); return false; // false = 拒绝握手 } // 2) 从查询参数或 ticket 里取令牌并校验 String ticket = UriComponentsBuilder .fromUri(request.getURI()).build() .getQueryParams().getFirst("ticket"); Long userId = authTicketService.consume(ticket); // 一次性票据 if (userId == null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put("userId", userId); // 传给 Handler 用 return true; } @Override public void afterHandshake(ServerHttpRequest req, ServerHttpResponse res, WebSocketHandler h, Exception ex) { } }
// 3) 注册:把 handler 挂到路径上 @Configuration @EnableWebSocket public class WsConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(echoHandler, "/ws/echo") .addInterceptors(authHandshakeInterceptor) .setAllowedOrigins("https://app.example.com"); // 别写 "*" // 装消息大小上限,防止有人发一个 100MB 的帧把你打爆 ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(64 * 1024); container.setMaxBinaryMessageBufferSize(64 * 1024); container.setMaxSessionIdleTimeout(300_000L); } }
原生 WebSocket 的四个坑

① 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 的核心概念只有四个,理解它们就理解了一切:

概念类比说明
帧 FrameHTTP 的请求行文本格式:命令 + 头 + 空行 + 体。命令有 CONNECT、SUBSCRIBE、SEND、MESSAGE、DISCONNECT。
目的地 Destination消息队列的 topic以 / 开头的字符串,如 /topic/orders。以 /topic 开头是广播,/queue 通常按点对点理解(但 Spring 的 SimpleBroker 里两者都当作广播,语义差别要自己维护)。
@MessageMapping@RequestMapping服务端接收消息的入口,路径前缀由 setApplicationDestinationPrefixes 决定(默认 /app)。
SimpMessagingTemplate服务端的"广播喇叭"在任意地方(包括非 WebSocket 线程,如定时任务、MQ 消费者)往某个目的地推消息。

STOMP 配置:Broker + 端点 + 前缀

@Configuration @EnableWebSocketMessageBroker public class StompConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 1) 服务端出口:以 /topic 开头的是广播,/queue 开头的是点对点 registry.enableSimpleBroker("/topic", "/queue") .setHeartbeatValue(new long[]{10_000, 10_000}) // {服务端发, 客户端发} 毫秒 .setTaskScheduler(heartbeatScheduler()); // 心跳必须有调度器,否则不生效 // 2) 客户端入口前缀:客户端发到 /app/xxx 会被路由到 @MessageMapping("/xxx") registry.setApplicationDestinationPrefixes("/app"); // 3) 点对点前缀:配合 convertAndSendToUser 使用 registry.setUserDestinationPrefix("/user"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") // 握手地址 .setAllowedOriginPatterns("https://*.example.com") // SockJS:给不支持 WebSocket 的老浏览器降级到 xhr-streaming 等方案 .withSockJS() .setHeartbeatTime(10_000L); } @Bean public TaskScheduler heartbeatScheduler() { var s = new ThreadPoolTaskScheduler(); s.setPoolSize(2); s.setThreadNamePrefix("ws-heartbeat-"); s.initialize(); return s; } }

服务端:接收消息与主动推送

@Controller public class ChatController { private final SimpMessagingTemplate messaging; public ChatController(SimpMessagingTemplate messaging) { this.messaging = messaging; } // 1) 客户端发到 /app/chat.send,@MessageMapping 收到 /chat.send @MessageMapping("/chat.send") public void handle(@Payload ChatMessage msg, Principal principal) { // STOMP 会话里的身份 msg.setFrom(principal.getName()); msg.setTime(Instant.now()); // 广播到 /topic/room.{id},所有订阅了这个目的地的客户端都会收到 messaging.convertAndSend("/topic/room." + msg.getRoomId(), msg); } // 2) 点对点:只推给某个用户。目的地要写 /user/{name}/queue/xxx public void notifyUser(String username, String text) { messaging.convertAndSendToUser(username, "/queue/notice", text); } // 3) 想用注解返回式推送(返回值自动发到指定目的地) @MessageMapping("/ping") @SendTo("/topic/pong") public String ping() { return "pong"; } }
STOMP 的五个坑,全都踩过

① 心跳配了但不生效: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。

引入与暴露:默认只开两个端点

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
management: endpoints: web: exposure: # 默认只暴露 health,info。要开别的必须显式写。 # 别偷懒写 "*" —— 那等于把 env/heapdump/threaddump 全公开了。 include: health,info,metrics,prometheus,loggers exclude: env,beans,configprops,heapdump,threaddump,shutdown base-path: /actuator # 可以改成 /_internal 之类,降低被扫概率 endpoint: health: show-details: when-authorized # 关键!默认 never,详情里可能含数据库地址 probes: enabled: true # 开启 k8s 存活/就绪探针端点 info: env: enabled: true # 允许 /actuator/info 读取 info.* 配置 info: app: name: order-service version: @project.version@ # Maven 资源过滤注入版本号 java: version: ${java.version} server: port: 8081 # 强烈建议:管理端点走独立端口,只在内网开放 address: 127.0.0.1
端点返回什么暴露建议
/health整体健康状态 + 各组件明细(DB、Redis、磁盘、自定义检查)可以暴露 给 LB / K8s 探针用,但不要 show-details: always,那会泄露内网地址与依赖清单。
/info应用元信息:版本号、构建时间、Git 提交号可以暴露。配合 git-commit-id-maven-plugin 能自动带上 commit 与分支,排查"线上到底是哪个版本"极有用。
/metrics / /prometheusJVM、HTTP、数据库连接池、自定义指标限内网。给 Prometheus 抓取用,必须走内网或加认证。
/loggers查询与动态修改日志级别限内网。线上排障神器(不改包重启就能开 DEBUG),但也是"能改变运行行为"的写操作。
/env / /configprops所有配置项与属性源不要暴露。会泄露数据库密码、密钥、第三方 AK/SK(虽然有脱敏,但不完全可靠)。
/heapdump / /threaddump堆转储、线程快照绝对不要暴露。heapdump 里能捞到内存中的明文密码与 token。
/shutdown关闭应用(POST)默认禁用且不要开启。要优雅停机用 server.shutdown=graceful,不要开这个远程关机按钮。

自定义 HealthIndicator:把"业务健康"也纳入探针

// 场景:不只要 JVM/DB 活着,还要保证"依赖的下游服务可用"才算健康 @Component public class DownstreamHealthIndicator implements HealthIndicator { private final RestClient restClient; public DownstreamHealthIndicator(RestClient restClient) { this.restClient = restClient; } @Override public Health health() { long start = System.currentTimeMillis(); try { // 探针必须"快 + 轻",别在里面跑重查询,否则会拖垮自己 restClient.get().uri("/ping") .retrieve().toBodilessEntity(); return Health.up() .withDetail("latencyMs", System.currentTimeMillis() - start) .build(); } catch (Exception e) { // 非关键依赖:用 OUT_OF_SERVICE 而不是 DOWN,避免整机被 LB 摘掉 return Health.status(Status.OUT_OF_SERVICE) .withDetail("error", e.getMessage()) .build(); } } }

论探针不是"越全越好":三个必须克制的理由

① 探针决定流量,误判会造成雪崩:如果健康检查里包含一个"非关键"的下游服务,那个服务一抖,所有实例全部变成 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。

依赖与基础配置

<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.6.0</version> <!-- 2.x 对应 Boot 3;具体补丁号以仓库最新稳定版为准 --> </dependency>
springdoc: api-docs: path: /v3/api-docs # OpenAPI JSON 的默认路径 swagger-ui: path: /swagger-ui.html # UI 入口;默认会 302 到 /swagger-ui/index.html operations-sorter: method # 按 HTTP 方法排序,比按字母序好找 tags-sorter: alpha # 生产环境务必关掉,或者只在内网 profile 打开 enabled: false

注解:给接口补上"机器猜不出来"的信息

@RestController @RequestMapping("/api/products") @Tag(name = "商品管理", description = "商品的增删改查") @SecurityRequirement(name = "bearerAuth") // 声明本类接口都要带 JWT public class ProductController { @Operation(summary = "查询商品详情", description = "按 ID 查询;商品不存在时返回 404") @ApiResponses({ @ApiResponse(responseCode = "200", description = "查询成功"), @ApiResponse(responseCode = "404", description = "商品不存在", content = @Content(schema = @Schema(implementation = ErrorResp.class))) }) @GetMapping("/{id}") public ProductVO get( @Parameter(description = "商品 ID", example = "1001", required = true) @PathVariable Long id) { return productService.get(id); } } // DTO 上用 @Schema 描述字段,UI 上会显示示例值与格式 @Schema(description = "商品视图对象") public record ProductVO( @Schema(description = "商品 ID", example = "1001") Long id, @Schema(description = "商品名", example = "机械键盘") String name, @Schema(description = "价格(分)", example = "29900") Long priceInCents ) { }

把 JWT 认证配进 Swagger UI,实现"文档里直接调通"

@Configuration public class OpenApiConfig { @Bean public OpenAPI openAPI() { final String schemeName = "bearerAuth"; return new OpenAPI() .info(new Info() .title("订单服务 API") .version("v1") .description("订单与商品相关接口")) // 声明一个 Bearer JWT 的安全方案 .components(new Components().addSecuritySchemes(schemeName, new SecurityScheme() .name(schemeName) .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT"))); } }
文档与可观测性的五个坑

① 生产环境暴露 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 与可观测性一句话总结

① 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 配进去就能在页面里直接调。

章末面试 · WebSocket 与可观测性(5 题)

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 隧道或临时开启,用完立刻关。解析:面试官想听的是"你能在安全和可用性之间给出可执行的折中方案",而不是简单说"不行"。