☰
网页实时推送选型:SSE与WebSocket对比及EventSource实践指南
2026/10/17 12:05:47 网站建设 项目流程

1. 一个具体项目的选型:为什么我从WebSocket改回了SSE

先交代一下背景。前阵子给某内部管理系统做一个实时通知中心,表面上看起来不复杂:后端一有新消息,网页上要弹出小红点,还要更新一个最近消息列表。第一反应自然是"上WebSocket",毕竟这几年大家都在用,生态也成熟。但真正动手之后,我发现这个场景有一堆WebSocket用起来别扭的地方,后来干脆换成了SSE(Server-Sent Events,服务器推送事件),反而用最朴素的HTTP方式解决得干干净净。

当时遇到的第一个不舒服点,是WebSocket的服务端成本。聊天室、实时白板这类双向高频交互场景,WebSocket是必须的,但消息通知这种"服务器单向推进"的场景,WebSocket一半的能力根本用不上。我还要专门维护一个socket连接池、处理心跳握手、考虑断线重连后房间怎么恢复,所有这些工作换到SSE之后几乎全部消失。SSE本来就是跑在HTTP上的,它的连接发起就是一次普通GET请求,服务端保持这个连接不关闭,不断往里面写入特定格式的文本就行。浏览器端则由EventSource这个API自动处理连接、自动重连,连状态机都不用我操心。

说一个最直观的对比。我当时的通知中心需要做到"新消息到达后1秒内前端弹提示",WebSocket方案里我需要写:建立连接的回调、监听message、监听close、监听error、写心跳定时器、写断线重连逻辑、写重连成功后的补偿拉取。而换成SSE方案后,核心代码不到原来了三分之一。整个连接就是一个普通HTTP请求,后端往响应流里写数据,前端用EventSource监听同名事件,一切就这么简单。

这不是说SSE比WebSocket优秀,两者是不同定位的工具。SSE只做单向推送,而且在设计之初就考虑好了"断线后自动恢复"这件事,很多协议层面的细节(比如用Last-Event-ID续传)是浏览器帮你完成的。如果你的场景也是"服务端产生事件,网页只需要被动接收并展示",SSE很可能比WebSocket更顺手,也更省资源。这篇文章我就围绕"网页SSE"这条主线,把从选型、协议格式、服务端实现、浏览器接入到生产环境各种坑完整梳理一遍,给想用SSE的人一份可以直接照着做的完整方案。

2. SSE与WebSocket的技术分岔口:双向和单向不是唯一差异

2.1 协议层面的根本差异

很多文章解释SSE和WebSocket的区别,只会说一句"一个单向一个双向",但实际选型时,这句话远远不够。WebSocket是一个独立于HTTP的协议,握手阶段借用了HTTP的Upgrade机制,成功后连接就从HTTP切换到了WebSocket帧协议。这意味着部署WebSocket服务时,你需要考虑它和HTTP服务是走同一个端口还是单独端口、接入层代理是否能正确转发WebSocket帧、负载均衡是否需要开启会话保持等等。

SSE则完全不同,它从头到尾都没有离开HTTP。客户端发一个普通GET请求,服务端返回时把Content-Type设成text/event-stream,然后连接保持打开,服务端持续向响应体写入文本。这种设计带来一个隐藏好处:所有现有HTTP基建基本不用改。域名、端口、HTTPS证书、普通反代,全都照常工作。对于部署在复杂网络环境里的业务系统来说,这个优势非常实在,少引入一个新协议,就少一整套排查链路。

还有一个容易被忽略的事实:SSE的客户端实现极简单,因为它就是浏览器原生的EventSource对象。你不需要引入任何第三方库,不用管理socket实例,不用处理二进制帧拆包,只需要在onmessage里拿数据。WebSocket要做到同样体验,你得自己封装连接管理、重试机制、消息序列化规范,这些工作量在项目初期往往被低估。

2.2 各自真正擅长什么场景

任何技术选型都应该先看消息流向。WebSocket适合"双向、高频、低延迟"的交互,比如在线协作编辑、多人游戏、即时聊天。这类场景里客户端也要持续给服务端发消息,且对往返延迟极其敏感,用SSE就没法做,因为SSE客户端不能通过同一路径往服务端推数据。

