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

Go gzip 怎样给 HTTP 流增加压缩

来源:17golang原创

时间:2026-10-22 00:37:45 352浏览 收藏

如果 Go 服务端要给 HTTP 流增加 gzip 压缩,核心不是把所有输出都交给一个压缩对象,而是先划清响应边界:客户端声明支持 gzip 时,提前设置 Content-Encoding: gzip 和 Vary: Accept-Encoding,再让 gzip.Writer 接管正文;普通响应结束时调用 Close,流式响应则同时刷新 gzip 层和 HTTP 层。

官方地址:https://pkg.go.dev/compress/gzip

HTTP 接口文档:https://pkg.go.dev/net/http

我更愿意把它看成一个“响应承载层”的设计问题。压缩节省的是网络字节,代价却落在服务端 CPU、响应延迟和缓存变体上。边界没有处理好时,最常见的结果不是“压缩率不够”,而是客户端拿到无法解码的正文、缓存混用了两种编码,或者流式输出迟迟看不到新数据。

先把压缩放在响应边界

服务端不能只看“我能不能压缩”,还要看请求是否声明了 gzip。最小中间件可以把原始 http.ResponseWriter 包成一个只负责写压缩正文的对象。响应头必须在第一次写正文以前设置,因为第一次 Write 可能已经提交状态码和头部。

HTTP 客户端能力、Go Handler、gzip Writer 和压缩响应之间的静态关系结构图
图1:静态结构说明图,展示 Accept-Encoding、Handler、gzip.Writer 与压缩响应元数据之间的边界关系。
package main

import (
    "compress/gzip"
    "net/http"
    "strings"
)

// gzipResponseWriter 把正文写入转交给 gzip.Writer,头部仍由原始 ResponseWriter 管理。
type gzipResponseWriter struct {
    http.ResponseWriter
    writer *gzip.Writer
}

// Write 只压缩正文;Header 和 WriteHeader 由嵌入的 ResponseWriter 提供。
func (w gzipResponseWriter) Write(p []byte) (int, error) {
    return w.writer.Write(p)
}

// gzipMiddleware 演示响应压缩的边界,生产代码还应完整解析 q 值和通配符。
func gzipMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 没有 gzip 能力声明时,保持原始响应,避免客户端无法解码。
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }

        // 头部要在正文写入前完成,并让缓存区分不同编码的响应。
        w.Header().Set("Content-Encoding", "gzip")
        w.Header().Add("Vary", "Accept-Encoding")
        w.Header().Del("Content-Length")

        gz := gzip.NewWriter(w)
        defer func() {
            // Close 会刷新剩余数据并写入 gzip footer,但不会关闭底层 w。
            _ = gz.Close()
        }()

        next.ServeHTTP(gzipResponseWriter{ResponseWriter: w, writer: gz}, r)
    })
}

这个示例把“压缩正文”和“响应头”分开了:Header、WriteHeader 仍然作用于原始响应对象,Write 才进入 gzip 层。示例中的 strings.Contains 只是为了突出边界,真正的协商逻辑还要处理 gzip;q=0、多个编码和通配符,不能把“出现了 gzip 字样”当成完整的 HTTP 协商器。

Content-Length 不要沿用原始正文长度

原始正文的长度和压缩后长度不是同一个值。只要响应采用在线压缩,就不要继续沿用上游设置的 Content-Length。让 HTTP 服务端选择合适的传输方式,或者在你确实预先压缩了完整数据并知道压缩后长度时,再显式设置正确的值。

这也是为什么在线压缩不适合直接套在所有文件服务路径上。Go 的 ServeContent 依赖可寻址、长度明确的内容;Go 源码也特别说明了,若要即时压缩,应避免把它当成可搜索文件来服务,或先把完整压缩文件准备好再提供。

另一个容易遗漏的头部是 Vary: Accept-Encoding。它不是为了告诉客户端怎么解码,而是告诉共享缓存:同一个 URL 可能因请求头不同而有不同响应表示。没有它,缓存层可能把 gzip 版本交给不支持 gzip 的请求方。

流式输出时要同时处理两层缓冲

普通 JSON 响应往往等到函数返回才结束,Close 通常足够。事件流、长轮询或大文件分块则不同:gzip.Writer 自己有压缩缓冲,HTTP 响应承载也可能有缓冲。只刷新其中一层,读者仍可能看不到新数据。

