必须绕过 gin 默认中间件,否则 sse 会静默失败:gzip 重写 content-type、cors 提前 writeheader、日志中间件阻塞 flush;须用 gin.new() 创建无中间件引擎,每次消息后手动 f.flush() 并检查 error,配合心跳与超时控制。

必须绕过 Gin 默认中间件,否则 SSE 会静默失败
Gin 的 gzip、CORS、日志等默认中间件会破坏 SSE 关键响应头:比如 gzip 中间件把 Content-Type: text/event-stream 改成 Content-Encoding: gzip,浏览器直接拒绝解析;CORS 中间件可能提前调用 WriteHeader(),导致你后设的 Cache-Control 或 Connection 头被忽略。
正确做法是用独立路由实例或禁用中间件:
- 推荐:
r := gin.New()新建一个无任何中间件的引擎,专跑 SSE 路由 - 次选:
r.NoRoute()或r.GET("/sse", handler)配合r.Use()显式清空中间件(但不如新建实例干净) - 绝对不要在带
gin.Default()的路由上挂 SSE handler —— 即使看起来能连上,前端EventSource也常卡在connecting状态且不报错
每次写入后必须手动 Flush(),且要检查 error
SSE 不是“写完就发”,Go 的 http.ResponseWriter 默认缓冲,不 Flush() 就等于没推送。更关键的是,Flush() 可能失败(比如客户端已断开),不检查会导致 goroutine 泄漏或 panic。
安全写法分三步:
- 先做类型断言:
f, ok := c.Writer.(http.Flusher),!ok就直接返回 500 - 用
fmt.Fprintf(w, "data: %s\n\n", msg)拼事件(注意结尾是两个\n) - 立即调
f.Flush(),并检查:if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, syscall.EPIPE)—— 其他错误需记录并退出循环
c.SSEvent() 看似方便,但格式控制受限且易出错
Gin 内置的 c.SSEvent(eventName, data) 会自动封装成 event: xxx\n + data: yyy\n\n,看似省事,但有硬伤:
- 它对
data值不做转义 —— 如果传入含换行、双引号或中文的 JSON 字符串,整个事件格式就崩了(data:行内不能有裸换行) - 无法自定义
id:、retry:等字段,丢失重连控制能力 - 底层仍是调
Write()+Flush(),没省掉核心逻辑,反而掩盖了细节
建议直接手写格式,例如:
fmt.Fprintf(w, "id: %d\n", seq) fmt.Fprintf(w, "event: message\n") fmt.Fprintf(w, "data: %s\n\n", strings.ReplaceAll(jsonStr, "\n", "\ndata: ")) f.Flush()
客户端断连检测不能只靠 CloseNotify()
c.Writer.CloseNotify() 在较新 Go 版本中已被标记为 deprecated,且在某些代理或负载均衡下不可靠(比如 Nginx 默认关闭 keepalive,连接实际已断但通知未触发)。
更健壮的做法是组合使用:
- 设置合理的 HTTP 超时:
http.Server{ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second} - 加心跳事件:
fmt.Fprint(w, ":keepalive\n\n")每 15 秒一次(冒号开头是注释,不触发事件) - 配合
select+time.After()实现超时退出,避免 goroutine 永久阻塞
真正稳定的 SSE 服务,不是“写得出来”,而是“断得干净、重连得稳、错得明白”。Flush() 和错误处理那几行,往往比业务逻辑还关键。











