1. 从轮询到WebSocket再到SSE,我为什么最终选择了它
如果你做过实时推送类的功能,大概率会和我一样,把传统HTTP轮询、WebSocket、SSE这三个方案来回比较过好几轮。先说结论:在"服务端主动向浏览器推送消息"这个单一需求上,SSE往往是被低估的那一个,尤其适合通知、日志、状态同步这类"服务端到客户端单向推送"的场景。
我刚接触这个需求的时候,第一反应也是WebSocket。毕竟WebSocket名声在外,双向通信、全双工、低延迟,听起来就是实时通信的标配。但在实际评估中我发现,WebSocket带来的复杂度远高于需求本身:需要额外维护协议握手、心跳保活、断线重连、消息帧格式,如果是网关或代理环境,升级请求还要做专门配置。而我的业务场景是"用户下单之后,后台服务把订单状态变化推给前端页面",团队里还有几个刚入门的前端同学要一起维护,这时候能明显降低心智负担的SSE就成了更现实的选择。
SSE全称是Server-Sent Events,字面意思就是"服务端发送事件"。它走的是标准HTTP协议,客户端只需要一个EventSource对象就可以监听服务端流式返回的数据,不需要额外引入任何库。服务端也不需要专门协议,就是把响应头设置为text/event-stream,然后持续输出特定格式的文本即可。这条链路从本质上就决定了它比WebSocket更容易上手、更容易调试、更容易跨越各种中间层。
当然SSE也有它的边界。它是单向的,客户端如果想向服务端发送数据,仍然要通过普通HTTP请求完成。这也是WebSocket至今不可替代的原因之一。但反过来想,如果你的需求恰好是纯推送,那么单向反而是优点:协议更简单、自动重连带好了、跨域限制更宽松、还能直接复用你现有的HTTP层配置。下面我会从底层协议拆起,再给出一套完整可跑的示例代码,最后聊聊那些教程里很少提到的生产环境问题。
2. 协议拆解:text/event-stream的运行机制与事件流格式
2.1 一次普通HTTP请求如何变成持续事件流
SSE本质上没有发明新协议,它只是重新利用了一个HTTP特性:响应体不关闭,持续向客户端输出内容。
浏览器发起new EventSource('/api/stream')时,会发送一个普通的GET请求,请求头里带上了Accept: text/event-stream。服务端收到请求后,返回状态码200,并设置响应头:
Content-Type: text/event-stream:告诉浏览器这是SSE流Cache-Control: no-cache:避免中间层缓存事件流Connection: keep-alive:保持长连接
关键是最后一点:正常HTTP响应在返回完body后连接就关闭了,而SSE服务端不关闭连接,而是持续向客户端写入事件数据。浏览器端的EventSource会识别这种流式响应,持续解析内容并触发对应的事件回调。从协议栈的角度说,TCP连接在应用层表现为"挂起"状态,但实际上数据一直在流动。
2.2 事件流格式:每个字节都有讲究
SSE协议的数据格式非常简洁,遵循标准的"字段: 值"结构。最核心的字段有三个:
data:消息的数据内容event:事件类型,不写默认是messageid:事件ID,用于断线重连retry:重连间隔,单位毫秒
一个最小的事件消息是这样的:
data: 这是第一条消息注意末尾必须有一个空行来表示事件结束。如果一条消息的数据有多行,那么多行data会被合并成一个值,中间用换行符连接:
data: 第一行 data: 第二行这样浏览器收到的数据就是第一行\n第二行。除此之外还有以冒号开头的注释行,比如:
: 这是一条注释,通常用作心跳保活注释行不会触发任何事件,但能让连接保持活跃。这个机制在后面的实战中非常重要,因为某些中间代理设备会在空闲一段时间后主动断开连接。
我把SSE协议的基本格式整理成了一张表,方便你记忆:
| 字段 | 作用 | 示例 | 注意事项 |
|---|---|---|---|
data | 消息内容,可多行 | data: hello | 多行会自动拼接 |
event | 自定义事件类型 | event: order-status | 不写则默认触发message事件 |
id | 事件序号 | id: 10086 | 断线后会自动携带此值重连 |
retry | 重连间隔 | retry: 3000 | 单位是毫秒 |
| 注释行 | 心跳/占位 | : ping | 以冒号开头,被忽略 |
2.3 断线重连机制:Last-Event-ID的巧妙设计
SSE最让我满意的一点,是它把断线重连做进了协议层。浏览器端的EventSource一旦检测到连接中断,会自动发起重连,不需要你写一行代码。
更妙的是重连时会自动携带一个Last-Event-ID请求头,值是上一次收到的事件id。服务端读取这个值,就知道客户端消费到哪条消息了,从而只推送缺失的后续消息。配合服务端的事件序号设计,几乎可以做到不丢消息。举一个实际场景:前端收到ID为10086的事件,网络突然断开,再重连时请求头会带Last-Event-ID: 10086,服务端看到这个值,就知道应该从10087开始推送。
重连间隔默认由浏览器控制,但服务端可以通过retry字段动态调整。在流量高峰时把retry调大一点,可以避免客户端频繁冲击服务端。
2.4 EventSource对象的浏览器行为细节
EventSource看似简单,但它的行为有一些坑主要在细节里。它默认只支持GET请求,且无法自定义请求头,这就限制了带身份凭证的方式——只能通过URL参数或Cookie来完成认证。跨域场景则需要设置withCredentials属性。
EventSource维护了三种连接状态:CONNECTING(值为0)、OPEN(值为1)、CLOSED(值为2)。当遇到网络错误或服务端主动关闭连接时,状态会在CONNECTING和OPEN之间流转,直到调用close()方法才会进入CLOSED并停止重连。
另外一个容易忽略的点:当HTTP返回非200状态码时,浏览器不会进入重连逻辑,而是直接抛错。这和你预期的"断线重连"不同,只有连接真正中断(如TCP断开、504网关超时)才会触发自动重连。所以服务端如果主动返回403之类的错误码,客户端就必须自己处理和提示用户。
3. 手写完整示例:PHP服务端推送与浏览器实时展示
3.1 为什么选PHP做示例
我没有选择Node或Python,而选了PHP,是因为SSE在实际生产中大量出现在这类传统Web环境下,很多同学的线上环境就是Nginx加PHP-FPM的组合。另外PHP脚本处理流式输出时有一些容易被忽视的坑(比如缓冲区、超时),把这些坑一并说明更有参考价值。
3.2 服务端完整代码
下面是一个订单状态推送的完整服务端示例。核心逻辑是:根据客户端传来的Last-Event-ID决定从哪一条消息开始推送,然后每隔一段时间推一条订单状态,直到全部推完。
<?php // sse_server.php // 1. 关闭PHP执行超时,SSE连接是长连接 set_time_limit(0); // 2. 关闭输出缓冲区,确保数据能立即写入客户端 while (ob_get_level() > 0) { ob_end_flush(); } // 3. 设置响应头 header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); // 4. 模拟从数据库读取订单状态 function getOrderStatus($orderId) { // 这里替换成你的真实数据源 $statusMap = [ 1 => '已下单', 2 => '支付成功', 3 => '商家已接单', 4 => '配送中', 5 => '已完成' ]; return $statusMap[$orderId] ?? '未知状态'; } // 5. 读取客户端断线重连的偏移量 $lastEventId = isset($_SERVER['HTTP_LAST_EVENT_ID']) ? (int)$_SERVER['HTTP_LAST_EVENT_ID'] : 0; // 6. 模拟事件ID从1开始递增 $eventId = max($lastEventId + 1, 1); for ($i = $eventId; $i <= 5; $i++) { // 发送事件ID echo "id: {$i}\n"; // 发送自定义事件类型 echo "event: order-status\n"; // 发送JSON格式的数据 $data = json_encode([ 'order_id' => 10086, 'status' => getOrderStatus($i), 'time' => date('Y-m-d H:i:s') ]); echo "data: {$data}\n"; echo "\n"; // 关键:立即刷新输出缓冲区 flush(); // 模拟业务处理耗时 sleep(2); } // 7. 推送结束,关闭连接 echo "event: done\n"; echo "data: 推送完毕\n\n"; flush();代码里第六步的循环是关键。我们看到flush()被调用在每个事件推送之后,这是SSE在PHP环境下最容易出问题的地方。如果不刷新缓冲区,数据会积压在服务端,客户端将一直收不到消息。
3.3 前端完整代码与EventSource监听
前端代码更简单,因为大部分重连逻辑都交给浏览器了:
// sse_client.js const source = new EventSource('/sse_server.php'); // 监听默认的message事件(服务端未指定event字段时触发) source.addEventListener('message', (event) => { console.log('收到默认消息:', event.data); }); // 监听自定义的order-status事件 source.addEventListener('order-status', (event) => { const data = JSON.parse(event.data); const statusEl = document.getElementById('status'); statusEl.textContent = `订单 ${data.order_id} 当前状态:${data.status}`; statusEl.dataset.time = data.time; }); // 监听服务端定义的done事件 source.addEventListener('done', (event) => { console.log('推送完毕事件:', event.data); source.close(); }); // 监听连接错误 source.onerror = (error) => { console.log('连接异常,浏览器将自动重连...'); // 如果需要停止重连,可以调用 source.close() };对应一个极简的HTML页面:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>订单状态实时展示</title> </head> <body> <h1>订单状态</h1> <div id="status">等待推送...</div> <script src="/sse_client.js"></script> </body> </html>把服务端脚本放到你的PHP运行环境,前端页面打开浏览器控制台,就能看到每隔两秒打印一条订单状态。
3.4 调试SSE的正确打开方式
SSE的调试很容易被误解,因为你在浏览器Network面板里看到请求一直处于Pending状态,这其实是正常的,因为连接本来就保持着。想验证数据流向,有两个办法:
第一个是在Network面板左侧找到那个/sse_server.php请求,点击后在"事件"标签页里能看到解析好的事件流列表。第二个是直接打开浏览器的Console,不过默认只会看到报错,不会看到数据。
如果你用命令行调试,我强烈推荐curl -N命令:
curl -N http://localhost/sse_server.php-N参数禁用curl的缓冲,让输出立即打印。这样你能以最快速度确认服务端输出格式是否正确——比如是否漏了空行、retry字段是否生效、事件ID是否自增。在我个人实践里,90%的"前端收不到消息"问题都能通过curl一眼定位。
4. 上生产后才会遇到的坑:连接上限、代理缓冲、重连风暴
4.1 浏览器并发连接数上限
我第一次把SSE弄上线后就接到反馈:某个页面打开后,其他请求全部卡住了。排查了半天才想起浏览器的连接数限制。
HTTP/1.1时代,每个域名下浏览器最多只允许同时保持6个连接(不同浏览器略有差异)。一个SSE长连接占掉其中一个配额,如果页面里还有其他长轮询或并行请求,很容易撞上限。特别是同一个域名下开了多个SSE连接,很快就会触顶。
应对方案无非这几种:把SSE服务器独立到子域名,让推送连接不占用主业务域名的连接配额;同一页面里尽量合并多个事件源为一个,通过自定义事件类型做区分;或者上HTTP/2,多路复用不再受6连接限制。
4.2 Nginx代理缓冲:你推了数据但浏览器没收到
这是生产环境中最隐蔽的一个坑。你的服务端明明一秒推一条数据,浏览器却要等很久才一次性收到一堆数据。
原因多半是Nginx开启了proxy_buffering。Nginx默认会缓冲上游(PHP-FPM)的响应,攒够一定量或等到连接关闭才把数据转发给客户端。SSE是持续输出、长时间不关闭的连接,所以数据被积压在Nginx层,浏览器端自然就一直干瞪眼。
解决方法是关闭对该路由的代理缓冲:
location /sse_server.php { proxy_pass http://php-fpm-backend; proxy_set_header Connection ''; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }这里面有个细节:proxy_read_timeout如果设得太短(默认60秒),长连接会被Nginx切断。我建议至少调到3600s,并根据业务实际需要决定是否更长。如果前面还有CDN或负载均衡,类似的问题也需要逐层检查。
4.3 重连风暴与retry控制
SSE的自动重连是把双刃剑。假如服务端某次因故障重启,几百上千个客户端会同时断开连接,然后几乎在同一时间发起重连请求,瞬间把服务端压垮。
这就是我常说的重连风暴。在客户端规模较大时,必须想办F控制住。一种做法是服务端在发现异常时主动下发一个较大的retry值,比如:
retry: 15000 data: 服务端繁忙,请稍后重试不过这个字段只对下一次重连生效,而且要在连接断开前有机会发出去。更保险的做法是在客户端做指数退避的二次控制:监听onerror后,先close(),再通过setTimeout主动重建连接,间隔随时间递增,比如第一次3秒、第二次6秒、第三次12秒,直到恢复。
4.4 服务端PHP-FPM的长期占用问题
PHP-FPM处理SSE长连接时,要知道这个过程会一直占着一个worker进程。如果你的FPM进程数配置为10,那么10个客户端连着,后面第11个请求就要排队等待。这在连接量上来后是非常大的隐患。
我经历过一次事故,当时S S E服务没有和普通业务接口分开部署,结果一个活动页开SSE后,整个站点API全线超时。后来把SSE独立拆分到单独的服务,才彻底解决。给你的建议:如果预期连接数超过几十个,考虑用更擅长长连接的技术栈来做SSE,或者直接用消息队列加专用网关。PHP不是不能做,而是需要更小心地规划容量。
5. 进阶玩法:自定义事件、主动断连与私有协议扩展
5.1 利用event字段区分业务消息
很多教程只讲了默认的message事件,但实际业务里,一个SSE连接往往要同时推送多种类型的数据。比如一个客服工作台页面,既需要推送新消息提醒,又需要推送客户在线状态,还可能推送工单更新。
正确的做法是利用event字段定义多个事件类型,前端分别监听:
event: new-message data: {"from":"用户A","content":"你好,请问这件商品有货吗"} event: customer-status data: {"id":123,"status":"online"} event: ticket-update data: {"ticket_no":"TK-1001","status":"已回复"}前端对应地注册三个addEventListener即可。这样一条长连接就能承接整个页面的实时业务,连接数占用也能降到最低。
5.2 服务端主动关闭连接的几种场景
有时推送的临时任务完成了,或者用户权限被回收,服务端需要主动关闭SSE连接。最简单的方式就是关闭响应的输出流,PHP里直接exit;或return;即可。但要让前端明确知道"这次关闭是业务结束,不是故障",我会在关闭前推一条业务结束事件。
前端必须在addEventListener里监听这个事件,主动调用source.close()。因为前面说过,如果连接只是服务端断开,浏览器会认为这是一次异常断开,自动重连。你不想让一个已经结束的任务继续反复发起无效连接,所以主动关闭很重要。
5.3 用fetch自行解析SSE的场合与代价
原生EventSource不支持自定义请求头,这是它最明显的短板。某些业务需要带Token鉴权,而Token又放在请求头里而非URL参数(怕日志泄露URL),就可以考虑用fetch加流式读取来模拟SSE。
基本原理是拿到ReadableStream后,手动按换行解析数据块:
const response = await fetch('/api/sse', { headers: { 'Authorization': 'Bearer ' + token } }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); // 这里按 \n\n 切分事件块,再解析 data: 字段 }但这样做,你就必须自己实现事件解析、重连机制、Last-Event-ID维护,成本一下子高了起来。我的建议是:不是必须用自定义请求头的场景,就别动这个心思,老老实实EventSource加URL参数鉴权,或者用Cookie鉴权,让浏览器把重连这件烦心事替你扛着。
5.4 SSE在实际业务中的扩展思路
最后聊聊SSE适合做和不太适合做的事。经过前面的分析,你大概已经摸清了它的脾气:简单、单向、走标准HTTP。归根结底,它是"服务端消息推送"这一批需求里的轻量级选手。
我做得比较顺利的场景是:后台任务的进度条实时更新、运维页面的日志流式输出、客服系统的消息提醒、大屏数据看板的定时刷新。这些场景传的数据量不大,且都是单向的,SSE用起来舒服又省心。
不太适合的场景有:在线协作编辑这类需要频繁双向交互的,以及超大规模消息推送(比如几十万并发在线),前者建议WebSocket,后者建议考虑其他架构或接入专门的推送通道。
一句话总结我的经验:技术选型不是找一个最牛的方案,而是找一个最合适的方案。SSE输出了WebSocket没有的简单性,也吃下了轮询搞不定的实时性,在服务端单向推送这条路上,它的性价比相当高。你在实际项目里如果也遇到这类需求,我建议你先花二十分钟写个SSE demo实测一下,再决定要不要上WebSocket——很多时候实测完就会发现,SSE已经够了。