1. 从轮询到长连接:为什么我们需要SSE?
如果你做过Web实时数据展示,比如一个后台的实时监控大盘,或者一个聊天应用的“对方正在输入...”状态,那你肯定对“数据如何从服务器实时推送到浏览器”这个问题头疼过。早期最朴素的做法是轮询(Polling),就是让浏览器每隔几秒就发个请求去问服务器:“有新数据吗?”。这就像你每隔五分钟就跑去问前台有没有你的快递,效率低下不说,还浪费体力(网络带宽和服务器资源)。后来有了长轮询(Long Polling),浏览器发一个请求,服务器如果没新数据就“挂起”这个请求,直到有数据了或者超时了才返回。这好比你让前台一有快递就立刻打电话通知你,但每次通知完,你又得马上再打一次电话去“订阅”下一次通知,连接无法复用。
于是,HTML5带来了两种更优雅的“服务器主动推送”方案:WebSocket 和 Server-Sent Events (SSE)。WebSocket是双向的,像打电话,建立连接后双方可以随时畅聊,适合游戏、聊天等强交互场景。而SSE是单向的,服务器向客户端的单向广播,就像你订阅了一个新闻频道,服务器有新闻就推给你,但你没法通过这个频道回话。它基于普通的HTTP协议,因此比WebSocket更简单、更轻量,天然支持断线重连、事件ID等机制。
最近,随着大模型和AI应用的爆发,SSE突然又火了起来。因为它正是实现“流式输出”的绝佳载体。当你向ChatGPT提问时,你肯定不希望等它完全生成一大段文字后再一次性显示给你,而是希望看到一个字一个字“流”出来的效果。这种体验的背后,很多场景用的就是SSE协议。它让服务器可以持续不断地向浏览器发送数据片段,而浏览器则能实时地渲染这些片段。所以,无论你是想做一个实时股票报价系统、一个日志监控面板,还是一个AI对话界面,SSE都是一个必须掌握的核心技术。
然而,当你兴致勃勃地按照教程搭建好SSE服务,打开心爱的Chrome浏览器测试时,可能会遭遇一盆冷水:页面一片空白,F12打开控制台,发现Fetch请求状态一直是“pending”,或者干脆报错。更诡异的是,同一个页面在Firefox或者Safari里可能工作正常。这个问题,十有八九就是撞上了“浏览器的HTTP/1.1连接限制”这堵隐形的墙。今天,我们就来彻底搞懂SSE,并亲手拆掉这堵墙。
2. SSE协议深度拆解:不止是“流”那么简单
很多人以为SSE就是服务器一直不关闭HTTP响应,然后往里面写数据。这么说对,但不全对。SSE有一套轻量但严谨的规范,理解这些细节,是你写出健壮代码和解决奇葩问题的前提。
2.1 核心:基于文本的事件流格式
SSE的响应内容类型是text/event-stream。服务器发送的数据不是任意格式,而必须遵循特定格式。一个最基本的SSE响应体看起来是这样的:
HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive data: 这是第一条消息\n\n data: 这是第二条消息的第一行 data: 这是第二条消息的第二行\n\n event: customEvent data: 这是一个自定义事件的消息\n\n id: 123 data: 这条消息带有一个ID\n\n我们来拆解一下关键指令:
data:: 这是消息内容字段。一行data:定义一行消息内容。如果消息有多行,就用多个data:字段。一个消息块以两个换行符\n\n结束。浏览器端的EventSourceAPI 会将所有data:字段的值用换行符连接起来,作为最终的消息内容。event:: 定义事件类型。默认类型是message。如果指定了自定义事件(如event: update),那么浏览器端就需要用addEventListener('update', ...)来监听,而不是用默认的onmessage。id:: 为消息设置一个ID。这个ID会被浏览器记录下来。如果连接意外中断,当浏览器自动重连时,会在HTTP请求头中带上Last-Event-ID: 123,服务器可以根据这个ID决定从哪条消息开始重新发送,实现断点续传。retry:: 指定重连间隔(毫秒)。例如retry: 10000告诉浏览器如果连接失败,10秒后再尝试重连。
注意: 消息的结束标志是连续的两个换行符
\n\n。很多新手在服务端拼接字符串时,只写了一个\n,导致浏览器一直等待消息结束,数据无法被正常解析和触发。这是最常见的编码错误之一。
2.2 浏览器端:EventSource API的功与过
在浏览器端,使用SSE简单得令人发指:
const eventSource = new EventSource('/your-sse-endpoint'); // 监听默认的 message 事件 eventSource.onmessage = (event) => { console.log('收到数据:', event.data); }; // 监听自定义事件 eventSource.addEventListener('customEvent', (event) => { console.log('自定义事件数据:', event.data); }); // 监听连接打开事件 eventSource.onopen = () => { console.log('SSE连接已建立'); }; // 监听错误事件 eventSource.onerror = (error) => { console.error('SSE连接错误:', error); // 根据eventSource.readyState判断状态 // 0: CONNECTING, 1: OPEN, 2: CLOSED }; // 关闭连接 // eventSource.close();EventSourceAPI 的优点在于其简洁和自动化的能力:自动重连、自动解析事件流、自动处理连接状态。但它也有明显的缺点:
- 只支持GET请求: 这意味着你不能用POST发送大量初始数据。如果需要,通常的做法是在URL参数中传递,或者先通过一个普通API建立会话,再将会话ID通过SSE连接传递。
- 不支持自定义请求头: 这是最致命的限制之一。在现代Web应用中,我们通常使用
Authorization: Bearer <token>这样的头部来进行身份验证。原生的EventSource不支持设置任何请求头。社区有使用fetch模拟EventSource的方案,但这失去了自动重连等原生特性。 - 单向通信: 只能服务器向客户端发,客户端不能通过这个连接回传数据。
2.3 服务端实现要点:保持连接活跃
服务端的核心任务是创建一个保持打开的HTTP响应,并周期性地向其中写入格式正确的SSE数据。这里以Node.js (Express) 和 Python (Flask) 为例:
Node.js + Express 示例:
const express = require('express'); const app = express(); app.get('/stream', (req, res) => { // 1. 设置SSE必需的响应头 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', // CORS 头(如果需要) 'Access-Control-Allow-Origin': '*' }); // 2. 立即发送一个注释行(可选),有助于防止代理缓冲 res.write(':ok\n\n'); // 3. 定期发送数据 const clientId = Date.now(); const intervalId = setInterval(() => { const data = `服务器时间: ${new Date().toISOString()}`; // 注意格式:`data: <内容>\n\n` res.write(`id: ${clientId}\n`); res.write(`data: ${data}\n\n`); // 测试:10秒后发送一个自定义事件 // if (条件) { // res.write(`event: special\n`); // res.write(`data: 特殊通知\n\n`); // } }, 2000); // 每2秒发送一次 // 4. 客户端断开连接时清理资源 req.on('close', () => { console.log(`客户端 ${clientId} 断开连接`); clearInterval(intervalId); res.end(); }); }); app.listen(3000, () => console.log('SSE服务运行在 3000 端口'));Python + Flask 示例:
from flask import Flask, Response, request import time, json app = Flask(__name__) def generate_events(): client_id = int(time.time()) count = 0 try: while True: count += 1 # 构建SSE格式数据 data = f"服务器时间: {time.ctime()}, 计数: {count}" # 使用 yield 生成器持续输出 yield f"id: {client_id}\ndata: {data}\n\n" time.sleep(2) # 每2秒发送一次 except GeneratorExit: print(f"客户端 {client_id} 连接已关闭") @app.route('/stream') def sse_stream(): # 返回一个流式响应 return Response( generate_events(), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' # 对Nginx代理特别重要,禁用缓冲 } ) if __name__ == '__main__': app.run(threaded=True, port=5000)实操心得: 在服务端,尤其是 behind 反向代理(如 Nginx)时,必须注意代理的缓冲行为。默认情况下,Nginx会缓冲后端应用的响应以达到优化目的,但这会破坏SSE的实时性。务必在SSE响应头或Nginx配置中加上
X-Accel-Buffering: no;或proxy_buffering off;。
3. 浏览器连接限制:HTTP/1.1时代的“遗产”与应对之策
现在,我们来到最核心的难题。你写好了服务端和客户端代码,在本地用Firefox测试一切正常。但一用Chrome或基于Chromium的Edge、Thorium等浏览器打开,连接就卡住了。打开开发者工具的“网络”(Network)标签,你会发现那个/stream请求一直处于“待处理”(Pending)状态,而同一标签页下的其他API请求也无法发出。
3.1 问题根源:同一个域名下的HTTP/1.1连接数限制
这个问题的根源是HTTP/1.1协议规范和浏览器厂商对其的实现优化。
在HTTP/1.1中,同一个域名(host+port)下,浏览器允许同时建立的**持久连接(Persistent Connection)**数量是有限制的。这个限制不是标准强制规定的,而是浏览器为了性能和安全做出的实现决策。一个经典的数值是6。这意味着,同一个标签页(甚至整个浏览器进程)对https://api.your-app.com这个域名,最多只能有6个HTTP/1.1连接同时处于活跃状态。
SSE连接是一个长连接,它会长期占用一个连接“名额”。如果你的页面在打开SSE连接的同时,还需要加载大量其他资源(如图片、CSS、JS文件)或并发调用多个API,那么很容易就会把这6个名额占满。一旦占满,后续的所有请求(包括新的API调用、图片加载)都会被放入队列等待,直到有连接被释放。这就是为什么你的SSE请求和页面其他请求都“卡住”了。
为什么Firefox/Safari可能没事?不同浏览器对这个限制的实现略有不同。有的浏览器可能将限制放宽到每个标签页,有的则是全局的。Firefox的默认限制曾经是每个服务器6个,但新版本可能有所调整。Safari的行为也可能不同。而Chrome系浏览器对此限制的执行非常严格,因此问题暴露得最为明显。
为什么HTTP/2能解决这个问题?HTTP/2引入了“多路复用”(Multiplexing)特性。在同一个TCP连接上,可以并行交错地传输多个请求和响应,而不会互相阻塞。因此,一个SSE流和其他几十个API请求可以共享同一个连接,从根本上避免了连接数限制的问题。如果你的前端和后端都部署在支持HTTP/2的服务器/CDN上,并且使用HTTPS,那么这个问题几乎不会出现。
3.2 解决方案一:为SSE使用独立的域名或子域名
这是最经典、最有效的解决方案。既然限制是针对“同一域名”的,那我们就把SSE服务放到另一个域名下。
- 主应用域名:
www.your-app.com(或app.your-app.com) - SSE专用域名:
sse.your-app.com或stream.your-app.com
这样,浏览器对www.your-app.com有最多6个连接限制,对sse.your-app.com也有另外6个连接限制。SSE长连接只会占用它自己域名的连接池,不会影响主应用的资源加载和API调用。
实施步骤:
- 在DNS服务商处为你的服务器IP添加一个A记录或CNAME记录,指向
sse.your-app.com。 - 在后端服务器配置中,确保你的SSE端点可以通过这个新域名访问。这通常意味着在Web服务器(如Nginx)中配置一个新的
server块,或者在你的应用框架中配置路由。 - 前端代码中,将
EventSource的连接地址改为新的域名。// 之前 // const eventSource = new EventSource('/api/stream'); // 之后 const eventSource = new EventSource('https://sse.your-app.com/api/stream'); - 处理跨域问题: 因为域名不同,会触发CORS。你需要在SSE服务的响应头中添加正确的CORS头。
Access-Control-Allow-Origin: https://www.your-app.com Access-Control-Allow-Credentials: true # 如果需要携带Cookie注意: 对于
EventSource,如果设置了withCredentials,服务器端的Access-Control-Allow-Origin不能是通配符*,必须是具体的域名。
3.3 解决方案二:升级到HTTP/2
如果条件允许,将你的整个网站升级到HTTP/2(或HTTP/3)。这不仅是解决SSE连接限制的终极方案,也能极大提升网站整体性能。
- 前提: HTTP/2通常要求使用HTTPS。你需要为你的域名配置SSL/TLS证书(现在有很多免费证书,如Let‘s Encrypt)。
- 服务端支持: 确保你的Web服务器(Nginx >= 1.9.5, Apache >= 2.4.17)或后端运行时(Node.js的
spdy/http2模块)启用了HTTP/2支持。 - 验证: 在浏览器开发者工具的“网络”标签中,查看请求的“协议”列,如果显示
h2,就说明正在使用HTTP/2。
一旦启用HTTP/2,所有请求都在一个连接上多路复用,SSE连接将不再占用额外的连接“名额”,问题迎刃而解。
3.4 解决方案三:优化页面资源加载策略
如果暂时无法使用新域名或HTTP/2,可以通过优化来缓解问题:
- 减少初始并发请求: 合并CSS/JS文件,使用雪碧图减少图片请求,懒加载非首屏资源。
- 域名分片: 这是一个HTTP/1.1时代的经典优化技巧。将静态资源(图片、字体、样式)放在另一个或多个子域名下(如
static1.your-app.com,static2.your-app.com),以利用浏览器对每个域名的连接限制。但这会增加DNS查询和TCP连接建立的成本,在HTTP/2环境下反而是反模式。 - 延迟建立SSE连接: 不要在页面加载伊始就建立SSE连接。可以等待页面主要资源加载完毕(监听
DOMContentLoaded事件),或用户执行某个操作(如点击标签页)后再建立连接。
3.5 诊断与验证:如何确认是连接限制问题?
当SSE不工作时,按以下步骤排查:
- 打开浏览器开发者工具 -> 网络(Network)标签。
- 清空记录,刷新页面。
- 观察
/stream或其他SSE请求的状态。如果它一直处于“待处理”(Pending)状态,且同一时间有很多其他对同一域名的请求也处于Pending状态(接近6个),那么基本可以断定是连接数限制。 - 尝试在浏览器地址栏打开一个新的隐私窗口(无痕模式)测试。因为隐私窗口的会话是隔离的,可能不受其他标签页连接的影响。
- 尝试使用不同的浏览器(Firefox, Safari)进行测试,对比结果。
4. 进阶实践与常见陷阱
解决了连接限制,SSE的征途才走完一半。在实际生产环境中,还有更多细节需要打磨。
4.1 身份验证与自定义头部
如前所述,原生EventSource不支持设置请求头。对于需要Token验证的场景,有几种变通方案:
方案A:URL查询参数将认证Token作为URL的查询参数传递。这是最简单的方法,但Token会暴露在浏览器历史记录、服务器日志和Referer头中,安全性较低,仅适用于低敏感场景。
const token = getAuthToken(); const eventSource = new EventSource(`/api/stream?token=${encodeURIComponent(token)}`);方案B:使用Cookie如果SSE服务与主站同域(或设置了正确的CORS和Cookie域),可以将认证信息放在Cookie中。浏览器会自动携带Cookie。这种方式更安全,但需要处理CSRF等安全问题。
方案C:使用fetch API模拟EventSource这是功能最强大的方案。你可以用fetch发起请求,然后手动读取流、解析事件、实现重连逻辑。虽然复杂,但可以完全控制请求头。
async function createCustomEventSource(url, options = {}) { const { headers = {}, onMessage, onError, onOpen } = options; async function connect() { try { const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'text/event-stream', ...headers // 可以在这里传入 Authorization 等自定义头 }, credentials: 'include' // 如果需要Cookie }); if (!response.ok || !response.body) { throw new Error(`SSE连接失败: ${response.status}`); } onOpen?.(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // 最后一行可能是不完整的,放回缓冲区 let event = { id: null, event: 'message', data: '' }; for (const line of lines) { if (line.startsWith('id:')) event.id = line.substring(3).trim(); else if (line.startsWith('event:')) event.event = line.substring(6).trim(); else if (line.startsWith('data:')) event.data += line.substring(5).trim() + '\n'; else if (line === '') { // 空行表示一个消息结束 if (event.data) { event.data = event.data.trimEnd(); onMessage?.(event); } event = { id: null, event: 'message', data: '' }; } } } } catch (err) { onError?.(err); // 实现重连逻辑 setTimeout(() => connect(), 3000); } } connect(); // 返回一个关闭函数 return () => { /* 关闭逻辑 */ }; }4.2 连接稳定性与生产环境考量
- 心跳机制: 为了防止代理或防火墙因长时间无数据而断开空闲连接,服务器应定期发送“心跳”消息。这可以是一个只包含冒号的注释行(
:keepalive\n\n),或者一个不包含实际数据的普通消息。 - 错误处理与重连: 虽然
EventSource有自动重连,但生产环境需要更精细的控制。例如,在onerror事件中,根据eventSource.readyState判断错误类型,实现指数退避重连策略,避免网络抖动时频繁重连冲击服务器。 - 服务端连接管理: 当有成千上万个客户端保持长连接时,服务端资源管理至关重要。你需要一个机制来存储和管理所有活跃的连接对象(如放在Map中),并在客户端断开时(监听
req.on('close')或res.on('close'))及时清理,防止内存泄漏。 - 负载均衡与粘性会话: 如果你的服务端是多实例部署,SSE连接必须通过负载均衡器。由于SSE是长连接,负载均衡器必须支持“粘性会话”(Session Affinity),确保来自同一客户端的后续请求(包括重连)能被路由到之前那个持有连接的后端实例。否则,重连后会找不到之前的连接上下文。
4.3 与WebSocket的选型对比
最后,我们再来明确一下SSE和WebSocket的选型边界,这能帮你避免用错技术。
| 特性 | Server-Sent Events (SSE) | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务器 -> 客户端) | 双向(全双工) |
| 协议 | 普通 HTTP/HTTPS | 独立的ws://或wss://协议 |
| 数据格式 | 文本 (UTF-8),格式固定(data:,id:,event:) | 文本或二进制,格式完全自定义 |
| 浏览器API | EventSource(简单,功能有限) | WebSocket(功能强大) |
| 自动重连 | 内置支持(带Last-Event-ID) | 需手动实现 |
| 自定义请求头 | 不支持(原生API) | 支持 |
| 适用场景 | 实时通知、新闻推送、监控数据流、日志流、AI流式输出 | 在线聊天、协同编辑、实时游戏、股票交易终端 |
简单决策树:
- 如果你的场景主要是服务器向客户端推送数据,且客户端不需要频繁地向服务器发送消息(或者可以通过普通的HTTP API发送),那么SSE是更简单、更轻量的选择。AI对话的流式输出就是典型场景。
- 如果你需要真正的双向、低延迟、高频次通信,比如构建一个聊天室或在线游戏,那么WebSocket是唯一的选择。
我个人在构建后台数据监控大屏和AI功能的前端流式输出时,首选SSE。它的实现成本低,基于HTTP的特性使其更容易集成到现有架构中,并且自动重连等特性非常省心。而遇到连接限制问题时,使用独立子域名是最快最稳的解决方案。