很多列表接口一开始会把所有数据查出来,放进切片,再 json.NewEncoder(w).Encode(slice)。数据少时没问题,数据一多,内存会飙升,用户也要等全部数据准备好才能收到响应。对于导出类接口,可以考虑流式 JSON 响应:边查、边编码、边写给客户端。
本文用导出用户列表做例子。目标不是让每个 API 都流式化,而是理解 json.Encoder、http.Flusher、客户端断开和错误边界。
最简单的数组流式输出
func StreamUsers(w http.ResponseWriter, r *http.Request, users <-chan User) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
if _, err := w.Write([]byte("[\n")); err != nil {
return err
}
first := true
for user := range users {
if !first {
if _, err := w.Write([]byte(",\n")); err != nil {
return err
}
}
first = false
if err := enc.Encode(user); err != nil {
return err
}
}
if _, err := w.Write([]byte("]\n")); err != nil {
return err
}
return nil
}
这里手写了数组的 [、,、],每个用户用 Encoder.Encode 写出。Encode 会在每个对象后加换行,这对 JSON 数组是可以接受的。
配合分页查询
真实数据通常来自数据库分页:
func StreamUsersFromStore(ctx context.Context, w http.ResponseWriter, store Store) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
w.Write([]byte("["))
first := true
var afterID int64
for {
users, err := store.ListUsers(ctx, afterID, 500)
if err != nil {
return err
}
if len(users) == 0 {
break
}
for _, user := range users {
if !first {
w.Write([]byte(","))
}
first = false
if err := enc.Encode(user); err != nil {
return err
}
afterID = user.ID
}
}
w.Write([]byte("]"))
return nil
}
这避免一次性把所有用户读进内存。分页大小要结合数据库和响应速度调整,500 或 1000 都只是示例。
Flush 让客户端更早收到
如果希望客户端更早看到数据,可以使用 http.Flusher:
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
可以每写一页 flush 一次。不要每条都 flush,太频繁会增加网络开销。反向代理也可能缓冲响应,所以 flush 不一定保证客户端马上看到,但它能表达服务端愿意推送。
错误边界很难
流式响应有一个现实问题:一旦你已经写出 [ 和部分数据,状态码就基本定了。后面数据库出错时,不能再返回标准 JSON 错误响应。客户端可能拿到一个不完整 JSON。
所以流式响应更适合导出和内部数据拉取,不适合普通业务接口。普通接口通常应该先完成业务处理,再返回完整 JSON。流式化是为了解决大数据输出,不是默认写法。
一种替代方案是 NDJSON,每行一个 JSON 对象:
for _, user := range users {
if err := enc.Encode(user); err != nil {
return err
}
}
NDJSON 不需要数组闭合,部分失败时客户端至少能处理已经收到的完整行。但客户端必须支持这种格式。
客户端断开
写响应时如果客户端断开,Write 或 Encode 会返回错误。查询数据库时也应该使用 r.Context():
func handler(w http.ResponseWriter, r *http.Request) {
if err := StreamUsersFromStore(r.Context(), w, store); err != nil {
log.Printf("stream users: %v", err)
}
}
用户取消下载后,context 会取消,数据库查询和循环应该尽快停止。不要在流式接口里用 context.Background()。
测试响应格式
func TestStreamUsers(t *testing.T) {
users := make(chan User, 2)
users <- User{ID: 1, Name: "A"}
users <- User{ID: 2, Name: "B"}
close(users)
rec := httptest.NewRecorder()
err := StreamUsers(rec, httptest.NewRequest(http.MethodGet, "/", nil), users)
if err != nil {
t.Fatal(err)
}
var got []User
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("len = %d", len(got))
}
}
测试时不要只看字符串包含,最好反解析 JSON,确认输出结构合法。
响应头和缓存
流式接口的响应头要尽早写清楚。普通 JSON 可以先拼完再决定状态码,但流式响应一旦写出第一段内容,状态码通常就不能改了。因此校验参数、检查权限、确认游标是否合法,都应该在第一次 Encode 之前完成。
func streamLogs(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("token") == "" {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Cache-Control", "no-store")
enc := json.NewEncoder(w)
_ = enc.Encode(map[string]string{"level": "info", "msg": "started"})
}
Cache-Control: no-store 不是必须,但日志、导出进度、任务事件这类接口一般不希望被中间层缓存。若公司有网关或 CDN,最好和运维确认它们是否会缓冲响应。某些代理为了优化吞吐,会等缓冲区达到一定大小再发给客户端,这会让“实时流”看起来像卡住。
和 gzip 的关系
流式 JSON 可以配合 gzip,但要小心刷新。gzip 会自己维护压缩缓冲,如果中间件只在请求结束时关闭 writer,客户端可能很久看不到数据。简单做法是:对真正要求实时的接口先关闭 gzip,确认链路稳定后再优化。
如果一定要压缩,可以让中间件支持 Flush,并在每条记录后同时刷新 gzip writer 和 HTTP writer。入门阶段不要把这个复杂度塞进业务 handler,先把协议和错误处理写清楚更重要。
服务端日志
流式接口的失败经常发生在响应中途,比如客户端关闭浏览器、网络断开、下载工具超时。此时 Encode 可能返回 broken pipe 或上下文取消。日志级别不要一律记成 error,否则线上会被正常断连刷屏。
if err := enc.Encode(row); err != nil {
if errors.Is(r.Context().Err(), context.Canceled) {
log.Printf("client canceled stream")
return
}
log.Printf("write stream row: %v", err)
return
}
判断是否为客户端取消,可以先看 r.Context().Err()。真正需要报警的是数据库持续失败、生成数据异常、权限绕过等服务端问题,而不是用户关掉页面。
小结
Go 可以用 json.Encoder 和 io.Writer 流式写 JSON 响应,适合大列表导出和边查边写场景。结合分页查询可以减少内存占用,结合 http.Flusher 可以让客户端更早收到数据。
流式响应的错误边界更复杂:写出部分内容后,很难再返回统一错误 JSON。普通业务接口不要盲目流式化。只有当数据量和等待时间确实成为问题时,再使用这种模式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。