Go 的 Server-Sent Events 实现:比 WebSocket 更轻量的实时推送方案

本文从 Go 工程实践出发,讲解 Server-Sent Events 的实现原理与核心代码,对比 WebSocket 的适用边界,分析连接断开、心跳、跨域、分布式广播等常见坑,适合需要轻量实时推送的开发团队参考。

SSE 本质上是一条“不会结束的 HTTP 响应”

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

Go 的 Server-Sent Events 实现:比 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/

(0)
上一篇 31分钟前
下一篇 13分钟前

相关推荐