跳至主要內容

SSE事件 (Server-Sent Events)

wzCoding大约 11 分钟Httpsse

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(代理缓冲)

  1. 正常情况下,网关收到后端的 HTTP 响应后,会把它缓存在自己的内存里。
  2. 等到把后端的完整响应全部接收完毕后,再统一打包发送给客户端。
  3. 但是,SSE 是一个永远不结束的 HTTP 响应(长连接)!
  4. 网关的“水桶”永远等不到接满的那一刻(或者要等很久才达到缓冲区上限),所以它死死把数据攥在手里,不肯发给前端。
  5. 最终现象:前端发起了请求,状态一直是 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 时,希望你能会心一笑,因为你已经洞悉了它背后的所有秘密。