拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot + Electron 实现 SSE 消息推送:从轮询到流式输出的实战指南

最近在给一个桌面端知识库问答工具做实时消息推送服务端用的是 Spring Boot客户端是 Electron Vue。最开始消息推送用的是接口轮询每 1 秒拉一次体验可以用“心电图”来形容——AI 回答像打字机一样一顿一顿地往外蹦服务端压力也不小。后来我换成了 SSEServer-Sent Events整个过程瞬间顺畅了很多。这篇文章就把我踩过的坑、服务端的封装思路、以及 Electron 这边如何正确处理 SSE 的消息完整讲一遍。如果你是第一次用 SSE或者已经在 Web 前端里用过但没在 Electron 里试过应该都能找到参考。1. 为什么是 SSE这套方案的选型理由1.1 轮询、WebSocket、SSE 三者怎么选做消息推送绕不开三件事轮询、WebSocket、SSE。很多人的第一反应是“实时通信肯定选 WebSocket”但实际业务里 WebSocket 并不总是最优解。轮询是最简单粗暴的前端定时发 HTTP 请求后端返回最新数据。缺点很明显——大部分请求是空轮询浪费带宽和服务端连接资源。对付“机器状态监控”这种低频场景还行但在“大模型流式输出”这种场景下UI 会卡顿而且每秒钟的轮询请求堆积起来很吓人。WebSocket 是真正意义的全双工长连接客户端和服务端可以互相推消息。但它也有代价协议复杂需要处理握手、心跳、断线重连、消息帧解析服务端要做连接管理器客户端也要维护一套状态机。如果你只是“服务端往客户端单向推消息”用 WebSocket 有点像开卡车去取快递。SSE 走的是纯 HTTP 协议服务端打开一条长连接客户端用浏览器内置的EventSource接口订阅就能持续收到text/event-stream格式的数据。它天然支持自动重连服务端代码也简单Spring Boot 直接封装了SseEmitter类不用引入额外依赖。三者的简单对比如下维度轮询WebSocketSSE通信方向双向但靠请求触发全双工仅服务端到客户端实现复杂度低高低HTTP 协议是需要升级握手是自动重连无需自己实现内置适合场景低频状态刷新聊天、游戏、协作AI 流式输出、通知推送1.2 SSE 在 AI 流式输出上的优势最近两年,很多开发者在做 AI 对话功能时都在用“SSE 流式输出”。原因很简单大模型回答是一段一段生成的服务端拿到一个 token 就推一个 token客户端看到的效果就是“一个字一个字蹦出来”交互上很像真人打字。这个体验用 WebSocket 也能做但多了一道“封装协议”的工序还要处理连接关闭、重连、心跳检测调试成本更高。SSE 借助EventSource的自动重连机制浏览器断网之后能够自动恢复连接而且可以通过Last-Event-ID告诉服务端上次推到哪儿了服务端可以在断点处继续推。对 AI 聊天这种“长文本连续生成”的场景这个能力几乎是为它量身定做的。另外SSE 传输的是标准文本流中间要是夹杂了一段异常日志、报表数据、或者是系统通知只要消息格式定义好前端拿到后可以直接分类渲染。这一点在 Electron 桌面端特别有用因为桌面端经常要把后台任务状态推送到多个窗口还要兼顾系统通知SSE 的接入成本低改动少。2. Spring Boot 服务端SSE 推送的核心实现2.1 基础依赖与配置Spring Boot 里做 SSE 推送不需要装额外依赖核心就是spring-boot-starter-web里自带的SseEmitter。如果你的项目用的是 Spring Boot 2.x 或 3.x都能直接用。最简单的服务端代码只需要三步创建SseEmitter保存到连接管理器异步推送消息。但实际项目中绝对不能这么草率至少要考虑超时、心跳、连接清理以及和 AI 模型的流式对接。先看一个最小可用例子的结构RestController RequestMapping(/api/sse) public class SseController { private final MapString, SseEmitter emitters new ConcurrentHashMap(); GetMapping(value /connect/{clientId}, produces text/event-stream;charsetUTF-8) public SseEmitter connect(PathVariable String clientId) { SseEmitter emitter new SseEmitter(0L); // 不主动超时用 0L 表示长期有效 emitters.put(clientId, emitter); emitter.onCompletion(() - emitters.remove(clientId)); emitter.onTimeout(() - emitters.remove(clientId)); emitter.onError((e) - emitters.remove(clientId)); // 先发一条初始化消息让客户端确认连接成功 try { emitter.send(SseEmitter.event() .name(init) .data(connected)); } catch (IOException e) { emitter.completeWithError(e); } return emitter; } PostMapping(/push/{clientId}) public String push(PathVariable String clientId, RequestBody String message) throws IOException { SseEmitter emitter emitters.get(clientId); if (emitter ! null) { emitter.send(SseEmitter.event() .name(message) .data(message)); return ok; } return client not found; } }这里有一个最容易被忽略的点produces text/event-stream;charsetUTF-8。如果不写charsetUTF-8中文在部分系统上会乱码。另外SseEmitter的构造函数接收超时时间单位是毫秒0L表示永不超时。一般生产环境不建议直接设0L因为长时间空闲连接会占用服务端资源后续我会专门讲心跳和超时配合。2.2 多客户端管理和连接清理真实项目里不可能只有一个客户端连接所以要用一个公共的连接管理器。上面代码里用了ConcurrentHashMapString, SseEmitter以clientId作为 key。这个方案足够支撑中小规模场景例如同时在线几百个客户端的桌面通知系统。但要注意几个细节连接管理的增删动作要保证线程安全ConcurrentHashMap能做完基础保障。客户端断线时Spring 会自动触发onCompletion、onTimeout、onError三个回调之一。你要在这三个回调里清理 Map 中对应的连接否则会积累内存泄漏。如果客户端不是主动断开的服务端不会立刻感知到只能靠心跳超时。所以连接管理器里最好还保存“最后活跃时间”定期扫一遍把超时的连接清理掉。给连接加一个“过期时间”的优雅方式是用一个DelayedQueue或ScheduledExecutorService每 30 秒扫描一次emitters把超过 N 分钟没有消息活动的连接complete()。实际项目中我们就是额外记了一个MapString, Long lastHeartbeat定时任务里对比当前时间和最后心跳时间超过阈值就清理。2.3 对接 AI 模型流式输出做 AI 聊天场景时Spring Boot 服务端要做的不是“拿到完整回答再返回”而是“拿到一部分就推一部分”。假设你的后端要调用某个大模型平台的 HTTP 流式接口一般会返回一个可以按行读取的流。你需要一个转换器读一行解析一行然后把解析出的token推给客户端的SseEmitter。这里的关键是异步执行。我在实际项目里用的是CompletableFuture.runAsync 独立线程池避免把 Servlet 线程阻塞住Service public class ChatService { private final ExecutorService aiExecutor Executors.newFixedThreadPool(8); public void streamAnswer(String clientId, String question) { CompletableFuture.runAsync(() - { try { // 这里只是示例伪代码实际调用大模型 InputStream inputStream modelApiClient.streamChat(question); try (BufferedReader reader new BufferedReader( new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { // 大模型流式返回通常是按 SSE 格式封装的 if (line.startsWith(data:)) { String token parseIdToken(line); SseEmitter emitter emitterManager.get(clientId); if (emitter ! null) { emitter.send(SseEmitter.event() .name(token) .data(token)); } } } } } catch (Exception e) { // 记日志把错误也推送出去 emitterManager.sendError(clientId, e.getMessage()); } finally { // 推送完成后关闭连接让客户端结束 emitterManager.complete(clientId); } }, aiExecutor); } }有几个细节值得展开用try-with-resources一定要包住InputStream防止流没关导致连接泄漏。parseIdToken(line)要自己写解析逻辑。大模型平台返回的数据格式通常是data: {choices:[{delta:{content:答案片段}}]}你只需要把里面的content字段抽出来。emitter.send()如果抛IOException说明客户端已经断了这时候不需要继续调模型接口应该立刻break并关闭连接省流量也省计算资源。2.4 心跳与空闲超时的配套方案长时间保持一条 SSE 连接中间如果没有任何数据代理服务器可能认为连接已死。这也是为什么很多人在生产环境遇到“连接 1 分钟自动断开”的原因。解决方式很简单服务端每隔 15 到 30 秒往客户端发一条注释数据作为心跳。Spring Boot 的SseEmitter接收注释格式的数据可以用如下方式发送emitter.send(SseEmitter.event() .comment(heartbeat) .data(ping));在浏览器端EventSource收到这种event: heartbeat事件会触发监听不影响正常业务消息。但注意有些浏览器对EventSource的默认超时并不敏感真正的瓶颈是 Nginx 或者负载均衡器的proxy_read_timeout。我遇到过最典型的报错是stream disconnected before completion: idle timeout waiting for SSE这句话直译就是“连接空闲超时”。服务端明明还在等用户发消息但网关因为一段时间没收到数据就当成了死连接。这时候有两个思路给服务端加心跳让 SSE 连接在空闲时间也有数据流动。调大 Nginx 的proxy_read_timeout比如从默认 60 秒调到 600 秒。心跳间隔不要太短太短会增加网络开销也不要太长否则网关还是可能在你发送下一个心跳之前断掉。我一般设为 25 秒配合 Nginx 的proxy_read_timeout 60s刚刚好。3. Electron 客户端主进程、渲染进程与 IPC 通信3.1 Electron 的进程模型和 Vue 是什么关系Electron 应用可以简单分成两层主进程和渲染进程。主进程负责创建窗口、管理生命周期、访问系统能力渲染进程负责页面 UI跟我们平时写的 Vue 组件完全一样。所以“Electron 主渲染进程 IPC 通信和 Vue 有关系吗”答案是没关系。Vue 只管页面显示IPC 是 Electron 的底座能力两者互不干扰。但在实际开发里Electron 渲染进程出于安全考虑默认禁用了 Node.js 环境。如果你直接在 Vue 组件里用eventSource这种浏览器 API它能用但如果你想调用 Node 的fs模块、弹系统通知、访问本机资源就必须通过 IPC 让主进程来做。我建议的安全配置是在BrowserWindow创建时把nodeIntegration设为false并开启contextIsolation: trueconst { BrowserWindow } require(electron); const win new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), nodeIntegration: false, contextIsolation: true, }, });contextIsolation: true会把 preload 脚本和页面脚本隔离在各自上下文里避免页面脚本里注入外部代码后直接拿到 Node 权限。这是 Electron 安全基线务必开。3.2 渲染进程用 EventSource 接收 SSE如果只是接收 SSE 消息渲染进程直接用浏览器内置的EventSource就够了。Vue 组件里可以这样写const eventSource new EventSource(http://localhost:8080/api/sse/connect/ clientId); eventSource.addEventListener(init, (event) { console.log(连接建立:, event.data); }); eventSource.addEventListener(token, (event) { // 把新 token 追加到 UI 的文本流里 answerText.value event.data; }); eventSource.onerror (err) { // EventSource 会自动重连不需要手动处理太多 console.error(SSE 连接异常尝试重连中..., err); };注意EventSource有一些雷区默认只支持 GET 请求不能自定义 Header也不能带请求体。如果服务端要求鉴权比如要传Authorization你可以把 token 放在 URL 查询参数里或者改用fetch手动解析流。服务器响应的 MIME type 必须是text/event-stream否则EventSource不会触发message事件只会触发error。EventSource自动重连后会带一个Last-Event-ID请求头服务端如果实现了断点续传就能从断点继续推。3.3 IPC 桥接把 SSE 消息变成系统通知很多桌面应用的效果是窗口缩到托盘或者不在前台时AI 回答完问题需要弹一个系统通知。这种情况就不能只靠渲染进程的EventSource了因为渲染进程没有系统级推送能力。我的做法是通过 IPC 把消息抛给主进程主进程再调用 Electron 的NotificationAPI。preload 脚本中暴露一个安全接口// preload.ts import { contextBridge, ipcRenderer } from electron; contextBridge.exposeInMainWorld(sseBridge, { sendNotification: (title: string, body: string) { ipcRenderer.invoke(show-notification, { title, body }); }, closeWindow: () { ipcRenderer.send(close-main-window); } });主进程里监听// main.ts import { ipcMain, Notification } from electron; ipcMain.handle(show-notification, (_event, { title, body }) { if (Notification.isSupported()) { new Notification({ title, body }).show(); } });渲染进程的 Vue 组件里收到 token 时如果判断当前窗口不可见就调用window.sseBridge.sendNotification(AI 回答完成, 已生成新回答)。这套设计的好处是渲染进程保持干净所有操作系统能力都收敛在主进程后续要加菜单、加托盘、升级包管理都不用再改动页面逻辑。3.4 abort 取消请求的正确姿势如果你做的是 AI 对话功能用户很可能在回答还没结束时点了“停止”。这时候前端不能只清空 UI还要让服务端停止继续调用大模型。最稳妥的方式是用AbortController控制一个流式请求而不是直接用EventSource。示例代码const controller new AbortController(); const response await fetch(/api/ai/stream, { method: POST, body: JSON.stringify({ prompt }), headers: { Content-Type: application/json }, signal: controller.signal, }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); while (true) { const { value, done } await reader.read(); if (done) break; const text decoder.decode(value); chunkHandler(text); } // 停止按钮 function stop() { controller.abort(); }这种方式的优点是可以自定义请求头和请求体鉴权方便也支持 POST 请求。缺点是需要自己解析SSE分割的 chunk不能直接用EventSource的事件绑定。实际项目中我一般会封装一个SseClient工具类内部用fetchReadableStream对外暴露onmessage、onerror、close方法。服务端收到连接断开后应当在读取大模型流的同时检查客户端连接状态一旦IOException就停止调用。这样用户点了“停止”服务端资源也能及时释放。4. 实战中的坑超时、乱码、线程阻塞与重连策略4.1 网关空闲超时的完整排查思路开头提到过idle timeout waiting for SSE这是我在测试环境里踩得最深的一个坑。排查时可以按下面顺序走先看客户端是不是长时间没有收到消息。如果是优先看 Nginx/网关配置。确认网关上proxy_read_timeout和proxy_send_timeout设置。默认 60 秒对于 25 秒心跳来说够用但如果你自己改了 20 秒就要调成70s或者更长。确认服务端定时器是否真的发出去了。SSE 连接空闲会泄漏但不会“假活跃”只有实际写入数据才能保住连接。我当时就是只写了注释消息没写换行符结果没有达到“发送数据”的效果。最后还可以在服务端用一个拦截器记录 SSE 请求的访问日志看连接建立时间和最后写入记录之间的间隔多台机器对比就很容易定位问题。我在项目中给心跳加了一个“最后写入时间戳”每发送一条心跳就更新它如果超过 30 秒没有成功写入就主动complete()连接然后让客户端自动重连。这样既保活也能及时回收病态连接。4.2 中文乱码、字符截断的避坑方案SSE 乱码大多数原因是服务端响应头没有指定编码。务必在接口注解中固定为produces text/event-stream;charsetUTF-8如果用的是 Spring MVC还可以在方法上额外加ResponseBody并确认没有全局字符串转换器干扰。另一种情况是字符截断。因为 SSE 是按 UTF-8 字节流推送的如果你在大模型返回的流中按行切割一个中文字符可能被拆成两个 chunk。为了避免切出半个字符统一用BufferedReader按行读并保证客户端用TextDecoder(utf-8)解码。如果你是用fetch ReadableStream做的要把decoder.decode(value, { stream: true })传入这样能自动处理多字节字符跨 chunk 的情况。4.3 全局过滤器对 SSE 连接的隐形影响很多项目会在 Spring Boot 里加全局过滤器用来做统一鉴权、日志、XSS 过滤。这些过滤器如果对SseEmitter的响应做了包装比如压缩 Gzip、打印响应体很可能导致实际字节流被缓冲前端迟迟收不到消息。我遇到过两个典型问题过滤器开启了response.setBufferSize(bufferSize)错误地缓冲了 SSE 输出导致第一次emitter.send()后没有立即到达客户端。过滤器在chain.doFilter()之后往response里写了额外内容导致text/event-stream被破坏客户端直接断开。解决方案很简单在过滤器里显式跳过 SSE 路径的包装逻辑。例如用HttpServletResponseWrapper时先判断请求 URI 是否以/api/sse结尾如果是就直接放行不包装response。4.4 Electron 打包后 SSE 连接失效Electron 开发模式下 API 地址通常是http://localhost:8080打包后这个地址就不能写死了。常见原因包括生产环境访问的是https://地址但服务端没配 SSL 证书或没开 CORS。API 地址写死在了前端代码里打包后无法修改。我的做法是支持环境变量配置文件config.js在 Electron 启动时动态读取。Electron 渲染进程如果启用了webSecurity: true对跨域请求要求严格服务端必须返回正确的 CORS 响应头或者通过主进程的net模块转发请求避免 CORS。如果你用了比较新的 Electron还可以用主进程的net.fetch或session.webRequest统一拦截网络请求给 SSE 请求附加自定义头。这样既能绕开 CORS又能统一管理 token。这个方法不便宜但对大型桌面应用很值得做。4.5 线程泄漏与连接清理策略SSE 本身是异步长连接如果不在页面打开时创建、关闭时销毁内存和连接数会缓慢增长。Electron 端尤其要注意当你打开了很多窗口每个窗口都建了EventSource关窗口时如果没有显式close()渲染进程虽然销毁了但浏览器底层 socket 可能还挂着一段时间。所以 Vue 组件销毁前一定要做onBeforeUnmount(() { if (eventSource) { eventSource.close(); } controller?.abort(); });服务端也要配套SseEmitter的onCompletion/onTimeout/onError回调必须把连接从ConcurrentHashMap里移除。我见过不少资源耗尽案例就是这里少写了emitters.remove(clientId)导致每次页面刷新都留下一个死连接。5. 经验沉淀与扩展方向实际做下来我觉得 Spring Boot SSE Electron 这套组合非常适合“服务端主动推送、客户端被动接收”的场景。如果只是做 AI 流式回答甚至不需要 WebSocketSSE 足够支撑从“用户提问”到“完整回答渲染”的整个生命周期。要注意的是服务端要做好连接生命周期管理客户端要在合适的时机关闭和重连中间加一层心跳保活这套体系基本就稳了。如果后续要扩展我会优先考虑三件事一是把 SSE 连接按用户和会话维度做隔离支持断点续传二是给连接管理器加上数量上报和监控配合 Grafana 看在线连接数三是在 Electron 端封装统一的SseClient支持自动重连、错误码分类、请求取消。这三个方向做扎实整个推送体系基本就没什么可担心的了。最后分享一个个人经验在联调时不要只测试“正常情况”一定要专门用浏览器开发者工具模拟网络断开再恢复看看EventSource重连之后能否继续收到后续消息。这个测试能帮你一次排查掉大多数底层问题省去后面大量无头绪的排查时间。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门