Go HTTP gzip 压缩入门:什么时候压缩响应,什么时候不要压

用 HTTP 中间件示例讲 Go 服务里的 gzip 响应压缩,包括 Accept-Encoding、Content-Encoding、小响应跳过和测试。

HTTP 响应压缩是一个常见优化。JSON、HTML、CSS、文本日志这类内容通常能压缩很多,网络传输更省;图片、视频、已经压缩过的 zip 文件再压缩意义不大,还会浪费 CPU。Go 标准库有 compress/gzip,可以写一个简单中间件理解压缩流程。

本文不追求写一个覆盖所有边界的生产中间件,而是讲清楚 gzip 响应的基本机制:客户端通过 Accept-Encoding 表示支持,服务端压缩后设置 Content-Encoding: gzip,并注意哪些场景不该压。

判断客户端是否支持

func acceptsGzip(r *http.Request) bool {
	return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
}

真实解析可以更严格,入门阶段先够用。只有客户端声明支持 gzip,服务端才能返回 gzip 内容。否则旧客户端会把压缩字节当普通文本读,结果就是乱码。

gzip ResponseWriter

type gzipResponseWriter struct {
	http.ResponseWriter
	writer io.Writer
}

func (w gzipResponseWriter) Write(data []byte) (int, error) {
	return w.writer.Write(data)
}

中间件:

func Gzip(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !acceptsGzip(r) {
			next.ServeHTTP(w, r)
			return
		}

		w.Header().Set("Content-Encoding", "gzip")
		w.Header().Add("Vary", "Accept-Encoding")
		gz := gzip.NewWriter(w)
		defer gz.Close()

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

Vary: Accept-Encoding 很重要。它告诉缓存系统:同一个 URL 会因为请求头不同返回不同内容。否则代理缓存可能把 gzip 版本发给不支持 gzip 的客户端。

测试压缩

func TestGzipMiddleware(t *testing.T) {
	handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "hello hello hello")
	}))

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set("Accept-Encoding", "gzip")
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Header().Get("Content-Encoding") != "gzip" {
		t.Fatal("missing gzip encoding")
	}

	reader, err := gzip.NewReader(rec.Body)
	if err != nil {
		t.Fatal(err)
	}
	defer reader.Close()
	data, err := io.ReadAll(reader)
	if err != nil {
		t.Fatal(err)
	}
	if string(data) != "hello hello hello" {
		t.Fatalf("body = %q", data)
	}
}

测试时要解压后比较内容,而不是直接比较响应体字节。gzip 里可能有时间戳等细节,直接比较压缩字节不稳定。

小响应不一定要压缩

压缩有 CPU 成本。几十字节的小响应,gzip 头本身就有额外开销,压完可能更大。生产中间件通常会在缓冲一定内容后判断是否压缩,或者只对特定 Content-Type 压缩。

入门版本可以通过路由选择:

mux.Handle("/api/", Gzip(apiHandler))
mux.Handle("/download/", downloadHandler)

API JSON 压缩,文件下载不压。不要把所有响应一刀切 gzip。

不要压已经压缩的内容

这些通常不需要 gzip:

  • jpg、png、webp
  • mp4、mp3
  • zip、gz
  • pdf,视内容而定

如果你用 Go 直接服务静态文件,可以根据扩展名跳过。很多情况下,CDN 或反向代理更适合做压缩,应用只负责生成正确内容。是否在 Go 应用层压缩,要看部署结构。

Flush 和接口转发

简单 gzipResponseWriter 只实现了 Write,没有转发 http.Flusherhttp.Hijackerhttp.Pusher 等接口。对普通 JSON 响应没问题,但对流式响应、WebSocket、SSE 就可能出问题。生产级中间件需要处理这些接口。

入门阶段要知道这个边界:中间件包装 ResponseWriter 后,可能改变它支持的能力。不要把简单 gzip 中间件直接套到所有路由上,尤其是流式接口。

Content-Length 问题

压缩后内容长度变了。如果下游 handler 提前设置了 Content-Length,gzip 中间件可能让它不准确。简单做法是在压缩时删除:

w.Header().Del("Content-Length")

标准库会使用 chunked 传输或在合适时处理长度。对于动态响应,不设置 Content-Length 通常没问题。

按 Content-Type 决定是否压缩

更实际的中间件会先看响应类型。问题是 Content-Type 往往在 handler 写响应时才知道,所以简单中间件很难提前判断。一个折中做法是只在明确的路由上启用 gzip,例如 API JSON、服务端渲染 HTML,不把它套在下载路由上。

如果你愿意写得更完整,可以用一个缓冲 writer 先缓存少量响应头和 body,等知道 Content-Type 和长度后再决定是否压缩。但这会让中间件复杂不少。入门阶段先用路由边界控制,通常更容易维护。

func shouldCompress(contentType string) bool {
	return strings.HasPrefix(contentType, "application/json") ||
		strings.HasPrefix(contentType, "text/html") ||
		strings.HasPrefix(contentType, "text/plain")
}

