SSE 本质上是一条“不会结束的 HTTP 响应”
很多团队遇到过这样的场景:监控平台的任务状态需要实时刷新,后台导入数据时要向前端推送进度,或者运维告警出现了新事件,希望页面能立刻弹出来。最直接的做法是轮询,写起来简单,但轮询间隔短了压力大,间隔长了又不“实时”。于是大家很容易想到 WebSocket,毕竟一说到实时推送,WebSocket 几乎成了默认答案。

但这里有个前提经常被忽略:如果数据只会从服务器发往客户端,而客户端不需要给服务器传消息,那么 WebSocket 很大一部分能力是浪费的。更轻量的方案是 Server-Sent Events,也就是 SSE。它不是一个新概念,却经常被 Go 开发者低估。SSE 基于普通 HTTP,服务端返回一段流式数据,客户端用 EventSource 接口订阅。在 Go 里实现一个可用的 SSE 端点,标准库就够了。
SSE 之所以“轻”,核心在于它没有改变 HTTP 的语义。WebSocket 需要先发一个 Upgrade 请求,完成协议升级后,连接就不再走 HTTP 了。而 SSE 就是一次普通 GET 请求,服务端把响应头设成 text/event-stream,然后保持连接,不断往响应体里写事件。整个链路还是 HTTP/HTTPS,中间代理、负载均衡器都可以继续工作,只是需要注意一些缓冲行为。
Go 实现 SSE:标准库就够了
用 Go 写一个 SSE 端点,不需要引入第三方库。核心是设置正确的响应头,然后通过 http.Flusher 把缓冲的数据立刻刷给客户端。下面是一个最简单的实现,每秒推送一条当前时间。
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")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
ctx := r.Context()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
flusher.Flush()
}
}
}
这段代码有几个关键点。第一,必须把 Content-Type 设为 text/event-stream,浏览器会以事件流方式解析。第二,Connection: keep-alive 是告诉中间层不要把这个连接当成普通请求处理。第三,r.Context() 会在客户端断开连接时触发 Done,这样 goroutine 能及时退出,不会泄漏。
事件格式也值得注意。每一条消息由若干个字段行和一个空行组成。最常见的字段是 data,表示消息内容。还可以用 event 指定事件类型,客户端用 addEventListener 来监听不同类型的事件,默认事件是 message。如果一行 data 不够用,可以写成多行 data,浏览器会把它合并成一条消息。
这里的小细节:当消息体只有一行时,直接用 data: 结尾加两个换行。如果消息体包含多行,比如一个 JSON 对象,可以写成连续多个 data: 行,最终触发 message 事件时,收到的数据会用换行符拼接起来。但这不是标准 JSON 解析的最终文本,所以实际项目里更常见的是把 JSON 序列化到一行里,避免客户端解析出意外。
标准库已经能覆盖大部分需求。如果项目里需要更完整的订阅管理、客户端断线通知、事件广播,可以基于标准库封装,也可以找现成的第三方库。但坦白说,SSE 本身不复杂,过度封装反而是负担。自己写一个几十行的连接管理器,往往比引入一个并不活跃的库更可控。
SSE 和 WebSocket 怎么选:别被“双向”带偏
很多人会拿 SSE 和 WebSocket 做对比,但两者并不是平级关系。WebSocket 是全双工通信协议,SSE 是单向事件流。如果你的业务必须双向实时交互,比如在线聊天的输入框、多人协作编辑器,那确实只能用 WebSocket。但如果只是服务器向浏览器推送通知、状态、进度,SSE 在大多数场景下都够用,而且实现成本低不少。
下面这张表我整理了几个容易混淆的维度:
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 只能服务器→客户端 | 双向全双工 |
| 协议基础 | 纯 HTTP,无需升级 | 先 HTTP Upgrade,再切换协议 |
| 实现成本 | Go 标准库即可完成 | 需要处理握手、帧、心跳、关闭 |
| 自动重连 | 浏览器原生支持 | 需要自己实现 |
| 二进制传输 | 需要 base64 编码 | 原生支持二进制帧 |
| 调试难度 | 开发者工具直接看网络流 | 需要额外抓包工具 |
| 典型场景 | 通知、监控、流式响应 | 聊天、游戏、协作工具 |
这里有个工程上常见的误区:以为 WebSocket 是“实时”的代名词,于是所有推送都上 WebSocket。但 WebSocket 引入的状态管理、心跳维护、断线重连,都是需要额外代码的。SSE 把这些问题交给浏览器和 HTTP 层,服务端只需要关心什么时候往连接里写数据。
举个例子,一个告警系统,后端在检测到异常后需要把告警消息推送到所有在线的管理员页面。用 WebSocket 做,要维护连接组、处理客户端在线状态、设计消息协议。用 SSE 做,每个管理员页面打开一个 EventSource,服务端在告警发生时把消息广播给所有连接的响应流,事情一下子就变得简单了。
还有一个被低估的点:SSE 的跨域支持。SSE 本质是 HTTP 请求,所以可以通过 CORS 跨域访问。你只需要在响应头里加上 Access-Control-Allow-Origin。当然,EventSource 请求无法像 XMLHttpRequest 那样自定义某些头部,这意味着如果你需要身份验证,要么用 cookie,要么在 URL 里带 token,这也是一个工程取舍。
Go 项目里实现 SSE 容易踩的坑
SSE 实现起来简单,但到了生产环境,有几个坑几乎是必然会遇到的。
- 客户端断开检测不及时。 如果只是往响应里写数据,而不监听
r.Context().Done(),连接断了之后服务端 goroutine 可能还一直挂着。尤其在推送频率低的场景,问题更难发现。 - 中间代理把连接缓冲住。 Nginx 默认会缓冲响应,如果不开
proxy_buffering off,SSE 的消息可能会攒一批才发给客户端,实时性就没了。 - 重连后的消息补偿。 SSE 自带重连,但重连后中间丢失的事件是不会补发的。客户端必须主动拉一次最新状态,否则界面会停留在断线那一刻。
- 连接数管理。 每个 SSE 连接占用一个 goroutine,一旦连接管理不规范,几百个客户端就可能让服务端内存飙升。
这些坑并不难解决,但需要提前意识到。连接断开检测,只要在写数据前检查 context 就能做到。缓冲问题,可以通过设置响应头加 Nginx 配置来规避。消息补偿需要设计上留一个状态查询接口,让客户端重连后先调用一次。连接数管理可以参考 WebSocket 的做法,用一个连接池保存所有活跃连接。
还有一个细节容易忽略:SSE 的心跳。虽然 EventSource 自带了断线重连,但如果连接长时间没有数据,某些网络设备会认为空闲而掐断连接。这时候服务端可以定期发送一行注释,例如 : ping,也能被客户端正常解析,但不触发事件。
生产环境落地:从单机到分布式
如果服务部署在一个实例上,SSE 就是维护一个连接列表,推送时遍历列表写数据。但业务规模变大后,服务会部署到多个实例,问题就来了:某个客户端连到了实例 A,而推送事件的任务跑在实例 B 上,B 不知道 A 上有这个连接。
解决思路通常有两种。一种是做粘性会话,让同一个客户端的请求始终落到同一个实例,但这在容器环境下并不总是可控。另一种是用 Redis pub/sub 做事件广播:推送方把事件发到 Redis 频道,所有实例都订阅这个频道,收到事件后只推给自己的连接列表。这样连接信息不需要跨实例同步,架构也清晰。
连接池的并发安全也需要仔细处理。一个推送循环可能同时被多个 goroutine 调用,比如定时任务和用户操作都会触发推送,所以连接列表必须加锁,或者用 sync.Map。每次写数据前检查连接是否还在,写失败后把连接从池里移除。
另外,如果前面有 Nginx,需要像下面这样关掉缓冲:
location /events {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_read_timeout 3600s;
}
这段配置里的 proxy_read_timeout 要设得足够长,否则 SSE 连接虽然一直开着,但长时间没有数据推送时,也可能被 Nginx 判定为超时并断开。
什么时候别用 SSE
SSE 不是银弹。如果你的业务需要客户端频繁向服务器发送数据,或者消息里必须携带二进制大数据,那 SSE 就很别扭。前者用 WebSocket 更自然,后者可以直接用 WebSocket 或 HTTP/2 流。再或者,你的团队已经在 WebSocket 上有成熟的连接管理、心跳、鉴权体系,强行改成 SSE 反而不划算。
但反过来,如果你只是需要一个轻量级的实时推送通道,SSE 的低成本优势非常明显。管理后台的状态刷新、运维监控大屏、前端消息通知,这些都是 SSE 的典型主场。而且随着 HTTP/2 的普及,SSE 不再受限于浏览器连接数限制,可用的场景会更多。
总结
SSE 是一个被低估的协议。它用最简单的 HTTP 语义解决了“服务器向浏览器推送”这一类高频问题,而 Go 的标准库让实现变得非常直接。相比 WebSocket,它的边界很明确:单向推送、文本优先、接受自动重连。只要业务符合这些前提,完全可以用更少的代码拿到稳定的实时体验。
如果你现在正准备做实时推送,不妨先问自己两个问题:业务一定是双向的吗?客户端需要收到二进制数据吗?如果答案都是否,那先试试 SSE,可能比直接上 WebSocket 轻松不少。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/958/