WebSocket 是个好东西,但你不需要它——从 AI 流式到实时推送,SSE 的逆袭

AI 流式输出正在让 SSE 从边缘协议逆袭为核心基础设施。本文从成本账出发,用实测数据对比 SSE 与 WebSocket,给出四维选型决策框架。

封面

WebSocket 是个好东西。真的,我没在阴阳。

2008 年诞生,2011 年标准化,它解决了 HTTP 协议的一个根本缺陷:服务端不能主动给客户端发消息。有了 WebSocket,实时推送、在线协作、即时通讯——这些以前要靠轮询或长轮询才能勉强实现的功能——一下子变得自然了。

但问题恰恰在这里。

因为 WebSocket 解决了"实时推送"这个痛点,它变成了一个默认选项。看到"实时"两个字,很多团队不加思考就选了 WebSocket。就像看到"性能优化"就想到加缓存一样——不是不对,是太粗糙了。

大多数只需要单向推送的场景,WebSocket 是过度设计。而且这不是技术判断,是成本账。


一、你的 WebSocket 可能是个"豪华车"

我之前在一个后台系统项目里做过推送功能。

需求很简单:服务端任务状态变更时,通知前端更新界面。典型的管理后台场景——任务队列进度、系统告警、数据同步状态。没有一个场景需要前端往服务端发消息。

你猜我们选了什么?

WebSocket。

理由当时觉得非常充分:“实时推送嘛,当然用 WebSocket。稳定、成熟、大家都在用。”

然后踩坑就开始了。

首先是连接维护。 WebSocket 建连要走 6 次消息交换——TCP 三次握手 + HTTP 升级 + WS 帧交换。而且建连只是开始,连接建立后每隔几十秒要发一次心跳,否则代理层(Nginx、ALB 等)默认超时会断开连接。每个连接服务端都要维护状态。前端还需要自己实现重连逻辑——因为 WebSocket 没有内置重连。你需要在客户端维护一个完整的连接状态机:连接中、已连接、断开中、重连中——每个状态都要处理边界情况。

基础设施那边也不省心。 负载均衡器要开粘性会话,不然客户端换了个实例连接就断了。Nginx 要配 proxy_set_header Upgradeproxy_set_header Connection "upgrade"。AWS ALB 要开 WebSocket 支持。防火墙要放行 WebSocket 端口。如果是公司内部网络,可能还要申请安全策略变更——一套流程下来两三天。

运维更烦人。 连接断开的场景太多了——浏览器休眠、网络切换、代理超时。每断一次,前端要重新走一遍握手流程。而且 WebSocket 的调试比 HTTP 复杂得多,出了问题上 Wireshark 抓包才能看清。HTTP 接口出问题,curl 一下就知道。WebSocket 呢?你得装专门的 WebSocket 客户端工具,还要理解帧结构。

回头看,这个场景的实际需求只有一条:“服务端有更新时通知前端”。纯单向。这不只是管理后台的问题——2025-2026 年增长最快的实时通信场景,AI 流式输出,也是这个模式:你发一个请求,服务端把 Token 一条一条吐回来。同样是纯单向。

而我们选择了一个为"双向实时通信"设计的协议,为不需要的能力付了全价。

这不是个例。我见过太多团队掉进同一个坑里:

  • 一个内部运营后台,用 WebSocket 做通知推送——需求就是"任务完成时弹个 toast"
  • 一个数据看板,用 WebSocket 推送图表更新——需求就是"新数据来了刷新图表"
  • 一个告警系统,用 WebSocket 推送告警消息——需求就是"有告警时显示在屏幕上"
  • 一个 DevOps 面板,用 WebSocket 推送部署日志——需求就是"日志一行一行出来"

它们大部分是单向的——至少单向占了 90% 以上的流量。

四种典型的"单向场景但用了 WebSocket"的对比插图

你可能会说:“用 WebSocket 也没什么大问题吧?反正也能工作。” 对,能工作。就像一个超市用冷藏车运矿泉水一样——能运到,但成本不对。


二、WebSocket 的"奢侈税"

单向推送用 WebSocket,到底多花了多少冤枉钱?我们来算一笔账。

建连成本

WebSocket 建连需要 2 个网络往返(RTT),而 SSE 只需要 1 个。

