bigywater.tech
Gin · 42

流式响应:c.Stream 与 SSE

用 Gin 做 SSE 推送,c.Stream 和 c.SSEvent 到底怎么配合?什么时候事件才会真正发给客户端?客户端断开后会怎样?这篇用真实的 HTTP 流量记下每个事件的到达时间,并验证 c.Stream 最容易踩的“step 不阻塞”问题。

203 / 404 / 604 / 805 ms
c.Stream 每 200 ms 发一个事件,客户端收到各事件的时间
806 ms 一起到
循环里只 SSEvent、不 Flush 时 4 个事件到达的时间
191 万次 vs 3 次
step 不阻塞与等 channel 时,100 ms 内 step 被调用的次数

Stream 与 SSEvent

实测 · 本机执行输出
  context.go 的 Stream(Gin v1.12.0):
    1322 | func (c *Context) Stream(step func(w io.Writer) bool) bool {
    1323 |     w := c.Writer
    1324 |     clientGone := w.CloseNotify()
    1325 |     for {
    1326 |         select {
    1327 |         case <-clientGone:
    1328 |             return true
    1329 |         default:
    1330 |             keepOpen := step(w)
    1331 |             w.Flush()
    1332 |             if !keepOpen {
    1333 |                 return false
    1334 |             }
    1335 |         }
    1336 |     }
    1337 | }
  context.go 的 SSEvent(Gin v1.12.0):
    1313 | func (c *Context) SSEvent(name string, message any) {
    1314 |     c.Render(-1, sse.Event{
    1315 |         Event: name,
    1316 |         Data:  message,
    1317 |     })
    1318 | }

SSEvent 只是把事件写进 ResponseWriter,并不保证已经发给客户端。Stream 在每次 step 之后调用 Flush,所以事件即时到达。它的循环本身不等待:节奏完全由 step 决定。

事件的格式

实测 · 本机执行输出
    状态 200;Content-Type="text/event-stream;charset=utf-8";Cache-Control="no-cache";Connection="";X-Accel-Buffering=""
    原始正文:
      |event:message
      |data:hello
      |
      |event:num
      |data:42
      |
      |event:obj
      |data:{"a":1,"b":"x\u003cy"}
      |
      |event:multi
      |data:line1
      |data:line2
      |data:line3
      |
      |data:没有事件名
      |
      |event:bytes
      |data:raw
      |
      |event:nilmsg
      |data:<nil>
      |
      |

响应头 Content-Type: text/event-stream;charset=utf-8 与 Cache-Control: no-cache 由 SSEvent 设置;反向代理常用的 X-Accel-Buffering: no 与 Connection 需要自己加。多行文本会拆成多个 data: 行,结构体序列化成 JSON,传 nil 会输出字面量 <nil>,别把它当作“空数据”。

Flush 的重要性

实测 · 本机执行输出
    /stream   响应头到达 203 ms;事件到达:[1@203ms 2@404ms 3@604ms 4@805ms];TransferEncoding=[chunked]
    /noflush  响应头到达 805 ms;事件到达:[1@806ms 2@806ms 3@806ms 4@806ms];TransferEncoding=[]
    /flush    响应头到达 200 ms;事件到达:[1@200ms 2@402ms 3@603ms 4@805ms];TransferEncoding=[chunked]

小于缓冲区(约 4 KB)的响应,不 Flush 就要等 handler 结束才发出——实测 4 个事件在 806 ms 一起到达。自己写循环时,每个事件之后都要 c.Writer.Flush()(与 c.Stream 的效果相同)。经过带缓冲的代理(nginx 默认)还需要关闭代理缓冲,本文没有验证。

客户端断开

实测 · 本机执行输出
    客户端在约 330 ms 时断开,此时 step 已调用 6 次
    断开后 31 ms:Stream 返回 clientGone=true;step 共被调用 7 次(断开之后又多调用 1 次)

Stream 用 CloseNotify(已被标准库标记为废弃,但仍然可用)发现断开,检查发生在下一轮循环,所以断开之后 step 还会多执行一次。自己写循环时应该改用 c.Request.Context().Done() 判断。

step 必须阻塞

实测 · 本机执行输出
    /spin  step 被调用 1911526 次
    /wait  step 被调用 3 次

Stream 不会替你等待:step 如果立即返回 true,就会变成一个空转的死循环,100 ms 内调用了近 200 万次。把 step 写成“从 channel 取一个事件、写出、返回 true”(或者等 ticker),这样既有节奏也不占 CPU。

分步演示

流式响应与 SSE
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。用真实的 httptest.NewServer;客户端用 http.Get 与 bufio 逐行读取并记录相对 GET 开始的毫秒数;断开实验用原始 TCP 连接手写请求后 Close;每种情形各一次(数字每次运行有几毫秒抖动,空转次数每次不同)
    • 没有验证:经过 nginx 等反向代理时的缓冲;HTTP/2 下的 SSE;Last-Event-ID 的重连处理(Gin 不处理,需要自己写);大量并发 SSE 连接的资源占用;改用 Request.Context().Done() 判断断开的实际效果
    京ICP备2026067502号-1京公网安备11010502063487号