1. 先说清楚问题:SSE 在网关面前为什么这么难搞
最近在一个项目里接实时告警推送,技术选型定了 SSE(Server-Sent Events)。后端服务本身写得挺顺,浏览器直连一点毛病没有,结果把接口挂到 Spring Cloud Gateway 后面,前端页面直接白屏等不到数据。一查日志,报的是 PrematureCloseException,还有一堆 ReadTimeoutException,当时就意识到问题没这么简单。
先说 SSE 是什么。它本质上是一条 HTTP 长连接,服务端通过 Content-Type: text/event-stream 这个响应头,把数据一段一段地往客户端推。和 WebSocket 那种全双工通道不同,SSE 是单向的,只能服务端往客户端发,客户端想给服务端说话还得走普通接口。但正因为它是纯 HTTP,所以实现成本极低,前端拿 EventSource 对象就能接,还能自动重连,对于做告警推送、工单进度、AI 对话流式输出这类“服务端有数据就往外面吐”的场景,非常合适。
问题恰恰出在“它是纯 HTTP”这件事上。因为 SSE 就是一个特别长的 HTTP 响应,而且这个响应的数据不是一次性返回的,是断断续续地、一直开着连接不断往客户端吐。如果中间隔了一个网关层,网关就必须“理解”并且“容忍”这种长时间挂着的流式响应,而绝大多数网关的默认行为都是“拿到响应就缓冲、缓冲完一次性转发”,这就把 SSE 彻底废掉了。
Spring Cloud Gateway 是基于 WebFlux 和 Netty 实现的,底层本身是支持异步非阻塞的,按理说转发 SSE 没有任何障碍。但我实际操作下来,从“能通”到“稳定”,中间隔着一堆配置和坑。这篇文章就是把我踩过的这些坑,以及背后的原理、排查思路、最终能落地的配置,全部整理出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂转发链路:Gateway 收到一个 SSE 请求后到底发生了什么
2.1 一次 SSE 请求在 Gateway 内部的完整旅程
你可能想不通,一个普普通通的 HTTP 转发,为什么偏偏 SSE 会有问题。我建议你先把这条链路搞清楚,后面所有配置你就知道为啥要那么写了。
一个 SSE 请求从客户端发出来,先打到 Spring Cloud Gateway,Gateway 内部的 RoutingFilter 会拿着请求信息去找匹配的路由,然后通过内置的 HttpClient(基于 Netty 的 Reactor Netty HttpClient)发起转发请求到下游服务。下游服务收到请求后,返回 200,同时设置 Content-Type: text/event-stream,然后开始往响应体里写数据。
关键就在这一步:下游服务的响应体是一股持续流动的数据流。Gateway 在拿到下游响应后,会把响应写回到客户端。但是写回的时候,它得拿着这个下游的响应体(Flux<DataBuffer>)去做转发过滤链。这里如果你没有做任何特殊处理,默认情况下 Gateway 内部会尝试用内存把这块数据读入然后再往客户端写。这就导致两个极端问题:
一个是下游服务不断发数据,Gateway 这边迟迟不往客户端转发,因为它在等“响应结束”再去处理;另一个是客户端那边一直傻等,等到超时直接断开,然后你就在日志里看到 ReadTimeoutException。
我打个比方你就明白了。你让一个快递员(Gateway)帮你传输一个水龙头里流出来的水(SSE 流),快递员的默认做法是拿一个桶(缓冲)把水接着,接满一整桶再出发送给你。但水是不停流的,他永远等不到“接完水”的那一刻,你那边却已经渴得受不了了。解决的办法就一个:别用桶接,让他拿一根水管直接对接(流式转发)。
2.2 为什么默认配置会“吞”掉 event-stream 响应
再往深挖一步。Spring Cloud Gateway 的转发逻辑里,NettyRoutingFilter 负责从下游读数据,然后 NettyWriteResponseFilter 负责往客户端写数据。正常情况下,这两个 filter 是通过 Reactor 的异步流水线衔接的,理论上可以做到一边读一边写。
但实际默认配置下,Gateway 的内置 HttpClient 有一个叫 response-timeout 的全局配置,默认值是 5 秒(不同版本可能略有差异)。这个超时是指“从发出请求到拿到完整响应头”的超时时间,对于普通接口无所谓,但对于 SSE 场景,下游服务虽然很快就返回了 200 响应头,可这个响应头之后是无限期的数据流。如果你的下游服务在建立连接后、发送第一帧数据前有一点点延迟(比如因为服务端在处理数据),这个 5 秒超时就很容易触发。
而且这还不是最坑的。更麻烦的是,默认情况下 Reactor Netty 的 HttpClient 在收到响应头之后进入读响应体阶段,但如果你在 Gateway 层给路由配置了 ReadTimeout(有些版本读超时没有暴露在配置项里,就由全局 response-timeout 兜底),它对“读操作之间”的间隔是敏感的。SSE 场景下服务端可能两帧数据间隔十几秒(这其实是很正常的,比如服务端在等待某个任务完成),只要间隔超过配置的读超时时间,连接就会被判定为超时,客户端那边就断了。
所以默认配置下,SSE 转发失败的根基问题就两个:超时机制不允许“长时间没有数据”,缓冲行为不允许“数据边产生边推送”。你只需要把这两个问题的根源处理掉,后面就顺了。
3. 实操配置:从“转发能通”到“稳定不丢”的完整修改方案
3.1 第一步:调整全局 HttpClient 的超时与连接池配置
首先最基础的一步,修改 Gateway 的全局 HttpClient 配置。在 application.yml 里加:
yaml复制spring:
cloud:
gateway:
httpclient:
response-timeout: -1
connect-timeout: 5000
pool:
type: fixed
max-connections: 500
max-idle-time: 5000
max-life-time: 30000
ssl:
handshake-timeout: 5000
close-notify-flush-timeout: 3000
close-notify-read-timeout: 0
这里最重要的就是 response-timeout: -1,意思是永远不超时。注意这个 -1 不是随便写写,它是让 HttpClient 对“等待响应头”不做超时限制。因为 SSE 连接一旦建立,这个连接理论上就一直挂在那里,跟短请求完全是两码事。
有朋友可能担心,配置成 -1 会不会把其他普通接口的超时保护也干掉了?我的经验是:不要让全局配置一刀切。你完全可以在路由层面单独设置元数据,让不同路由拥有不同的超时策略。比如:
yaml复制spring:
cloud:
gateway:
routes:
- id: sse-route
uri: http://upstream-service
predicates:
- Path=/api/sse/**
metadata:
response-timeout: -1
connect-timeout: 1000
注意,路由级 metadata 里的键名是 response-timeout 和 connect-timeout,这是 Gateway 内置识别的两把钥匙。代码里也可以用 RouteDefinition 的 metadata.put("response-timeout", -1) 来做,效果一样。
除了超时,连接池也要注意。SSE 连接是长连接,而且连接数会随着客户端数量线性增长。默认的连接池是弹性连接池(elastic type),最大连接数 max-connections 默认是 maxProcessors * 2。在服务器核心数不高的情况下,这个值很可能一压测就被打满。我习惯直接切换成固定连接池类型,显式指定一个比较高的 max-connections 数,再配好 idle 和 life 时间,避免连接被服务端判定为“僵死连接”回收掉。
3.2 第二步:自定义全局过滤器,确保流式响应不被缓冲
如果你把上面配置改完后去测,可能会发现有一些场景仍然不通。比如某些版本下,即使 response-timeout 配置正确了,事件流的输出仍然是一帧一帧卡顿,或者干脆连第一帧都收不到。这时候你需要确认:Gateway 有没有对响应做缓冲处理。
Spring Cloud Gateway 本身不专门缓冲 SSE 响应,但它有一个全局过滤器机制。我在生产项目里保留了一个自定义的 GlobalFilter 用来强制设置写响应的策略:
java复制@Component
public class SseStreamingFilter implements GlobalFilter, Ordered {
private final List<ServerHttpResponseDecorator> decorators = new ArrayList<>();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpResponse response = exchange.getResponse();
ServerHttpResponseDecorator decorator = new ServerHttpResponseDecorator(response) {
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
if (body instanceof Flux) {
return getDelegate().writeAndFlushWith(
Flux.from(body)
.windowUntilChanged(it -> false)
.map(window -> window)
);
}
return super.writeWith(body);
}
};
return chain.filter(exchange.mutate().response(decorator).build());
}
@Override
public int getOrder() {
return -1;
}
}
我把关键方法抄给你看一下。核心点是:针对 Flux 类型的响应体,使用 writeAndFlushWith 去写,而不是 writeWith。前者每收到一块数据就 flush 一次到客户端,后者是攒一攒再写。这是保证 SSE 流式输出的关键。
这个 filter 也让很多第一次接触的人踩坑:以为只要配好 yml 就完事了,结果忘了在代码里做流式写出的适配。实际上,在 Spring Cloud Gateway 的版本迭代里,某些版本默认的 writeWith 对 text/event-stream 已经做了特殊处理,但不同版本行为差异很大,最稳妥的方式就是自己写这个流式过滤器来兜底。
注意:如果你用的 Spring Cloud Gateway 版本比较新(比如 2021.x 以后),可能已经内置了部分 SSE 优化逻辑,但自定义过滤器并不会冲突。这个 filter 的作用是显式声明“我是流式响应”,避免各种奇怪时序问题。
3.3 第三步:禁用 GZIP 压缩,避免压缩把流式堵死
这个坑我印象特别深。当时配置都改完了,小流量测试一切正常,结果一到压测阶段,客户端收到的全是被截断的乱码,而且连接经常在中途断开。排查了半天,最后发现是客户端请求头里带了 Accept-Encoding: gzip,Gateway 转发的时候把这个头也带给了下游,下游服务如果是用 Nginx 暴露的,Nginx 会对 SSE 响应做 GZIP 压缩。
有人可能会想,压缩不是很好吗?流量小了,传输更快。但问题是:压缩算法是要攒缓冲的。Nginx 默认会对 text/event-stream 做 GZIP 压缩,压缩之后再通过流量管道传输,如果内容不够大,Nginx 会等待积累到一定字节数才吐出来,这就把“边产生边推送”变成“攒够一批再推送”,前端体验直接就卡成 PPT 了。而且某些代理层的压缩实现还会强制给响应加 Content-Length,完全破坏了流式传输。
我当时做的两件事:
一是把下游服务(或者 Nginx 层)对于 text/event-stream 的压缩关掉:
nginx复制gzip off;
location /api/sse/ {
proxy_pass http://upstream;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
}
二是从 Gateway 转发请求头里把 Accept-Encoding 去掉,保证下游根本不会考虑压缩响应。可以用一个简单的 Filter 实现:
java复制ServerHttpRequest request = exchange.getRequest().mutate()
.headers(headers -> headers.remove("Accept-Encoding"))
.build();
这两步做完,流式传输立刻恢复正常。
4. 更隐蔽的坑:客户端断连、并发抢占与负载均衡漂移
4.1 客户端断开后,上游服务如何感知连接关闭
上面只是解决了“正常转发”的问题,但 SSE 场景还有一个绕不开的坎:客户端打开页面之后,可能刷新、关闭、断网,此时 TCP 连接就断了。客户端断开了,Gateway 要继续往下游转发数据,就会出现 PrematureCloseException——连接提前关闭异常。
默认情况下,这个异常在日志里会刷屏,而且如果处理不当,Gateway 内部会一直残留着对下游服务的连接资源,导致连接泄露。我遇到过一次,压测 200 个客户端同时订阅,然后批量关闭页面,Gateway 的线程池直接被异常打满,整个服务雪崩。
解决方法是:在自定义过滤器里捕获这个异常并静默处理,同时对断开的连接做好资源清理。
java复制return chain.filter(exchange).onErrorResume(throwable -> {
if (throwable instanceof PrematureCloseException
|| throwable.getCause() instanceof PrematureCloseException) {
// 客户端断开,属于正常情况,无需额外处理
return Mono.empty();
}
return Mono.error(throwable);
});
其实这里有个更底层的逻辑:SSE 这条链路本来就是“客户端断开、服务端还在推”的常态,如果不允许“客户端断开”这个行为,反而说明系统设计有误。所以需要在网关层允许并容忍这种“提前关闭”。
4.2 心跳机制与连接空闲超时的拉扯
SSE 本身有一个 heartbeat 机制,服务端可以通过定时发送注释(comment)或 heartbeat 事件来维持连接。但问题在于,这个心跳包如果设计得太频繁,网关和下游服务、客户端之间的网络设备都会增加无谓的负载;如果设计得太稀疏,又容易触发中间网络设备的“空闲连接回收”策略,导致连接被路由器、防火墙、Nginx、负载均衡器悄悄断开。
我的经验是:心跳间隔设置成 15 到 30 秒比较合适,而且心跳包要真的发一个事件,不要发纯注释。有些实现里只发一个空注释 : ping\n\n,在标准 SSE 协议里这叫注释,前端 EventSource 不会触发任何回调,但网络层确实能看到数据包在传输,可以防空闲超时。
我和后端约定的事件协议里,额外加了一个 event: heartbeat 事件,数据字段是时间戳。前端收到心跳事件后可以做本地健康检查,如果超过 45 秒没有收到任何帧(包括心跳),前端主动重连。这个机制上线后,SSE 断连率大幅下降。
注意到一个细节:Gateway 转发的 SSE 连接,如果没有经过心跳维持,连接处在“半开”状态时,Gateway 自己是感知不到的。等到数据真正推过来,才发现 socket 已经坏了,这时候你看到的就是一堆 Connection reset by peer。所以前置的心跳设计特别重要。
4.3 多实例部署下,重连导致的事件漂移问题
Spring Cloud Gateway 如果是多节点部署,前面挂负载均衡,客户端每次重连可能被分发到不同的 Gateway 实例。如果你的下游服务是无状态的,那问题不大。但 SSE 场景往往是后端服务内有“订阅会话”的,那么客户端断线重连之后,新连接的 Gateway 实例怎么找到原来那个会话?这是一个容易忽略的架构问题。
我一个项目里是这样的:下游服务是一个用 Redis Pub/Sub 做消息广播的推送服务,客户端连接到任意 Gateway 实例,Gateway 转发到下游的服务节点。断线重连时,Gateway 换了台机器,但只要下游服务是通过“用户 ID”来注册订阅者会话的,那么新的连接仍然能通过 Redis 订阅拿到同样的事件序列,问题不大。
但如果你下游用的是本地内存维护的会话,那么重连到另一个节点后就丢数据了。这种场景下你要么限制 SSE 请求只路由到固定的实例(用网关的 LoadBalancer 加 instanceId 路由),要么把会话信息外置到 Redis。
这块我强烈建议在一开始做架构设计时就考虑清楚。因为“Gateway 能不能转发 SSE”是技术问题,“重连后能不能续上之前的状态”是架构问题。前者改配置就行,后者是要重新设计代码的。
还有一个我踩过的坑:客户端重连时带了 Last-Event-ID,前端 EventSource 会自动携带这个 header。但 Spring Cloud Gateway 默认的转发规则不会自动放行所有 header,你需要检查一下跨域配置和 header 过滤规则,确保 Last-Event-ID 这个头能够被正常转发到下游。否则下游服务收不到断点续传标识,重连后只能从最新事件开始推,之前丢的数据就永远丢了。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 直接原因 | 解决方案 |
|---|---|---|
| 前端 EventSource 一直处于 CONNECTING,收不到任何数据 | Gateway 的 response-timeout 触发了超时,连接被提前关闭 | 将 response-timeout 设置为 -1,或路由级设置 metadata |
| 连接建立成功,但数据一帧一帧卡顿刷新 | 响应数据被缓冲,没有按流式写出 | 添加自定义 GlobalFilter,用 writeAndFlushWith 写响应 |
| 收到乱码或数据片段不完整 | GZIP 压缩导致流式被截断,或 chunked 编码异常 | 去掉 Accept-Encoding,下游 Nginx 关闭压缩 |
| 客户端断开后,Gateway 日志疯狂报 PrematureCloseException | 没有处理异常,日志打印频繁 | 用 onErrorResume 捕获该异常并静默处理 |
| 连接数一高,Gateway 报连接拒绝或超时 | 连接池太小 | 切换固定连接池,调大 max-connections |
| 重连后事件缺失或重复 | 下游会话与网关实例绑定,或 Last-Event-ID 未转发 | 会话外置到 Redis;放行 Last-Event-ID header |
| 前端可以连上,但隔几分钟自动断开 | 空闲连接被中间网络设备回收 | 下游加心跳机制,15-30 秒发一个事件 |
5.2 排查工具与方法
关于排查工具,我推荐直接用在线 SSE 客户端测试工具,这类工具能够帮你快速区分问题出在前端还是网关。不过很多在线工具也不支持设置自定义 headers,所以你需要一个本地的测试手段。
我自己写了一个几十行的 Node.js 测试脚本,用来模拟 EventSource 的长连接请求:
javascript复制const http = require('http');
const req = http.request({
hostname: 'localhost',
port: 8080,
path: '/api/sse/test',
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Accept-Encoding': 'identity',
},
timeout: 0,
}, res => {
console.log('Status:', res.statusCode);
console.log('Headers:', res.headers);
let buffer = '';
res.on('data', chunk => {
buffer += chunk.toString();
const frames = buffer.split('\n\n');
buffer = frames.pop();
frames.forEach(frame => {
if (frame.trim()) {
console.log('--- frame ---');
console.log(frame);
}
});
});
res.on('close', () => {
console.log('--- connection closed ---');
process.exit(0);
});
});
req.on('timeout', () => {
console.log('--- request timeout ---');
req.destroy();
});
req.end();
// 30 秒后断开测试连接
setTimeout(() => {
console.log('--- client manually aborted ---');
req.destroy();
}, 30000);
这个脚本的价值在于,它把客户端行为完全暴露在你的掌控之下。你可以看到 HTTP 状态码、响应头、每一帧 SSE 数据、连接关闭的原因。如果脚本直连下游服务没问题,但经过 Gateway 就有问题,那问题就锁定在 Gateway 层了。
5.3 日志级别调整与关键日志看什么
Spring Cloud Gateway 日志默认是 INFO 级别,但你排查 SSE 问题时,最好把相关日志级别调到 DEBUG 或 TRACE。以下是几个需要关注的日志组:
reactor.netty.http.client:看 HttpClient 的网络事件,重点关注 connect、request、response、channel 的读写和释放。org.springframework.cloud.gateway.filter.NettyRoutingFilter:看路由过滤器对请求转发的处理。org.springframework.cloud.gateway.filter.NettyWriteResponseFilter:看响应写的流程。reactor.netty.channel:看连接分配和释放,排查连接泄露。
配置方式:
yaml复制logging:
level:
reactor.netty.http.client: DEBUG
org.springframework.cloud.gateway.filter.NettyRoutingFilter: DEBUG
org.springframework.cloud.gateway.filter.NettyWriteResponseFilter: DEBUG
reactor.netty.channel: DEBUG
我遇到过一种情况:用 DEBUG 日志看到连接被 channelReadComplete 之后就没有后续读事件了,怀疑是连接被池化后返回给了连接池。后来发现是连接池的 max-idle-time 配置太短,在 SSE 场景下,连接处于空闲状态后就被回收,导致后续数据到达时连不上。这个问题的排查就是靠日志里那几行连接释放记录看出来的。
5.4 性能与限流:SSE 长连接对网关的额外压力
这里我还想多提醒一句:SSE 长连接对网关的性能压力是普通接口的好几倍。一个普通 HTTP 请求从建立到响应完成也就几百毫秒,连接就释放了。但一个 SSE 连接会长期占用一个连接池里的连接资源,假设你有 1 万个客户端同时订阅,那么 Gateway 至少要维持 1 万个长连接(以及可能额外 1 万个到下游的连接)。这不仅是内存的开销,也是线程、文件描述符、TCP 缓冲区等系统资源的开销。
所以在生产上,我给 SSE 路由做了独立的限流策略。用 Redis + Lua 做全局限流,确保客户端数量不至于把 Gateway 打挂。代码大致思路是:在过滤器里拦截 SSE 请求,通过 Redis 计数器记录当前活跃的 SSE 连接数,超过阈值直接返回 429。这个做法是防患于未然,因为连接数一旦失控,网关首先挂掉,下游服务反而还活着。
另外要注意的是,SSE 请求最好不要走 Gateway 默认的重试逻辑。自动重试那套机制虽然很成熟,但它不了解 SSE 的语义——SSE 本身就有客户端自动重连机制,一个请求异常断开,客户端会重新发起连接,如果你在 Gateway 层面还给它重试一次,会导致一次断线产生两个订阅连接,下游会话混乱,事件重复。基本做法就是针对 SSE 路由关闭重试:
java复制@Bean
public RouteLocator sseRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("sse-route", r -> r
.path("/api/sse/**")
.filters(f -> f.retry(config -> config.setRetries(0)))
.uri("http://upstream-service"))
.build();
}
5.5 跨域、鉴权与预检请求的联动坑
最后还说一个不太起眼但很折腾的坑:CORS 预检请求。SSE 是 EventSource 发起的,这种跨域请求本身会触发 preflight。如果你的 Gateway 配置了全局 CORS,预检请求是 OPTIONS,而 SSE 请求是 GET,两者需要分开处理。我遇到的问题是:CORS 过滤器对 OPTIONS 直接返回了,但对 GET 请求的响应头里漏了 Access-Control-Allow-Origin,导致前端 EventSource 收到响应后因为跨域规则不符合而抛异常,连接被前端主动关闭。
排查这类问题看浏览器控制台或者后端日志都很难发现,因为从服务端看,连接确实建立了,数据也返回了。但浏览器因为跨域校验失败把连接给掐了。解决办法是,在 CORS 配置里不仅对 OPTIONS 返回跨域头,任何 SSE 的 GET 响应也要带上 Access-Control-Allow-Origin 和 Access-Control-Allow-Headers。
如果你用了 Spring Security,还需要注意 SSE 长连接场景下的 CSRF 配置,以及 Last-Event-ID 这个 header 是否被安全框架当成非法头给拦掉了。Spring Security 默认对 CORS 的处理远比你想的麻烦,建议在一开始就把 Gateway 层的 CORS 与安全配置一起理清,不然后面排查会非常痛苦。
6. 最后的配置清单与个人体会
把上面所有内容压缩成一份可直接落地的配置清单:
- 超时配置:
spring.cloud.gateway.httpclient.response-timeout: -1,用路由级 metadata 控制不同路由的超时。 - 连接池配置:固定连接池,max-connections 根据压测结果调优,max-idle-time 不要设太短。
- 自定义过滤器:实现流式写出,用
writeAndFlushWith替代默认写方式;捕获PrematureCloseException并静默处理。 - 去掉
Accept-Encoding:避免下游做 GZIP 压缩。 - 心跳机制:下游 15-30 秒发一次心跳事件,客户端 45 秒无帧主动重连。
- 关闭 SSE 路由的重试策略。
- 放行
Last-Event-ID请求头。 - 配置 CORS 与安全策略,确保跨域头正确返回。
- SSE 路由单独做连接数限流。
我在实际项目中把这些配置全部落地后,SSE 转发的稳定性从“时不时断连”变成了“可以持续跑几十个小时不掉线”。整个过程踩了很多坑,但回过头看,其实核心就两件事:一是理解流式响应和普通响应在网关层的本质区别,二是把超时、缓冲、连接这些底层参数调到符合 SSE 的语义。
在我个人经验里,最容易被忽略的反而不是配置本身,而是“自定义 GlobalFilter 的代码实现对响应做了 writeWith vs writeAndFlushWith 的选择”。这个点比较钻,但只要你理解了 Netty 的 flush 机制,就能明白为什么 SSE 必须要用 writeAndFlushWith。毕竟 SSE 的场景里,每一帧数据都可能是用户等了很久的关键信息,早一秒推送出去,用户的等待体验就完全不一样。
