前言:
最近在给公司搭建一些复用的基础服务,给主业务集成网关来进行请求转发时遇到了SSE出现了响应流得等待sseEmitter.complete()后才会一次性全部响应,导致SSE请求在网络链路中变成一个响应阻塞请求。
依赖版本
SpringCloud:2025.0.0 对应的SpringCloudGatewayServerWebMVC:4.3.0
代码配置
上游简单的网关
yml
spring:
cloud:
gateway:
server:
webmvc:
routes:
- id: bs-notify
uri: http://127.0.0.1:8082
predicates:
- Path=/api/bs/notify/**
filters:
- StripPrefix=3
下游服务API
kotlin
//一个间隔发送的sse请求
@GetMapping("subscribe")
fun subscribe(): SseEmitter = UTF8SseEmitter(0).also {sseEmitter ->
CoroutineScope(Dispatchers.IO).launch {
var count = 0
while (isActive) {
sseEmitter.send("你好${count++}")
if (count == 4) {
sseEmitter.complete()
return@launch
}
delay(1.seconds)
}
}
}
//带上字符集声明避免一些客户端没有自动处理,中文乱码
class UTF8SseEmitter(timeout: Long) : SseEmitter(timeout) {
override fun extendResponse(outputMessage: ServerHttpResponse) {
super.extendResponse(outputMessage)
outputMessage.headers.contentType = MediaType(MediaType.TEXT_EVENT_STREAM, StandardCharsets.UTF_8)
}
}
原因分析和诊断
猜测原因
出现问题后认为可能Gateway不是Webflux的版本所以在转发时会进行阻塞等待下游完全响应后才响应,但是考虑到2025.0.0的版本中的Gateway-MVC并没有很老所以应该会适配这种不是很罕见的事件流的场景才对,然后上Gateway的Issues中搜素发现了Server Side event Responses from a server behind Spring-cloud-gateway-server-mvc · Issue #3410 · spring-cloud/spring-cloud-gateway这个24年7月的Issue并且已经解决了。
在此Issue中得知MVC版本本身确实会阻塞等待响应体缓冲区写入完成才响应,所以会出现不兼容情况,比如上述的SSE阻塞问题。

证实猜测
在最下方能找到Adds flushing after each buffer write in RestClientProxyExchange in c... · spring-cloud/spring-cloud-gateway@6a1b21f的提交,并且在4.16标记已经完成

我们可以看到之前的mvc版本实际上是通过StreamUtils.copy(inputStream, httpServletResponse.getOutputStream())阻塞的等待下游响应完成后才网上游进行转发回客户端,修复是创建了一个HttpUtils类封装了响应操作 对响应进行了简单的识别分流,如果下游响应中是SSE的Content-Type则进行刷新式的实时写入
java
private static int copyResponseBodyWithFlushing(InputStream inputStream, OutputStream outputStream) throws IOException {
int readBytes;
var totalReadBytes = 0;
var buffer = new byte[BUFFER_SIZE];
while ((readBytes = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, readBytes);
outputStream.flush();
if (totalReadBytes < Integer.MAX_VALUE) {
try {
totalReadBytes = Math.addExact(totalReadBytes, readBytes);
}
catch (ArithmeticException e) {
totalReadBytes = Integer.MAX_VALUE;
}
}
}
outputStream.flush();
return totalReadBytes;
}
pull/3486在这个Issues关联的pull历史中我们找到了最终的版本, 5b73fdb中重构抽象了出了一个AbstractProxyExchange.java统一代理操作的类,并且创建了GatewayMvcProperties.java配置,统一了流响应类型的管理,并不是之前写死的equals
java
protected int copyResponseBody(ClientHttpResponse clientResponse, InputStream inputStream, OutputStream outputStream) throws IOException {
Assert.notNull(clientResponse, "No ClientResponse specified");
Assert.notNull(inputStream, "No InputStream specified");
Assert.notNull(outputStream, "No OutputStream specified");
int transferredBytes;
if (properties.getStreamingMediaTypes().contains(clientResponse.getHeaders().getContentType())) {
transferredBytes = copyResponseBodyWithFlushing(inputStream, outputStream);
}
else {
transferredBytes = StreamUtils.copy(inputStream, outputStream);
}
return transferredBytes;
}
诊断问题所在
至此,流式响应的适配就已经结束了。但是为什么我响应的时候也有SSE的Content-Type无法被识别呢?通过Debug发现streamingMediaTypes中的流响应类型定义是默认的MediaType.TEXT_EVENT_STREAM,我的Content-Type多了个UTF8的编码声明,所以还是走的StreamUtils.copy(inputStream, outputStream),可能当时适配的时候没考虑Content-type中还有别参数的情况,就比如后面还有个字符集的编码声明。🤪

解决办法
妥协 (不推荐)
和MediaType.TEXT_EVENT_STREAM定义对应,不带上字符集编码参数去适配类型
kotlin
@GetMapping("subscribe")
fun subscribe(): SseEmitter = SseEmitter(0).also {sseEmitter ->
CoroutineScope(Dispatchers.IO).launch {
var count = 0
while (isActive) {
sseEmitter.send("你好${count++}")
delay(1.seconds)
if (count == 4) {
sseEmitter.complete()
return@launch
}
}
}
}
缺点是比如直接浏览器访问api的时候没有手动告知编码会导致预览的时候中文乱码
开发中前端EventSource会自动进行编码设置去适配中文不会受影响,移动端可以拿到数据后手动指定编码,不过这都不是好的结果,只是对于做Web开发来说可以自适应所以开发中是否有编码声明可能感知不强,并且将字符编码的责任交给客户端去猜测都是不可靠的
办法2:覆盖 (推荐)
我们上面提到了判断是否是流响应实际上是通过获取streamingMediaTypes判断的,他位于配置类中,所以我们可以进行覆盖,在上游网关中自定义响应流类型来适应下游
上游自定义响应流类型
yml
gateway:
server:
webmvc:
routes:
- id: bs-notify
uri: http://127.0.0.1:8082
predicates:
- Path=/api/bs/notify/**
filters:
- StripPrefix=3
streaming-media-types:
- text/event-stream;charset=UTF-8
下游保持UTF8的SseEmitter不变
kotlin
@GetMapping("subscribe")
fun subscribe(): SseEmitter = UTF8SseEmitter(0).also {sseEmitter ->
CoroutineScope(Dispatchers.IO).launch {
var count = 0
while (isActive) {
sseEmitter.send("你好${count++}")
delay(1.seconds)
if (count == 4) {
sseEmitter.complete()
return@launch
}
}
}
}
解决了的不改变下游代码时提供了灵活的定义的并且客户端可以显式的获取到字符集编码 
衍生
在群里和群友分享这个问题后有群友找到了另一个有趣的Issue github.com/spring-clou... ,目前相对好的办法就是通过覆盖自定义的方式来进行适配,这个Issue中提到了关于MediaType本身会有一些别的参数,比如字符集声明,做了一个更灵活的响应类型适配fix: improve media type comparison for streaming responses #3948 by jerolba · Pull Request #3969 · spring-cloud/spring-cloud-gateway
java
private static boolean isStreamingMediaType(List<MediaType> streamingMediaTypes, MediaType mediaType) {
for (var streamingMediaType : streamingMediaTypes) {
if (streamingMediaType.equalsTypeAndSubtype(mediaType)) {
return true;
}
}
return false;
}
把原本List的Contains比较这种较为粗暴的方式改为使用equalsTypeAndSubtype进行媒体类型匹配,忽略后面的参数,对于这种字符集声明的内容来说会很方便,因为本身的streamingMediaTypes中默认定义就包含了大部分的响应类型,不用去因为多参数就要直接整个覆盖的去适配,