这类函数最好配测试,避免后续把图片、压缩包也加进压缩列表。

反向代理和应用层不要重复压缩

如果 Nginx、Caddy、CDN 已经负责 gzip 或 brotli,Go 应用里再压一次没有意义,甚至可能造成错误。部署时要明确压缩发生在哪一层。一般来说,静态资源交给 CDN 或反向代理压缩更合适;动态 JSON 如果直接由 Go 暴露,也可以在应用层压缩。

排查时可以用 curl 看响应头:

curl -H 'Accept-Encoding: gzip' -I http://localhost:8080/api/items

看到 Content-Encoding: gzip 就说明当前链路某一层做了压缩。不要只看代码判断,实际响应头才是准确信息。

常见问题 FAQ

Q: gzip 中间件影响 Content-Length 怎么办?
A: 压缩后内容长度不确定,应删除原始 Content-Length,标准库会自动使用 chunked 传输。

Q: 压缩级别怎么选?
A: gzip.DefaultCompression 通常是最佳平衡。BestCompression 更省流量但 CPU 更高,BestSpeed 则相反。benchmark 后可以按需调整。

Q: 流式响应能用 gzip 吗?
A: 简单包装可能丢失 http.Flusher 接口。生产中间件需要显式转发 FlusherHijacker 等子接口,否则 SSE、WebSocket 会出问题。

常见陷阱

  1. 流式接口被中间件截断:检查被包装的 ResponseWriter 是否完整转发了子接口。SSE、WebSocket 需要特殊处理。
  2. 反向代理重复压缩:CDN 或 Nginx 已压缩时,后端再 gzip 一次没有意义,甚至可能出错。部署时确认压缩层位置。
  3. 对所有 Content-Type 压缩:图片、压缩包、PDF 再压一次往往浪费 CPU,应根据 Content-Type 筛选。

对比表

情况是否压缩原因
JSON 响应文本格式,压缩率高
HTML 页面文本格式,压缩率高
jpg/png 图片已压缩,再压无意义
小响应(<1KB)gzip 头开销可能更大
文件下载由对象存储或 CDN 处理

小结

Go 里用 compress/gzip 可以写出基础 HTTP 压缩中间件。核心流程是检查 Accept-Encoding,设置 Content-Encoding: gzipVary: Accept-Encoding,用 gzip writer 包装响应。

压缩不是越多越好。小响应、图片、压缩包、流式接口都要谨慎。入门阶段先在 JSON 和 HTML 这类文本响应上使用,理解边界后再考虑全站压缩。部署时确认 CDN 或反向代理是否已做压缩,避免重复工作。流式接口需要显式转发 Flusher 等子接口,不要直接把简单中间件套在所有路由上。

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./...golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroupcontext.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroupcontext 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

生产级 gzip 中间件

进阶的 gzip 中间件需要处理更多边界:

type compressResponseWriter struct {
    http.ResponseWriter
    gz     *gzip.Writer
    status int
    buf    *bytes.Buffer
}

func (w *compressResponseWriter) WriteHeader(status int) {
    w.status = status
}

func (w *compressResponseWriter) Write(b []byte) (int, error) {
    if w.status == 0 {
        w.WriteHeader(http.StatusOK)
    }
    if w.buf.Len() < 1024 {
        w.buf.Write(b)
        return len(b), nil
    }
    if w.gz == nil {
        w.ResponseWriter.Header().Del("Content-Length")
        w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
        w.gz = gzip.NewWriter(w.ResponseWriter)
    }
    return w.gz.Write(b)
}

这个版本先缓冲 1KB,如果内容很小就跳过压缩。动态决定是否压缩比一刀切换策更合理。

压缩级别调优

gzip 提供了不同压缩级别:

级别速度压缩率适用场景
BestSpeed最快最低CPU 受限
Default平衡平衡通用
BestCompression最慢最高带宽受限
gz, _ := gzip.NewWriterLevel(w, gzip.BestCompression)

通常情况下 Default 已经足够优秀,不需要为了一点压缩率牺牲 CPU。

与其他压缩算法对比

算法压缩率速度浏览器支持
gzip全部
deflate大部分
brotli现代浏览器
zstd很高很快较少

如果性能允许,应用层做 gzip,CDN 做 brotli。Go 标准库 gzip 已经很成熟,除非有特殊需求否则不需要引入第三方压缩库。

中间件完整示例

package middleware

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

type GzipMiddleware struct {
    Level int
}

func (m *GzipMiddleware) Wrap(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }
        
        wrapped := &gzipResponseWriter{
            ResponseWriter: w,
            status:         http.StatusOK,
        }
        w.Header().Set("Content-Encoding", "gzip")
        w.Header().Add("Vary", "Accept-Encoding")
        
        defer func() {
            if wrapped.gz != nil {
                wrapped.gz.Close()
            }
        }()
        
        next.ServeHTTP(wrapped, r)
    })
}

这个设计提供了更灵活的控制和缓存支持。实践中要注意接口转发(Flusher/Hijacker/Pusher)问题,生产级中间件需要显式支持这些子接口。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页