WebSocket 建连:TCP SYN → SYN-ACK → ACK → HTTP Upgrade → 101 Switching → 数据
SSE 建连:     HTTP GET → 200 OK + 流式数据

建连阶段,WebSocket 比 SSE 多 1 个 RTT + 约 400 字节的 HTTP 头。这些额外头包括:Upgrade: websocketConnection: UpgradeSec-WebSocket-Key(16 字节随机值)、Sec-WebSocket-Version: 13。服务端回复的 101 Switching Protocols 还包含 Sec-WebSocket-Accept 头。

WS 6 次握手 vs SSE 1 次 HTTP 请求的建连过程对比

可能你会说:“才 1 个 RTT,不在乎。” 但连接不是一次性的——每次断线重连,你都要多付这 1 个 RTT。如果客户端网络不稳定(移动端尤其),一天断连十几次,累计的成本就很可观了。

维护成本

WebSocket 没有内置心跳,但生产环境中几乎每个实现都要加。具体间隔取决于代理层配置(Nginx 的 proxy_read_timeout 默认 60s,ALB 默认 350s),通常每 30-60 秒一次 Ping/Pong,双向交换。按 30 秒间隔算,每小时每连接约 120 次心跳。

SSE 也不需要心跳——但生产环境中同样需要维持连接。区别在于 SSE 的心跳是服务端单向发送的注释行(: keepalive\n\n),不需要客户端响应。这比 WebSocket 的 Ping/Pong 省了一半的带宽和一次额外的处理开销。

从协议状态的角度看,差距更大。WebSocket 服务端需要为每个连接维护的状态比 SSE 复杂得多——TCP 连接状态、协议帧缓冲、掩码键、应用层会话、粘性会话绑定。大致来说,每个连接约 2-5 KB 的协议层状态开销。而 SSE 走标准 HTTP 响应,每个连接约 0.5-1 KB——少了约 75%。在千级连接规模下,这意味着几 MB 到十几 MB 的内存差异。对单台服务器来说不算大,但如果你有几十台实例,差距就出来了。

还有一个很多人忽略的点:WebSocket 的粘性会话要求意味着你无法使用轮询(round-robin)负载均衡。如果你的某个实例挂了,上面维护的所有 WebSocket 连接都会中断。而 SSE 基于标准 HTTP,任何实例都可以接管。

这就是差距。

数据说话

光算理论太虚。我在本地跑了一组 benchmark:1000 个并发连接,每个连接推送 100 条消息,模拟典型的管理后台推送场景。

服务端用 Go 实现,SSE 走标准 text/event-stream,WebSocket 走简易文本帧(无 mask,纯服务端推送)。两者推送相同内容(JSON 格式,每条约 200 字节),客户端用并发 HTTP 请求模拟。

测试环境:Apple M4 Pro / Go 1.26.4

指标 SSE WebSocket 差异
耗时 1125ms 2324ms WebSocket 慢 106%
内存分配 75MB 93MB WebSocket 多 23%
分配次数 552K 730K WebSocket 多 32%

在同等推送量的单向场景下,WebSocket 的耗时是 SSE 的两倍多,内存分配多近四分之一。

benchmark 数据可视化——柱状图对比 SSE vs WS 的耗时/内存/分配次数

这不是说 WebSocket 性能差——它只是在做它该做的事:维护双向通道、支持帧级控制、处理掩码。问题是,这些能力在你的场景中用不上。你为"双向"付了钱,但只用了"单向"。

有人可能会说:“那用 WebSocket 做单向推送,加上心跳,成本也没高到哪去吧?”

算一笔账。假设你有 1 万个在线连接,每个连接每 30 秒一次心跳。WebSocket 每小时需要处理约 120 万次 Ping/Pong 交换。这些心跳本身也是资源——CPU 处理帧、网络带宽、中断处理。SSE 的注释行虽然也占带宽,但不需要客户端响应,服务端少了一半的工作。

做会议机器人基础设施的 recall.ai 在 2024 年分享过他们的经验:WebSocket 导致的额外 CPU 开销让他们每年多付了约 $100 万的 AWS 费用。通过用共享内存替代 WebSocket 做进程间通信,他们将 CPU 使用率降低了 50%,节省了超过 $100 万/年的 AWS 成本。虽然 recall.ai 的优化方案不是简单的 SSE 替换,但故事的核心是一样的——你为 WebSocket 的"双向"能力付出的隐性成本,远比你想的大。


