SSE 与实时通信方案:Server-Sent Events、WebSocket 对比与选型实战

深度讲解 Server-Sent Events(SSE)的原理与实时通信选型:SSE 工作机制(EventSource、text/event-stream)、与 WebSocket 的全面对比、断线重连与自动恢复、单向推送 vs 双向通信场景、多实例下的推送方案、常见避坑与工程实践。

“实时推送"听起来只有 WebSocket 一种方案,其实 SSE(Server-Sent Events) 在很多场景下更简单、更省心:天然基于 HTTP、自动断线重连、兼容性好、代码量小。本文讲透 SSE 的原理与工程细节,并给出与 WebSocket 的选型框架,让你在"通知推送、实时刷新、AI 流式输出"等场景做出最优选择。

关键概念:SSE 是服务端单向推送的实时通信方案,基于普通 HTTP 长连接,内容类型 text/event-stream。与 WebSocket 的双向全双工不同,SSE 让服务端持续把事件流推给客户端,浏览器原生支持,无需额外库。


一、SSE 工作原理

1.1 从普通 HTTP 到事件流

普通 HTTP:请求 → 响应(一次性)
SSE:请求 → 响应保持打开,服务端持续推送事件块

协议要点:
  Content-Type: text/event-stream
  响应体不是一次性 JSON,而是多个以空行分隔的事件块

事件块格式(每块用空行分隔):
  event: message        ← 事件类型(可选,默认 message)
  data: {"id": 1}       ← 数据内容(可多行,自动拼接)
  id: 123               ← 事件 ID(用于断点续传)
  retry: 3000           ← 重连间隔(毫秒,可选)

1.2 浏览器端 EventSource

// 客户端:原生 API,几行搞定
const es = new EventSource('/api/events');

es.onopen = () => console.log('连接建立');
es.onmessage = (e) => {
  console.log('收到事件:', e.data);
  // 渲染推送的数据 / 更新页面
};
es.onerror = () => {
  // 自动重连是内置行为,除非显式 es.close()
};

// 自定义事件类型
es.addEventListener('stock', (e) => {
  console.log('股票事件:', e.data);
});
// 服务端(Go 标准库):设置关键响应头即可
func SSEHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("X-Accel-Buffering", "no")   // 关键:禁止 Nginx 缓冲
    flusher := w.(http.Flusher)
    for i := 0; ; i++ {
        fmt.Fprintf(w, "data: {\"n\": %d}\n\n", i)
        flusher.Flush()       // 立即刷给客户端
        time.Sleep(time.Second)
    }
}

ℹ️ 核心:SSE 的"推送"本质是保持 HTTP 响应不关闭、按行写事件块 + Flush。它复用了 HTTP 的所有基础设施(鉴权、代理、压缩),这是它相比 WebSocket 最省心的地方。


二、SSE 与 WebSocket 全面对比

2.1 一张表看透

