ARTICLE DETAIL

资讯详情

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

SSE与WebSocket选型指南:基于HTTP实现实时服务端推送的完整实践

SSE与WebSocket选型指南:基于HTTP实现实时服务端推送的完整实践 1. 从“回答一个字一个字蹦出来”说起HTTP 为什么做不了实时推送如果你做过 AI 对话类的 Web 项目一定见过这种效果对话框里的回答不是一次性出现而是一个字一个字往外蹦。这个体验背后就是今天要聊的主角之一——SSEServer-Sent Events服务器发送事件。最开始我接手这类需求时第一反应是上 WebSocket后来真正读过规范、跑过线上流量才发现很多场景其实用 SSE 更合适而且它就托管在 HTTP 协议之上不需要额外引入一套通信协议。在聊 SSE 之前有必要先回到 HTTP 本身。HTTP 的请求-响应模型非常直白客户端发一个请求服务端给一个响应然后这次交互基本就结束了。这个模型天然是“一问一答”适合页面加载、接口调用但一旦涉及服务端持续往客户端推数据就会很拧巴。举个例子你要做一个交易提醒服务端每秒钟都可能产生新数据用最原始的 HTTP 轮询客户端就得定时发请求去“有没有新消息”服务端每次都要完整走一遍握手、响应、断开的流程。HTTP/1.1 时代虽然有了 keep-alive 连接复用同一个 TCP 连接可以连续处理多个请求响应但本质上服务端仍然没法在没有请求的情况下主动开口。所以就出现了短轮询和长轮询两种过渡方案。短轮询最简单setInterval 每 3 秒调一次接口数据量小、频率低的时候能用但服务端压力大及时性也没保障。长轮询稍微聪明一点客户端发一个请求之后服务端先不响应一直挂到有新数据了再返回客户端拿到数据立刻发下一个请求相当于把“问”的节奏交给了服务端。但长轮询的实现很别扭中间隔着一堆代理和网关挂住的连接经常被静默断开服务端还得维护一大堆悬挂请求这套“Comet”技术写起来是真心累。这里还要顺带提一句 HTTPS。很多人分不清 HTTP 和 HTTPS 的区别简单说就是 HTTPS 在 HTTP 和 TCP 之间加了一层 TLS 加密会话传输的内容无法被中间人直接读取或篡改默认端口也从 80 变成 443。SSE 本身是明文文本协议生产环境如果直接用 HTTP 暴露消息内容等于裸奔而且浏览器对混合内容还有限制——HTTPS 页面里发起 HTTP 的 EventSource 请求直接就被安全策略拦下来。所以我们的 AI 对话应用、流式接口都要求统一跑在 HTTPS 之下这样 EventSource 建立连接时不会被卡住。这个基础没有打好的话下面所有推送方案都会埋雷。2. SSE 协议背后的硬核细节text/event-stream 与 EventSourceSSE 其实不是什么新框架而是一套被纳入 HTML5 标准协议的服务器推送方案。它的做法很简单客户端通过 EventSource 接口发起一个普通的 HTTP GET 请求服务端不关闭连接而是把 Content-Type 设为 text/event-stream然后持续不断地往响应体里写入特定格式的文本块。浏览器收到之后会逐行解析每解析出一条完整消息就触发一次 onmessage 事件。下面给一个最原始的网络响应示例你抓包看到的差不多就是这样HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive data: hello data: world每条消息由若干字段组成字段之间用换行分隔消息与消息之间用空行即两个 \n分隔。其中 data 字段是消息主体一行一条如果一条消息想传多行文本可以连续写多行 data浏览器解析时会自动用换行符拼接起来再触发事件。除了 data还有三个辅助字段值得重点了解event自定义事件类型。默认是 message如果你写了event: user_login前端就需要用 addEventListener(user_login) 来接收而不是挂在 onmessage 上。这个字段在“一条连接推多种消息”的场景里特别有用。id消息的唯一标识配合 Last-Event-ID 做断线续传。服务端每发一条消息都带个递增 id客户端重连时会把最后收到的 id 带回服务端。retry告诉浏览器如果断开连接多少毫秒后自动重连。不设置时浏览器默认大概几秒重试一次设置之后就能自定义节奏。这段协议的聪明之处在于它让“服务端推送”变成了“HTTP 连接上的持续响应”不需要像 WebSocket 那样先完成一次协议升级握手也没有那些 ws 二进制帧的复杂度。它的整个生命周期就是TCP 连接建立、HTTP 响应开始、响应体无限延长直到某一边主动断开。再来说说 EventSource 对象它是浏览器原生提供的 API用法比 fetch 简洁得多const es new EventSource(/api/chat/stream); es.onopen () console.log(连接建立); es.onmessage (event) { console.log(JSON.parse(event.data)); }; es.onerror () { // 网络断开或服务端异常时会走到这里 // 浏览器默认会自动重连 };EventSource 的连接状态用 readyState 表示0 表示 CONNECTING1 表示 OPEN2 表示 CLOSED。平时不需要特意去看它但调试断线问题时很有用。最值得说的是重连机制——TCP 断开之后浏览器不会立刻放弃而是会间隔 retry 字段指定的时间自动重新发起请求如果我们服务端在消息里带了 id 字段下一次重连的请求头会自动带上 Last-Event-ID服务端就能据此从断点继续推数据效果类似“续传”。这个能力是原生自带的行为用 WebSocket 时你得自己实现心跳和重连逻辑而 SSE 帮你把最烦的一部分做完了。为了对比更直观我把 SSE 和 WebSocket 的核心差异整理成了表格后面选型时你直接用这张表就够对比项SSEWebSocket传输方向服务端到客户端单向推送客户端可用普通 HTTP 请求补充全双工双方可同时发消息协议基础基于 HTTP无需升级握手独立协议依赖 HTTP Upgrade消息格式纯文本UTF-8文本帧和二进制帧都支持断线重连浏览器原生支持自动带 Last-Event-ID需要自己实现重连逻辑自定义 HeaderEventSource 不支持只能用 fetch 方案握手时支持自定义 Header浏览器连接数限制每个域名有数量上限类似限制也受 HTTP/1.1 并发数约束适用场景通知、进度、AI 流式输出聊天、游戏、协同编辑3. 服务端推送实现从原生 Servlet 到 Spring Boot SseEmitter服务端实现 SSE 的核心说白了就是“拿到响应流、别关闭、持续写入符合格式的文本”。Java 生态里可以分两个层级来做先看原生 Servlet 版本。直接写是在一个 Controller 接口里设置响应头然后往 OutputStream 里写数据并 flushGetMapping(value /stream, produces text/event-stream;charsetUTF-8) public void stream(HttpServletResponse response) throws IOException { response.setHeader(Cache-Control, no-cache); response.setHeader(Connection, keep-alive); response.setHeader(X-Accel-Buffering, no); ServletOutputStream out response.getOutputStream(); for (int i 0; i 10; i) { out.write((data: {\index\: i }\n\n).getBytes(StandardCharsets.UTF_8)); out.flush(); Thread.sleep(1000); } }这段代码在 Demo 里没问题但只能服务单个请求。真正线上项目一个服务可能同时挂着成百上千个 SSE 连接每个连接都占着一个线程阻塞在 sleep 或者等待数据上线程池很快就扛不住。所以原生方式在 Java 里通常要和 Servlet 3.1 的异步支持配合用 AsyncContext 把请求丢到线程池释放容器线程。这也是为什么大多数 Spring 项目不会直接这么写。Spring Boot 项目里我们很少直接跟 Servlet API 打交道更常见的做法是使用 Spring 提供的 SseEmitter 封装。它的设计思路是Controller 方法直接返回 SseEmitter 对象Spring 在内部帮我们完成异步化我们只需要在业务线程里往 emitter 发送数据最后调用 complete() 结束流。下面是一个支持长连接、配合线程池发送的大致模板RestController public class ChatController { private final ExecutorService executor Executors.newFixedThreadPool(16); private final MapString, SseEmitter clients new ConcurrentHashMap(); GetMapping(/api/chat/stream) public SseEmitter stream(RequestParam String sessionId) { SseEmitter emitter new SseEmitter(0L); // 0 表示不超时 clients.put(sessionId, emitter); emitter.onCompletion(() - clients.remove(sessionId)); emitter.onTimeout(() - clients.remove(sessionId)); emitter.onError(e - clients.remove(sessionId)); executor.execute(() - { try { for (int i 0; i 10; i) { MapString, Object payload Map.of(content, 第 i 段内容); emitter.send(SseEmitter.event() .name(message) .data(payload)); Thread.sleep(300); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }这里有几个细节值得注意。第一SseEmitter 有默认超时时间具体时长由容器决定如果不希望连接被自动关闭要显式传入超时时间传 0L 表示不限时。第二发送数据时 SseEmitter 会把对象交给消息转换器序列化所以直接传 Map 或 POJO 类前端拿到的就是 JSON 字符串不用手动拼 JSON。第三同一时间可能有多个客户端连接必须用 ConcurrentHashMap 这类线程安全容器保存 SseEmitter并在完成、超时、异常三个回调里都做清理否则连接会越积越多最终把服务拖垮。多客户端场景下还有一个高频需求是“广播”。比如后台管理员点了“给所有在线用户推送一条公告”做法就是把 clients 这个 Map 遍历一遍向每个 SseEmitter 写入同一份数据。如果某个连接已经断开send 会抛异常记得 catch 住并移除对应项。再来说说心跳保活这是线上最容易忽略的一环。SSE 连接本质是一条长时间不关闭的 HTTP 响应但链路上总有些中间设备nginx、负载均衡、网关喜欢把“长时间没数据流动的连接”判定为闲置直接断开。想让连接保持常见做法是服务端每 15 到 30 秒发送一条“注释行”作为心跳。SSE 的规范里以冒号开头的行是注释浏览器收到后会自动忽略不会触发任何事件。所以心跳可以这样写// 每 20 秒发送一次心跳 executor.execute(() - { try { emitter.send(SseEmitter.event().comment(ping)); } catch (Exception e) { clients.remove(sessionId); } });前端在这段时间内不会感知到任何异常但这个“无意义但真实存在”的数据流会让中间代理认为连接还活着从而避免被 idle timeout 干掉。这个技巧比起在业务数据里塞假消息要干净得多也是线上稳定性最关键的保障。4. 前端接入实战EventSource 不够用时的 fetch 流式方案前端接入 SSE 的默认姿势是 EventSource。但我在真实项目里碰到过两个 EventSource 搞不定的场景。一个是需要带 Authorization 请求头。EventSource 的构造函数只接受 URL没法设置 Header除非你把 token 放到 query string 里否则后端鉴权就过不去。第二个是某些网关环境会拒绝 event-stream 的跨域请求或者需要你自定义 Content-Type 之外的参数。这时候我就会放弃 EventSource直接用 fetch 配合 ReadableStream 手写一个 SSE 客户端。先看一个 React 组件里的完整例子场景是 AI 问答页面的流式渲染import { useEffect, useState } from react; function ChatPage({ sessionId }) { const [text, setText] useState(); const [controller, setController] useState(null); useEffect(() { let canceled false; const es new EventSource(/api/chat/stream?sessionId${sessionId}); es.addEventListener(message, (e) { const data JSON.parse(e.data); if (canceled) return; setText((prev) prev data.content); // 逐字累加 }); es.onerror () { // 如果是服务端正常结束EventSource 会自动断开并不再重连 // 如果网络异常这里会持续触发需要考虑手动 close }; return () { canceled true; es.close(); // 组件卸载时及时释放 }; }, [sessionId]); return div classNamechat-content{text}/div; }React 里用 EventSource 坚持三个原则连接建立放在 useEffect 里、组件卸载时调用 close、收到数据后批次更新而不是全量替换。这样即使对话流很长UI 也不会被高频 setState 击穿。如果要用 fetch 方案核心代码长这样async function fetchSSE(url, token, onMessage, signal) { const res await fetch(url, { headers: { Authorization: Bearer ${token} }, signal, // AbortController 的 signal }); if (!res.ok || !res.body) { throw new Error(SSE 连接失败: ${res.status}); } const reader res.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events buffer.split(\n\n); buffer events.pop(); // 最后一段可能是不完整消息留到下一次处理 for (const chunk of events) { const dataLine chunk .split(\n) .find((line) line.startsWith(data:)); if (dataLine) { const payload dataLine.slice(5).trim(); onMessage({ data: payload }); } } } }这里最重要的思想是“按空行切分、保留下一个不完整片段”。因为 fetch 的流式读取和网络分片没有对齐语义一次 read 可能返回半条消息也可能一次包含好几条必须维护一个 buffer 做切分。只要把这一层解析逻辑写对后面就能完全复制 EventSource 的体验。关于请求取消AbortController 在这里才是真正的主角。EventSource 只能 close根本原因在于它连不连、断开重连都由浏览器自动管理我们作为业务方没有太细的控制力。而基于 fetch 的实现创建一个 AbortController 实例把 signal 传给 fetch需要在组件卸载或用户点击“停止生成”时调用 controller.abort()。abort 会让 read() 抛出一个 AbortError我们捕获后主动结束循环就能干净地终止一条流。在 AI 对话场景里“停止生成”按钮就用这个实现点击后页面马上停止渲染服务端也会收到连接断开的通知。5. 线上断流和超时我踩过的 SSE 坑流式输出最容易在开发环境跑得开心、上一线就断。最经典的一个报错长这样stream disconnected before completion: idle timeout waiting for sse我印象里第一次遇到这个报错是在一个用负载均衡挂了两台 Java 服务的项目里。现象是页面能收到前几段内容然后过了大约 60 秒流就断了前端 EventSource 开始自动重连服务端日志里能看到连接被 reset。当时我第一反应是代码有 bug排查了半天发现业务线程还在正常发数据问题根本不在应用层。这个报错本质上就是链路上某个组件等不及了把我们这条常年不关闭的响应流判定为“空闲”然后掐断。常见的凶手有三个。第一个是 nginx 的 proxy_read_timeout默认值只有 60 秒第二个是云负载均衡的会话超时不少 SLB 产品默认空闲超时也就在几十秒到几分钟第三个是防火墙或者机房设备对 TCP 空闲连接的限制。每种设备的判断逻辑各不相同但它们有一个共同点只关心“这条连接多久没动静”不关心“服务端是不是还在往后写”。解决办法通常分两步。第一步尽量让链路上每个人都不缓冲响应体。SSE 要求数据实时到达但一些反向代理默认会把上游响应缓冲到一定大小再转发给客户端导致前端看到的效果变成一段一段地跳而不是一字一字地出。nginx 里需要这样配置proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s;同时服务端要返回一个X-Accel-Buffering: no响应头这是 nginx 专门用来告诉代理“别缓冲这个响应”的开关。配置之后数据才能一路畅通地从服务端流到浏览器。第二步就是我在上一章提到的心跳。即使你把 nginx 超时调得再大链路上仍可能存在不归你管的设备所以每隔 15 到 30 秒发一条注释行是成本最低的保命手段。这两步配合起来我遇到的 idle timeout 断流问题基本就消失了。另一个坑是连接管理不善导致的服务端资源耗尽。SseEmitter 如果迟迟不 complete客户端又异常退出连接就不会自动释放。Java 服务这边表现是线程池线程数慢慢涨上去最后新的客户端根本连不进来。我在代码里会同时保障三件事客户端心跳超时兜底。服务端记录每个连接的最后活跃时间超过 90 秒没动静就主动 complete释放线程和连接。三个回调统一走同一个清理方法。onCompletion、onTimeout、onError 都调用同一个 removeClient(sessionId)避免漏删。发布时优雅停机。应用关闭前遍历所有 SseEmitter发送event: server_shutdown通知前端切到备用地址让用户感知不到服务重启。还有一个多实例部署的隐藏问题值得单独说。SSE 连接是粘在一台实例上的当负载均衡把同一个用户的不同请求分到两台机器时A 实例上挂着 EventSource 连接B 实例却不知道这个连接存在。如果你在业务代码里用本地 Map 保存所有连接那“广播”这个动作永远只能打到一部分用户。可行的方案有三个成本最低的是开启负载均衡会话保持把同一个来源 IP 固定到同一台实例更健壮的是引入 Redis pub/sub一个实例收到广播就发布消息其他实例订阅后再推送给自己持有的连接再往上就是换消息中间件但除非连接数量非常大否则 Redis pub/sub 已经足够。最后提一个浏览器层面的限制。HTTP/1.1 时代浏览器对同一个域名下的并发连接数通常是 6 个EventSource 再轻量也要占一个。如果一个页面同时开了多个 SSE 流或者页面本身并行加载着大量接口资源早期的连接会被排队严重时表现就是流迟迟不建立。这不算代码问题但排查时容易绕远路。缓解方式有两个思路给 SSE 接口单开一个子域名或独立路径避免和页面主资源的连接池抢位置或者把多个业务合并到同一条 SSE 流里在前端按 event 类型分流。我在做“AI 对话 文件解析进度”混合页面时就用了一条流加两个自定义事件event: token和event: progress消息量不大但连接占用只有一份效果很好。6. SSE 还是 WebSocket选型对照表与我的建议每次做实时功能团队都会争论“到底用 SSE 还是 WebSocket”。我的判断标准从一开始的技术时髦度慢慢变成“谁更简单、谁更容易维护”。先看最后一张选型表需求特征推荐方案理由AI 大模型回答一段段输出SSE单向推送天生匹配浏览器原生支持后台任务进度条报表生成、文件转码SSE服务端往客户端发客户端不需要反推行情价格、监控告警推送SSE高频单向数据SSE 足够且重连简单在线聊天室双方实时互发WebSocket全双工双向消息更自然多人在线协同编辑WebSocket需要互相广播操作双向低延迟大量二进制数据音视频帧、文件传输WebSocketSSE 以文本传输为主二进制处理麻烦从协议本质看SSE 的优势是简单、可靠、可增量升级。它跑在 HTTP 之上鉴权、跨域、日志、监控都能复用 Web 基础设施浏览器的自动重连和 Last-Event-ID 又省去了自己写断线续传的麻烦。缺点也明显单向、文本、连接数限制浏览器对单域名连接数有硬限制以及 EventSource 不能带自定义 Header。WebSocket 则能力更全面但它引入了一套独立协议断了要自己重连二进制帧要自己设计消息格式运维侧还要单独盯 Socket 状态。我个人的经验是如果一个实时场景主要方向是“服务端向客户端推送”优先用 SSE只有在确认需要双向高频交互或者消息内容里有大量二进制数据时才上 WebSocket。很多项目一上来就选 WebSocket结果 80% 的推送都是单向的换来的是复杂度和一堆连接异常不划算。如果项目初期用了 SSE后期真的要往 WebSocket 迁移也有一条平滑路径先把业务消息抽象成统一的“事件名 数据 JSON”格式SSE 里有 event 和 data 字段WebSocket 里你也可以约定第一条消息是事件名、第二条是数据。前端再封装一个推送客户端接口内部实现可以随时切换这样上层业务不会因为底层换协议而重写。我从 SSE 迁移到 WebSocket 的项目里页面组件几乎只改一处构造函数的调用其余逻辑原封不动。最后再分享一个实操细节。大模型场景里很多 AI 网关除了返回 SSE 事件流还会在流结束前的最后一条消息里带上 usage 信息比如 token 消耗。前端千万别只盯着 message 事件要把最后一个事件单独拆出来处理否则 token 统计就会漏。我的做法是在前端解析时维护一个 lastEvent 变量循环结束后如果内容是 usage 类型就走统计逻辑不和正文渲染混在一起。这个小细节不踩一次坑很难想到写在这里给后来的人省点时间吧。
返回列表