三、AI 来了,SSE 的春天来了

如果 SSE 只是"更轻量的推送方案",它不会成为 2026 年的热门话题。真正让 SSE 翻身的,是 AI。

想想看,LLM 的流式输出是什么样的?你发一个 Prompt 过去,服务端把 Token 一个接一个吐回来。这是一次 POST 请求 + 持续的、单向的 Token 流。

这就是 SSE 最擅长的模式。

OpenAI 的 Chat Completions API 用 SSE,Anthropic 的 Messages API 用 SSE,Google Gemini 的 StreamGenerateContent 也用 SSE。这不是巧合——在"一问多答"的场景下,SSE 是天然的匹配。具体来说:

  • OpenAI/v1/chat/completions 带上 stream: true,返回的就是 text/event-stream 格式。每条事件包含一个 data: {"choices":[{"delta":{"content":"token"}}]},最后以 data: [DONE] 结束
  • Anthropic/v1/messages 带上 stream: true,返回 SSE 流。事件类型包括 message_startcontent_block_deltamessage_stop 等,粒度比 OpenAI 更细
  • Google Gemini/v1/models/{model}:streamGenerateContent,同样是 SSE 格式

而 WebSocket 在这个场景下反而尴尬:你需要先建立双向通道,然后告诉服务端"我只收不发",然后维持一个不需要双向能力的双向连接。相当于你买了一辆能跑 300 码的跑车,每天只开 30 码去菜市场。

AI 流式输出时序图——POST 请求发出后 Token 逐字推送的 SSE 流式过程

AI 流式输出不是一个边缘场景——从 2023 到 2026 年,OpenAI、Anthropic、Google 的流式 API 调用量增长了数个数量级,它已经成为实时通信中增长最快的场景之一。 大多数 AI 应用的背后,都有一条 SSE 数据流。Claude、Gemini、通义千问、文心一言——所有主流 LLM 的流式 API 都基于 SSE。

这个趋势把 SSE 从一个"有人用但不多"的协议,推到了基础设施的位置。就像 HTTP/2 普及让长连接不再是问题一样,AI 普及让 SSE 从一个"替代方案"变成了"默认方案"。

如果你正在开发一个 AI 应用——不管是大模型对话、代码生成、还是文档总结——你大概率已经在用 SSE 了,只是你可能没意识到它叫 SSE。你的前端代码里可能早就写好了 onmessage 回调,只是你以为那是 WebSocket。


四、HTTP/2 帮 SSE 补上了最后一课

SSE 有一个历史问题:HTTP/1.1 下,每个源最多 6 个并发连接。如果一个页面需要同时打开多个 SSE 通道(比如一个推通知、一个推数据更新),超过 6 个后新的连接会被阻塞。

这是 SSE 最大的硬伤,也是很多人放弃它的理由。

但这个限制在 HTTP/2 时代已经不存在了。

HTTP/2 的多路复用(Multiplexing)允许在单个 TCP 连接上承载最多 100 个并发流。每个 SSE 连接对应一个流,帧(frame)在连接上交错传输。

换句话说,HTTP/1.1 下你需要 6 个 TCP 连接来跑 6 个 SSE 通道,HTTP/2 下 1 个 TCP 连接就够了——而且能跑 100 个。

HTTP/1.1 + SSE:
浏览器 ←─── TCP 连接 1 (SSE 通道 1) ───→ 服务器
        ←─── TCP 连接 2 (SSE 通道 2) ───→ 服务器
        ←─── ...(最多 6 个,第 7 个排队等待)

HTTP/2 + SSE:
浏览器 ←─── 1 个 TCP 连接 ───→ 服务器
              ├── 流 1 (SSE 通道 1)
              ├── 流 2 (SSE 通道 2)
              ├── 流 3 (SSE 通道 3)
              ├── ...(最多 100 个流)

不仅如此,HTTP/2 还带来了头部压缩(HPACK)。HTTP/1.1 下每个 SSE 响应都重复发送相同的 HTTP 头(Content-Type、Cache-Control 等),HPACK 压缩后这些重复头的开销几乎为零。

