Spring Cloud Gateway下SSE流式输出:超时、缓冲与断流治理实践
最近接了个任务把老项目里的轮询接口改成SSE推送网关用的Spring Cloud Gateway后端是WebFlux。本来以为SSE协议就是Content-Type: text/event-stream加一个长连接浏览器原生EventSource直接能用顶多把网关超时调大一点。结果联调第一周就翻车前端收不到实时数据、网关日志刷出before completion: idle timeout waiting for sse、长连接跑十几分钟被静默断开。这篇文章不写官方文档里那些Hello World直接梳理我在Spring Cloud Gateway下做SSE流式输出时的完整排查链路核心就三个关键词超时、缓冲、断流外加网关集群、鉴权、限流这些绕不开的边界问题。踩过坑的同行可以直接对照自己的配置检查遇到类似问题也能少走弯路。1. 网关为什么会吞掉SSE流从一次前端卡死说起1.1 EventSource收到的事件不是实时流更像是整包响应当时我第一个版本很简单后端接口用Flux.interval(...)每秒钟发射一个事件生产环境拓扑是浏览器 - Spring Cloud Gateway - 后端服务。用Postman直连后端接口事件一个接一个出来没有任何问题。但一挂到网关后面前端EventSource就跟失灵了一样页面等了好几秒才一次性蹦出一大堆事件后续事件也不推送了看上去跟普通HTTP响应没区别根本不流。这个现象的本质是响应被某个组件整体缓存了。EventSource也好fetch的ReadableStream也好请求发出后都要等服务端逐块返回。如果中间某个代理收到响应数据后不往下转发而是攒到响应结束才一次性回给浏览器浏览器层面就会表现为一批数据延迟到达而不是一条条实时更新。1.2 真正的元凶响应体被缓冲的三种常见写法排查发现问题不在Spring Cloud Gateway本身而在于我们网关里加的几个顺手的配置和Filter。整理下来有三类情况会把SSE流硬生生变成整包响应第一类全局Filter里对响应体做了聚合。比如为了记录响应日志、统计流量大小很多团队会在全局Filter里写类似这样的代码// 错误示范把流式响应body收集成ListSSE必挂 MonoListDataBuffer listMono response.getBody() .collectList() .doOnNext(list - log.info(本次响应大小 {}, list.size()));collectList()会把流式数据全部聚合到内存等后端整个事件流发完才继续往下游传送。SSE是源源不断的流这种写法轻则导致浏览器事件全部延迟到连接断开才收到重则直接把内存打爆。第二类网关层开启了响应压缩。压缩器通常要把整个body收齐、压缩完才能写出Content-Length对应内容这跟流式传输天然冲突。SSE场景下如果网关用gzip压缩流就废了。我把响应压缩从SSE路径上排除后实时性肉眼可见地恢复。第三类路由Fitler或装饰器改变了Response Content-Type。有些团队会在Filter里统一设置响应头比如Cache-Control、Content-Type如果这里把text/event-stream覆盖成application/json浏览器EventSource会直接当作协议错误触发重连逻辑表现也是收不到事件。提示SSE路径上的Filter要只读不改。确有必要修改响应时优先用ServerHttpResponseDecorator按块处理不要做任何collectList()或join()操作。2. before completion: idle timeout waiting for sse超时并非只有response-timeout一个参数2.1 这个报错在什么时机出现网关转发SSE后我们重点调超时一开始只改了spring.cloud.gateway.httpclient.response-timeout从默认值改成5分钟以为够用了。结果连接空闲1分钟左右网关日志就开始刷一行异常The connection observed an error, the connection will be closed. [before completion: idle timeout waiting for sse]这行日志的来源是Reactor Netty的HTTP客户端层。它的语义不是接口整体耗时超过阈值而是连接上某一次读或写操作的空闲时间超过配置。SSE推送有个特点如果上游业务侧长时间不发数据比如等待某个任务完成、或者AI生成内容时思考停顿连接就处于空闲状态。此时如果网关侧配了空闲超时就会把这根连接判定为等不到数据强制关闭。我最后定位下来的原因有两层一是responseTimeout设的还不够大期间有几次下游写数据的时间间隔超过了它二是我们自定义的HttpClientBean里加了idleTimeout(Duration.ofSeconds(60))这才是真正的罪魁祸首。2.2 网关下实际生效的三层超时在Spring Cloud Gateway代理SSE的过程中一个事件流从后端到浏览器会经过多个超时控制点我梳理下来至少有四层超时层控制位置典型配置对SSE的影响客户端网关连接浏览器/前端EventSource原生没有超时影响不大主要靠心跳保活网关到浏览器Netty HttpServerHttpServer的idleTimeout空闲会被迫断开网关到后端Netty HttpClientHttpClient.responseTimeout/idleTimeout最容易触发before completion报错后端到网关后端框架Spring WebFlux响应超时空事件间隔过长会提前结束流其中网关到后端这一层最容易被忽略。Spring Cloud Gateway通过NettyRoutingFilter转发请求底层是Reactor Netty的HttpClient这个客户端的responseTimeout并不是首次响应时间而是包含对后续body数据到达间隔的约束。如果事件间隔或者心距间隔大于这个值连接就会被判定超时。2.3 我最终采用的时间参数组合排查后我先把配置改成这样spring: cloud: gateway: httpclient: connect-timeout: 3000 response-timeout: 300s但实测下来还是不够因为某些下游任务导致事件间隔超过300秒。最能说明问题的是就算我把response-timeout调成很大自定义的idleTimeout依然会按秒级关闭连接。最后直接用HttpClientCustomizer把这两个超时全部放开Bean public HttpClientCustomizer sseHttpClientCustomizer() { return httpClient - httpClient .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) .responseTimeout(Duration.ofSeconds(0)) .idleTimeout(Duration.ofSeconds(0)); }这里Duration.ZERO在Reactor Netty中的语义是禁用超时。同时在后端业务代码中把每次事件之间的间隔控制在30秒以内保证连接一直处于有人说话的状态。调完之后before completion: idle timeout waiting for sse这个日志就从生产环境消失了。注意彻底关闭超时并不是推荐所有项目都这么做。建议给SSE路径单独建一个Route和独立的HttpClient定制器不要影响普通REST接口的默认超时治理。3. 拆掉缓冲内核socket缓冲与应用层背压如何配合3.1 内核收发缓冲区不是越大越好超时问题解决后我们又遇到了看起来连着但数据延迟的怪现象。压测时并发100个SSE连接每个连接后端每秒发10条事件总吞吐量并不大但前端延迟却从几十毫秒涨到了几秒。核查下来发现问题出在内核socket缓冲区和应用层背压的配合上。TCP收发缓冲区默认由系统自动调整UDP方向的数据包容易丢TCP方向的缓冲过大反而会造成延迟。SSE是典型的少量高频数据流如果接收方读取慢发送方缓冲区一直堆积数据延迟会越来越大。尤其在内核参数上不要盲目调大# 参考而非照抄调整前后要结合监控 sysctl -w net.core.rmem_default1048576 sysctl -w net.core.wmem_default1048576 sysctl -w net.ipv4.tcp_rmem4096 87380 6291456 sysctl -w net.ipv4.tcp_wmem4096 65536 2097152缓冲区越大延迟越高同时内存占用越大。SSE不需要大缓冲区反而要保证小数据包能及时flush出去。网络链路上建议开启TCP_NODELAY关闭Nagle算法避免小数据包在缓冲里等待合并而增加毫秒级延迟Bean public HttpClientCustomizer tcpNoDelayCustomizer() { return httpClient - httpClient .option(ChannelOption.TCP_NODELAY, true); }3.2 Spring Cloud Gateway里的流式转发与显式缓冲点Spring Cloud Gateway核心转发链路NettyRoutingFilter本身是支持流式的数据块到达后会继续转发给下游。但前面说的全局Filter聚合响应、压缩、响应体装饰如果编码不当就会成为显式缓冲点。还有spring.codec.max-in-memory-size这个参数它决定DataBuffer能够聚合的最大字节数如果某段代码内部依然使用了collectList聚合大小超过这个阈值会直接报错spring: codec: max-in-memory-size: 10MB这个值不是给SSE流的总积压量设上限的而是给那些不得不聚合的缓冲操作设上限。正常SSE路径不应该走到数据聚合所以我建议把SSE路由和普通接口路由分开SSE路由不进入日志统计、不进入限流聚合、不进入响应压缩。在压测SSE时还要观察一个关键指标网关所在机器的TCP发送队列和接收队列。ss -t -o state established ( sport :8080 )可以查看每条连接的发送队列Send-Q。如果发送队列长期不为0说明下游处理速度跟不上上游背压已经传导到内核缓冲光是调大Spring配置解决不了要从下游消费速度找原因。3.3 Nginx挡在网关前面时怎么处理很多生产环境不是浏览器直接连Spring Cloud Gateway前面还有一层Nginx。Nginx反向代理SSE时默认会启用proxy_buffering把上游响应缓冲起来这是另一个容易踩的隐性缓冲点。需要在location里明确关闭location /sse/ { proxy_pass http://gateway-cluster/; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里proxy_buffering off是必须的proxy_read_timeout要跟事件间隔匹配如果心跳间隔30秒proxy_read_timeout至少留3到5倍余量。Nginx默认的60秒读超时会把SSE连接杀掉这是很多跑十分钟就断问题的真正元凶。4. 断流治理心跳、Last-Event-ID与连接死亡检测4.1 断流表现与产生原因断流和超时不太一样超时是有明确报错日志断流更像是安静地消失前端EventSource的onerror偶尔触发重连后又能收到数据但中间的事件丢了或者后端认为连接还活着持续推送但实际客户端已经断网导致服务端资源白白占用。我总结SSE断流主要有四种来源。第一是链路上的闲置回收Nginx、负载均衡器、云厂商LB对空闲长连接都有回收策略通常60秒到300秒不等。第二是网络设备NAT映射老化尤其是移动端网络公网NAT超时会切断看似活跃的连接。第三是网关或后端进程重启、发布、扩缩容连接被迫断开。第四是客户端进入后台、系统休眠、网络切换TCP连接对端不感知。4.2 心跳设计间隔必须小于链路最小空闲阈值的一半针对闲置回收最稳妥的方案是服务端定时发送SSE注释行。SSE协议允许以冒号开头的注释行浏览器收到后不会触发onmessage但能证明连接是活跃的// 伪代码每隔25秒发送一次心跳 Flux.interval(Duration.ofSeconds(25)) .map(i - : System.currentTimeMillis() \n\n) .subscribe(sink::tryEmitNext);为什么是25秒因为我们需要小于链路最小空闲阈值的一半。如果前置Nginx的proxy_read_timeout是60秒心跳间隔25到30秒就够。如果前面还有云厂商LB有些LB默认空闲回收是60秒有些是15秒必须根据实际情况确认。心跳间隔太短会增加无意义网络包太长又起不到保活作用我一般按最小阈值的40%确定。还要注意一点Spring WebFlux写SSE时如果事件本身不带空行客户端可能无法正确解析。SSE格式要求每个事件以\n\n结束。心跳注释行同样要带两个换行符。4.3 客户端重连与断点续传前端用原生EventSource时连接断开后会自动重连并且按规范会在请求头带上Last-Event-ID。后端如果能解析这个头就可以实现断点续传——把客户端断线期间漏掉的事件补发过去。但很多团队根本没设计事件ID体系导致重连后只能从头推或者丢弃旧事件。在Spring WebFlux中可以通过ServerHttpRequest拿到Last-Event-ID头String lastEventId exchange.getRequest().getHeaders().getFirst(Last-Event-ID);具体业务上我建议SSE事件自带递增ID服务端把最近一段时间的事件存到本地缓存或Redis收到Last-Event-ID后从下一条开始推。注意这个ID是整个用户维度的不是连接维度的重连后换了一个连接也能续传。原生EventSource的缺点是只要连接异常它就会拼命重连没有退避策略。如果接口返回401或403EventSource不会停止而会陷入请求-失败-再请求-再失败的循环把鉴权服务打爆。这里我建议用fetch ReadableStream自己做一套可控的SSE客户端既能自定义请求头又能根据HTTP状态判断是否需要重连。4.4 服务端如何确认连接真的死了判断客户端断没断开是SSE服务端最容易被忽略的问题。TCP不主动通知对端消失了如果没有数据往socket上写服务端永远不知道连接已经死掉。要主动发现半开连接最有效的手段就是在心跳发送时检查写入结果。在Reactor中可以用Sinks管理每条连接的事件推送。每次tryEmitNext返回失败或者低层写入抛出异常时说明这条连接已经死亡需要主动释放if (sink.tryEmitNext(event).isFailure()) { // 连接失效清理资源关闭订阅 disposable.dispose(); sessionRegistry.remove(userId); }另外还要给整个SSE流设置最大存活时间兜底。比如每个用户单次SSE连接最长保持24小时到期后服务端发送一条event: close客户端收到后主动重连。这样可以避免极端情况下连接泄漏。5. 集群、鉴权、限流这几个SSE三不管地带5.1 网关集群下SSE连接的落点与事件广播Spring Cloud Gateway本身是无状态的做集群没问题但SSE是点对点长连接一旦网关集群化连接会固定落在某一台网关实例上。浏览器通过负载均衡器连接到实例A后续这条连接的所有流量都走实例A这本身不影响功能。问题是某台网关实例发布重启、或者负载均衡器把新连接调度到实例B时旧连接全部断掉前端必须重连。这种场景不需要强求重连后还落在同一台实例因为Gateway只做转发真正持有用户SSE会话的是后端服务。后端服务如果是多实例部署一个用户的SSE连接必然只建在其中一台实例上那业务事件发生时怎么保证事件能推到正确的那台实例我的方案是引入事件广播。业务服务产生事件后发到Redis Pub/Sub或消息队列所有后端实例都订阅主题某个实例收到事件后检查本机会话Registry只有持有该用户连接的实例才真正推送。这样不用在Gateway层做会话粘滞后面服务扩缩容时SSE重连也能自动落到新的实例上。如果坚持在网关层做粘滞可以用基于Cookie的Sticky Session或根据用户ID做哈希路由但代价是负载不均衡实例故障时连接全部失效。个人建议后端无状态化 事件广播比网关粘滞干净得多。5.2 EventSource无法自定义请求头的三种鉴权思路SSE鉴权踩过一个细节浏览器原生EventSource不支持自定义请求头Token没法像普通AJAX那样放在Authorization头里。结合网关统一鉴权的场景我见过三种可行思路第一种Token放Query参数。简单直接网关解析?tokenxxx完成鉴权但Token会出现在访问日志、浏览器历史里必须设置短期有效且做脱敏。第二种Token放Cookie。网关从Cookie里取Token前端设置withCredentials: true。这种方式比Query安全但要处理Cookie的跨域和CSRF问题。第三种短期票据。先用普通fetch接口换一个5秒内有效的one-time-ticket再把票据作为Query参数传给EventSource网关校验票据成功后不存缓存。我目前在生产环境用的是第三种安全性和兼容性比较均衡。网关在SSE路径的鉴权Filter中切记不要把Query参数原样打到后端至少要做一层脱敏避免日志和监控系统把Token打出来。5.3 SSE路径不要直接套RequestRateLimiter限流在SSE路径上特别容易误伤。Spring Cloud Gateway内置的RequestRateLimiter基于令牌桶当一个SSE长连接建立后这个请求会长期占用一个正在处理中的许可导致同一用户的后续请求被限流。实际表现是登录后第一次打开SSE页面正常过一会儿再点按钮频繁触发401或429。我最初的配置给SSE路由配了RequestRateLimiter压测到200个连接时网关直接拒绝了大量请求。原因是限流器的维度是每个用户每秒允许请求数但SSE连接并发数跟QPS是两码事。正确做法是SSE路径单独用并发连接数限流而不是令牌桶。我写了一个简单的Session计数Filter每建一个SSE连接就递增关闭就递减超过阈值直接返回503这比令牌桶更符合长连接场景。5.4 关闭SSE路径上的重试过滤器还要特别提醒Spring Cloud Gateway的Retry过滤器是针对普通请求设计的SSE路径上一定要关掉。原因很简单普通接口可以整个重试SSE流一旦开始推送数据如果中途失败再重试客户端会收到重复的事件。尤其是有的事件带业务副作用比如已创建已扣费重复推送会导致重复消费。我实际测试过SSE路由开启重试后后端服务在推送第50条事件时重启网关自动重发请求客户端把前50条事件又收了一遍。排查半天才确认是Retry过滤器在起作用后来在SSE路由上彻底关闭了它事件重复问题随之消失。最后再分享一个运维层面的小经验SSE上线后一定要在监控大盘上增加两路指标一是同时活跃的SSE连接数二是SSE连接平均存活时长。如果存活时长总是很短大概率是某个空闲回收参数没调对如果连接数持续上升但TPS没有变化大概率是连接泄漏去检查服务端有没有正确处理客户端断开时的资源释放。这两个指标能帮你在用户投诉之前发现问题比任何代码注释都管用。