SpringGateway-MVC对SSE转发出现阻塞响应问题的分析和解决

前言:

最近在给公司搭建一些复用的基础服务,给主业务集成网关来进行请求转发时遇到了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中默认定义就包含了大部分的响应类型,不用去因为多参数就要直接整个覆盖的去适配,

相关推荐
l***77522 小时前
总结:Spring Boot 之spring.factories
java·spring boot·spring
zhangphil2 小时前
Android宽高不均等Bitmap缩放为指定宽高FitCenter到正方形Bitmap,Kotlin
android·kotlin
x***01063 小时前
springboot中配置logback-spring.xml
spring boot·spring·logback
b***46244 小时前
IoT DC3 是一个基于 Spring Cloud 的开源的、分布式的物联网(IoT)平台本地部署步骤
物联网·spring cloud·开源
q***2514 小时前
Spring容器的开启与关闭
java·后端·spring
0***m8224 小时前
Maven Spring框架依赖包
java·spring·maven
K***43064 小时前
三大框架-Spring
java·spring·rpc
后端小张4 小时前
【JAVA 进阶】深入探秘Netty之Reactor模型:从理论到实战
java·开发语言·网络·spring boot·spring·reactor·netty
2501_941886867 小时前
边缘计算崛起:推动万物互联时代高效运算的新引擎
spring