ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSE服务端事件流实战:从进度条到大模型流式输出的完整实现

SSE服务端事件流实战:从进度条到大模型流式输出的完整实现 1. 项目概述SSE进度条到底要解决什么问题1.1 核心需求解析进度条背后真正要解决的痛点如果你做过带文件上传、视频转码、数据导出或者AI大模型对话的产品一定遇到过这个场景用户点了一个按钮然后盯着Loading转圈几秒甚至几十秒过去了页面上没有任何反馈用户要么刷新页面重试要么直接退出。这不是用户体验问题这是产品信任问题——用户不知道任务到底跑没跑、跑到哪了、还要等多久。进度条的核心价值其实不是“展示一个百分比数字”而是把服务端正在发生的状态变化实时、可靠地同步给前端。用我最常说的一个比喻普通HTTP请求就像你打电话问快递员“货到哪了”他只能告诉你“还在路上”而SSE就像是快递公司给你开了个实时物流轨迹推送每一站扫描都有通知你在手机上看着包裹从发货到派送心里踏实。在这个项目里我需要实现的服务端事件流Server-Sent Events简称SSE进度条功能解决的正是这个“过程可视化”的需求。它不像WebSocket那样需要复杂的双向通信协议和独立的服务器资源维护也不像Ajax轮询那样需要定时去打接口制造无效请求——SSE走的是常规HTTP协议由服务端单向持续推送事件前端通过EventSource接口自动接收实现从“问一次答一次”到“连上就一直报”的转变。那这个技术适合谁来学、适合什么场景用如果你正在做大模型问答的实时回复渲染、长任务进度的前端展示、文件处理/转码的状态同步或者任何需要“服务端主动找前端说话”的功能建议认真看完这篇文章。我在里面会顺着真实项目的推进顺序把SSE的实现原理、Java后端接入、React前端渲染、Android端处理以及最容易踩的坑全部过一遍。1.2 方案选型为什么选SSE而不是WebSocket或轮询先摆结论在“进度条”和“大模型流式输出”这两个场景下SSE是综合成本最低、稳定性最高的方案。为什么我给你拆开对比一下。先看轮询。轮询是最原始的思路前端每隔2秒或者3秒发一次请求问服务端“现有结果了吗”。小规模用户量、单机部署、任务周期短的情况下能用但问题很直接第一绝大多数请求都在做无用功服务端根本没变化还得给你返回一包没变化的JSON白白消耗带宽和数据库连接第二实时性是“卡点”的2秒轮询最多只能感知到2秒前的状态做不到真正的实时。更要命的是当并发用户多起来之后轮询把服务端的压力放大了好几倍简直就是拿基础设施的寿命换开发省事。再看WebSocket。WebSocket确实是全双工的服务端能主动推前端也能主动发看着什么都能干。但你要想清楚WebSocket在架构上是一个独立于HTTP的协议需要服务端单独维护协议切换、长连接管理、心跳检测、断线重连一旦中途要经过Nginx这类反向代理还得额外配置Upgrade头、调整超时时间部署复杂度和运维成本明显上一个台阶。而且如果你的WebSocket服务端没有设计好推送频率和消息大小很容易把连接打爆。来看个对比表格会更直观维度轮询WebSocketSSE通信方向单向前端主动双向单向服务端主动协议基础HTTP独立协议握手走HTTPHTTP实时性取决于轮询间隔最高高连接维护代价低但请求量大高协议状态管理低浏览器自动管理自动重连无需业务实现无需业务实现内置机制穿透代理/网关容易需额外配置容易适合场景低频数据同步双向实时交互如聊天、游戏服务端单向推送进度、通知、流式文本SSE恰好站在中间的位置实时性够用实现只需要服务端把响应头设置成text/event-stream保持连接不关闭前端new EventSource(url)就能接入连重连逻辑都给你内置好了。这不是说SSE比WebSocket先进而是在“进度条”这个具体场景里SSE是那种“杀鸡用牛刀但刚好够用”的方案。另外还要说一个很多初学者不知道的点SSE的数据格式是纯文本的UTF-8流这意味着你不但可以推JSON还可以直接推文本片段。这一点在做大模型流式回答的时候特别占便宜——模型生成一个token推一个token前端收到就直接追加渲染用户看到的就是“打出来的字”这就是热词里说的“通过SSE流式输出实现大模型回答实时渲染”的本质。2. 深入SSE协议看懂事件流格式与连接生命周期2.1 EventSource事件流格式解析data、event、id、retry很多同学SDK用得很溜但对SSE背后的协议格式一知半解。这里必须补上因为一旦你开始接触Java底层的Emitter写入或自己封装协议不懂格式会出大问题。SSE的协议格式由MIME类型为text/event-stream的响应体承载本质就是一段持续不断的文本流。每一条消息由若干个字段行组成字段之间用换行符\n或\r\n分隔不同消息之间用空行分隔。最核心的字段有四个data事件数据内容。可以有多行多行data会被前端合并为一条消息用换行符连接。event事件类型。缺省时前端触发onmessage指定了则触发对应名称的addEventListener。id事件ID。浏览器会记住最后一条消息的ID重连时通过Last-Event-ID请求头发给服务端用于断点续传。retry重连时间。告诉浏览器断开后隔多少毫秒再重连默认是3000ms。举个例子服务端推送一段进度更新的消息在网络上实际传输的原始字节长这样event: progress id: 1 data: {taskId: abc123, percent: 10, status: processing} event: progress id: 2 data: {taskId: abc123, percent: 45, status: processing} event: done id: 3 data: {taskId: abc123, percent: 100, status: success}这里每个事件之间用空行分割。前端用EventSource接的时候如果没注册addEventListenerprogress和done都走不到onmessage里去因为默认事件只处理没有event字段的消息。这一点我在第一次调试时踩过坑后文还会细说。理解这个格式的意义在于协议本身就是“文本行 空行分割”的简单结构你完全可以在不用任何SSE库的情况下用Socket级别的输出流手动拼接字段来推送。Java后端SseEmitter封装了这些细节但如果你要深度定制、或者排障时用curl直接模拟连接看原始数据流协议知识就是刚需。比如我在开发流程里会用这样一条curl命令验证服务端是不是真的在推流curl -N -H Accept: text/event-stream http://localhost:8080/api/progress/task/abc123看到终端里一行行刷出来的data字段说明服务端没问题问题在前端解析或网络链路。2.2 连接生命周期与自动重连机制SSE连接的生命周期是理解整个功能的关键前端发起HTTP请求服务端收到后返回200状态码和一串text/event-stream的响应头随后保持这个连接不关闭继续往响应流里写数据。从这一刻起连接就处于“打开”状态直到发生以下几种情况才会关闭服务端主动完成推送并关闭客户端调用abort或者关闭页面网络异常导致TCP连接断开反向代理触发空闲超时。其中最容易忽略的是浏览器自动重连机制。原生EventSource在你断开连接后会自动重新发起请求并且用Last-Event-ID带上次收到的最后一条id。这个设计的意图很明确保证消息不丢。但具体到业务里你要想清楚——如果任务已经执行完了重连之后服务端还需要把最终结果再推一次否则前端会一直挂在等待状态。这也是为什么我在做后端的时候用一个内存Map把每个任务最后N条事件缓存下来收到带Last-Event-ID的请求后从这个ID之后开始补推。有缓存和没缓存在弱网环境下的体验差距是肉眼可见的。还有一个运维层面的细节因为SSE是长连接反向代理服务器以Nginx为例默认的proxy_read_timeout是60秒如果60秒内服务端一条数据都没写Nginx会主动掐断这个连接。所以后端必须做心跳每隔十几秒写一个冒号开头的注释行SSE规范里以冒号开头的行是注释会被浏览器忽略但不影响连接存活或者一个自定义event: ping的包让连接不至于被代理误杀。2.3 为什么SSE特别适合进度条和流式文本渲染我想用一个具体场景把这一点打透假设你在做视频转码一个转码任务要分为“读取源文件、初始化编码器、逐帧转码、封装输出、清理临时文件”五个阶段总共耗时约40秒。用传统轮询你每隔3秒拿一次进度最多能画出一个阶梯状的进度条用户看到的是“跳着走”用SSE服务端每处理完一帧就推一次进度前端拿到的就是一个平滑递增的百分比曲线。同样是进度条后者给用户的感知是“系统正在流畅工作”而前者更像“卡了然后突然跳一下”。再来看大模型回答的渲染。大模型生成回答是流式的——模型本身一边生成一边输出token而不是等全部生成完再一次性返回。这就形成了一个天然的“生产-消费”管道模型每生成一个token服务端就通过SSE推一个data片段前端收到后直接追加到对话气泡里。用户看到的效果是字一个个蹦出来跟ChatGPT官网的效果一致。如果不用SSE你就只能等模型全部生成完了再让用户看到完整回答——对于动辄几十秒的生成时间来说那种体验几乎等于把用户丢进了一个黑盒。所以我一直觉得选型不只是看技术参数更要看技术是否匹配“用户感知”。SSE在进度条和流式渲染这两个场景里胜出不是因为协议复杂而是因为它的模型最简单一条单向管道源源不断随到随推。3. Java后端落地用SseEmitter完整实现进度推流3.1 基于Spring Boot搭建SSE推流接口我后端的实现基于Spring Boot 2.x/3.x核心类是org.springframework.web.servlet.mvc.method.annotation.SseEmitter这是Spring MVC自带的支持类不需要额外引依赖。如果你用的是Spring WebFlux对应的方案是Flux.just(...)配合MediaType.TEXT_EVENT_STREAM不过我这篇文章主讲Servlet技术栈的应用更符合大部分老项目的实际情况。先上一个最基础的结构一个Controller接收前端传过来的任务标识返回SseEmitter对象然后在另一个业务线程里往这个Emitter里写数据。这里的关键是理解Spring MVC的异步机制——SseEmitter类型的返回值会让Spring把HTTP连接交给异步处理容器Controller方法直接返回真正的时间消耗放在业务线程里Tomcat的工作线程不会被长期占用。如果你在方法体里sleep阻塞那跟普通接口没区别连接数一多Tomcat线程池就得被打满。最基本的代码长这样RestController RequestMapping(/api/progress) public class ProgressController { private final MapString, SseEmitter emitters new ConcurrentHashMap(); GetMapping(value /stream/{taskId}, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(PathVariable String taskId) { SseEmitter emitter new SseEmitter(0L); // 0L表示不超时 emitters.put(taskId, emitter); emitter.onCompletion(() - emitters.remove(taskId)); emitter.onTimeout(() - emitters.remove(taskId)); return emitter; } }注意我传了0L给构造函数意思是连接不自动超时。生产环境里这个设置要谨慎——SSE长连接本身就该一直开着直至任务结束所以一般确实要设成0或者足够长的时间。但也要配套后面的心跳机制避免代理层掐断。然后写一个任务服务模拟分段推进Service public class TaskService { public void executeTask(String taskId, SseEmitter emitter) { // 单开一个线程执行任务避免阻塞Controller new Thread(() - { try { sendProgress(emitter, 0, 任务已接收); Thread.sleep(1000); sendProgress(emitter, 20, 读取源文件); Thread.sleep(1500); sendProgress(emitter, 40, 初始化编码器); Thread.sleep(1200); for (int i 45; i 95; i 5) { sendProgress(emitter, i, 逐帧转码中); Thread.sleep(500); } sendProgress(emitter, 100, 转码完成); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }).start(); } private void sendProgress(SseEmitter emitter, int percent, String message) throws IOException { MapString, Object data new HashMap(); data.put(percent, percent); data.put(message, message); emitter.send(SseEmitter.event() .name(progress) .data(data, MediaType.APPLICATION_JSON)); } }这段模拟代码体现了两个最佳实践一是用SseEmitter.event()工厂方法构建事件对象可以很方便地指定event名称、id、data二是发送数据时指定MediaType.APPLICATION_JSON避免前端解析时拿到的是一串字符串而不是JSON对象。3.2 真实业务数据处理把一个长任务拆成N个推送点上面是模拟数据但真实项目里没人天天sleep。如果你要在文件上传后做解析、转码、导出Excel这种真实任务核心思路是一样的找到你业务里那些“阶段性的耗时点”在每个耗时点后面插入一次sendProgress调用把完成量和总量算成百分比推出去。拿“批量导出Excel”这个最常见的进度条需求举例。导出一批数十万行的数据耗时大头通常在三个位置查询数据库、组装数据模型、写入文件流。我给读者一个我常用的通用任务模板在关键节点上报进度public void export(String taskId, SseEmitter emitter) { long total 200000; sendProgress(emitter, 1, 开始查询数据); ListDataRow rows queryPage(1, 5000); int queryRows rows.size(); // 分页查询阶段每查完一页上报一次 int page 1; while (rows.size() 5000) { page; rows queryPage(page, 5000); queryRows rows.size(); int percent Math.min((int) (queryRows * 30L / total), 29); // 查询阶段最多给30% sendProgress(emitter, percent, 正在读取第 page 页数据); } // 组装阶段 sendProgress(emitter, 35, 数据组装中); // ... 组装逻辑 sendProgress(emitter, 50, 数据组装完成开始写入); // 写入阶段 for (int i 0; i list.size(); i 100) { writeToExcel(list.subList(i, Math.min(i 100, list.size()))); int percent 50 (int) ((i 100) * 50L / list.size()); sendProgress(emitter, Math.min(percent, 99), 写入中); } sendProgress(emitter, 100, 导出完成); emitter.complete(); }这里面有个细节很容易忽略百分比虽然是0到100的整数但不同阶段的重要程度不一样。查询、组装、写入各自该占多少比例这个你要根据真实耗时的占比去分配而不是平均分。我在上面的实现里把查询阶段的上限压到30%写入阶段占50%因为写入Excel是最大的耗时点。这个比例你调试一两次就能找到最合理的值。另一个实操经验sendProgress方法里不要直接写System.out之类的日志因为SSE连接一多控制台刷屏会非常严重。建议把这些进度信息记到内存日志队列或者按任务ID写到独立的日志文件排查问题时再按taskId去查。3.3 连接管理回收、超时与多客户端订阅SseEmitter一个重要特性是同一个任务可能有多个客户端在订阅。比如用户开了两个浏览器标签页或者一个PC端一个移动端同时看进度。我的实现里用Map把同一个taskId对应的SseEmitter存放起来如果是多客户端场景这个Map的值类型要改成List 当任务状态变化时遍历列表推送。至于SseEmitter的回收很多人会忽略。Spring官方提供了onCompletion、onTimeout、onError三个回调你要做的就是把Map里的entry清理掉否则每次连接断开都残留一个SseEmitter对象时间一长就是一个隐藏的内存泄漏点。别小看这个问题线上出过一次事故一个测试环境连着跑了一周Map里积累了上万个已经断开的EmitterGC都救不回来。我的建议是至少要实现与释放类似这样SseEmitter emitter new SseEmitter(0L); emitter.onCompletion(() - removeEmitter(taskId, emitter)); emitter.onTimeout(() - removeEmitter(taskId, emitter)); emitter.onError(e - removeEmitter(taskId, emitter));然后provide一个移除方法注意因为可能多个客户端移除时要判断是不是同一个对象。真实业务中还有一个比较头疼的问题如果用户在进度跑到80%的时候刷新页面浏览器会断开旧连接并自动发起新连接新连接带着Last-Event-ID。后端要处理这个重连请求从第80%所在的断点继续推送而不是重新从0开始。这就要求你在服务端缓存每个任务最近的进度快照哪怕是内存Cache或者Redis都行。我在项目里用了一个简单的ConcurrentHashMap记录taskId到最新进度的映射重连时先推一条快照数据再从后续断点继续体验上基本能做到无缝。4. React前端接入从EventSource到进度条渲染的全流程4.1 原生EventSource封装连接、监听与数据解析前端这块我直接用React TypeScript来做示例因为现在新项目基本都这套组合。原生EventSource的使用门槛非常低浏览器自带全局对象不需要npm install任何库。最基础的连接代码只需要几行const eventSource new EventSource(/api/progress/stream/${taskId});然后就可以监听事件了。回到我2.1节提到的坑如果你在后端发送事件时指定了event名称比如progress前端必须用addEventListener(progress, handler)才能收到而不是用最常见的eventSource.onmessage。我在最初写演示代码的时候没注意后端推了一堆progress事件前端onmessage死活不触发调试了半天才发现问题。这里给出一份较完整的封装把连接、事件注册、异常处理、关闭方法都包进去export function createProgressListener( taskId: string, handlers: { onProgress: (percent: number, message: string) void; onDone?: () void; onError?: () void; } ) { const es new EventSource(/api/progress/stream/${taskId}); es.addEventListener(progress, (event) { const data JSON.parse((event as MessageEvent).data as string); handlers.onProgress(data.percent, data.message); }); es.addEventListener(done, () { handlers.onDone?.(); es.close(); }); es.onerror () { handlers.onError?.(); }; return { close: () es.close(), }; }注意一点EventSource内置了自动重连所以服务端任务已经结束并complete之后前端必须显式调用es.close()或者收到done事件后立刻关闭否则浏览器会因为你没关闭而自动又发起一次请求导致循环重复拉取。这个逻辑别写在onerror里因为onerror在网络抖动时也会触发但你并不希望每次网络抖动都断开进度监听。4.2 进度条UI状态管理与动态渲染拿到实时进度后前端的核心工作是状态管理。我用React的useState管理三个核心状态百分比、当前状态文案、是否完成。const [percent, setPercent] useState(0); const [message, setMessage] useState(等待开始); const [status, setStatus] useStatepending | processing | done | error(pending); useEffect(() { if (!taskId) return; const listener createProgressListener(taskId, { onProgress: (p, msg) { setPercent(p); setMessage(msg); setStatus(processing); }, onDone: () { setPercent(100); setStatus(done); }, onError: () setStatus(error), }); return () listener.close(); }, [taskId]);这里有一个会被大多数人忽视的关键点useEffect的清理函数里必须调用listener.close()。因为React 18 StrictMode在开发环境下会双向执行useEffect先挂载、再卸载、再挂载如果你不在清理函数里关闭连接一次进入页面就会创建两个EventSource连接。生产环境虽然没有双执行但组件在路由切换后卸载时没关掉连接同样会造成连接泄漏。这种问题很难靠肉眼看出来要在浏览器Network面板里看到有多个pending状态的HTTP请求没释放才能意识到。进度条UI本身我最常用的是一个简单的div宽度百分比方案不需要引入重型UI库div classNameprogress-track div classNameprogress-bar style{{ width: ${percent}%, transition: width 0.3s ease }} / /div p classNameprogress-message{message}/p加一句transition就是为了让进度变化平滑过渡避免SSE推送频率过高时进度条一顿一顿地跳。这个细节很小但对视觉体验的改善很明显。4.3 abort机制如何在页面销毁或用户取消时断开SSE连接热词里专门提到了“配合abort”这个坑是真实存在的。原生EventSource里没有像fetch那样可以直接调用的abort()方法我见过错误的做法是把EventSource赋给一个变量以为设为null就完事了。实际正确的断开方式只有es.close()。但要记住close只是把前端的连接关了服务端的SseEmitter并不会自动感知到浏览器断开。你如果只在前端close服务端那个连接会继续占着直到服务端下一次send的时候发现IOException。所以在真实的产品逻辑里还需要一个取消接口由前端在前置取消按钮时主动通知服务端停止任务并释放连接async function cancelTask(taskId: string) { await fetch(/api/progress/cancel/${taskId}, { method: POST }); listener.close(); }后端收到取消请求后除了在业务标志位里标记任务取消还要调用emitter.complete()或直接注入一个错误事件把连接关闭。这个前后端配合做掉才能保证“用户点取消资源立刻释放”。如果你不做取消接口只靠前端关闭服务端任务还会继续跑、连接还会继续挂在高并发场景下就是资源浪费。我踩过的一个实际教训是文件上传解析的任务比较重用户在浏览器里上传到一半又取消了。前端虽然关了页面但服务端还在继续解析文件白白占用了几分钟的CPU。后来引导用户走取消流程这种情况才杜绝。4.4 离线重连时的Last-Event-ID处理这里特别说一下EventSource自动重连配合id字段的实现细节。如果想做到断线后恢复进度而不是从头开始前端几乎零成本——EventSource会自动把Last-Event-ID请求头发给服务端你都看不到显式的代码。问题全在服务端怎么处理这个请求头。我的后端代码是这样处理的当收到请求时先检查Header里有没有Last-Event-ID如果有就查一下这个任务的事件缓存只推送这个ID之后的事件。所以前端需要在每次服务端推送时由后端在事件里带上递增的id前端代码不需要任何额外操作es.addEventListener(progress, (event) { // event.lastEventId 可以拿到本次事件ID但通常不需要前端处理 // 自动重连时浏览器会自行放入请求头 const data JSON.parse((event as MessageEvent).data as string); onProgress(data.percent, data.message); });坦白说如果只是做一个普通的进度条功能且网络环境够稳定Last-Event-ID这个机制你可以暂时不深究。但既然你选择SSE我建议还是把这个能力留在设计里因为很多开发者自定义事件时容易忘了指定id真到弱网环境出问题补起来远比先设计好要费劲。5. 大模型流式输出场景SSE如何配合AI交互逻辑5.1 AI对话实时渲染的技术本质最近几乎所有的新热词都指向AI交互场景——“基于什么技术栈封装ai交互逻辑”“通过sse流式输出实现大模型回答实时渲染”。说实话这两个点正是当代前端与AI对话产品之间最核心的粘合剂。我接下来要重点讲一下做AI对话界面时SSE怎么配合封装尤其是和普通进度条的区别在哪里。普通进度条推送的是“百分比、状态文案”这类结构化信息而大模型流式输出推送的是一段一段的非结构化文本token。两者的共同之处在于都是“服务端往浏览器推一长串持续数据”不同之处在于大模型的输出往往频率高、单个数据块小、而且没有明确的阶段性边界。在这种场景下你更需要关注封装质量否则用户看到的AI回答会一会儿蹦一大段文字、一会儿卡顿半天。大模型输出流的数据格式通常长这样OpenAI的接口风格就是这种chunk模式data: {id:chatcmpl-xxx,choices:[{delta:{content:你好},index:0}]} data: {id:chatcmpl-xxx,choices:[{delta:{content:今天},index:0}]}后端拿到大模型返回的这些chunk之后经过安全过滤、格式校验等处理再以SSE的格式透传给前端。这里存在一个常见误区有人会把大模型SDK的原始流直接塞给前端让前端自己解析。这样做在demo阶段可行但在做产品时必须封一层服务端协议转换因为原始流里可能携带非业务数据、格式差异、敏感词命中标记等等直接透传会让安全和稳定性全部暴露给前端。5.2 技术栈封装把SSE的复杂度收敛到一个统一引擎里如果一个项目里有多个地方用到SSE进度条、大模型对话、系统通知我强烈建议在前后端各自封装一套通用逻辑核心就是热词里说的“技术栈封装”。以React侧为例我把SSE的通用能力抽成了一个可复用的hook既支持进度事件也支持自定义事件名import { useEffect, useRef, useState } from react; export function useSSET(url: string, options?: { eventName?: string }) { const [data, setData] useStateT | null(null); const [connected, setConnected] useState(false); const esRef useRefEventSource | null(null); useEffect(() { if (!url) return; const es new EventSource(url); esRef.current es; es.onopen () setConnected(true); const eventName options?.eventName ?? message; es.addEventListener(eventName, (event) { setData(JSON.parse((event as MessageEvent).data as string)); }); es.onerror () setConnected(false); return () { es.close(); esRef.current null; }; }, [url, options?.eventName]); return { data, connected, close: () esRef.current?.close() }; }在AI对话场景里我还会加一个额外的缓存层因为React的state更新是异步批处理的如果服务端推送频率极高每几十毫秒一条大量setData调用会导致渲染性能问题。我的方案是在回调里先把数据写到ref的缓冲数组中然后以requestAnimationFrame的节奏批量刷进state保证界面更新频率与显示器同步减少无谓的重渲染。这个优化在交互流畅度上非常有效尤其是在中低端移动设备上差异明显。举个例子大模型输出时可能每秒推几十次每次只加几个字如果直接setState几十次React的diff和渲染调度会非常吃力页面可能掉帧。用缓冲后每帧最多渲染一次效率就上来了。5.3 衍生场景SSE与轮询在文件变化监控中的取舍热词里还有一条“react sse/websocket 轮询文件变化”大概率是想说用SSE替代WebSocket或轮询实现对文件系统变化的实时监控。这个场景在我的理解里是这样的用户上传了一个比较大的文件服务端在后台解析前端需要实时感知文件是否处理完以及处理到了哪一步。用SSE实现文件变化的监控比轮询的优势很明显——服务端根据实际文件状态变化主动推送事件而不是前端固定间隔去探测。比如一个文件从“上传中”变为“解析中”再变为“解析完成”这三个状态通过SSE推过去前端只需对应更新UI。如果用轮询你得每秒钟去查一次文件的元数据查不到变化就白白浪费一秒钟的带宽和IO。WebSocket在文件变化场景确实也能做而且体验上几乎无差别。但考虑到这里不是聊天室这种高频率双向交互场景我也依然会选SSE。理由很朴素少一个协议多一份稳定少一条连接多一分省心。真到了要同时监听多个文件任务、且每个任务状态变化频率并不高的场景SSE和多路复用方案简直是绝配。还得提一个容易忽略的点如果通过SSE做文件变化监控服务端要防止“查询文件状态”这个动作过于频繁。有些同学写出来的代码表面上用了SSE服务端内部却是在每次推送事件前做全量扫描一旦文件量大或者目录层级深CPU消耗会非常惊人。正确做法是为每个被监听的任务建立状态快照只在某个任务的事件源如ftp上传回调、消息队列通知真正触发时才去比对快照、决定是否推送。6. 多端扩展Android接入SSE的注意事项6.1 OkHttp的SSE支持与EventSource接入如果你的产品是移动端App同样会遇到进度条实时更新的需求。Android原生端没有浏览器自带的EventSource主流方案是借助OkHttp库加一个EventSource接口来实现SSE。OkHttp本身就支持流式响应但要做好SSE协议解析通常要引入okhttp-sse这个扩展包。依赖配置如下implementation com.squareup.okhttp3:okhttp:4.12.0 implementation com.squareup.okhttp3:okhttp-sse:4.12.0接入代码的大致抽象val request Request.Builder() .url(https://api.example.com/api/progress/stream/$taskId) .header(Accept, text/event-stream) .build() val eventSource EventSources.createFactory(client).newEventSource(request, object : EventSourceListener() { override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String?) { if (type progress) { val json JSONObject(data) handler.post { updateProgress(json.getInt(percent), json.getString(message)) } } } })注意以上的onEvent回调是OkHttp的工作线程UI更新必须切回主线程通过Handler或者协程调度器否则会直接触发网络线程刷新UI的异常。6.2 Android端进度条的渲染与生命周期管理Android端进度条用ProgressBar组件核心代码差别不大。真正需要注意的是组件生命周期管理在Activity或Fragment销毁时一定要关闭EventSource否则会造成内存泄漏和连接残留。这段看起来像老生常谈但在SSE这种长连接场景里后果比普通网络请求严重得多。普通请求发完就结束了连接随之释放SSE连接会一直开着。所以建议在onDestroy或ViewModel的onCleared回调里统一执行eventSource.cancel()而且在切换后台/恢复前台时还要考虑是否需要主动重连。如果App退到后台超过一定时间系统网络策略可能会杀掉长连接恢复前台时EventSource不会自动恢复不像浏览器那样有内置重连所以你需要自己实现一套重连机制。我常用的做法是在onResume里检查EventSource的状态如果已经cancel或者连接异常就重新创建。这里没有标准答案取决于你业务对实时性的要求。还要提到一个Android特有的坑部分国产ROM对后台长连接有严格管控进程一旦进入后台SSE连接可能被系统主动断开。如果你做的是强实时进度场景后台保活的复杂度会显著上升甚至可能需要用前台服务来保活。但如果只是用户在App前台使用期间的进度展示用SSE就足够了没必要为了后台保活过度设计。7. 常见问题排查从协议到框架的避坑指南7.1 连接建立却收不到数据这是SSE最经典的问题。当我看到用户反馈“进度条一直没动但Network面板请求是pending状态”时我通常按顺序排查三个环节。第一步确认服务端响应头是否正确。SSE要求响应头必须包含Content-Type: text/event-stream且不能包含Content-Encoding: gzip之类的压缩编码有些代理/framework会默认开启压缩会破坏流式格式。Spring Boot的produces属性已经搞定了Content-Type如果你手动写ServletResponse记得加这两行response.setContentType(text/event-stream;charsetUTF-8); response.setHeader(Cache-Control, no-cache);第二个原因是事件名不匹配。前端用了onmessage后端却发送的是自定义event名的数据这种代码写的没毛病但就是收不到。最彻底的解决方案是统一约定进度类事件一律用自定义event名并在前端用addEventListener监听只有简单的文本推送才用默认的onmessage。第三个原因是数据格式问题。如果你在服务端send时没指定MediaType.APPLICATION_JSON传给前端的数据会被当字符串处理前端JSON.parse时直接抛异常。这类异常会被浏览器吞进console静默失败。所以我会在前端加一个try...catch包裹JSON.parse看到解析失败就把原始字符串打印出来定位到底是哪一层出了问题。7.2 连接频繁断开代理超时、心跳与缓冲如果你在网络链路上挂了NginxSSE连接被频繁断开十有八九是代理缓冲和超时配置的问题。Nginx默认开启proxy_buffering会先把服务端的响应缓冲起来等缓冲区满了或者连接关闭才一次性推给客户端这根本不符合SSE的长连接特性。需要在Nginx的location配置里加上location /api/progress { proxy_pass http://backend; proxy_set_header Connection ; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_buffering off是关键它让Nginx拿到服务端的数据后立即转发给客户端而不是吞在缓冲区里。proxy_read_timeout设置成300秒甚至更长配合服务端心跳基本能保证连接稳定。服务端心跳的实现可以每隔15到20秒向Emitter写入一条注释消息emitter.send(SseEmitter.event() .comment(heartbeat)); // 注释行浏览器会忽略也可以发一条自定义的ping事件前端可以借此判断连接仍然存活顺手实现应用层的断线检测emitter.send(SseEmitter.event() .name(ping) .data({}, MediaType.APPLICATION_JSON));7.3 服务端线程模型与连接数压力评估SSE的功能实现起来简单但架构上有一个天然的隐忧每一个SSE连接在服务端都占用一个长期存活的HTTP连接。如果你们公司部署在Tomcat等Servlet容器上大量并发SSE连接会占用容器里的连接器线程和Socket资源而容器默认的最大线程数一般是200Spring Boot默认配置下超过这个数的连接会被排队或拒绝。这就是为什么我在正式项目里会把SSE接口和普通API接口分开部署或者做独立的线程池隔离。举个实际数字一个视频平台的上传转码任务高峰时段可能有5000个用户同时在等待进度推送如果每个用户占一个Tomcat线程业务接口基本也跑不动了。SSE接口独立部署后连接数再多也不会影响普通业务API的可用性。另一个思路是考虑像Nginx这样的事件驱动型代理层来承接SSE连接把压力从Tomcat转移出去业务服务只在有事件需要推送时反向连接到代理层。这种架构复杂一些但在千万级用户量时属于必然选择。小团队的话我的建议是先按单机500并发评估如果估算超过这个数字尽早规划SSE服务拆分。7.4 常见问题速查表我把项目里最常被问到的几个问题和对应解法整理成一个速查表方便你直接查阅现象可能原因解决方法请求一直pending但前端无数据事件名不匹配检查后端event name与前端addEventListener是否一致前端收到字符串但JSON.parse报错send时未指定JSON媒体类型设置MediaType.APPLICATION_JSON连接每60秒断开一次代理空闲超时服务端加心跳或调大proxy_read_timeout前端自动重连后进度从头开始未处理Last-Event-ID服务端按ID缓存最近进度重连后从断点续推换路由/退出页面后还有多余连接未在清理函数关闭连接useEffect清理或onUnmount调用es.close()多客户端订阅同一任务互相干扰Map中Emitter被覆盖数据结构改为MapString, List Android端收到事件但UI不更新回调在非主线程切到主线程再更新UI8. 实战经验补充从项目上线到稳定运行的进阶细节8.1 服务端EventSource数据格式的容错设计在正式项目里我倾向于给SSE推送的数据包设计一个统一格式让不同业务都能复用。格式不一定复杂但字段要有约定{ code: 0, type: progress, data: { percent: 65, message: 正在写入文件, extra: {} } }code为0表示正常非0表示业务异常type用来区分进度、完成、失败、心跳extra可以携带错误信息或跳转参数。这个设计一旦固定下来前端解析逻辑就不用每个场景单独开发只写一个通配处理即可。半年后要新增一个“导出PDF”的进度功能前后端只需要关注业务逻辑本身不需要再重复实现一遍通信协议。8.2 部署与监控SSE连接数、推送频率、异常率上线SSE功能后必须把“连接数、消息推送总量、连接错误率”这三个指标纳入监控。我在搭建监控时会为每个Emitter设置两个生命周期钩子onOpen时增加活跃连接数指标onComplete/onError时减少活跃连接数指标。同时在send方法内部包装一层每次推送成功后给计数器加一这样运维后台就能直接看到每小时推送了多少条消息、平均单条消息耗时多少、当前活跃连接数多少。如果没有监控线上出现了连接泄漏你可能要等到服务器Socket耗尽才知道出了事。有监控之后指标曲线会提前报警处理起来从容得多。另外我还会给每个连接加一个自研的“最后活动时间”由定时任务扫描发现超过30秒没有任何推送的闲置连接主动把它关掉。表面上看这有点浪费实际能帮我及时清理掉那些因为客户端异常退出的僵尸连接。8.3 架构演进的建议从单体走向独立SSE服务如果产品持续增长单体服务里的SSE连接会逐渐成为容量规划的主角。我通常会建议团队在连接数超过1000并发时认真评估把SSE模块拆成独立服务的可能性。独立服务的好处是连接数突增不会拖垮核心APISSE服务可以部署到靠近用户的边缘节点减少网络延迟技术上可以按需横向扩容连接数的瓶颈被单独管理。拆分的代价是用例里的服务端推送逻辑要做跨服务通信通常会借助Redis的发布订阅或者消息队列把任务进度广播给SSE服务。这个演进不是一蹴而就的前期在单体结构里就应该把SSE的推送逻辑通过接口隔离出来不要跟其他业务代码强耦合。等到拆的时候只把接口实现换掉上层业务完全不受影响。就拿我项目里的具体经验来说最开始所有代码都堆在一个工程里后来连接数上来了就把SSE独立部署到单独的服务和域名因为域名也独立以前那些代理层、容器线程、超时配置就不用去动了整个迁移过程一天就完成。这件事给我最大的启示就是技术方案的初期选择决定了后期演进的代价SSE的简单性让这种演进比其他方案顺滑得多。写在最后的实操心得与技巧把后端、前端、Android这几个月跑下来再回头看我当初选SSE的决策我觉得最关键的点是它把“实时推送”的复杂度控制在了我可以完全掌控的范围内。协议简单、浏览器内置支持、服务端实现只有几十行代码出了问题也容易靠curl一步步排查不用像WebSocket那样同时兼顾协议层和应用层的调试。我在实际项目里最想告诉你的一条经验是从第一天就要设计好心跳机制和事件缓存不要等连接频繁断开后再去补。因为联调阶段网络环境好、代理层少很多问题藏得住一旦上了生产、挂了Nginx、用户网络又复杂缺心跳、缺断点续推的毛病会集中爆发到时候排查的成本远高于提前设计好。另外关于前端封装我建议不要在业务组件里直接new EventSource。把连接管理、协议解析、生命周期清理收敛到统一的hook或工具类里会让你的代码好维护得多。等到你真的要在大模型对话、进度推送、文件状态通知三个功能里复用同一套逻辑时这种封装带来的收益会被放大好几倍。文章到这里SSE实现进度条功能的核心要点就讲透了。如果你正在做一个需要过程反馈的功能试着用SSE去实现从后端推送一条hello测试开始逐步扩展到真实的业务场景。相信你会发现实时反馈带来的用户体验提升比你想象的更明显。
返回列表