☰
SSE与WebSocket选型指南:基于HTTP实现实时服务端推送的完整实践
2026/9/29 15:41:22 网站建设 项目流程

1. 从“回答一个字一个字蹦出来”说起:HTTP 为什么做不了实时推送

如果你做过 AI 对话类的 Web 项目,一定见过这种效果:对话框里的回答不是一次性出现,而是一个字一个字往外蹦。这个体验背后,就是今天要聊的主角之一——SSE(Server-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 与 EventSource

SSE 其实不是什么新框架,而是一套被纳入 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 表示 CONNECTING,1 表示 OPEN,2 表示 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 里写数据并 flush:

@GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-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 Map<String, 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++) { Map<String, 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 className="chat-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 类型就走统计逻辑,不和正文渲染混在一起。这个小细节不踩一次坑很难想到,写在这里给后来的人省点时间吧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询