SSE事件 (Server-Sent Events)
SSE事件 (Server-Sent Events)
引言 在上一篇《WebSocket 协议》中,我们见识了全双工通信的威力。但你是否想过:
如果我只是想做一个股票行情看板,或者仅仅是接收 ChatGPT 吐出来的逐字回答? 在这些场景下,客户端根本不需要向服务端高频发送消息,只需要服务端单向、源源不断地把数据“推”给客户端就够了。
为了这种“单向推送”的场景去搭建一套沉重复杂的 WebSocket 架构,还要处理心跳保活、协议升级,显然是杀鸡用牛刀。
那么,有没有一种技术,既能像 WebSocket 一样实时推送,又能在底层完美复用现有的 HTTP 协议,极其轻量优雅?
答案是肯定的,它就是今天的主角 —— Server-Sent Events (SSE)。
一、 SSE 的诞生:化繁为简的单向奔赴
在 WebSocket 诞生之前,如果服务端想主动给浏览器发消息,前端只能用恶心的“长轮询(Long Polling)”死等。
HTML5 标准委员会认为,长轮询太野蛮了,我们需要一个原生的、优雅的单向推送标准。于是,Server-Sent Events (SSE) 应运而生。
1. SSE 与 WebSocket 的巅峰对决
很多面试官喜欢问:“既然有了 WebSocket,为什么还要用 SSE?” 其实它们的定位完全不同:
| 特性对比 | WebSocket (WS) | Server-Sent Events (SSE) |
|---|---|---|
| 通信方向 | 全双工 (双向随时发) | 单向 (仅服务端 -> 客户端) |
| 底层协议 | 独立的 WebSocket 协议 (需升级) | 纯正的 HTTP 协议 |
| 数据格式 | 二进制帧 / 文本流 | 纯文本 (UTF-8) |
| 自动重连 | ❌ 需前端手写重连逻辑 | ✅ 浏览器原生自带断线自动重连 |
| 复杂程度 | 高 (需引入 Socket.io 等庞大库) | 极低 (原生 EventSource API) |
一句话总结:
- 需要双向疯狂交流(如多人联机游戏、聊天室),用 WebSocket。
- 只需要服务端单向广播数据(如 ChatGPT 打字机、系统通知、实时大屏),SSE 是绝对的王者。
二、 SSE 的底层原理:一个永远不会结束的 HTTP 响应
很多新手觉得 SSE 很神秘,其实它的底层原理极其简单,甚至简单到让人觉得“就这?”。
SSE 的本质,就是一个普通的 HTTP GET 请求,只不过服务端把响应头做了一点手脚,并且永远不挂断连接。
1. 核心的 HTTP Header 魔法
当浏览器通过 SSE 发起请求时,服务端只需要返回三个特殊的 Header:
HTTP/1.1 200 OK
Content-Type: text/event-stream <-- 🌟 核心:告诉浏览器,我要开始发事件流了!
Cache-Control: no-cache <-- 必须的:禁止浏览器和代理缓存这些数据
Connection: keep-alive <-- 必须的:保持 TCP 连接不要断开
当浏览器看到 Content-Type: text/event-stream 时,它就明白了:“哦,原来后端老哥不是要一次性给我一个完整的 JSON,而是要像挤牙膏一样,一段一段地给我发纯文本事件。”
2. 独特的“挤牙膏”数据格式
连接建立后,服务端怎么给前端发数据呢?SSE 规定了一种极其简单的纯文本格式(必须以 \n\n 结尾代表一条消息结束):
data: 这是第一条消息\n\n
data: {"user":"Tom","msg":"JSON也能发"}\n\n
event: userlogin <-- 还能自定义事件名字!
data: {"id": 123}\n\n
id: 99 <-- 还能给消息编号!(用于断线续传)
data: 这是带编号的消息\n\n
服务端只要按照这个格式不断地 res.write() 输出字符串,前端就能源源不断地收到事件触发。
三、 使用场景:ChatGPT 是如何利用 SSE 的?
近年来,随着大语言模型(LLM)的爆发,SSE 迎来了它的高光时刻。
如果你用过 ChatGPT,你会发现它的回答是一个字一个字蹦出来的(打字机效果)。
- 如果用普通 HTTP 请求:前端必须死等 AI 把几千字的回答全部生成完毕,这可能需要几十秒,用户早就关闭网页了。
- 为什么不用 WebSocket?:杀鸡用牛刀,而且公司原本的 Nginx 网关和 HTTP 负载均衡设施可能不支持 WebSocket 升级,改造成本极高。
- 完美契合的 SSE:ChatGPT 采用了 SSE。前端发送提问后,AI 后端每生成一个词,就按照
data: 词语\n\n的格式推给前端。前端瞬间渲染,用户体验极佳,且完全复用了现有的 HTTP 基建。
其他经典场景:
- 股票/加密货币的实时 K 线图
- 服务器系统状态/日志的实时监控面板
- 扫码登录的确认状态推送
四、 前端实战:优雅至极的 EventSource API
在现代浏览器中,前端使用 SSE 简直就是一种享受。不需要安装任何第三方库,浏览器原生提供了一个极其强大的对象:EventSource。
1. 建立连接与监听默认消息
只需三行代码,你就能搞定实时推送:
// 1. 发起一个普通的 GET 请求,告诉服务器我准备接收事件流了
const sse = new EventSource('https://api.example.com/stream');
// 2. 监听默认的 'message' 事件(当后端不指定 event 名字时触发)
sse.onmessage = function(event) {
// event.data 就是后端发来的字符串内容
console.log('📩 收到服务器推送的新消息:', event.data);
// 如果后端发的是 JSON 字符串,记得解析
const dataObj = JSON.parse(event.data);
document.getElementById('stock-price').innerText = dataObj.price;
};
// 3. 监听连接建立成功事件
sse.onopen = function() {
console.log('✅ SSE 连接已成功建立!');
};
2. 监听自定义事件与错误处理
SSE 的强大之处在于,后端可以对消息进行分类(触发不同的事件名),而且浏览器自带极其强大的断线重连机制。
// 监听后端指定的自定义事件 (例如后端写了: event: user-login\n data: ...\n\n)
sse.addEventListener('user-login', function(event) {
console.log('👤 有新用户登录了:', event.data);
});
// 监听错误事件(网络断开、服务器崩溃等)
sse.onerror = function(event) {
console.error('❌ SSE 连接发生错误!');
// 🌟 SSE 的超级特性:你什么都不用写,浏览器会自动在几秒后尝试重新连接服务器!
// 并且如果后端发过 id 字段,浏览器重连时会自动在请求头里带上 Last-Event-ID,
// 告诉后端:“我刚才断线前最后收到的消息ID是99,请从100开始发给我。”
};
3. 手动关闭连接
因为 SSE 是一个永远不结束的 HTTP 响应,如果你离开了当前页面,一定要记得把它关掉,否则会一直占用浏览器的并发连接数。
// 当组件销毁或用户退出时调用
function closeStream() {
sse.close();
console.log('🔌 SSE 连接已手动关闭');
}
五、真实场景避坑指南
当你在本地开发环境(Localhost)测试 SSE 时,一切都如丝般顺滑,ChatGPT 的打字机效果完美呈现。但是,一旦把代码部署到线上(经过了 Nginx、阿里云 WAF、公司内网网关、CDN 等多重关卡),前端页面就会一直 Loading,或者等了很久之后突然把所有字一次性全吐出来。
这就是 SSE 在企业级架构中最著名的**“网关缓冲陷阱”**。
为了让你在文章中把这个问题讲透,我为你梳理了它的底层原理(为什么会拦截)以及终极解决方案。你可以直接把这部分内容加入到你的 SSE 文章中作为“实战避坑指南”。
1. 核心原罪:网关的“水桶机制” (Proxy Buffering)
要理解为什么会被拦截,我们需要对比一下 SSE 的工作模式和传统网关的工作模式。
【形象的比喻】
- SSE 服务端:像一个水龙头,一滴一滴地往下滴水(不断输出流式数据)。
- 前端浏览器:拿着一个小杯子在水龙头底下,滴一滴水就喝一口(立刻渲染到页面上)。
- 网关(Nginx):如果在中间加了一个网关,它默认的办事逻辑是拿一个大水桶接水。它觉得:“一滴一滴给前端送太麻烦了,等我把水桶接满,或者等水龙头关了,我再端过去一次性给前端!”
【技术层面的真相】 传统的 HTTP 代理服务器(如 Nginx 默认配置)开启了 Proxy Buffering(代理缓冲)。
- 正常情况下,网关收到后端的 HTTP 响应后,会把它缓存在自己的内存里。
- 等到把后端的完整响应全部接收完毕后,再统一打包发送给客户端。
- 但是,SSE 是一个永远不结束的 HTTP 响应(长连接)!
- 网关的“水桶”永远等不到接满的那一刻(或者要等很久才达到缓冲区上限),所以它死死把数据攥在手里,不肯发给前端。
- 最终现象:前端发起了请求,状态一直是
Pending(Loading),什么数据也拿不到。
2. 其他导致拦截的“连环杀手”
除了缓冲机制,多重网关还会带来以下三个致命阻碍:
1. HTTP/1.0 降级问题
有些老旧的网关或代理(或者配置不当的 Nginx),在转发请求时,默认会把协议降级为 HTTP/1.0。
- HTTP/1.0 不支持
Transfer-Encoding: chunked(分块传输编码)。 - 它要求必须有
Content-Length(知道文件总大小)才会开始传输。 - 而 SSE 的大小是未知的!这会导致网关直接懵掉,强行切断连接或一直死等。
2. 网关超时限制 (Timeout)
多重网关为了保护自己不被耗尽资源,通常会设置严格的超时时间(比如 Nginx 默认的 proxy_read_timeout 是 60 秒)。
- 如果你的 AI 生成比较慢,或者服务端 60 秒内没有吐出任何字。
- 网关就会认为:“后端是不是死机了?” 然后**“咔嚓”一刀强行切断 TCP 连接**。
- 现象:前端突然报错,或者频繁触发 SSE 的断线重连。
3. WAF(Web 应用防火墙)的安全拦截
公司的安全网关或防毒软件,为了防止恶意代码,通常要求审查完整的 HTTP 响应体。
- 既然要“完整审查”,它就必须等所有数据传完。
- 面对 SSE 这种无限流,WAF 根本无法完成审查,直接将其判定为异常流量并拦截。
3. 终极实战:如何打通网关,让 SSE 畅通无阻?
在实际工作中,遇到这个问题,作为前端你不仅要知道原因,还要能指导运维/后端去修改配置。
解法一:后端代码层面的“魔法 Header”(最推荐 ✅)
如果你无法修改 Nginx 配置,你可以让后端在代码里加上一个特殊的 HTTP 响应头: X-Accel-Buffering: no
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no <-- 🌟 就是这行魔法代码!
原理:这是 Nginx 官方支持的专属 Header。Nginx 看到这个头,就会乖乖收起它的“大水桶”,立刻变成一根透明的水管,后端滴一滴水,它就瞬间转发给前端。这能解决 90% 的网关缓冲问题。
解法二:修改 Nginx 配置(运维层)
如果你有 Nginx 的控制权,在对应的 location 块中加入以下配置:
location /sse-endpoint {
# 1. 关闭代理缓冲 (撤掉水桶)
proxy_buffering off;
# 2. 强制使用 HTTP/1.1,防止被降级
proxy_http_version 1.1;
# 3. 清除 Connection 头,防止代理自动加上 close 导致连接断开
proxy_set_header Connection '';
# 4. 把超时时间拉长 (比如设为 1 小时),防止网关主动断开
proxy_read_timeout 3600s;
}
解法三:应对超时断开的“心跳保活” (Heartbeat)
为了防止被企业防火墙或网关的超时机制(Timeout)切断,后端必须每隔一段时间(比如 15 秒)向前端发一条毫无意义的空注释或空白事件。
# 这是一条注释,前端的 EventSource 会自动忽略它
: ping\n\n
data: 真正的数据\n\n
原理:网关只要看到网络管道里一直有数据在流动,就不会触发闲置超时(Idle Timeout)的断开机制。
结语:
从最古老简陋的 HTTP/0.9,到为了性能拼命挤海绵的 HTTP 缓存控制; 从被动防御跨域(CORS)和黑客攻击的各种 Header 盾牌,再到突破单向请求枷锁的 WebSocket 与极其轻巧的 SSE。
在这六篇文章中,我们剥开了前端那些光鲜亮丽的 UI 框架外衣,深入到了互联网数据传输的骨髓里。
HTTP 协议从来不是什么高深莫测的黑魔法,它只是一群先驱者为了让信息流转得更安全、更快速而不断修补的一套“规矩”。 当你下次再在控制台看到
304 Not Modified,或者在 Network 面板里看到text/event-stream时,希望你能会心一笑,因为你已经洞悉了它背后的所有秘密。