2026 年,Chrome、Firefox、Safari 15+ 都已默认启用 HTTP/2。大部分生产环境的反向代理(Nginx 1.9+ 需主动开启 http2 配置、ALB、Cloudflare)都支持 HTTP/2 转发。不过要注意一个边界条件:浏览器支持 HTTP/2 不等于连接一定使用 HTTP/2——如果客户端网络环境受限(如企业代理拦截 HTTP/2 升级、CDN 回源到后端的连接仍是 HTTP/1.1),SSE 的连接数限制仍然存在。生产环境中建议确认从客户端到代理的整条链路都支持 HTTP/2。

HTTP/1.1 6 连接 vs HTTP/2 多路复用的对比示意

这意味着:如果你已经在用 HTTP/2,SSE 的连接数限制对你来说不存在。

这是一个"补课式"的解决——传输层升级让这个 bug 不再是问题。就像 IPv4 地址不够用,不是改了 IP 协议,是 NAT 帮忙扛了十几年。至于 HTTP/3(基于 QUIC),它对 SSE 更友好——0-RTT 建连、连接迁移——但 2026 年的生产环境,HTTP/2 已经够用了。


五、什么时候选什么——决策框架

说了这么多,到底怎么选?

我总结了一个四维决策框架:通信模式 → 可靠性要求 → 基础设施 → 性能边界

第一步:你需要双向通信吗?

这是最关键的一问。如果答案是"不需要"或"很少需要"(偶尔发个指令,大部分时间在收推送),SSE 是更好的选择。

单向推送用 SSE,双向交互用 WebSocket。就这么简单。

判断标准:数一下你的应用里,服务端发消息给客户端的次数和客户端发消息给服务端的次数。如果比例超过 10:1(收 10 条才发 1 条),SSE 值得考虑。

第二步:你需要多高的可靠性?

如果每条消息都必须送达、顺序必须保证、失败必须重试——这是 WebSocket 的领地,它有标准的 ACK 机制。金融交易、实时协作编辑这类场景需要这种保证。WebSocket 的应用层可以自定义确认帧,服务端知道"客户端确实收到了这条消息"。

如果丢几条没关系、断线后自动恢复就行——SSE 的 EventSource 内置重连,大部分场景够用。状态更新、进度推送、通知提醒这类场景,SSE 的可靠性已经足够。EventSource 断线后会自动重连,并携带 Last-Event-ID 头告诉服务端"我从哪里开始重连"。

有一个中间方案值得注意:SSE + 定期快照。服务端每隔一段时间(如 30 秒)发一条完整状态快照,客户端即使丢了几条增量事件,下次快照来了也能恢复。这比 WebSocket 的逐条 ACK 更轻量,对大部分"实时展示"场景已经足够。

第三步:你的基础设施长什么样?

对比项 SSE WebSocket
负载均衡 标准 HTTP(无需特殊配置) 需粘性会话(sticky sessions)
防火墙 标准 HTTPS 端口(443) 可能被企业防火墙阻断
客户端实现 浏览器内置 EventSource 需引入第三方库(socket.io、SockJS 等)
代理兼容性 天然兼容(标准 HTTP 流) 需额外配置(Nginx Upgrade、ALB WebSocket 支持)

从基础设施角度看,SSE 在几乎所有维度上都更"便宜"。如果你的团队不大、运维能力有限,SSE 的低维护成本是实实在在的优势。

第四步:你有性能方面的特殊要求吗?

  • 需要传输二进制数据?→ WebSocket(SSE 需要 Base64 编码,增加 33% 开销)
  • 需要超低延迟(<50ms)?→ WebRTC / WebTransport(还在等 Safari 默认启用)
  • 需要在 HTTP/1.1 下开大量推送通道?→ 合并为单个 SSE 连接,用 event 字段区分

决策速查

需要双向通信?      → 是 → 需要超低延迟或 P2P?→ 是 → WebRTC / WebTransport
                                      → 否 → WebSocket
                  → 否 → 需要传输二进制?→ 是 → WebSocket
                                       → 否 → 需要自定义请求头鉴权?→ 是 → fetch + ReadableStream
                                                                     → 否 → 浏览器支持 EventSource?→ 是 → SSE
                                                                                                       → 否 → 长轮询

核心判断:大多数实时推送场景不需要 WebSocket。这不是技术判断——是成本账。

决策框架四维速查图——通信模式/可靠性/基础设施/性能边界的选型路径


六、生产陷阱与救火指南

选了 SSE 不代表万事大吉。生产环境中 SSE 有几个常见的坑,踩一个就够你折腾半天。

