1. 为什么流式接口要换 WebClient
先说一个我踩过的坑。早些年对接大模型平台的流式输出,我用 RestTemplate 硬写了一个轮询拉取的方案,结果线上服务频繁超时,某个慢请求甚至直接把 Tomcat 的线程池打满,整个应用跟着假死。后来换成 WebClient 做 SSE 流式消费,问题迎刃而解。这篇文章就围绕这个方案展开,讲清楚 WebClient 驱动 SSE 接口的完整玩法。
Spring Boot 项目里,SSE 全称 Server-Sent Events,是一个标准的长连接推送协议。服务端可以持续向客户端输出数据块,客户端不必发起多次请求,就能源源不断地收到文本增量。这个特性天然适合 AI 对话生成、日志实时推送、任务进度刷新这类场景。很多人误以为 SSE 只能从浏览器端通过 EventSource 接收,其实后端同样可以用 WebClient 来消费这类接口。我们作为中间服务,可以把上游的 SSE 流经过处理后转发给前端,也可以存库、做过滤、做聚合,自由度高很多。
那为什么选 WebClient,而不是继续用 RestTemplate?关键在于 RestTemplate 是同步阻塞模型。只要服务端不返回完整响应,调用线程就得一直等着,响应式流的核心价值完全没法发挥。WebClient 建立在 Reactor 之上,请求发出后立刻返回,后续数据以 Flux 数据流的形式逐块到达,每次有新的字节块都会触发一次回调,线程不被占用。你可以理解为 RestTemplate 是让你把地图全部下载完再开车,WebClient 是开着导航边往前走边更新路况,吞吐和延迟表现完全不在一个量级。
不过这也带来了一个思维转换:以前写代码是“拿返回值”,现在写代码是“订阅事件”。很多人第一次接触 WebClient 会觉得别扭,因为眼睛里看到的不是返回值,而是一堆 Lambda 回调。但当你真正理解 bodyToFlux 的机制之后,会发现流式处理的表达力比同步拉取强太多。
另外,Spring Boot 3.x 默认使用 WebFlux 的响应式栈,WebClient 在 WebMvc 工程里也能正常使用。如果你的项目是传统 Spring Boot Web 工程,不需要把整个项目迁移成响应式,单独引入 WebFlux 依赖、在业务代码里用同步阻塞方式去订阅 Flux 结果,同样可行,这也是大多数生产项目的折中做法。
2. WebClient 驱动 SSE 的底层链路解析
2.1 三个核心 API 的适用场景
WebClient 消费 SSE 接口主要涉及三个方法:retrieve()、exchangeToFlux()和bodyToFlux()。这三个方法前后关联,理解它们的分工是掌握流式客户端的第一步。
retrieve()是最顶层的方法,语义直白:发送请求、获取响应体。它内部做了一些默认处理,比如非 2xx 状态码默认抛 WebClientResponseException。对流式场景来说,直接调用retrieve().bodyToFlux(...)是最常见的写法,简单可靠。
exchangeToFlux()给了你直接操作 ClientResponse 的机会,适合需要手动判断状态码、读取响应头里的特殊信息、甚至处理重定向的场景。我一般只有在对接某些特殊平台,需要从响应头里取签名或会话标识时才用它。常规业务用 retrieve 就够了。
bodyToFlux()才是流式消费的核心。你要告诉它期望的响应体元素类型。如果上游发的是标准 SSE 格式,直接指定 ServerSentEvent.class,WebClient 会帮你按 SSE 协议解析事件边界,自动拆分出 event、id、data、retry 这些字段。如果上游虽然走 SSE 通道,但内容只是纯文本片段,也可以指定 String.class,一个元素可能对应一次数据分块,数量级偏高,需要自行拼接。
实际对接时我更喜欢 ServerSentEvent.class,因为它能结构化拿到事件类型和事件 ID,尤其是上游同时发送多个不同事件时(比如一个事件是文本增量,另一个事件是结束标记),这个类型带来的便利性无可替代。
2.2 SSE 数据格式与解析边界
SSE 的报文格式非常简单,每个事件由若干行组成,字段名和值之间用冒号分隔。常见的字段有 data、event、id、retry。事件与事件之间通过空行分隔。WebClient 底层的 HTTP 解码器识别到text/event-stream这个 Content-Type 后,会自动启用事件流解码逻辑,连续读出多个事件对象。
这里有个很容易忽视的细节:SSE 字段行结尾必须是换行符\n,并且data字段的值如果是多行文本,每一行都要以 data: 开头。有些服务端实现不严谨,字段行用了\r\n或者漏了最后的空行,就会导致解析错乱。遇到这种情况,优先向上游反馈格式问题,而不是在客户端强行容错。
我在对接某个开源模型网关的时候,它的 SSE 流里每隔一段时间会发一条注释行: keep-alive来维持连接。WebClient 对这类注释行会直接忽略,不会混入业务数据中,处理得很干净。但反过来,如果你手动按字符串切割事件边界,这类注释行往往会干扰你的拆分逻辑。所以如果你的上游 SSE 格式相对标准,直接交给 WebClient 解析是最稳的方案。
2.3 超时、缓冲与背压的配置逻辑
SSE 流式接口的特性决定了连接的生命周期很长,可能持续几十秒甚至几分钟。这个时候,HTTP 客户端的默认超时配置就非常不适用。默认情况下,WebClient 底层用的 Reactor Netty 对读取空闲时间有限制,一旦超过阈值没有数据,连接就会被判定为超时,直接中断。
解决办法是自定义 HttpClient 实例。核心配置有三块:
第一,连接超时(connectTimeout)控制建连阶段的最长等待时间,一般设置 5 到 10 秒足够。
第二,读取超时(readTimeout)控制的是相邻两次数据块到达的最大间隔。对于 SSE 场景,这个值不能太小,因为很多服务端在没有数据时会静默保持连接,不发任何字节。如果设置成 3 秒,很容易误杀正常连接。但如果不开心跳保活,设置一个很大的读取超时又会让连接假死。所以最合理的做法是把 readTimeout 设置成一个较大的值,同时依赖应用层的心跳机制来检测死连接。
第三,maxInMemorySize 控制单次数据块的最大字节数。默认值是 256KB,如果上游某次推送的数据特别大,比如一次性推送一整段完整结果,直接就把缓冲撑爆了。这类异常的表现是 DataBufferLimitException,经常被误判成网络问题。我在生产环境统一设置成 1MB,基本能覆盖绝大多数文本生成服务的单次输出。
背压方面,Reactor 天然支持响应式流背压。也就是说,消费者处理速度跟不上的时候,可以通过 limitRate 操作符限制上游发射速率,避免内存被瞬时涌来的数据塞满。SSE 长连接场景下,下游处理(比如写库、转发)如果比上游生成慢,这个机制能有效防止 OOM。
3. 从零搭建一个可落地的 SSE 流式调用工程
3.1 工程结构与依赖准备
先说清楚适用场景。我这边是要对接一个第三方大模型服务的流式生成接口,对外暴露成一个 JSON 格式的文本增量流给前端消费。整个链路是:大模型 SSE 接口 → 我的后端 WebClient 消费 → 格式转换 → 我的后端 SSE 接口 → 前端 EventSource 接收。
首先要引入依赖。如果你用的是 Spring Boot 3.x,直接加一个依赖即可:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>在传统的 WebMvc 工程里引入 WebFlux 依赖本身是安全的。但要注意,如果项目同时引入了 spring-boot-starter-web 和 spring-boot-starter-webflux,Spring Boot 会自动判定应用类型。默认优先级是 WebMvc 优先,WebFlux 的自动配置会被跳过。这种情况下 WebClient 类依然存在,只是它的自动配置有可能不生效。所以我这里选择了手动创建 WebClient Bean,避免踩自动配置的坑。
3.2 初始化一个生产可用的 WebClient 客户端
下面是创建 WebClient 实例的核心代码。我会逐段解释每个配置背后的原因。
import io.netty.channel.ChannelOption; import io.netty.handler.timeout.ReadTimeoutHandler; import io.netty.handler.timeout.WriteTimeoutHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; import reactor.netty.resources.ConnectionProvider; import java.time.Duration; @Configuration public class SseWebClientConfig { @Bean("sseWebClient") public WebClient sseWebClient() { // 连接池最大连接数,SSE场景下每条流式连接会长期占用一个连接, // 所以连接数上限要比普通HTTP接口场景设置得大一些。 ConnectionProvider provider = ConnectionProvider.builder("sse-pool") .maxConnections(500) .pendingAcquireTimeout(Duration.ofSeconds(30)) .maxIdleTime(Duration.ofSeconds(60)) .build(); HttpClient httpClient = HttpClient.create(provider) // 建立TCP连接的最长等待时间 .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) .doOnConnected(conn -> conn // 读取空闲超时,注意这个值是相邻两次数据包到达的最大间隔 .addHandlerLast(new ReadTimeoutHandler(60)) .addHandlerLast(new WriteTimeoutHandler(60)) ) // 响应式流的连接不能被读超时打断了, // 下面这个设置用于阻止连接在空闲时被回收 .keepAlive(true); return WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) .baseUrl("https://your-upstream-service.com") .build(); } }这里有几个关键配置我必须说明。
maxConnections(500)是我基于上游服务并发能力的估算结果。每条 SSE 长连接会一直占着连接池里的一个连接,线程不会被占用,但连接会。如果业务量超过了 500 路并发,后续请求会在连接池排队。这个值不能拍脑袋定,要看上游服务能撑住多少并发连接,以及你自身的业务峰值。
pendingAcquireTimeout(30s)表示当连接池没有空闲连接时,新请求最多等多久。如果超过 30 秒还拿不到连接,直接抛异常,避免无限等待拖垮业务线程。这个策略对用户体验影响很大,如果上游性能不行,宁可快速失败,也不要让用户卡住什么都不显示。
ReadTimeoutHandler(60)这个 60 秒是指相邻两次数据包的最大间隔。如果上游是一个比较实诚的服务,生成过程中每 10 秒会发一次心跳注释行,那这个 60 秒就非常安全。如果上游完全不发心跳,纯靠这个超时来保底,就需要把值调大一些,比如 120 秒甚至更大。我见过最坑的上游是生成过程中超过 3 分钟不发任何字节,最后直接吐完整结果,这种情况下固定超时几乎必然会误杀连接。对这种行为,只能用应用层心跳检测 + 主动重连来兜底。
3.3 使用 ServerSentEvent 消费流式内容
接下来是核心的消费代码。我这里定义了一个组件,专门负责订阅大模型的 SSE 流,并把文本增量逐块转发出去。
import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; import java.time.Duration; @Component public class SseStreamSubscriber { private final WebClient sseWebClient; public SseStreamSubscriber(WebClient sseWebClient) { this.sseWebClient = sseWebClient; } public Flux<String> subscribe(String prompt) { // 用Sinks.Many作为桥接,让外部消费者能订阅到内部的数据流 Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer(); sseWebClient.get() .uri("/v1/generate", uriBuilder -> uriBuilder .queryParam("prompt", prompt) .queryParam("stream", "true") .build()) .accept(org.springframework.http.MediaType.TEXT_EVENT_STREAM) .retrieve() .bodyToFlux(org.springframework.http.codec.ServerSentEvent.class) .doOnNext(event -> { // 有些上游通过event字段区分事件类型: // 比如event=delta表示增量,event=done表示结束 String eventType = event.event(); Object data = event.data(); if ("delta".equals(eventType)) { sink.tryEmitNext(String.valueOf(data)); } else if ("done".equals(eventType)) { sink.tryEmitComplete(); } }) .doOnError(error -> { // 异常时让下游也能感知到,而不是静默吞掉 sink.tryEmitError(error); }) .doOnComplete(() -> { // 上游主动关闭连接时,补一个完成信号 sink.tryEmitComplete(); }) .subscribe(); return sink.asFlux(); } }这段代码里有几个值得展开的地方:
Sinks.many().unicast().onBackpressureBuffer()是响应式世界里手动桥接的常用手段。WebClient的bodyToFlux方法返回的 Flux 是冷流,一旦被订阅,数据才开始流动。如果你直接在 Controller 里 return 这个 Flux,Spring WebFlux 会帮你处理订阅链,但如果你要把这个 Flux 存到某个缓存里复用,就必须用subscribe()主动触发,通过 Sinks 手动分发。
这里有个必须注意的细节:subscribe()必须放在最后。一旦调用 subscribe,数据流就处于活跃状态,无论有没有下游消费者,上游数据都在被消费。如果没人订阅 Sinks.asFlux(),数据就会在 Sinks 内部积压,积压到一定量后触发背压,上游拉取速度自动变慢。所以这个模式下,下游消费者必须尽快订阅,否则数据会在内存里堆积。
再一个要点是事件类型的区分。有些上游平台不区分事件类型,把所有数据都放在 event=message 里,这种情况下直接用 event.data() 就能拿到完整消息对象。但我在实战中确实遇到过一个平台,用 event 字段区分 delta、error、usage、done 四种事件。如果你不解析 event 字段,直接无脑把所有 data 塞给前端,前端会收到一堆听不懂的杂音。所以消费方一定要先搞清楚上游的事件协议,再决定怎么过滤。
3.4 对外暴露流式接口并解决异步线程池问题
后端拿到上游数据后,最终要把这个字符串流再通过 Spring Boot 的 SSE 接口吐给前端。Spring Boot 支持通过返回Flux<ServerSentEvent>直接构建 SSE 响应。但如果你的项目是传统 WebMvc 工程,直接返回响应式类型会报错,因为它依赖 WebFlux 的返回处理器。
Spring Boot 3.x 中,WebMvc 也支持了SseEmitter这个类,通过它你可以手动往响应流里写数据。把上面的 Sinks 桥接到 SseEmitter 上,代码大概长这样:
import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.time.Duration; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @RestController public class SseProxyController { private final SseStreamSubscriber subscriber; public SseProxyController(SseStreamSubscriber subscriber) { this.subscriber = subscriber; } @GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(@RequestParam String prompt) { // 默认超时 5 分钟,达到后自动断开连接 SseEmitter emitter = new SseEmitter(300_000L); ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); subscriber.subscribe(prompt) .doOnNext(text -> { try { emitter.send(SseEmitter.event().data(text).name("delta")); } catch (IOException e) { emitter.completeWithError(e); } }) .doOnError(emitter::completeWithError) .doOnComplete(emitter::complete) .subscribe(); // 心跳定时器,每 15 秒发一个注释事件,保证连接不被中间代理断开 scheduler.scheduleAtFixedRate(() -> { try { emitter.send(SseEmitter.event().comment("keep-alive")); } catch (IOException e) { emitter.completeWithError(e); scheduler.shutdown(); } }, 15, 15, TimeUnit.SECONDS); emitter.onCompletion(scheduler::shutdown); emitter.onTimeout(() -> { scheduler.shutdown(); emitter.complete(); }); return emitter; } }这个实现把响应式流桥接到了传统的 Servlet 异步模型上,适用于纯 WebMvc 工程。如果你用的是 WebFlux 工程,直接 return Flux 更简洁,不用手动管理 SseEmitter 的生命周期。不过既然是实战文章,我就默认大家多数情况下还在传统 WebMvc 工程里,这个方案通用性更强。
线程池的问题值得一提。当 SseEmitter 被创建后,数据写入是异步的。但如果你直接在 Controller 线程里订阅 SseEmitter,并且上游还没数据,Controller 线程就会被占用,而不是立即返回。所以实际项目中要把订阅动作放到独立线程池里执行。我的习惯是在项目里再定义一个专门执行外部调用任务的线程池,核心线程数 16、最大线程数 64、阻塞队列容量 1024,拒绝策略选择 CallerRunsPolicy。这样既能满足高并发下的吞吐要求,又不会因为队列满就直接丢弃任务。
拒绝策略这里需要解释一下。默认的 AbortPolicy 在队列满时会直接丢请求,对用户体验是灾难。CallerRunsPolicy 则是让当前线程自己执行这个任务,这样 Controller 线程会阻塞,但至少任务不丢。在流式接口场景里,任务不丢比实时性更重要,所以我推荐 CallerRunsPolicy。
4. 生产环境常见问题与排查技巧实录
4.1 DataBufferLimitException 缓冲溢出
这类异常在 SSE 流式调用中非常高频。很多人第一次遇到时完全摸不着头脑,因为报错信息长得像网络超时,但实际上就是数据块太大,超出了默认缓冲上限。
默认情况下,WebClient 对单个数据块的缓冲上限是 256KB。如果服务端一次推送了 300KB 的内容,连接直接中断。解决方式是在创建 WebClient 时给底层 HttpClient 显式设置数据缓冲区分配器的最大容量。我一般是用一个全局的 ExchangeStrategies 配置:
ExchangeStrategies strategies = ExchangeStrategies.builder() .codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(1 * 1024 * 1024)) .build(); WebClient.builder() .exchangeStrategies(strategies) .build();设置成 1MB 后,绝大多数场景都够用。但要注意,这个值是所有请求共享的,调太大也有风险,比如某个请求确实返回了异常大体积的二进制数据,缓冲溢出问题就被掩盖了。所以建议根据实际业务评估,不要无脑调大。
4.2 连接假死:既没超时也没数据
这类问题的表现是:连接建立成功,服务端不发数据,WebClient 也不报超时,整个调用卡死在那里。常见原因有两个:一是上游服务确实"假死了"(线程卡在某个 IO 上忘记了发数据),二是你的读超时设置过大,且上游没有心跳保活机制。
针对这个问题,我的做法是设计一个应用层看门狗。在订阅 pipeline 里加一个timeout(Duration.ofSeconds(N))操作符,只有当真正收到新事件时会刷新计时器,超过 N 秒没事件就主动报错断开。这里的 N 要根据业务特征设置,如果服务的平均生成间隔是 3 秒,设置 10 秒就比较合理。如果设置太小,反而会误杀正常慢响应。
bodyToFlux(ServerSentEvent.class) // 从上一个元素到达开始计时,超过 20 秒无元素则抛超时 .timeout(Duration.ofSeconds(20)) .retryWhen(...)配合 retryWhen 做有限次重试,就很稳了。
4.3 上游断连、重连风暴与退避策略
SSE 连接随时可能被中间代理断开,也可能因为上游重启而断开。断连后要不要重连,是一个策略选择问题。我的经验是对待 SSE 流式接口,要在客户端做一个"快速失败 + 有限重试"的策略。不要一断就连连重试,也不要彻底断开后默默等待。
Reactor 的 retryWhen 提供了丰富的重试机制:
.retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) .maxBackoff(Duration.ofSeconds(10)) .jitter(0.5))这个配置的含义是:最多重试 3 次,首次等待 2 秒,之后每次等待时间指数增长,但最多到 10 秒,每次等待加入 ±50% 的随机抖动。抖动(jitter)非常重要。如果没有抖动,所有失败请求会在同一时刻重试,给下游造成一波集中的流量尖峰,这就是典型的重连风暴。
我遇到过最离谱的情况是某平台凌晨发布版本,所有 SSE 连接同时断开,客户端全部在 1 秒后重连,直接把平台网关打挂。后来加了抖动,虽然整体恢复时间慢了那么几秒,但系统的稳定性明显好多了。
4.4 中文乱码、UTF-8 BOM 与文本错乱
SSE 是文本协议,编码必须统一。我曾经对接过一个内部系统,它的 SSE 流返回的是 GBK 编码的文本,我这边按 UTF-8 解码,中文全部变成问号。翻代码翻了半天,最后发现是服务端忘写编码声明。
WebClient 默认按响应头里的 Content-Type 决定解码字符集。如果你没有主动指定,而响应头里是text/event-stream; charset=utf-8,那就没问题。但有些老系统不写 charset,HTTP 默认就是 ISO-8859-1,中文必乱。这时候可以在 WebClient 的请求构建阶段显式指定:
webClient.get() .uri(uri) .accept(MediaType.TEXT_EVENT_STREAM) .acceptCharset(StandardCharsets.UTF_8) .retrieve() .bodyToFlux(String.class)还有一个隐性坑是 UTF-8 BOM。有些服务端会把 BOM 写在流的开头,导致第一个事件里混入一个\uFEFF字符。你用String.startsWith()判断开头时会因 BOM 而误判。稳妥的做法是在解析时先把开头的\uFEFF去掉,再做后续逻辑。
4.5 连接池耗尽与线程池抉择
WebClient 的底层连接池默认大小是 500,空闲回收时间是 60 秒。在 SSE 场景下,每条流式连接会长期占据一个连接,所以你的最大并发数直接受maxConnections限制。如果你的业务峰值是 1000 路并发,maxConnections就要相应调大。
但要记住,连接池不是越大越好。每条连接都有内存成本,且上游服务自己在同一时刻能维持的连接数也有上限。如果上游服务挂在 2GB 内存的边缘节点上,你开 2000 条连接,上游大概率直接 OOM。这里有两个实际决策点:一是容量评估要双方共同对好,二是连接池满后的排队时间不能太长。排队时间太长,用户体验直线下降。这也是为什么我上面配置了pendingAcquireTimeout(30s),超过时限先失败,再让前端做重试提示。
在实际运行中,我还会监控连接池状态,看当前活跃连接数是否接近 maxConnections 的 80%。如果持续在 80% 以上,就该考虑扩容或优化上游性能了。这类监控可以通过自定义连接池提供者的指标暴露接口实现,或者直接接 Micrometer,看 Reactor Netty 提供的reactor.netty.connection.provider.total和reactor.netty.connection.provider.active等指标。
4.6 消息乱序与最终一致性判断
SSE 协议本身不保证事件的到达顺序绝对和发送顺序一致,尤其在跨网络传输和多副本负载均衡的场景下。但在绝大多数内部接口调用中,底层 TCP 保证有序,所以乱序问题很少见。真正需要注意的是你自己处理数据的方式。
如果你在客户端里用了parallel()或flatMap()去并发处理事件,那么下游看到的顺序就可能错乱。SSE 流式返回的一个关键语义是:数据块到达的顺序就是最终文本拼接的顺序,一旦乱序,整段结果就废了。所以我强烈建议:SSE 流式消费全程使用concatMap(),保证每个事件按顺序处理完再处理下一个。只有在完全不依赖顺序的独立任务中才考虑用flatMap()。
我这边实测过的做法是,收到每个 delta 文本后,先累加到一个本地缓冲区,每满 10 个 delta 或者间隔 500 毫秒,就批量写入库里一次。这样既保证了顺序正确,又降低了高频写库带来的 IO 压力。流式接口的花活很多,但核心顺序逻辑一定要守住。
5. 一些实战后的经验沉淀
代码层面都梳理完了,最后聊几个我踩过多次坑换来的经验。
第一个经验是:对外部 SSE 接口,永远不要相信它不会断流。你写了再完美的连接管理,也必须考虑"服务端静默关闭连接"的情况。所以每次对接一个上游,第一件事是确认对方的心跳机制和断线通知方式,然后针对性设计重连策略。对方如果完全靠连接空闲超时来判定客户端死亡,那你客户端的心跳间隔就必须设得比对方短,否则连接会被对方优先回收,造成反复重连。
第二个经验是:WebClient 的配置不要太抠。连接数、超时时间、缓冲区大小,尽量给足余量。因为流式接口不像普通 HTTP 请求,失败重试的成本很高。一个已经执行到一半的生成任务,重连之后并不会接着生成,而是从头开始。所以宁可一次配置到位,也不要为了省几个连接导致整个业务流程失败。
第三个经验是:监控体系里必须加上流式连接的生命周期指标。连接建立数量、连接断开原因、平均连接时长、重连次数、单连接平均事件数,这些指标能帮你快速定位上游是不是稳定、客户端配置是否合理。一开始我没有接这些指标,出了问题只能在日志里大海捞针,费时又费力。后来加上了,很多问题还没被用户感知到就已经提前处理了。
另外有一个小技巧顺便分享一下。如果你的上游返回的 SSE 事件里,data 字段是 JSON 字符串,你最好直接用ServerSentEvent.class来接,然后手动把 data 字段转成目标对象。不要为了图省事直接接String.class,然后按\n\n切割自己翻 JSON。因为你自己切割很容易把多行 data 字段拆散,而且一旦 data 里含有\n结尾的 JSON 转义字符,切割逻辑会非常脆弱。让 WebClient 帮你解析好事件边界,你只做 JSON 反序列化,逻辑会干净很多。
就这些。如果你正在做类似的大模型对接、日志实时推送、任务进度轮询之类的功能,WebClient 这套 SSE 方案值得一试。有具体问题欢迎随时交流,我看到会回复。