SSE适合的场景则是"服务端事件驱动、客户端被动消费",我归类一下我实际遇到过的几类:

  • 通知类:系统消息、告警提醒、站内信。服务端产生事件,网页弹个提示就行。
  • 数据看板/大屏:指标数据每几秒更新一次,前端只是把最新数据画出来。
  • 日志流:部署系统里跟踪构建日志,或者查看实时运行日志。
  • AI会话中的流式输出:大模型回答逐字返回,这种场景现在非常常见,很多实现其实走的就是SSE。

反之,SSE不适合的场景有:客户端需要向服务端发指令的(比如远程控制)、数据是二进制大对象的(SSE基于文本帧)、单条消息体积很大的(后面会讲限制)、需要严格消息确认机制的。

为了更直观,我把两张技术的差别整理成一张对比表:

对比维度SSEWebSocket
底层协议HTTP(普通GET长响应)独立的WS帧协议
通信方向服务端到客户端单向双向
浏览器APIEventSourceWebSocket对象
自动重连原生支持需要自行实现
断点续传通过Last-Event-ID天然支持需要自己设计
自定义消息类型支持event字段区分需要自己约定消息格式
二进制数据不支持(仅文本)支持
请求方式仅GET握手后双向
服务端实现成本极低(HTTP框架即可)较高(需要协议实现)
连接数量影响占用一个普通HTTP连接占用一个长连接

从这张表能看出来,选SSE还是WebSocket,本质上不是"谁更先进"的问题,而是"消息模型是否匹配"。我见过不少项目,业务模型是典型的单向推送,却引了一整套Socket.IO,结果既要维护服务端库的版本兼容,又要在前端处理断线重连的复杂状态。如果当初评估一下SSE,代码量和运维成本都能砍掉一大半。

3. 服务端怎么把HTTP响应变成"持续推流"通道

3.1 Node.js实现一个最简SSE接口

SSE服务端的核心原理并不神秘:一个不结束的HTTP响应。只要你在响应对象上不停写入符合event-stream格式的文本,浏览器就会持续收到数据。我用Node.js的express写过一个最简版本,核心代码就这么点:

