Go SSE 推送的 Flush 触发与断开处理
来源:17golang原创
时间:2026-10-15 08:20:13 294浏览 收藏
Go 写 SSE(Server-Sent Events)时,循环调用 Write 并不等于客户端马上收到事件。稳定的处理方式是:先声明 text/event-stream,每个事件写成以空行结尾的 SSE 帧,再用 http.NewResponseController(w).Flush() 把当前缓冲提交出去;同时用 ticker 发送轻量心跳,并监听 r.Context().Done() 感知客户端断开。
Flush 解决的是服务端把已写入数据交给 HTTP 层的问题,不能保证所有代理立即转发;心跳解决空闲期间没有字节流的问题,Context 取消则负责让事件生产和 ticker 随连接结束。
- 响应头决定客户端按事件流解释内容,事件帧必须用空行分隔。
ResponseController.Flush比直接断言http.Flusher更适合需要处理错误、兼容 ResponseWriter 包装器的 Handler。- 心跳可以使用 SSE 注释行,既能产生字节流,又不会触发业务事件。
- 客户端离开后,
Request.Context会被取消,生产者、ticker 和 Handler 都应沿着这条信号退出。
先把 SSE 响应边界写清楚
SSE 响应的主体不是任意文本,而是由字段行组成的事件帧。最小的业务事件通常写成 data: 内容\n\n,其中最后的空行告诉客户端当前事件已经结束。响应头至少要让客户端知道内容类型,并避免缓存层把多个事件长时间合并。
Handler 不能在写出第一个事件后立即返回,因为返回意味着 ServeHTTP 已经结束,后续继续使用 ResponseWriter 就越过了它的生命周期。持续推送的循环必须留在 Handler 内,并且有明确的退出分支。
package sse
import (
"fmt"
"net/http"
)
func writeEvent(w http.ResponseWriter, name, data string) error {
// event 和 data 都是 SSE 字段;空行表示这一帧已经结束。
if name != "" {
if _, err := fmt.Fprintf(w, "event: %s\n", name); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return err
}
// Flush 只提交已经写入的响应数据,不负责跨代理保证即时可见。
return http.NewResponseController(w).Flush()
}
func initialHandler(w http.ResponseWriter, r *http.Request) {
// 这些头必须在第一次 Write 之前设置,避免响应状态已经发出后再修改。
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Content-Type-Options", "nosniff")
if err := writeEvent(w, "ready", "连接已建立"); err != nil {
// 客户端已离开时,记录错误并返回即可,不要继续写响应。
return
}
}
这里使用 ResponseController 的好处是它可以沿着包装器的 Unwrap 找到底层响应能力,并把不支持 Flush 的情况作为错误返回。Go 官方文档也提醒,标准 HTTP/1.x 和 HTTP/2 响应通常支持 Flusher,但 ResponseWriter 包装器未必支持,因此不要假设所有中间件都保留了该能力。

Flush 要放在每个事件帧之后
如果只调用 Write,数据可能仍停留在服务端或中间层的缓冲中。对于一个需要尽快到达客户端的事件,顺序应保持为“写字段、补空行、Flush”。不要先 Flush 再写空行,否则提交出去的可能只是半个事件,客户端仍要等待下一次数据。
可以把“写完整帧并 Flush”封装成一个小函数,让业务循环只关心事件内容。每次 Flush 返回错误时,应把它视为连接已经不可继续使用的信号,退出当前 Handler;不要在同一个连接上无限重试,因为失败重试本身不会重新建立 SSE 连接。
心跳让空闲连接仍有字节流
业务事件可能几分钟才产生一次,但负载均衡器、反向代理或客户端网络设备可能对长时间无数据的连接设置空闲阈值。心跳不需要伪装成业务事件,SSE 的注释行以冒号开头,例如 : ping\n\n,客户端通常会忽略它,但连接链路能看到新的字节。
心跳间隔应小于链路中最短的空闲超时时间,并给网络抖动留出余量。它不是越短越好:连接数量很大时,过密心跳会放大写入、Flush 和代理转发压力。关键是让这个 ticker 与 Handler 的取消信号共享同一个 select,连接结束后立刻停止。
func writeHeartbeat(w http.ResponseWriter) error {
// SSE 注释不会形成业务事件,但能让空闲连接继续产生字节流。
_, err := fmt.Fprint(w, ": ping\n\n")
if err != nil {
return err
}
// 心跳也要 Flush,否则它可能只停留在服务端缓冲中。
return http.NewResponseController(w).Flush()
}
用 Request.Context 处理客户端断开
对传入的 HTTP 请求,r.Context() 会在客户端连接关闭、HTTP/2 请求被取消,或者 Handler 返回时进入取消状态。SSE 循环应把它当作最高优先级的结束信号:收到取消后停止事件生产,停止 ticker,并从 Handler 返回。
下面的示例假设 events 由上游业务提供。生产者发送事件时也要选择 ctx.Done(),否则 Handler 虽然退出了,上游 goroutine 仍可能阻塞在一个无人接收的 channel 上。
import (
"fmt"
"net/http"
"time"
)
func streamHandler(w http.ResponseWriter, r *http.Request, events
上例的关键不在 ticker 的具体秒数,而在三个清晰的出口:Context 取消、事件源结束、写入或 Flush 失败。若业务事件来自数据库订阅、消息队列或其他 goroutine,应把同一个 ctx 继续传给上游,让上游在客户端离开后也停止等待。

Flush 不是“客户端已经看到”的证明
Go 官方 Flusher 文档明确提示,即使 ResponseWriter 支持 Flush,如果客户端经过 HTTP 代理,缓冲数据也可能在响应完成前才到达客户端。因此排查“服务端 Flush 了但浏览器没有逐条显示”时,不能只盯着 Handler,还要检查代理是否缓存响应、是否压缩了事件流,以及客户端是否按 SSE 规则解析双换行。
另一个边界是 ResponseWriter 的包装。日志中间件、压缩中间件或自定义响应包装器可能没有实现 Flush,或者没有提供正确的 Unwrap。此时 ResponseController.Flush 会返回不支持错误;如果 SSE 必须经过该中间件,应先确认它是否保留流式写入能力,不要用静默忽略错误来掩盖问题。
把 SSE 的三个生命周期边界放在一起
| 边界 | 负责什么 | 常见误区 |
|---|---|---|
| 事件格式 | data: 内容\n\n 组成完整帧 | 只写一行而没有结束空行 |
| 发送时机 | 每个事件和心跳写完后调用 Flush | 认为 Write 返回就代表客户端已看到 |
| 空闲保持 | ticker 发送注释心跳 | 心跳过密或连接断开后 ticker 仍运行 |
| 连接结束 | 监听 r.Context().Done() 并返回 | 后台生产者阻塞在无人接收的 channel 上 |
| 中间层 | 检查代理缓冲和 ResponseWriter 包装器 | 把 Flush 当成端到端实时性的保证 |
如果业务还需要给客户端发送 retry、id 或命名事件,可以沿用同一个“写完整帧后 Flush”的边界;不要让不同类型事件走不同的退出和错误处理方式。客户端断开后,服务端通常只需要记录一次断开原因并释放上游订阅,不需要向已经关闭的连接写最后一条消息。
常见追问
直接断言 http.Flusher 还可以吗?
可以用于非常简单的 Handler,但它只能调用无返回值的 Flush(),也不方便处理包装器和底层不支持的情况。新代码可以优先使用 ResponseController.Flush(),把错误纳入连接退出逻辑。
心跳一定要发送 data 事件吗?
不需要。冒号开头的 SSE 注释行适合做连接心跳,客户端不会把它当作业务事件;如果客户端协议明确要求 ping 事件,也可以发送命名事件,但要保持帧格式完整。
为什么 Flush 后仍然看不到实时消息?
可能是代理或压缩层继续缓冲,也可能是事件没有以空行结束,或者客户端没有按 SSE 解析。Flush 只控制 Go HTTP 响应侧的提交动作,不是所有链路的实时显示承诺。
客户端刷新页面后,服务端如何停止旧连接?
在 Handler 内监听 r.Context().Done()。客户端旧连接被关闭后,该 Context 会取消,事件循环和上游生产者都应沿着这个信号返回或停止。
官方资料(复制地址查看):https://pkg.go.dev/net/http#ResponseController、https://pkg.go.dev/net/http#Flusher、https://pkg.go.dev/net/http#Request.Context、https://go.dev/doc/go1.20。本文示例用于说明标准库接口边界,不声称已经在本机运行或代表完整生产配置。
-
860 收藏
-
843 收藏
-
826 收藏
-
809 收藏
-
792 收藏
-
183 收藏
-
377 收藏
-
374 收藏
-
395 收藏
-
300 收藏
-
119 收藏
-
121 收藏
-
239 收藏
-
488 收藏
-
182 收藏
-
476 收藏
-
284 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习