维度SSEWebSocket
方向服务端 → 客户端(单向)双向全双工
协议基于 HTTP(text/event-stream)独立协议(ws://),握手后升级
浏览器支持原生 EventSource原生 WebSocket
自动重连内置(带 ID 可断点续传)需自己实现
编码纯文本,JSON 为主文本或二进制帧
代理/防火墙友好(就是 HTTP)需额外支持升级头
鉴权复用 HTTP(Cookie/Header)需自己协商(协议内)
复杂度极低较高(帧、心跳、重连)
适用服务端推送、通知、流式双向交互、游戏、聊天

2.2 各自擅长的场景

选 SSE(服务端单向推送):
  - 消息通知 / 告警推送
  - 数据看板实时刷新(行情、监控)
  - AI 大模型流式输出(逐 token 返回)
  - 日志实时滚动
  - 长任务进度条(导出、构建)

选 WebSocket(双向交互):
  - 在线聊天(客户端要发言)
  - 多人协作编辑(双向同步)
  - 在线游戏 / 实时对战
  - 需要客户端主动频繁上报的场景

ℹ️ 判断口诀:“服务端往客户端推"为主 → 优先 SSE;“两端都要主动发” → WebSocket。SSE 能覆盖的别用 WS,代码量小一个数量级。


三、断线重连与可靠性

3.1 自动重连机制

EventSource 的内置行为:
  - 连接断开 → 浏览器自动重连(默认约 3s)
  - 可用 retry: 字段由服务端指定重连间隔
  - 支持 Last-Event-ID 断点续传

断点续传(关键能力):
  1. 服务端为每个事件分配递增 id
  2. 客户端断开重连时自动带上 Last-Event-ID 请求头
  3. 服务端据该 ID 从断点继续推送,不丢事件

Go 端读取:
  r.Header.Get("Last-Event-ID")

3.2 服务端侧的可靠性要点

1. 事件都要有唯一 ID → 客户端可去重 / 断点续传
2. 心跳:服务端定期发注释行 `: ping\n\n` 保持连接
   (防中间代理/防火墙闲置断开)
3. 客户端断开检测:读 r.Context() 或客户端取消
   → 及时释放 goroutine,防泄漏
4. 幂等:客户端断线重连可能重复收到,业务去重

四、多实例与负载均衡下的推送

4.1 单实例推送到多实例的问题

部署了 3 个后端实例 + 负载均衡:
  每个实例只持有"连接到它"的客户端
  实例 A 上要推给"连在实例 B"的客户端 → 推不到

解决方案(三种思路):
  方案1:Redis 发布/订阅做事件广播
    实例收到业务事件 → 发 Redis 频道
    所有实例订阅频道 → 各自推送给自己连接的客户端
  方案2:消息队列广播(Kafka/NSQ)
    适合事件量大、需要持久化
  方案3:网关层集中(推送网关)
    所有长连接收敛到一个/一组推送网关
// Redis 发布/订阅思路(伪代码)
// 发布侧(业务服务)
redis.Publish("sse:user:1001", eventJSON)
// 推送侧(每个 SSE 实例)
sub := redis.Subscribe("sse:user:1001")
for msg := range sub.Channel() {
    // 找到连接到本实例的 user:1001 的 EventSource,推送
    conns[1001].Send(string(msg.Data))
}

4.2 负载均衡的注意点

问题:轮询 LB 会把同客户端的重连打到不同实例
  → 客户端 A 断线重连后连到实例 B,但实例 B 无它的订阅

对策:
  1. 一致性哈希 / 按用户 ID 粘性路由(同一用户固定一个实例)
  2. 广播方案(Redis Pub/Sub)天然免疫粘性问题
  3. 网关集中模式最可控

Nginx 配置注意:
  proxy_buffering off;       # 禁止缓冲,否则事件被积压
  proxy_read_timeout 3600s;  # 长连接读超时放大

五、鉴权、安全与生产实践

5.1 鉴权

SSE 复用了 HTTP 的鉴权能力(这是 vs WebSocket 的巨大优势):
  - Cookie / Session:EventSource 默认带 Cookie,开箱即用
  - Header Token:需用 fetch + ReadableStream 替代 EventSource
  - 白名单校验:建立连接前校验用户是否订阅了该事件流

示例(先鉴权后开流):
  1. 客户端先调 /login 拿会话
  2. EventSource 连接自动带 Cookie
  3. 服务端在 Handler 里校验会话 → 通过才返回 event-stream

5.2 安全要点

1. 事件数据是纯文本 → 防止注入:服务端 JSON 转义
2. 订阅鉴权:只能收到自己有权限的事件(防横向越权)
3. 连接数量限制:每用户限制最大连接数,防资源耗尽
4. 敏感事件流不建议明文过 CDN,注意缓存策略
   (必须 Cache-Control: no-cache,且禁止 CDN 缓存 event-stream)

六、常见避坑

坑现象对策
忘设 X-Accel-Buffering: noNginx 缓冲导致事件延迟关闭代理缓冲
无心跳中间代理掐断空闲连接定期发 : ping 注释行
事件无 ID重连后丢事件每事件分配递增 ID
轮询 LB 打散重连换实例推送失败粘性路由 / Redis 广播
未处理客户端断开goroutine 泄漏监听 Context 取消
CDN 缓存 event-stream事件被缓存不更新no-cache + 禁用缓存
推送重复重连后重复处理事件 ID 去重
双向需求硬用 SSE客户端无法发言换 WebSocket / 混合方案

七、最佳实践清单

□ 判断单向还是双向,单向优先 SSE(省一个数量级代码)
□ 所有事件带唯一 ID,客户端可去重 + 断点续传
□ 服务端设心跳(注释行),防中间设备掐连接
□ 多实例场景用 Redis Pub/Sub 或粘性路由做推送路由
□ 关闭代理缓冲(Nginx:proxy_buffering off)
□ 连接鉴权复用 HTTP(Cookie/Header),别重复造轮子
□ 监控连接数与推送成功率,异常及时告警
□ 客户端断开要能及时释放服务端资源

一句话原则

SSE = 服务端单向推送的 HTTP 长连接,自动重连、断点续传、
复用 HTTP 鉴权;单向用 SSE,双向才上 WebSocket。

小结

SSE 是被低估的实时通信方案:它用最朴素的方式——保持 HTTP 响应、按行推送事件块——实现了服务端到客户端的实时推送,却免费获得了浏览器原生支持、自动重连、断点续传和完整的 HTTP 鉴权体系。在"通知推送、看板刷新、AI 流式输出、任务进度"这类单向场景,SSE 是远比 WebSocket 轻量的选择;只有当真正需要双向交互时,才值得引入 WebSocket 的复杂度。落地记住五件事:单向优先 SSE、事件都带 ID、服务端加心跳、多实例用广播或粘性路由、关闭代理缓冲。把选型建立在对"单向 vs 双向"的本质判断上,实时通信方案就不会选错。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「network」更多文章

  1. 云原生网络:CNI 容器网络、Overlay/Underlay 与 Service Mesh 数据面
  2. 网络故障排查实战:tcpdump/Wireshark/ss/iperf 工具链与分层诊断方法论
  3. Socket 编程与连接管理深度:状态机、超时、长连接、心跳与连接池