业务分块、gzip Writer 刷新、ResponseController 和 HTTP 输出缓冲的静态关系结构图
图2:静态结构说明图,展示 gzip.Flush、ResponseController.Flush、Close 与响应完整性之间的职责边界。
func streamHandler(w http.ResponseWriter, r *http.Request) {
    // 提前声明压缩表示,避免第一块正文写出后再修改头部。
    w.Header().Set("Content-Encoding", "gzip")
    w.Header().Add("Vary", "Accept-Encoding")
    w.Header().Del("Content-Length")

    gz := gzip.NewWriter(w)
    defer func() {
        // 函数结束时写出剩余压缩数据和 footer,错误要交给真实项目记录。
        _ = gz.Close()
    }()

    controller := http.NewResponseController(w)
    chunks := []string{"第一段\n", "第二段\n", "最后一段\n"}
    for _, chunk := range chunks {
        // 先把业务分块送进 gzip 层,Write 成功只代表进入压缩对象。
        if _, err := gz.Write([]byte(chunk)); err != nil {
            return
        }
        // gzip.Flush 让压缩层产生可读的同步边界。
        if err := gz.Flush(); err != nil {
            return
        }
        // ResponseController 再请求 HTTP 承载层把当前缓冲交给客户端。
        if err := controller.Flush(); err != nil {
            return
        }
    }
}

gzip.Flush 和 ResponseController.Flush 解决的是两件事:前者让压缩对象把待处理的压缩数据推到底层写入器,后者请求 HTTP 响应承载把缓冲交给客户端。Go 文档也提醒,代理仍可能继续缓冲,所以调用刷新并不等同于“网络上的每个中间节点都立即转发”。

ResponseController 从 Go 1.20 起提供了更统一的控制入口;如果项目需要兼容更老版本,可以按能力检查 http.Flusher,但包装过的 ResponseWriter 可能不再暴露它,因此中间件要保留或转发必要的接口。

Close 是压缩流完整性的边界

gzip.Writer.Close 会刷新未写出的数据并写入 gzip footer,但它不会关闭底层的 http.ResponseWriter。这正是 HTTP Handler 想要的所有权关系:压缩对象由当前层负责,底层响应仍归 HTTP 服务端管理。

因此不要只写 defer w.Close(),也不要在还会写正文时提前关闭 gzip。更稳妥的顺序是:先设置响应头,再创建 gzip.Writer,把所有正文写完,最后关闭 gzip.Writer;如果是持续流,则在每个分块处 Flush,结束时仍必须 Close。

高并发场景下怎样做取舍

压缩不是免费的网络优化。文本 JSON、HTML、CSS、JavaScript 通常更容易从压缩中获益;JPEG、PNG、ZIP、PDF 等已经高度压缩的内容往往只会消耗 CPU。响应很小的时候,gzip 头部和压缩计算也可能抵消节省的字节数。

场景建议原因
大段文本或 JSON按 Accept-Encoding 协商 gzip带宽收益通常更明显
事件流或分块响应gzip.Flush 后再刷新 HTTP 层避免数据停在任一层缓冲里
图片、压缩包、已压缩文件默认不重复压缩CPU 成本可能大于网络收益
可缓存响应保留 Vary: Accept-Encoding让缓存区分编码变体

我的判断顺序通常是:先确认内容是否值得压缩,再确认客户端是否能解码,然后确认头部和缓存语义,最后才讨论压缩等级。高压场景里,BestCompression 不一定比默认级别更划算;如果 CPU 已经是瓶颈,较快的级别或预压缩静态资源往往更稳。

常见问题

客户端没有发送 Accept-Encoding,服务端能不能直接返回 gzip?

不建议。客户端能力和质量值应由协商决定;直接返回 gzip 可能让不支持解码的客户端拿到不可读正文。

调用 gzip.Flush 后为什么客户端还是没有马上收到?

因为 HTTP 层、反向代理或客户端读取策略仍可能缓冲。应用层需要刷新 gzip 层和 HTTP 层,但不能把它当成端到端即时传输保证。

gzip.Writer.Close 会不会把 ResponseWriter 一起关闭?

不会。它只完成 gzip 数据流并写入 footer,底层 io.Writer 的生命周期仍由调用方管理。

小结

Go 给 HTTP 流增加 gzip 的关键是把压缩当成响应边界来管理:协商后设置 Content-Encoding,用 Vary 描述缓存变化,删除不再可信的原始 Content-Length,正文写入 gzip.Writer,流式场景同时刷新两层,结束时用 Close 写完 footer。这样得到的不是单纯“字节变少”,而是一条客户端、压缩层、HTTP 承载和缓存语义都能对齐的响应路径。

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