const express = require('express'); const app = express(); app.get('/api/events', (req, res) => { // 这三个响应头是SSE能正常工作的关键 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); // 立即把响应头发出去,让连接先建立起来 // 每2秒推送一条当前时间 const timer = setInterval(() => { const payload = { type: 'heartbeat', time: new Date().toISOString(), }; res.write(`data: ${JSON.stringify(payload)}\n\n`); }, 2000); // 客户端断开时清理定时器,否则会内存泄漏 req.on('close', () => { clearInterval(timer); res.end(); }); });

这段代码里有几个细节值得展开说。flushHeaders非常重要,如果你不调用它,很多服务端框架会等第一个write时才把响应头发出去,如果不发头,浏览器就不知道这是一个event-stream,可能会一直等下去。Cache-Control: no-cache是防止任何中间代理或浏览器缓存这个长响应,如果被缓存,你后面写入的数据就不会实时到达。Connection: keep-alive是告诉HTTP层这个连接需要保持,不能被复用或关闭。

另一个细节是req.on('close')的清理。SSE连接是长期存活的长连接,服务端如果为每条连接启动定时器或占用了其他资源,客户端一关页面连接就结束了,这时候不清理定时器,服务器上的悬挂定时器会越积越多,早晚拖垮进程。这个我在排查线上问题时遇到过,后来统一在所有SSE接口里加上连接关闭监听,才算根治。

3.2 event-stream格式的精确定义与多字段组合

SSE连接上传输的内容是一个个以空行分隔的消息块,每个消息由若干"字段行"组成,字段名的合法取值只有四个:data、event、id、retry。每一行格式是字段名: 值,一个消息块以空行结束,浏览器收到空行后才认为一条消息完整。

最基础的是data字段,它携带消息内容,可以是纯文本,但实际开发中几乎都是JSON字符串。一个data后面可以跟多行,浏览器会把连续多行的data按换行符拼接起来当成一个完整消息。比如:

data: {"name": "张三"} data: {"age": 30}

这里浏览器收到的是字符串{"name": "张三"}\n{"age": 30},注意中间有个换行。所以如果你要传完整JSON,建议只写一行data:,避免拼出非法的JSON。我自己的习惯是所有数据都压成一行JSON,再统一发送。

event字段用于自定义消息类型。比如服务端发:

event: userlogin data: {"username": "admin"}

浏览器端就要用addEventListener('userlogin', handler)来监听,而不是onmessage。这种机制很适合混合推送不同类型事件的场景,前端一个连接能按语义区分不同业务消息,代码结构也清晰得多。

id字段用来标记消息序号,它的作用是断点续传的基石。浏览器收到带id的消息后会记住这个值,如果连接意外断开并触发重连,自动重发的HTTP请求头里会带上Last-Event-ID: 上次的最新id。服务端读这个请求头,就知道要跳过哪些消息、从哪里继续推。这个机制完全是协议内建的,你不需要自己设计任何重传逻辑。

retry字段则用来覆盖浏览器默认重连等待时间,单位是毫秒。如果服务端希望断线后3秒再重连,写入retry: 3000即可。注意这个值只在重连时生效,不会影响当前连接。

3.3 用注释行做心跳:一个被低估的细节

event-stream格式还允许以冒号开头的行,这样的行会被当作注释忽略,不构成消息。这个特性除了注释外,最大的用处是保活。

很多网络环境,尤其是经过运营商或各类网关的长连接,如果一段时间内没有任何字节流动,会被默默掐断。浏览器端虽然会自动重连,但如果你不想看到频繁的断线重连,最好服务端每隔一段时间写一行注释下去:

: keep-alive ping

这个不算消息,浏览器端不会触发任何事件,但连接上确确实实有网络流量,能有效防止中间设备认为连接空闲而将其回收。我生产环境里用的是合并策略:正常推送业务数据时不需要额外心跳,但如果一个连接超过20秒没有业务事件,就主动写一条注释保活。实现也不复杂,在定时器里判断一下上次推送时间即可。

4. 浏览器端EventSource:比WebSocket客户端省心太多

4.1 三行代码接入一个实时通道

浏览器端接入SSE是我最喜欢的部分,因为代码量少得让人怀疑是不是漏了点什么。一个最基本的连接长这样:

const source = new EventSource('/api/events'); source.onopen = () => { console.log('连接已建立'); }; source.onmessage = (event) => { // event.data就是服务端data字段的字符串 const data = JSON.parse(event.data); updatePage(data); }; source.onerror = () => { // 连接断开或出错时触发,EventSource会自动重连 console.log('连接异常,浏览器会自动重连'); };

onmessage对应的就是没有event字段的默认消息。当你监听了onmessage,服务端发来的每条普通消息都会走到这里。一旦连接建立,浏览器会自动维护它,断开后按retry指定的时间重连,这些都不需要你写任何额外逻辑。对于只做页面刷新的场景,这三段回调基本够用到上线。

如果你用自定义事件,前端监听方式也很直接:

const source = new EventSource('/api/events'); source.addEventListener('userlogin', (event) => { const data = JSON.parse(event.data); showLoginToast(data.username); }); source.addEventListener('neworder', (event) => { const data = JSON.parse(event.data); refreshOrderList(data); });

4.2 EventSource的几个API细节和使用边界

EventSource的withCredentials属性值得专门说。如果你的SSE接口和页面不在同一个源上,浏览器默认不会携带Cookie,跨域请求也不会通过。设置new EventSource(url, { withCredentials: true })可以开启Cookie携带。对应地,服务端响应头必须设置Access-Control-Allow-Credentials: true,且Access-Control-Allow-Origin不能是通配符*,必须指定精确源。这里逻辑和普通XHR跨域完全一致,踩过一次坑后就能记住。

还有一个重要限制,EventSource只支持GET请求,无法自定义请求头,也没法携带请求体。这意味着鉴权不能靠Authorization头往里面塞,常见替代方案有三个:URL上带token参数、用Cookie鉴权、先请求一个普通接口换取一次性票据再建立SSE。我在项目里用的是URL参数方式,最直观,但要留意服务端日志和反代日志会记录完整URL,token别放太长或太敏感。

另外要注意连接数的限制。HTTP/1.1协议下,浏览器对同一域名最多开启约6个TCP连接,EventSource也是普通HTTP连接,所以要占用其中一个配额。如果用户同时打开了多个页面或同页面多次调用new EventSource,容易把浏览器连接池占满,导致其他普通请求被阻塞等待。我在数据大屏项目里就遇到过:打开多个监控页签后,页面普通刷新变得极慢。解决方式是把SSE连接全局复用,整个浏览器环境只保留一个EventSource实例,再在页面内做消息分发。

5. 生产环境里SSE最现实的四个坑:代理缓冲、连接数、心跳与断线

5.1 反代缓冲导致"半天不动"的排查链路

第一个坑来自反向代理。我在部署阶段遇到一个非常诡异的现象:本地开发环境SSE一切正常,部署到服务器后,浏览器要等好几分钟才收到第一条数据,而且收到的是一大坨累积消息。一开始我怀疑是应用代码问题,在服务器上直接用curl测试接口,发现数据也是瞬时返回的,说明应用层没问题。

后来怀疑到Nginx头上。查配置才发现,默认情况下Nginx开启了proxy_buffering,响应体不会立即转发给客户端,而是先被Nginx缓冲,等攒够一定量或响应结束后一次性吐出来。对普通接口这毫无感知,对SSE来说就是致命问题,它等于是把流式响应变成了批处理。修复方案是在SSE对应的location里加:

location /api/events { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; proxy_read_timeout 3600s; }

proxy_buffering off是核心,等于告诉Nginx别截留后端数据,来一点转发一点。proxy_read_timeout也很关键,默认值是60秒,SSE连接可能长期没有数据流动,如果心跳没做好,连接会被Nginx按超时断掉。我设置成1小时以上,让应用层自己控制心跳和断线逻辑,接入层只负责转发。

这次排错的完整链路是:先复现问题,再分位置测试(本地直连OK、服务器curl OK、经过Nginx异常),最后定位到接入层配置。这个排查顺序值得记住,以后遇到"HTTP接口正常但SSE不正常"的问题,第一时间考虑反代缓冲,能省很多时间。

5.2 连接数配额与多标签页冲突

第二个坑是连接数配额。我们内部工具使用率高,很多同事习惯同时开着三四个监控页签,每个页签都创建一个EventSource,结果第三方系统的普通接口请求反而出问题。这是因为所有长连接一起占满了浏览器对同域名的连接上限。

解决思路有两个方向。一是应用层全局复用连接:写一个单例管理EventSource,所有页面共享,按业务类型分发消息。二是如果条件允许,把SSE接口放到独立子域名上,这样它能用独立的连接数配额,不挤占主域名的普通请求额度。我实际使用中觉得第一种更通用,毕竟不是所有环境都能随意加子域名。

5.3 心跳和断线重连的实测数据

第三个坑是心跳。我一开始没做任何保活,结果线上收到的现象是连接平均存活三五分钟就会出现一次onerror重连,虽然不影响最终数据,但会让前端状态栏不断闪烁"连接中/已连接",体验很差。随后我抓包观察,发现断开前连接上已有近30秒没有任何字节流动。后来服务端加上15秒一次的保活注释,连接存活时间从几分钟提升到数小时以上。

断线逻辑上也有一点需要注意:EventSource的自动重连只保证"会重连",不保证"数据不丢"。如果断线期间服务端产生的消息没有地方缓存,重连后这些消息就永久丢了。所以要结合场景决定是否做补偿拉取。我在通知中心里就是重连成功后主动调用一次普通HTTP接口,拉取"从Last-Event-ID到当前时间"之间的消息差额。

5.4 单条消息过大的隐患

第四个坑是消息体积。SSE的data字段没有在协议层面定义长度上限,但浏览器和中转层各自有隐形的约束。我的实践感受是单条消息控制在几十KB以内非常安全,超过200KB就开始有风险,表现通常是消息被截断、连接异常关闭或者浏览器端解析不完整。SSE的正确用法应该是推"通知"或"增量",而不是把整个业务数据体从通道里倒给前端。如果某个事件附带的数据量本身就很大,正确设计是SSE只推送"有新数据"的信号,前端再通过普通HTTP去拉全量数据。

6. 让SSE更从容的进阶姿势:消息ID续传、自定义事件与鉴权接入

6.1 用消息ID实现真正的断点续传

SSE协议里最值钱的设计就是id字段与Last-Event-ID请求头配合的续传机制。这个机制让我不需要自己写任何"消息补偿队列"。服务端每次推送消息时带上自增ID,客户端断开重连时自动带上Last-Event-ID,服务端只需判断这个值就能决定是否重发。我用一个内存队列来模拟:

const messageStore = []; let sequence = 0; app.get('/api/events', (req, res) => { const lastEventId = Number(req.get('Last-Event-ID') || 0); const missedMessages = messageStore.filter(msg => msg.id > lastEventId); // 先补发断线期间错过的消息 missedMessages.forEach(msg => { res.write(`id: ${msg.id}\n`); res.write(`data: ${JSON.stringify(msg.data)}\n\n`); }); // 继续实时推送 const timer = setInterval(() => { const msg = { id: ++sequence, data: { value: Date.now() } }; messageStore.push(msg); if (messageStore.length > 500) messageStore.shift(); res.write(`id: ${msg.id}\n`); res.write(`data: ${JSON.stringify(msg.data)}\n\n`); }, 3000); });

需要提醒的是,如果消息队列在服务端内存里无限增长,迟早会出问题,我这里的500条限制就是一个简单的滑动窗口,只保留最近500条用于补发,更早的就让前端通过普通HTTP额外拉取了。

6.2 自定义事件区分业务语义的实践

自定义事件这个特性不用起来很可惜。试想一下,一个连接里混着"登录提示""订单更新""库存预警"三种消息,如果全部走onmessage,前端一个回调里塞一堆if...else判断,代码迅速恶化。用event字段区分后,每个业务类型有专属的监听入口,前端代码几乎是声明式的:

source.addEventListener('stockalert', onStockAlert); source.addEventListener('orderupdate', onOrderUpdate);

服务端发送时也保持简单:

event: stockalert data: {"sku": "A100", "level": 3} event: orderupdate data: {"orderId": "O20241101", "status": "paid"}

我在实际项目中甚至会把"不同类型的消息走不同的前端处理管线"做成一个映射配置,新增一种事件时不需要修改SSE核心代码,只加一条路由映射。对长期演进的项目来说,这种扩展性很实惠。

6.3 鉴权方案:URL参数、Cookie与一次性票据

EventSource不能自定义Header这件事,让很多习惯JWT的开发者第一次接触时很不适应。不能往Authorization里塞token,那就只能从请求本身想办法。我梳理过三个可行方案。

URL参数方案最直接:new EventSource('/api/events?token=xxx'),服务端从query里拿token校验。优点是实现简单,缺点是要注意日志泄密,且token一旦泄漏,直接就能用。Cookie方案是让SSE请求自动携带Cookie,服务端校验Session或Cookie值,好处是对前端透明,但需要跨域配置正确,同时要做好CSRF防护。一次性票据方案更稳健一点:前端先调用一个普通接口换取一次性的短时有效票据,再带着这个票据建立SSE,服务端用票据换取正式身份并建立通道。票据一次性、有效期短,即使落在日志里风险也可控。

三种方案适用不同场景,如果服务端和前端同源,Cookie方案最省事;如果跨域部署,一次性票据更稳。我是在同一个系统里先用URL参数过渡,后来换成票据方案,因为日志审计自查时发现token出现在访问日志里,实在不心安。

6.4 SSE与HTTP/2的配合情况

如果你的服务已经用了HTTP/2,SSE还有一个额外优势:HTTP/2的多路复用机制可以让多个流共享一个TCP连接,每个域名下的连接数限制基本不是问题了。但在实际运维中,我看到很多反代配置依然走HTTP/1.1转发到后端,所以浏览器到反代这一段可能是HTTP/2,反代到后端可能是HTTP/1.1,后端看到的还是普通长连接。这个现实下,连接数的考量仍然不能省,全局复用连接的习惯依然是稳妥的。

7. 我个人在实操中最想强调的几点体会

手写了几个SSE项目后,我最大的感受是:这个技术最吸引人的地方不是"新",而是"省"。它让你用最熟悉的HTTP知识解决一类实时问题,不需要引入新协议、新库、新部署要求。凡是消息单向流动的场景,我现在的默认选项都是先考虑SSE,确实不够再换WebSocket。

最后分享一个排查心法。SSE出问题时,不要急着改代码,先确定"HTTP层面到底发生了什么"。用curl模拟连接,观察响应头是否正确,观察数据是否是流式分段到达;打开浏览器开发者工具的Network面板,看SSE请求的状态是否一直是pending,看EventStream标签页里有没有数据流动。大多数SSE诡异的线上问题,到头来都是响应头不对、被代理缓冲、被网关掐连接这几个原因,用这种分层的排查方式比碰运气改代码快得多。

如果你准备在自己项目里引入网页SSE,我的建议是从最小的业务闭环开始:先跑通一个定时推送的接口,再在页面上用EventSource接收,确认通了以后再逐渐加心跳、加消息ID、加鉴权。这个技术本身并不复杂,复杂的是生产环境里的各种隐性约束,把每一步都走稳了,自然会发现它是网页实时推送里一个相当省心的选择。

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

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

立即咨询