陷阱 1:Nginx 缓冲

这是 SSE 最常见的生产 bug。Nginx 默认缓冲响应体,SSE 的流式数据会被缓存后才一次性推给客户端。表现是 EventSource 连接正常,但数据"卡住"——等了半天突然一次性收到大量数据。

解法:设置响应头 X-Accel-Buffering: no,或者在 Nginx 配置中关掉 proxy_buffering

location /sse {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
}

如果后端是 Go,在 http.ResponseWriter 里设置头:

w.Header().Set("X-Accel-Buffering", "no")

陷阱 2:反向代理超时

SSE 是长连接,但反向代理有默认超时。AWS ALB 默认 60s,Cloudflare 默认 100s,Nginx proxy_read_timeout 默认 60s。

解法:把超时设大(如 3600s),同时在应用层每 30-60s 发一条 : keepalive\n\n 注释行刷新超时计数器。

location /sse {
    proxy_pass http://backend;
    proxy_read_timeout 3600s;
    proxy_buffering off;
}

Go 服务端的心跳:

ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
go func() {
    for {
        select {
        case <-ticker.C:
            fmt.Fprintf(w, ": keepalive\n\n")
            flusher.Flush()
        case <-r.Context().Done():
            return // 客户端断开,安全退出
        }
    }
}()

陷阱 3:浏览器连接数限制

HTTP/1.1 下最多 6 个 SSE 连接。虽然 HTTP/2 解决了这个问题,但如果你还在用 HTTP/1.1:

解法:把多个推送通道合并为单个 SSE 连接,用 event 字段区分不同类型。

const evtSource = new EventSource("/sse/all");
evtSource.addEventListener("notification", (e) => { /* 处理通知 */ });
evtSource.addEventListener("data_update", (e) => { /* 处理数据更新 */ });

陷阱 4:EventSource 的羊群效应

EventSource 在连接断开时会自动重连。服务端宕机恢复后,大量客户端同时重连——这就是羊群效应(thundering herd),可能导致服务端再次过载。

解法:服务端实现指数退避,或者用 fetch + ReadableStream 替代 EventSource 来精细控制重连策略。

async function createSSEWithBackoff(url, maxRetries = 5) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            const response = await fetch(url);
            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 });
                // 按 SSE 协议分割:每条消息以 \n\n 结尾
                const parts = buffer.split("\n\n");
                buffer = parts.pop(); // 保留未完成的部分
                for (const part of parts) {
                    for (const line of part.split("\n")) {
                        if (line.startsWith("data: ")) {
                            const data = line.slice(6);
                            if (data === "[DONE]") return;
                            console.log("收到事件:", JSON.parse(data));
                        }
                    }
                }
            }
            return;
        } catch (err) {
            const delay = Math.min(1000 * Math.pow(2, i), 30000);
            await new Promise(r => setTimeout(r, delay));
        }
    }
}

生产环境 Checklist

  • Nginx 关掉 proxy_buffering 或设置 X-Accel-Buffering: no
  • 反向代理超时设到 3600s 或以上
  • 应用层心跳(每 30-60s 发 : keepalive
  • 使用 HTTP/2 避免连接数限制
  • 优雅关闭处理(部署时旧进程不立刻断开连接)
  • 监控连接数,异常增长及时告警
  • 考虑用 fetch + ReadableStream 替代 EventSource(更灵活)

回到开头

WebSocket 是个好东西。它解决了实时通信的核心问题,是双向交互场景的最佳选择。

但大部分实时推送场景不需要双向。

SSE 不是 WebSocket 的"替代品"——它是不同场景的"正确选择"。WebSocket 是"我能做所有事",SSE 是"我只做一件事,但做得更好"。

选技术不是选最厉害的,是选最合适的。下次有人跟你说"我们需要 WebSocket",先问一句:“我们真的需要双向吗?”

你可能会发现,答案比你想象的更简单。

原文发布于 止语Lab


附录:实验代码和原始数据

本文 1 组 Benchmark 实验的代码和原始数据已开源:

GitHub:zhiyulab-evidence/sse-vs-websocket

  • benchmark/:SSE vs WebSocket 1000 并发推送 Benchmark(Go 实现,可独立运行)

每个子目录都有独立 README,说明如何复现。二进制编译产物不入库,跑实验前自己 go build


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →