登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go 流式响应怎么做:ResponseController.Flush、SSE 与断开回收的取舍

来源:17golang原创

时间:2026-07-26 13:41:38 463浏览 收藏

后台导出、大模型流式输出和长轮询类接口,普遍都会碰到同一个问题:服务端明明已经算完第一段结果了,浏览器却要等整个请求全跑完才能看到内容。Go 里可以用 http.ResponseController.Flush 把已经写进响应缓冲区的数据尽快推给客户端,再配合 SSE 的事件格式明确消息边界;但这个方法只负责刷新服务端本地的缓冲,没法自动绕过反向代理的缓冲逻辑,也不会主动帮你处理客户端断开后的资源回收问题。

要点速览

  • Flush 解决的是服务端已经写入数据的即时发送问题,不等于数据能端到端直接被客户端秒级收到。
  • SSE 响应要设置 text/event-stream、禁用缓存,每条事件末尾必须加空行作为结束标识。
  • 循环发送数据之前要提前监听 r.Context().Done(),客户端主动断开时要立刻停止定时器和后台常驻协程。
  • 反向代理、响应压缩和 HTTP/2 特性都会改变实际观察到的推送延迟,一定要用真实生产链路做一次完整验收。

先把“数据写出去了但浏览器看不到”的问题拆解开

排查流式响应异常的时候,别上来就把消息发送间隔调得特别短。一次正常的流式请求至少会经过业务Handler、Go原生HTTP服务、反向代理、客户端这四个节点,任意一层做了小块数据的攒包合并,浏览器那边看起来就会像卡住了一样完全没反应。

这也是 Flush 最容易被误解的地方。http.NewResponseController(w).Flush() 只会尝试刷新当前响应写入器所持有的缓冲区,如果外层的包装类没有暴露原始的 ResponseWriter,调用刷新方法返回的可能是 http.ErrNotSupported。所以第一步不是直接上SSE,而是先确认中间件层没有把底层的刷新能力给隐藏掉。

Go 流式响应从 handler 写入到 Flush 再到客户端的调用链,标出代理缓冲瓶颈

用 ResponseController.Flush 实现一个可直接调试的 SSE 接口

下面的示例接口每800毫秒就往客户端推送一条事件,事件内容里带有序号和服务端的当前时间。示例里特意保留了错误处理分支:只要响应写入操作或者刷新操作失败,就直接终止本次请求,避免循环继续往已经断开的无效连接里写数据。

package main

import (
    "fmt"
    "net/http"
    "time"
)

func streamHandler(w http.ResponseWriter, r *http.Request) {
    h := w.Header()
    h.Set("Content-Type", "text/event-stream")
    h.Set("Cache-Control", "no-cache")
    h.Set("Connection", "keep-alive")

    ctl := http.NewResponseController(w)
    tick := time.NewTicker(800 * time.Millisecond)
    defer tick.Stop()

    for seq := 1; seq 

这里特意加的两个换行不是没用的装饰,SSE协议就是靠空行来标识一个事件的结束,如果只写一行内容或者把分隔符漏了,客户端侧可能一直等不到能正常解析的完整消息。event 是可选项,data 才是SSE协议要求的最小可用字段,JSON只是事件承载的内容,不属于SSE协议本身的强制要求。

客户端断开后,Context 是最核心的第一条回收路径

浏览器关闭页面、网络临时中断或者上游主动取消请求之后,r.Context().Done() 就会被自动关闭。循环发送逻辑在等待定时器触发的同时,必须同时监听这个上下文的关闭信号,否则Handler会一直运行直到预设的所有发送次数跑完,线上真正跑起来的长连接接口甚至会一直占着协程资源不释放。

Flush 能解决什么问题,不能解决什么问题

在本机直接访问服务端的场景下,可以用 curl -N http://127.0.0.1:8080/events 观察每条事件是不是按设定的间隔准时输出。-N 参数能让curl本身不做标准输出缓冲,方便你直观看到服务端刷新操作的实际效果。切到浏览器侧的 EventSource 之后,再打开Network面板对照着看实时的响应流数据。

节点位置能否直接靠 Flush 解决核对方法
Go Handler 刚写入的业务数据可以尝试主动刷新curl -N 分段读取输出验证
反向代理自带的缓冲逻辑不能绕过代理和经过代理分别发起请求做对比
gzip压缩或者自定义响应转换中间件无法保证生效查看实际返回的响应头和代理配置规则
客户端断开后的资源自动回收不能自动代替处理监控 Context 状态、协程数量和定时器销毁情况

HTTP/2 协议下Go服务端可以更自然地处理同一个连接上的并发读写操作,但“客户端立刻收到小块内容”这件事,仍然完全取决于中间全链路的配置情况,千万别把本机curl测试出来的结果直接当成生产环境的最终结论。

SSE 客户端断开后由 Context 通知并停止 ticker 的 Go 资源回收路径

什么场景适合用 SSE,什么场景该换成其他方案

SSE 特别适配服务端持续向客户端推送消息、客户端主要做接收的场景,比如异步任务进度回传、构建日志实时输出、轻量通知流这类需求。它基于普通HTTP协议实现,浏览器侧接入门槛很低,断线之后的自动重连也有很成熟的 Last-Event-ID 实现思路。

如果业务场景需要客户端频繁往服务端双向发送消息,WebSocket会是更合适的选择;如果只是要一次性返回大体积文件,io.Copy 配合合理的超时设置和请求边界规则会更简单直接。单纯为了看起来更实时就把普通JSON接口全改成SSE,反而会额外增加连接数管控、代理配置调整和线上监控的成本。

上线前一定要做完这四点核对

  • 响应头是否为 text/event-stream,有没有被意外开启缓存或者自动压缩。
  • 每条推送事件末尾是不是都带了空行,JSON字符串内部的换行符和引号有没有做好正确转义。
  • Handler逻辑在写入出错、Flush刷新出错、Context被取消的时候,有没有正确退出循环。
  • 请求经过Nginx、API网关或者CDN之后,首字节返回耗时和每条事件的推送间隔,还能不能满足业务的实际需求。

相关问题

只调用 Write 写数据,不调用 Flush,SSE 一定不能用吗?

不一定,但小体积的事件大概率会被服务端或者链路层暂存起来,客户端收到消息的时间完全不可控。如果需要低延迟的分段反馈,一定要主动调用刷新方法,并且在真实的代理链路上做完整验证。

ResponseController.Flush 返回不支持刷新的错误要怎么处理?

先检查传入方法的是不是原始的 ResponseWriter,或者外层中间件有没有提供获取底层原始写入器的 Unwrap。如果底层链路确实不支持刷新操作,就不要把低时延流式推送的承诺建立在这个特性上。

客户端断开之后还要手动关闭 ticker 定时器吗?

要的。用 defer tick.Stop() 作为兜底回收机制,并且在 Context.Done() 分支里立刻返回退出当前Handler;长期运行的长连接业务,还要为单连接协程数量和发送队列配置可观测的监控指标。

把流式延迟的承诺落到可实际测试的链路上

流式接口的价值根本不是代码里多写了一个 Flush 方法,而是用户能更早看到有用的第一段返回结果,并且连接断开之后服务端能干净利落地回收所有相关资源。先用直连请求验证事件的边界逻辑是正常的,再把反向代理、压缩规则、超时设置和取消逻辑一起放进验收范围里;如果中间链路没办法稳定交付小块数据,直接用普通JSON接口加异步任务轮询的方案,反而往往更稳妥可靠。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>