“实时推送"听起来只有 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 一张表看透
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务端 → 客户端(单向) | 双向全双工 |
| 协议 | 基于 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: no | Nginx 缓冲导致事件延迟 | 关闭代理缓冲 |
| 无心跳 | 中间代理掐断空闲连接 | 定期发 : 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 双向"的本质判断上,实时通信方案就不会选错。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。