在微服务架构里,Spring Cloud Gateway 作为统一流量入口,基本是标配了。平时转普通 HTTP 请求,改改路由规则就能跑,大家也都习以为常。可真当你遇到 SSE(Server-Sent Events,服务端推送事件流)的时候,网关那些隐藏的“脾气”就全都暴露出来了。我去年在做 AI 应用的后台管理端时,需要在浏览器里实时展示大模型返回的流式内容,前端用的 SSE,结果在网关这一层卡了整整两天。不是说请求不通,而是数据要么不回来,要么缓冲一大坨才回来,要么过几十秒就自动断开,各种玄学问题轮番上阵。这篇博客,我就把 Spring Cloud Gateway 转发 SSE 时踩过的坑、排查的思路、最后落地的解决方案,一次性整理出来。如果你是第一次在网关后面接 SSE,或者正被流式转发问题困扰,这篇文章应该能帮你省下不少时间。
1. 先搞明白:SSE 在网关中到底是怎么流转的
1.1 SSE 不是普通 HTTP 响应
很多人一看到 SSE,觉得“不就是 HTTP 长连接嘛,前端用 EventSource 接收数据,后端往响应流里写数据就行了”,这个理解方向没错,但实际操作上,SSE 和普通 HTTP 请求有很大差别。
普通 HTTP 请求是“一来一回”的模式:客户端发一个请求,服务端处理完返回完整响应,然后连接关闭。而 SSE 建立连接后,服务端可以持续地向客户端推送数据,连接保持打开,直到服务端主动关闭或客户端断开。从 HTTP 协议的角度来看,SSE 的响应头是 Content-Type: text/event-stream,并且响应是分块的(chunked),数据是一段一段发出去的,不是一次性组装完。
这个差别放在网关前面就麻烦了。网关作为中间层,它既要转发请求,又要转发响应。如果网关把后端回传的数据“攒起来”再一次性发给前端,那 SSE 就变成了“假流式”,用户体验大打折扣。更糟糕的是,网关注销机制的默认策略,往往就是把这“攒”的动作执行得过于积极。
1.2 网关转发的“代理”本质
Spring Cloud Gateway 内置的默认转发能力,是基于 Netty 的,它依赖 HttpClient 这个组件去和后端服务通信。官方文档里写得很清楚,它转发的是“HTTP 请求”和“HTTP 响应”,本身并不理解你传输的是 SSE 还是普通 JSON。关键在于,网关使用的 Netty HttpClient 里,有一堆隐式的参数在影响数据流的处理方式。
我用一个生活化的类比来解释。
想象一下,把网关想象成一个快递中转站。普通文件(HTTP 请求)到此站,拿上就送走;而 SSE 流式数据相当于一条输油管道,油(数据)应该持续不断地通过。中转站如果按照处理普通快递的方式,硬要等“攒满一箱”再发车,那这条管道自然就流不动了,或者流一会儿停一会儿。Spring Cloud Gateway 在转发 SSE 时的很多坑,本质就是中转站用“攒箱子”的方式去处理了“输油管道”。
因此,要解决转发问题,我们得从两个维度去调整:一是让网关别“攒箱子”(关闭缓冲),二是让中转站别“等太久”(调整超时和连接池)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 避坑第一关:响应缓冲与连接池榨干你的数据流
2.1 症状:前端拿不到“逐字输出”的效果
我最初用 Spring Cloud Gateway 转发 SSE 的请求,前端用的是原生 EventSource,后端是 Spring WebFlux 的 Flux<String>,在本地绕过网关测试的时候,数据是逐字渲染的。但一接上网关,两个问题立刻浮现:
第一个问题,前端长时间收不到任何数据,要等上五六秒,甚至更久,突然一次性渲染出一大片内容。这明显不是模型生成的速度慢,因为测试后端接口,数据是每秒钟都在往外吐的。
第二个问题,连接在 30 到 60 秒左右自动断掉,前端 onerror 触发,EventSource 自动重连,然后又开始新的一轮等待、接收、断开。
如果你也遇到这两个问题,大概率就是网关的缓冲和超时这两座大山压住了你的响应流。
2.2 根因分析:Netty 缓冲与 HTTP 连接池
Spring Cloud Gateway 默认的响应处理流程中,核心在于 NettyRoutingFilter。这个过滤器负责把网关接收到的请求转发给下游服务,同时也负责把下游服务的响应数据传递回客户端。
默认情况下,网关对响应体的处理不是“流式”的,它会从下游读取完整的数据字节,再生成一个 ServerHttpResponse 写回客户端。对于 SSE 这种长连接响应,这种“读完整再写回”的机制是致命的。虽然新版 Spring Cloud Gateway 对 text/event-stream 有特殊处理,会使用 writeWith 直接传输流,但实际执行过程中还是受底层 HttpClient 配置影响。
另外,连接池的配置也很关键。网关和下游服务之间建立的是 HTTP/1.1 连接,如果连接池太小,新请求会阻塞等待可用的连接,旧的 SSE 连接一直占着不放,资源就被慢慢耗尽。
2.3 解决方案:显式关闭缓冲与调整连接池参数
针对缓冲问题,最直接有效的方式是设置响应头 Cache-Control: no-cache 和 X-Accel-Buffering: no,并在网关配置中调整 HttpClient 的超时时间。其中 X-Accel-Buffering 本来主要是给 Nginx 用的,但有些网关服务器的代理组件也会参考这个头。
在 Spring Cloud Gateway 的 application.yml 里,一个关键的配置是:
yaml复制spring:
cloud:
gateway:
httpclient:
connect-timeout: 5000
response-timeout: 60000
pool:
max-connections: 500
max-idle-time: 30s
max-life-time: 60s
这里 response-timeout 设置的是单个响应读取的超时时间,如果后端一直有数据往外发,这个值并不会触发。但如果后端没有任何数据(连接空闲)超过这个时间,网关就会强制中断连接,你需要根据实际业务场景调整这个值。
同时,NettyRoutingFilter 底层默认使用 HttpClient 的响应连接。对于 SSE 这种流式响应,官方推荐禁用响应超时,设置为 -1 或 null,让连接一直存活,直到下游主动关闭。
yaml复制spring:
cloud:
gateway:
httpclient:
response-timeout: -1
把这个配置加上,再配合前端的重连机制,至少“连接 60 秒断开”的问题能得到明显缓解。
注意:
response-timeout: -1意味着网关永远不会主动切断与下游的连接,这需要你确信后端能正常管理连接生命周期。否则,如果后端程序崩溃且未关闭连接,网关这边会留下来一堆半开连接,反而增加运维负担。更稳妥的做法,是结合健康检查和客户端的主动断开逻辑。
3. 避坑第二关:超时配置一错,SSE 就“断气”
3.1 网关超时配置的两个层面
超时问题在 SSE 转发里非常普遍,而且它分为两个层面。
第一个层面是 HTTP Client 层面的超时,即网关和后端服务之间建立连接、读取数据的超时,典型参数就是我上面提到的 response-timeout。对这个参数,很多人的错误认知是“它等于总读取时长”,实际上它表示的是 ** 两次数据读取之间的最大空闲时间**。只要后端在每次设定的时间内都发一个字节的数据,连接就不会断。
第二个层面是 全局路由层面的超时,这是 Spring Cloud Gateway 中 Route 的 metadata 配置,它可以针对每个路由单独设置:
yaml复制spring:
cloud:
gateway:
routes:
- id: sse_route
uri: http://backend-service:8080
predicates:
- Path=/api/sse/**
metadata:
response-timeout: -1
connect-timeout: 5000
如果两个层面的超时都配置了,以更小值为准,这常常是坑的来源。我曾经在全局设置了 response-timeout: 60000,又在某个路由的 metadata 里配置了 response-timeout: 30000,结果忘记删掉全局配置,导致这个路由的 SSE 依然在 30 秒断开。
3.2 案例:链路空闲超时导致的“假死”
所谓假死,就是连接看着还在,前端 EventSource 的 readyState 是 OPEN,但数据永远不再传输了。这种问题的根源很多时候不在网关,而在更底层的网络链路。
我在排查一次线上问题时,发现网关到后端服务的连接空闲超过 50 秒后就会中断,但直接把后端服务暴露给公网测试,同样的网络环境下却不会断。后来用 tcpdump 抓包才发现,中间有一层负载均衡器,默认的空闲连接超时是 60 秒。HTTP 网关到负载均衡器的连接建立后,如果 60 秒内没有任何数据包传输,负载均衡器就会把连接关闭。
在这种情况下,网关的 response-timeout 调多大都没用,因为连接不是网关主动断的,而是中间链路断的。解决方式有两种:
一种是让网关定期发送心跳探测包,但是标准 HTTP/1.1 的 SSE 流里,很难在协议层主动发心跳。另一种更实用的方式是让后端业务代码主动发心跳注释。
java复制@GetMapping(path = "/api/sse/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream() {
return Flux.interval(Duration.ofSeconds(15))
.map(i -> ServerSentEvent.builder<String>()
.event("heartbeat")
.data("ping")
.build());
}
在这个示例中,每 15 秒发一个 heartbeat 事件,这样整个链路就不会因为空闲被中间层断开。这个办法几乎是所有 SSE 落地场景里最推荐的保活方案。
3.3 心跳与重连:客户端要做的事
服务端发心跳保活只是一半,客户端也要配合。浏览器原生 EventSource 有默认重连机制,但重连间隔是固定的 3 秒,而且重连时会带着 Last-Event-ID,这就带来一个新的坑。
如果你的网关开启了鉴权过滤器,重连时可能不会带上原来的 Authorization 头,导致重连请求被网关拦截,返回 401。前端就陷入“重连-失败-重连-失败”的死循环。这个问题的处理,我在第 4 节会详细展开,这里先给一个通用的前端判断逻辑:
javascript复制const es = new EventSource('/api/sse/stream');
es.onerror = (event) => {
// 根据 event.readyState 判断错误类型
// CONNECTING (0): 正在重连,不要立即销毁实例
// OPEN (1): 连接已建立,可能发生了内部错误
// CLOSED (2): 连接已关闭,可以尝试手动重建
};
如果是 401 这类鉴权错误,前端一般拿不到 readyState === CLOSED,因为 EventSource 发现连接被服务器关闭,会自动重新建立连接。这是个隐蔽的坑,从后端日志里,你只会看到 [401] 请求反复出现。
4. 避坑第三关:Header 丢失与鉴权踩踏
4.1 症状:转发后拿不到 Token
我们的实际业务中,网关统一做了 Token 鉴权,所有经过网关的请求都要在网关上校验 JWT,校验通过后,网关再把请求转发给下游服务,下游服务可以从请求头里读取用户名等用户信息。
本来这个链路在普通 HTTP 请求下跑得好好的。但是 SSE 的请求一进来,就发现下游服务经常拿不到用户信息。排查后发现,一部分重连请求压根就没走网关的鉴权过滤器,或者走了过滤器但鉴权没过。
后来断点一步步查,发现前端 EventSource 重连时,默认的浏览器行为是不会携带任何自定义 Header。也就是说,你 new EventSource(url) 的时候,想加个 Authorization 头,浏览器原生 API 根本不支持。虽然不像 fetch 那样能通过 headers 参数去添加,但使用普通的原生方式就只能干瞪眼。
4.2 根因:网关过滤链对 SSE 的无差别处理
如果你用了自定义过滤器,比如 GlobalFilter 来处理鉴权,你的过滤器可能依赖请求头里的 Token 去查询用户信息。而对于 SSE 的初次连接,Token 在 URL 参数里;对于重连,Token 却可能在 Last-Event-ID 或者更早的 Cookie 里。这种情况下,自定义过滤器一视同仁地去请求头里找 Authorization,自然就找不到了。
更麻烦的是,Spring Cloud Gateway 的过滤器默认顺序中,有些过滤器会在转发之前修改请求,比如 ForwardRoutingFilter、RouteToRequestUrlFilter 等,这些过滤器之间的数据传递如果设计不当,也可能让 Header 意外丢失。
4.3 处理方案:自定义过滤器保留关键 Header,避免重复鉴权
我的方案是专门写一个针对 SSE 的过滤器,放在鉴权过滤器之前,把 URL 参数里的 Token 提取出来,重新放到请求头里,再传递给后续的过滤器链。
java复制@Component
public class SseAuthHeaderFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
if (!path.contains("/api/sse/")) {
return chain.filter(exchange);
}
// 从 query param 或 Last-Event-ID 中提取 token
String token = request.getQueryParams().getFirst("token");
if (token == null) {
String lastEventId = request.getHeaders().getFirst("Last-Event-ID");
// 解析 lastEventId 中的 token,这里按业务规则解析即可
}
ServerHttpRequest mutatedRequest = request.mutate()
.header("Authorization", "Bearer " + token)
.build();
ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
return chain.filter(mutatedExchange);
}
@Override
public int getOrder() {
return -100; // 在鉴权过滤器之前执行
}
}
这个过滤器只在 SSE 路由上生效,避免影响其他普通接口的 Header 处理。同时,网关后面下游服务之间的鉴权,如果是基于 Headers 传递用户名等信息,也要注意下游服务读取的 Header 名是否和网关传过去的一致。
实操心得:在这个过滤器里,你没有必要重新实现完整的 JWT 校验。让它只负责“把 Token 放到正确的位置”,然后交给后面的常规鉴权过滤器去统一处理,逻辑会更干净,也避免“双重校验”带来的性能损耗和潜在安全盲区。
5. 手把手:写一个能跑通的 SSE 转发示例
5.1 网关依赖与基础配置
如果你还在用 Spring Cloud Gateway 的旧版本(比如 2020.0.x 之前),部分处理逻辑在后续版本中有所调整。我下面的示例基于 Spring Cloud 2021.0.5 和 Spring Boot 2.6.13,这是当时比较稳定的组合。
在 pom.xml 中引入基础依赖即可:
xml复制<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
application.yml 中按前面提到的配置调整好超时和连接池:
yaml复制server:
port: 8088
spring:
application:
name: gateway-service
cloud:
gateway:
httpclient:
connect-timeout: 5000
# response-timeout 设置为 -1,保证 SSE 连接不被网关主动断开
response-timeout: -1
pool:
max-connections: 1000
acquire-timeout: 5000
max-idle-time: 30s
routes:
- id: sse-demo
uri: http://localhost:8081
predicates:
- Path=/api/sse/**
filters:
- StripPrefix=1
StripPrefix=1 的作用是把路径里的第一段去掉。比如前端请求网关的 /api/sse/stream,实际上会被转发到后端的 /stream,这个看你的后端服务定义来定。
5.2 自定义过滤器代码
网关部分除了常规配置外,我建议加一个全局过滤器,用来记录 SSE 请求的开始与结束,方便排查问题:
java复制@Component
public class SseLogFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(SseLogFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
long start = System.currentTimeMillis();
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
if (!path.contains("/api/sse/")) {
return chain.filter(exchange);
}
log.info("SSE request start: {}, query: {}", path, request.getURI().getQuery());
return chain.filter(exchange).doFinally(signalType -> {
long cost = System.currentTimeMillis() - start;
log.info("SSE request finished: {}, cost: {} ms, signal: {}", path, cost, signalType);
});
}
@Override
public int getOrder() {
return -200;
}
}
这里使用 doFinally 监听连接结束,不管是正常关闭、异常中断,还是客户端断开,都能记录到日志,这对复盘“为什么连接总是断”非常有帮助。
5.3 验证方式
后端我写了一个简单的 SSE 接口,用 Flux.interval 模拟流式数据,每秒钟发一条消息:
java复制@RestController
public class SseController {
@GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream() {
return Flux.interval(Duration.ofSeconds(1))
.map(i -> ServerSentEvent.builder<String>()
.id(String.valueOf(i))
.event("message")
.data("hello-" + i)
.build());
}
}
启动网关和后端,用 Postman 发一个 GET 请求到 http://localhost:8088/api/sse/stream,在 Postman 的响应区域就能看到数据一行行地出现,验证转发成功。接着,你还可以用命令行工具测试:
bash复制curl -N --no-buffer http://localhost:8088/api/sse/stream
-N 参数是禁用 curl 的缓冲,直接显示流式内容,这对快速验证网关是否真的“流式”转发非常直观。如果发现 curl 依然要等很久才输出,那就说明缓冲问题还没解决干净,需要继续检查。
6. 常见问题与排查实录
6.1 典型问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端长时间收不到数据,一次性渲染大批量内容 | 网关响应缓冲导致数据被攒批 | 关闭缓冲,检查 X-Accel-Buffering: no 头;调大或禁用 response-timeout |
| 连接 30 秒或 60 秒自动断开 | 中间链路空闲超时导致 | 后端加心跳事件,每 15-30 秒发一条注释或心跳数据 |
| 前端 EventSource 重连后报 401 | 重连请求丢失 Authorization | 自定义过滤器从 query 或 Last-Event-ID 里提取 Token,补到 Header 中 |
| 网关日志显示连接池耗尽 | SSE 长连接占用连接池不释放 | 调大 max-connections,合理设置 max-idle-time; 客户端主动断开时要及时释放 |
网关转发后没有 Content-Type: text/event-stream |
路由过滤规则错误,重写了响应头 | 检查路由过滤器,避免改写 Content-Type |
| 返回 503 或 502 | 下游服务不可用或连接建立超时 | 检查下游健康状态,调整 connect-timeout |
6.2 排查思路:看链路日志与抓包
遇到 SSE 转发异常,不要一味地在代码里盲猜,要有一整套排查顺序。
第一步,看网关日志,重点关注 SseLogFilter 里记录的 signalType。signalType 是 ON_COMPLETE 还是 ON_ERROR,能直观看出是否是异常中断。
第二步,如果日志不够,直接在网关服务器上用 tcpdump 抓包:
bash复制tcpdump -i eth0 port 8088 -w sse.pcap
然后拿 Wireshark 打开,重点看三个时间段的包:连接建立时、数据流传输时、连接断开时。断开时如果是中间设备发了 FIN 包,那基本就是链路空闲超时;如果是网关自己发的 RST,那多半是内部超时或连接池问题。
第三步,检查后端服务日志里,有没有客户端异常中断的记录。如果后端能正常写完数据,但网关没送达到浏览器,那问题就出在网关响应写回客户端这一段。
排查 SSE 转发的本质,是追踪整条链路上的“数据包流动性”。缓冲区、超时、连接池、Header 保留,这四个关键词基本能覆盖 90% 的坑。
6.3 个人实操总结与最后提醒
回头想想,Spring Cloud Gateway 转发 SSE 其实没那么神秘,核心就是把网关从“快递中转站”的思维转换成“管道工”的思维。普通接口,你要保证快递不丢件、不送错;SSE 接口,你要保证管道里的油流速稳定、不能断供。
配置层面,记住三个核心点:一是关闭响应超时或调整到足够大;二是做好心跳保活,让中间设备不至于回收空闲连接;三是处理鉴权时,别让 SSE 的重连机制成为安全盲区,但又不能因为安全校验把流式数据卡在半路。
如果你在本地验证一切正常,部署到线上又出问题,优先怀疑中间链路(负载均衡器、防火墙、K8s Service 等)的空闲连接超时。这一步,我踩过太多太多次了。很多人在本地用 IDEA 跑网关和后端,内网通信没有设备干扰,所以什么问题都没有。一到线上,负载均衡器默认空闲超时 60 秒,就把问题暴露出来了。
最后还是建议你,SSE 的客户端一定要写重连逻辑,服务端一定要考虑发送心跳。这两个做扎实了,就算外围网关配置有些偏差,整个系统的容错能力也会强很多,至少用户恢复起来会更快,不会像自动售货机一样卡死在那里毫无响应。后续如果你还要支持多个客户端的 SSE 订阅、动态消息广播,那在网关这一层做转发时,还要额外考虑连接数上限和消息分发策略,那就又是一个更复杂的话题了。
