尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SSE生产级实践:断线重连与超时降级的完整方案

SSE生产级实践:断线重连与超时降级的完整方案 SSE这技术在Java面试里是越来越常见了。你只要把“基于HTTP长连接、text/event-stream、服务端主动推送、EventSource自动重连”这四句话说出去面试官基本都会点头。但点头归点头等你真把代码写出来部署到生产环境十有八九要翻车连接半夜断了、消息推不出去、Nginx那边给你断开、客户端日志里刷出一排before completion: idle timeout waiting for sse。我之前做在线文档协作服务时就是踩遍了这些坑才把SSE方案慢慢磨出来的。今天这篇文章我会把一套经过线上验证的SSE生产方案完整拆开讲主线就是标题里说的两件事断线重连和超时降级再带上鉴权、网关、线程模型、监控这些配套。适合两类人看一类是准备在简历里写“熟悉SSE”、想扛住面试官连环追问的另一类是线上SSE连接一多就断、告警停不下来、正在抓狂排查的。1. 先想清楚为什么你的SSE连测试都过不去很多同学对SSE的理解停留在“返回一个SseEmitter就行”的层面。但SSE并不是一个本地对象它是一条从客户端发起、服务端保持、可能存活几分钟甚至几小时的HTTP连接。这条连接上任何一环出问题整个链路就断了。1.1 你以为的推送本质上是一个“写不完的HTTP响应”普通HTTP接口像快递签收客户端发一个请求服务端把包裹响应给你签收完连接就结束。SSE完全不一样——客户端发出的请求还是普通的HTTP GET但服务端拿到请求后不结束响应而是把响应头固定成Content-Type: text/event-stream然后只要连接还活着就持续往响应体里写数据。浏览器这边SSE通常用EventSource对象消费。它的工作方式是把服务端发来的数据按事件格式解析然后触发onmessage、onopen、onerror这些回调。事件格式有严格约定最核心的字段是这几个data:表示消息正文可以有多行多行会被拼接成一个事件。id:给事件一个唯一ID客户端重连时可以把这个ID通过Last-Event-ID请求头带回给服务端。event:自定义事件类型前端需要addEventListener监听。retry:告诉浏览器断线后隔多久重试单位是毫秒。以冒号:开头的行是注释浏览器会忽略但协议层面它算“有数据流动”。面试时能把这几行说清楚就比大部分人强了。但放到生产环境光知道这些还不够因为真正杀掉连接的不是SSE协议本身而是连接链路上一层层看不见的超时机制。1.2 三个隐形杀手容器超时、空闲超时、网关超时先说容器超时。如果你用Spring Boot内嵌的Tomcat、Jetty或Undertow每种容器对异步请求都有自己的超时时间。Spring在创建SseEmitter时也有默认超时不同版本表现略有差异但通常不会太久。我见过太多同学直接new SseEmitter()就返回了结果连接挂在那里30秒左右就被容器主动关闭前端表现就是EventSource来回重连页面数据出不来。再说空闲超时。这词对应着你可能见过的报错before completion: idle timeout waiting for sse。SSE是长连接但它不是随时都有数据可发。假如你的业务是“文档一有变更就推给用户”用户可能10分钟都不编辑那服务端和客户端之间就出现了10分钟的空闲期。很多WebClient、Netty、网关组件都有空闲超时保护一旦发现这条连接长时间没有数据流动就会判定它是僵尸连接主动断开。字面翻译一下连接还没completed但是idle超时了连接被干掉了。第三个杀手是网关超时。大部分Java后端前面都挂着Nginx。Nginx的proxy_read_timeout默认只有60秒意思是后端在60秒内没有返回任何数据Nginx就自己断掉连接。如果服务端没做心跳即使Tomcat不杀你Nginx也会先动手。所以别觉得“业务逻辑没问题就万事大吉”。SSE要上生产第一步就是把容器、客户端、网关这一层层超时全部摸清楚然后在它们下面安排保活机制。1.3 为什么“写法对了”依然挂缺的是全局视角开发环境测SSE经常是“localhost直连后端”没有Nginx没有网关也没有客户端空闲超时所以一切正常。一上生产前面多了几层代理后面又接了网关和统一鉴权问题一下就全冒出来了。我总结过一句话SSE能不能跑起来看的是“连接生命周期管理”不是看“你调没调对API”。连接生命周期包括建立时的鉴权、存活期间的心跳、断线后的重连、超时后的降级、断开后的资源清理。任何一个环节没有设计SSE就上不了生产。标题里说90%的人写不上去我看一点都不夸张。2. 方案选型与整体设计思路别拿SSE当WebSocket用做技术方案的第一步不是写代码是选型。SSE、WebSocket、轮询这三者经常放在一起比较但它们的适用场景差别非常大。2.1 选型对比SSE、WebSocket还是干脆轮询我一直不建议为了“实时推送”这个需求一上来就上WebSocket。WebSocket确实强大双向、全双工、支持二进制帧但它的复杂度也摆在那里协议升级、心跳机制要自己实现、断线重连要自己写、鉴权要自己接。如果你的场景本质上只是“服务端单向推给客户端”那WebSocket就是杀鸡用牛刀。维度SSEWebSocket轮询连接方向服务端单向推送给客户端双向全双工客户端主动拉取协议基于HTTP兼容性极好独立协议需要升级握手普通HTTP自动重连浏览器自带需要自己实现每次轮询天然重连消息格式文本为主文本二进制任意HTTP响应实现复杂度低高最低适用场景通知、订阅、日志流、单聊聊天、游戏、协同编辑低频数据刷新在线文档的场景就很典型服务端要通知客户端“这篇文档被改过了”客户端并不需要给服务端实时发消息一个SSE就够。你还可以让前端在收到通知后再单独调一个REST接口拉最新内容这样SSE只负责“提醒”不负责“传输大对象”接口职责清晰流量也小。2.2 面试必问原理EventSource的重连比你想的聪明面试官问“SSE断线重连怎么做”如果你回答“前端把EventSource重新new一个”那基本就凉了。因为EventSource本身就带自动重连机制你要做的是理解它、利用它。EventSource一旦检测到连接断开默认等3秒就会自动重新发起请求。这个重试间隔可以由服务端返回的retry:字段动态修改。更关键的是Last-Event-ID机制每次收到带id:字段的事件浏览器都会记住这个ID当连接断开并重连时浏览器会在新请求里自动带上Last-Event-ID请求头。服务端拿到这个字段就知道该从哪里补发消息。所以服务端要做的事有两件第一推送每条事件时都带上id:这个ID最好是单调递增的第二接收到Last-Event-ID后从消息存储里把该ID之后的消息捞出来重新推送。这个就是SSE“断线不丢消息”的核心原理。把这些讲清楚面试官基本就会放你过了。2.3 生产方案的四个核心能力心跳、补偿、降级、可观测面试讲完了原理生产要落地的是能力。我给这套方案划了四条主线心跳保活定时往连接里发送协议注释行告诉每一层“这条连接还活着”。断线补偿用Last-Event-ID配合消息存储确保断线期间的消息不丢。超时降级客户端不能傻等一旦发现心跳停止或连接异常主动断开并切到备用通道。可观测性连接数、推送量、断线次数、重连次数全是指标线上出问题能快速定位。这四个能力和具体的Controller代码关系不大但决定了你这套SSE能不能在线上跑一个月不翻车。3. 生产级SSE落地断线重连 超时降级完整实现下面进入实战。我以Spring Boot为例把后端和前端的关键代码、配置逐个展开。这套代码不是我临时拼的是从线上项目里抽出来的基本可以直接改改就能用。3.1 服务端基础SseEmitter创建与超时控制Spring MVC里SSE最常用的载体是SseEmitter。先看最基础的用法RestController RequestMapping(/api/sse) public class SseController { private final SseSessionManager sessionManager; public SseController(SseSessionManager sessionManager) { this.sessionManager sessionManager; } GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String token) { // 0表示不超时但后面会讲这个0只对SseEmitter这一层有效 SseEmitter emitter new SseEmitter(0L); // 注册回调客户端断开或超时时清理资源 emitter.onCompletion(() - sessionManager.remove(token)); emitter.onTimeout(() - sessionManager.remove(token)); emitter.onError(e - sessionManager.remove(token)); sessionManager.add(token, emitter); return emitter; } }这里有几个点要特别强调。第一produces MediaType.TEXT_EVENT_STREAM_VALUE是必须的保证响应头正确否则浏览器EventSource不认。第二new SseEmitter(0L)表示不设置超时。但要注意这个0只管SseEmitter自身容器层的异步超时、网关层的超时依然会生效。所以别以为写了0就高枕无忧后面还要配合心跳和Nginx配置。第三onCompletion、onTimeout、onError三个回调一定要把连接引用从内存里清掉。很多线上内存泄漏、连接数只涨不降就是因为只add不remove。客户端断开后回调不触发引用就永远留在Map里了。3.2 心跳保活用注释行击穿所有空闲超时我前面提到idle timeout的问题根治办法就是让连接“永不空闲”。最简单有效的方案是定时往连接里发送一个协议注释行。浏览器EventSource收到注释行会忽略它不会触发任何事件但网络层面这算有数据流动所有超时检测器都会被刷新。心跳调度代码如下Component public class HeartbeatScheduler { private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); private final SseSessionManager sessionManager; public HeartbeatScheduler(SseSessionManager sessionManager) { this.sessionManager sessionManager; // 每15秒对所有活跃连接发送一次心跳 scheduler.scheduleAtFixedRate(this::broadcastHeartbeat, 15, 15, TimeUnit.SECONDS); } private void broadcastHeartbeat() { sessionManager.getAll().forEach((token, emitter) - { try { // 注释行客户端EventSource会忽略但能刷新连接活跃状态 emitter.send(SseEmitter.event().comment(heartbeat)); } catch (IOException e) { // 客户端已经断开直接结束这条连接并清理 emitter.completeWithError(e); } }); } }心跳间隔怎么定有一个经验公式心跳间隔必须小于所有层超时时间的一半以上。比如Nginx的proxy_read_timeout是60秒那心跳间隔就设在15到20秒千万不要设在45秒。因为网络抖动、GC停顿、调度延迟都可能拖慢心跳如果心跳间隔太接近超时上限一旦有一次抖动连接就保不住了。另外强烈建议用独立的调度线程池不要用业务线程池也不要借Spring的Scheduled默认线程池。原因是心跳任务如果堆积会拖慢所有定时任务。上面我用的是两个核心线程的独立池专门干这一件事线上跑下来非常稳。3.3 断线重连服务端支持Last-Event-ID补发心跳解决了“连接不被断开”的问题但现实是任何长连接都有被断开的可能比如用户手机切了网络、电脑休眠、公司WiFi切换。这时候靠EventSource自动重连但重连后如果不做消息补偿断线期间的数据就丢了。后端需要做两件事存消息、补消息。假设场景是文档变更通知消息存在一个简单的环形队列里。Controller改造如下GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String token, RequestHeader(value Last-Event-ID, required false) String lastEventId) { SseEmitter emitter new SseEmitter(0L); // 如果带了Last-Event-ID说明是断线重连补发这个ID之后的消息 if (StringUtils.hasText(lastEventId)) { long lastId Long.parseLong(lastEventId); ListNoticeEvent missedEvents eventStore.findAfter(lastId); for (NoticeEvent event : missedEvents) { try { emitter.send(SseEmitter.event() .id(String.valueOf(event.getId())) .data(event.getPayload())); } catch (IOException e) { emitter.completeWithError(e); return emitter; } } } sessionManager.add(token, emitter); return emitter; }对应的业务推送处每次发送事件时都必须带idemitter.send(SseEmitter.event() .id(String.valueOf(event.getId())) .name(notice) .data(event.getPayload()));前端最关心的一个点是EventSource自动重连时Last-Event-ID是浏览器自动带的完全不用前端手动处理。但如果你们的前端框架用了fetch或axios来读SSE流那Last-Event-ID就必须自己维护、自己加请求头了复杂度会高很多。所以能用EventSource尽量用EventSource别自己造轮子。3.4 超时降级客户端不傻等主动断开并切轮询超时降级是什么意思简单说就是连接已经不可用了但客户端还没反应过来。SSE连接断开后EventSource会自动重连但如果服务端挂了、网络出口被墙了、或者服务端一直不响应客户端的重连就会变成“无效重连”用户看到的就是页面一直在转圈。所以客户端必须有看门狗逻辑。思路很简单前端记录最后一次收到数据或心跳的时间然后定期检查这个时间如果超过阈值比如30秒就认为连接已经死了主动调用es.close()关闭EventSource然后降级到普通轮询接口兜底同时提示用户“实时连接不稳定已切换为自动刷新模式”。前端伪代码如下const es new EventSource(/api/sse/stream?token token); let lastHeartbeat Date.now(); // 服务端心跳是注释行默认EventSource不触发事件 // 可以用open事件或者自定义事件来判断收到数据的时间 es.addEventListener(notice, (event) { lastHeartbeat Date.now(); renderMessage(JSON.parse(event.data)); }); // 定时检查最后一次数据时间 setInterval(() { if (Date.now() - lastHeartbeat 30000) { // 超过30秒没有收到任何数据判定连接异常 es.close(); // 降级为轮询接口 startPollingFallback(); } }, 5000);这里有个细节EventSource的注释行不会触发任何事件所以如果只用心跳注释行保活前端lastHeartbeat根本不会刷新。解决方案有两种一是服务端除了发注释行再发一个不可见的业务事件二是前端利用onopen事件连接只要还开着就说明服务端活着。不过onopen只在初始连接建立时触发一次不能反映后续状态。更稳妥的做法是服务端心跳发一个独立的自定义事件比如event: heartbeat前端监听这个事件来刷新时间戳。这样代码量增加很少但可观测性大大增强。服务端也可以做超时降级。比如推送任务本身耗时很长那不要傻等任务完成再推数据可以先推一条status: processing的事件告诉前端“我还在处理”避免前端以为连接挂了。这个对用户体验提升非常大。3.5 鉴权方案EventSource带不了自定义Header怎么玩SSE鉴权是另一个高频面试题。浏览器EventSource只支持withCredentials这个配置不支持自定义Header所以你没法像axios那样直接把Authorization: Bearer xxx加进去。常见的方案有三种第一种URL参数带Token。最简单但Token会出现在网关日志、Nginx access log和浏览器历史记录里所以必须用短期Token或者一次性Token不能把长期有效的Token直接放URL里。第二种Cookie鉴权。EventSource和普通请求一样会自动携带同源Cookie所以可以在服务端的登录拦截器里直接读Cookie。这种方式对用户最透明但要注意跨域场景必须开启withCredentials同时服务端要处理CSRF风险。第三种用fetch代替EventSource。fetch请求可以自定义Header拿到ReadableStream后按SSE格式手动解析。这种方式最灵活但失去了EventSource的自动重连和Last-Event-ID能力实现成本高。我的建议普通场景用URL短token或Cookie非浏览器场景才考虑fetch流式解析。无论用哪种方案鉴权一定要放在过滤器或拦截器里统一处理不要在每个Controller里重复写。而且一定要在SSE响应头里加上Cache-Control: no-cache防止代理缓存导致连接被截断。4. 生产环境配套网关、线程、监控一个都不能少SSE连接一旦上了生产前面提到的各种底层超时、缓存、压缩都会成为变量。这一章全是实战配置照着抄能少踩很多坑。4.1 Nginx反代配置SSE三件套Nginx默认配置对SSE不友好尤其是proxy_buffering默认开启。缓冲区的意思是Nginx会把后端返回的数据攒够了再一次性发出去这对普通HTML接口是好事但对SSE是灾难——消息都攒在缓冲区里出不去实时性全没了甚至可能导致连接异常。SSE场景的Nginx配置我一般是这么写的location /api/sse { # 1. 关闭缓冲让数据实时流向客户端 proxy_buffering off; proxy_cache off; # 2. 空闲超时拉长配合心跳使用建议大于心跳间隔4倍以上 proxy_read_timeout 3600s; proxy_send_timeout 3600s; # 3. 确保HTTP/1.1并清空Connection头 proxy_http_version 1.1; proxy_set_header Connection ; # 4. SSE关闭压缩避免流式响应被压缩缓冲阻塞 gzip off; proxy_pass http://backend_server; }这里最容易被忽略的是gzip off。如果开着gzipNginx会把SSE数据流压缩后再转发看起来没问题但实际上压缩的缓冲机制可能让整个流变成一块一块地输出而且有些老版本的客户端对text/event-stream的压缩支持有问题。所以对SSE这个location直接把gzip关掉最省心。4.2 线程模型别让请求线程被长连接拖死SseEmitter这个API的设计是异步的Controller收到请求马上把emitter存起来然后返回请求线程就被释放了。真正的推送发生在后续的业务线程或调度线程里。这个模型本身没问题但如果你把SseEmitter当普通返回值又在业务处理里同步等待那就把异步接口变成同步接口了。我见过一个反面案例有人在Controller里用CountDownLatch等数据生成完成才返回emitter结果并发一高Tomcat线程池直接被打满整个应用卡死。正确做法是Controller只负责“建立连接、返回emitter”推送逻辑全放到独立的业务线程池里执行。任务提交到线程池后Controller立刻返回。另外所有发送操作都要捕获IOException并把异常当成“连接已断开”的信号。不要在网络异常时继续发也不要无限重试否则会产生大量垃圾日志和无效IO。4.3 可观测性连接数、断线数、重连数全部指标化SSE线上出问题最怕的是“客户端一直重连但服务端看不到”。我强烈建议你们给SSE做一套基础监控。最简单的做法用一个AtomicInteger统计活跃连接数在onCompletion和onTimeout里递减。再加几个计数点连接建立、心跳发送失败、消息推送失败、Last-Event-ID补偿条数。Spring Boot项目还可以直接接Actuator把这些统计暴露成一个自定义EndpointComponent public class SseMetrics { private final AtomicInteger activeConnections new AtomicInteger(); private final AtomicLong totalPushedMessages new AtomicLong(); private final AtomicLong totalReconnectTimes new AtomicLong(); public void incrementConnections() { activeConnections.incrementAndGet(); } public void decrementConnections() { activeConnections.decrementAndGet(); } public int getActiveConnections() { return activeConnections.get(); } }当线上有人反馈“消息收不到了”你看监控面板如果活跃连接数从1000掉到200说明是连接批量断开优先查Nginx和容器超时如果活跃连接数没变但推送次数为0说明业务推送线程有问题如果重连次数很高说明网络层不稳定。有数据做支撑排查速度至少快十倍。5. 常见问题与排查技巧实录最后这部分我把线上和面试中最常遇到的问题整理成速查表再分享几条反复念叨的经验。5.1 高频报错与对应解法现象 / 报错根因解决办法before completion: idle timeout waiting for sse服务端长期没有数据客户端或网关空闲超时断开服务端加心跳确保有数据流动stream disconnected before completion: idle timeout waiting for sse同上常见于流式客户端读取SSE场景检查客户端读空闲超时配置同时服务端加心跳EventSource一直重连控制台报401/403鉴权失败检查URL token或Cookie是否有效查看监听器鉴权逻辑连接建立后几秒到几十秒就断开SseEmitter超时 / 容器超时 / Nginx超时三层超时统一设置心跳间隔留足余量页面消息没有实时性一批一批到达Nginx缓冲或gzip压缩关闭proxy_buffering和gzip活跃连接数只增不减客户端断开后资源未清理检查onCompletion/onTimeout回调是否移除引用大量IOException堆积客户端已断开服务端还在推发送失败时把IOException当断开信号结束连接并清理资源5.2 踩坑实录几条我反复念叨的经验第一心跳一定用注释行不要用普通事件。很多同学图省事心跳直接用emitter.send(heartbeat)这会触发前端onmessage如果前端没做过滤就会不停处理一堆空消息白白浪费性能还可能干扰业务逻辑。协议注释行天然被忽略是最优雅的保活方式。第二超时时间要“层层留余量”。经验做法是Heartbeat间隔 Nginx read_timeout的一半 容器超时时间。比如心跳15秒Nginx设300秒Tomcat async timeout设600秒这样即使网关比预期慢一点也不至于断链。第三SseEmitter超时参数设0不意味着连接永恒。这个0只对SseEmitter这一层有效前面的Nginx、后面的防火墙都有各自的空闲检测策略。所以把“超时0”当成兜底方案可以真正的保活还是靠心跳。第四不要在心跳或推送方法里做耗时I/O。有人会把DB查询、Redis读取直接写在心跳任务里一旦查询慢整个心跳发送就堵住了所有连接全部跟着遭殃。心跳方法体要保持极简只做send不做别的。第五生产上线前一定要压测“长连接保持”。普通并发压测只测峰值很难发现SSE问题。我建议压测场景设计成建立1000条SSE连接持续运行1小时以上同时每分钟推送一次消息观察连接数是否有下降、内存是否增长、心跳是否稳定。很多问题不跑到半小时以上根本暴露不出来。5.3 上线前检查清单每次新项目接SSE我都会按这个清单逐项检查服务端SseEmitter是否显式设置了超时时间而不是裸用默认值。是否有独立的心跳线程池心跳间隔是否小于所有网关超时的一半。所有推送事件是否携带id服务端是否支持Last-Event-ID补发。客户端是否有超时看门狗是否配置了降级轮询逻辑。Nginx location是否关闭缓冲和gzipproxy_read_timeout是否足够。鉴权逻辑是否在所有SSE接口前统一生效。连接建立和释放是否有日志活跃连接数是否有指标监控。服务重启时前端能否自动重连并恢复消息。这套方案从代码到配置全落地SSE才算真正达到生产可用水平。我自己实际跑下来最大的感受是SSE本身并不难难的是把“连接生命周期”当成一条完整链路来设计任何一环漏了线上就会用报错来教育你。把这套模板沉淀成你们团队的通用组件以后接任何推送类需求都只是替换消息体的问题。
返回列表