在实时交互需求越来越强的今天,"一次请求、持续返回"正在成为很多业务场景的标配:AI 对话逐字输出、日志实时推送、任务执行进度播报、行情订阅、设备状态上报等。相比传统的"请求-响应"模式,这类场景对后端提出了新的要求:连接要保持、数据要增量、延迟要低、资源要可控 。
在 Spring Boot 技术栈里,SSE(Server-Sent Events)是最轻量、最易落地的一种流式方案,而 WebClient 则是构建上游流式调用与下游透传的关键组件。本文将从原理、架构、代码、排障、优化、生产实践六个层面,完整讲透 **"基于 WebClient 的 SSE 流式接口"**怎么做、怎么稳、怎么上线。
一、为什么是 SSE:先把技术选型讲清楚
在做流式接口前,团队通常会在 SSE、WebSocket、长轮询之间做选择。
1. SSE 的特点
SSE 基于 HTTP,服务端以 text/event-stream 持续向客户端推送消息。浏览器原生支持 EventSource,实现简单,尤其适合"服务端单向推送"的场景。
优点:
- 基于 HTTP,穿透代理和网关相对容易;
- 前端接入成本低,浏览器原生支持;
- 文本流天然适合逐段输出;
- 自动重连机制友好(前端可控)。
限制:
- 主要是单向推送(客户端到服务端不适合高频双向);
- 对二进制数据不友好;
- 连接管理、超时、代理缓冲需要额外关注。
2. WebSocket vs SSE
- 需要双向实时交互(如协同编辑、IM)优先 WebSocket;
- 需要"后端持续输出、前端展示"优先 SSE;
- 若你在做 AI 生成式输出、任务进度流,SSE 往往是更低成本方案。
3. 为什么要配合 WebClient
很多企业场景不是"本地生成流",而是"调用上游流式接口,再转发给前端"。
例如:你的 Spring Boot 服务调用大模型接口(也是流式),再进行鉴权、审计、脱敏、埋点后,SSE 输出给前端。
此时 WebClient 的响应式流处理能力(Flux)就非常关键。
二、核心架构:上游流式输入 + 中间层治理 + 下游 SSE 输出
典型链路如下:
- 前端发起 /api/stream 请求;
- Spring Boot Controller 返回 SSE;
- Service 内部用 WebClient 调用上游流式接口;
- 将上游数据解析为 Flux<Event>;
- 做中间处理(过滤、重试、限速、审计);
- 以 SSE 事件持续写回前端;
- 完成后发送结束事件并关闭连接。
这个架构的核心价值是:
把"流"作为一等公民贯穿全链路,而不是把流拆成多次普通请求。
三、项目准备:依赖与基础配置
先确保你使用的是 Spring Boot WebFlux(不是传统 Spring MVC)。
1)Maven 依赖
xml
<dependencies> <!-- WebFlux + Reactor + WebClient --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <!-- 可选:lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 可选:测试 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
2)application.yml(关键超时)
yaml
server: port: 8080 spring: codec: max-in-memory-size: 10MB
生产中你还要配置 Reactor Netty 的连接超时、读写超时、连接池参数,后文会讲。
四、第一版可运行代码:从 0 到 1 打通 SSE
1)定义 WebClient Bean
java
@Configuration public class WebClientConfig { @Bean public WebClient webClient(WebClient.Builder builder) { return builder .baseUrl("https://example-upstream.com") .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } }
2)Service:调用上游流并转为 Flux
假设上游返回的是逐行文本流(每行一段 JSON 或文本):
java
@Service @RequiredArgsConstructor public class StreamService { private final WebClient webClient; public Flux<String> upstreamStream(String prompt) { Map<String, Object> body = Map.of("prompt", prompt, "stream", true); return webClient.post() .uri("/v1/generate") .bodyValue(body) .retrieve() .bodyToFlux(String.class) // 视上游格式可换成 DataBuffer/自定���DTO .filter(line -> line != null && !line.isBlank()); } }
3)Controller:输出 SSE
java
@RestController @RequestMapping("/api") @RequiredArgsConstructor public class StreamController { private final StreamService streamService; @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> sse(@RequestParam String prompt) { return streamService.upstreamStream(prompt) .map(chunk -> ServerSentEvent.<String>builder() .event("message") .data(chunk) .build()) .concatWith(Flux.just( ServerSentEvent.<String>builder() .event("done") .data("[DONE]") .build() )); } }
到这里,一个最小可用版本就完成了。浏览器访问后会持续收到事件。
五、前端对接:最小可用示例
html
<script> const es = new EventSource('/api/sse?prompt=你好'); es.onmessage = (e) => { console.log('message:', e.data); }; es.addEventListener('done', (e) => { console.log('done:', e.data); es.close(); }); es.onerror = (e) => { console.error('sse error', e); es.close(); }; </script>
注意:EventSource 默认是 GET 请求。如果你需要 POST 传复杂参数,常见做法是:
- 先 POST 创建任务拿 streamId,再 GET /sse/{streamId} 订阅;
- 或前端改用 fetch + ReadableStream(不走 EventSource)。
六、企业实战增强:错误处理、心跳、取消、重试
最小版能跑,但离生产可用还差很远。
1)错误处理(给前端可理解事件)
java
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> sse(@RequestParam String prompt) { return streamService.upstreamStream(prompt) .map(data -> ServerSentEvent.builder(data).event("message").build()) .onErrorResume(ex -> Flux.just( ServerSentEvent.builder("stream failed: " + ex.getMessage()).event("error").build() )) .concatWith(Flux.just(ServerSentEvent.builder("[DONE]").event("done").build())); }
2)心跳保活(防止中间层断连)
某些网关/代理会在长时间无数据时断开连接。可定期发心跳:
java
Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration.ofSeconds(15)) .map(i -> ServerSentEvent.<String>builder().event("ping").data("heartbeat").build()); return Flux.merge( businessFlux, // 业务流 heartbeat ).takeUntil(event -> "done".equals(event.event()));
3)客户端取消感知
当前端关闭连接,后端应尽快释放资源。可在 doOnCancel 中记录并清理:
java
return streamService.upstreamStream(prompt) .doOnCancel(() -> log.info("client cancelled, prompt={}", prompt)) .map(...) .doFinally(signal -> log.info("stream finished: {}", signal));
4)重试策略(谨慎)
流式接口重试要非常小心,避免重复输出。可只对"建立连接失败"重试:
java
.retryWhen(Retry.backoff(2, Duration.ofMillis(300)) .filter(ex -> ex instanceof WebClientRequestException));
七、性能与稳定性:生产环境必须关注的 8 件事
- 网关缓冲关闭:Nginx 要关闭代理缓冲(proxy_buffering off;),否则"流"会变"攒一坨再发"。
- 超时合理化:读取超时不能按普通接口设置得太短。
- 连接池上限:高并发 SSE 会占用连接,要调大连接池并限制单用户并发。
- 背压与限流:上游过快、下游过慢时要有节流策略。
- 消息大小控制:避免超大 chunk,建议分片输出。
- 日志采样:流式接口日志量大,避免全量打印 chunk。
- 安全审计:对输出内容做脱敏、敏感词过滤。
- 可观测性:埋点首包延迟、平均流时长、中断率、完成率。
八、常见问题排查清单(非常实用)
- 前端收不到流,只在最后一次性收到?看网关是否缓冲、压缩是否干扰、响应头是否正确。
- 浏览器频繁断开重连?看心跳间隔、代理空闲超时、服务端是否抛异常。
- 中文乱码?确保 UTF-8,响应头与消息编码一致。
- 连接数暴涨?检查前端是否重复创建 EventSource 未 close。
- CPU 高?检查是否对每个 chunk 做重计算/重序列化。
九、一个更贴近 AI 场景的输出协议建议
建议统一事件类型,前端更好处理:
- start:流开始(带 requestId)
- delta:增量文本
- meta:token 用量、阶段信息
- error:错误信息
- done:结束标记
这样前后端协议稳定,后续接入多模型/多上游也不容易乱。
基于 Spring Boot + WebClient 实现 SSE 流式接口,本质是把"实时返回"能力工程化:
不仅要"能流式输出",更要"可治理、可观测、可扩展"。
你可以把它理解为三步:
- 用 WebFlux 把流打通;
- 用策略把稳定性补齐;
- 用指标把生产闭环建立。
做到这三点,SSE 就不再是 demo 技术,而是可承载核心业务的正式能力。