压缩和归档是很多后台工具的基础能力
服务端程序经常需要处理文件:日志按天压缩,报表打包下载,备份目录归档,上传的 zip 解开检查。Go 标准库提供了 compress/gzip、archive/tar、archive/zip 等包,足够覆盖很多入门和中小项目需求。
先分清两个概念:压缩是让数据变小,比如 gzip;归档是把多个文件组织成一个文件,比如 tar。.tar.gz 通常表示先 tar 归档,再 gzip 压缩。zip 则同时包含归档和压缩能力。
这篇文章用日志压缩和目录打包做例子,讲清楚基本数据流。重点仍然是 io.Reader、io.Writer 的组合。最后还会补充性能对比、常见错误和实战 FAQ。
gzip 压缩字符串或文件
压缩到 writer:
func GzipData(w io.Writer, data []byte) error {
gz := gzip.NewWriter(w)
defer gz.Close()
if _, err := gz.Write(data); err != nil {
return fmt.Errorf("write gzip: %w", err)
}
return nil
}
使用:
var buf bytes.Buffer
if err := GzipData(&buf, []byte("hello hello hello")); err != nil {
return err
}
fmt.Println(buf.Len())
压缩文件:
func GzipFile(dstPath, srcPath string) error {
src, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("create gzip file: %w", err)
}
defer dst.Close()
gz := gzip.NewWriter(dst)
defer gz.Close()
if _, err := io.Copy(gz, src); err != nil {
return fmt.Errorf("copy gzip data: %w", err)
}
return nil
}
gz.Close() 很重要,它会写入压缩尾部信息。忘记关闭可能得到损坏文件。
gzip 解压
func Gunzip(r io.Reader) ([]byte, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("create gzip reader: %w", err)
}
defer gz.Close()
data, err := io.ReadAll(gz)
if err != nil {
return nil, fmt.Errorf("read gzip: %w", err)
}
return data, nil
}
如果输入来自不可信来源,解压时要考虑大小限制。压缩文件可能很小,解开后很大。可以用 io.LimitReader 或限制写入目标。
gzip 压缩级别与性能选择
gzip.NewWriter 默认使用默认压缩级别。你可以通过 gzip.NewWriterLevel 调整:
gz, err := gzip.NewWriterLevel(dst, gzip.BestSpeed)
if err != nil {
return err
}
级别选项:
gzip.NoCompression(0):不压缩,只打包gzip.BestSpeed(1):速度优先,压缩比最低gzip.DefaultCompression(-1):默认平衡点gzip.BestCompression(9):压缩比最高,速度最慢
对于日志压缩,通常默认级别就够了。如果需要在高吞吐场景下压缩,可以考虑 BestSpeed。如果网络带宽是瓶颈而 CPU 充裕,可以用 BestCompression。
tar 归档多个文件
创建 tar:
func WriteTar(w io.Writer, files map[string]string) error {
tw := tar.NewWriter(w)
defer tw.Close()
for name, content := range files {
data := []byte(content)
header := &tar.Header{
Name: name,
Mode: 0644,
Size: int64(len(data)),
}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header: %w", err)
}
if _, err := tw.Write(data); err != nil {
return fmt.Errorf("write tar body: %w", err)
}
}
return nil
}
组合成 .tar.gz:
func WriteTarGz(w io.Writer, files map[string]string) error {
gz := gzip.NewWriter(w)
defer gz.Close()
return WriteTar(gz, files)
}
这个组合很能体现 Go 的数据流思路:tar 写到 gzip,gzip 再写到最终 writer。每层只关心自己的接口。
tar 添加目录和文件权限
tar 归档真实文件目录时需要保留目录结构和文件权限:
func AddDirToTar(tw *tar.Writer, basePath, dir string) error {
return filepath.Walk(dir, func(file string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(basePath, file)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(fi, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(rel)
if err := tw.WriteHeader(header); err != nil {
return err
}
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
}
这个函数遍历目录,把每个文件以相对路径写入 tar。filepath.ToSlash 保证归档内路径使用正斜杠,跨平台解压时更稳定。
zip 打包
func WriteZip(w io.Writer, files map[string]string) error {
zw := zip.NewWriter(w)
defer zw.Close()
for name, content := range files {
fileWriter, err := zw.Create(name)
if err != nil {
return fmt.Errorf("create zip entry: %w", err)
}
if _, err := io.WriteString(fileWriter, content); err != nil {
return fmt.Errorf("write zip entry: %w", err)
}
}
return nil
}
写到 HTTP 响应:
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="report.zip"`)
err := WriteZip(w, files)
zip 很适合下载多个报表文件。文件名要小心,不要直接信任用户输入的路径,避免生成奇怪目录结构。
zip 压缩级别与目录结构
zip 也可以设置压缩级别:
func WriteZipWithCompression(w io.Writer, files map[string]string) error {
zw := zip.NewWriter(w)
defer zw.Close()
for name, content := range files {
fw, err := zw.CreateHeader(&zip.FileHeader{
Name: name,
Method: zip.Deflate,
})
if err != nil {
return err
}
if _, err := fw.Write([]byte(content)); err != nil {
return err
}
}
return nil
}
zip.Store 表示不压缩直接存储,zip.Deflate 使用 deflate 算法。文本文件用压缩效果好,已经压缩过的图片、音频用 zip.Store 更快。
流式压缩 HTTP 响应
在 HTTP 服务中,可以直接对响应做 gzip 压缩:
func gzipHandler(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
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
gw := &gzipResponseWriter{ResponseWriter: w, Writer: gz}
next.ServeHTTP(gw, r)
})
}
type gzipResponseWriter struct {
http.ResponseWriter
io.Writer
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
return g.Writer.Write(b)
}
这是最基本的 gzip 中间件实现。生产环境建议使用标准库 gzip 包或成熟中间件库,它们会处理好 content-type 判断、长度头移除等问题。
常见错误与排查
忘记关闭 Writer
gzip.Writer、tar.Writer、zip.Writer 都需要 Close 来写入尾部信息。忘记关闭会导致文件不完整。
重复读取 io.Reader
io.Copy 会消费 reader。如果需要多次使用同一数据,先读到 []byte 或 bytes.Buffer。
路径穿越漏洞
处理用户上传的 zip 时,不要直接使用文件名:
// 危险
target := filepath.Join("/uploads", header.Name)
应该清理路径:
clean := filepath.Clean(header.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("invalid path: %s", header.Name)
}
target := filepath.Join("/uploads", clean)
压缩炸弹
不要信任用户上传的压缩文件大小。一个小 gzip 文件可能解压出数十 GB 数据。始终使用 io.LimitReader:
limited := io.LimitReader(gz, maxSize)
data, err := io.ReadAll(limited)
性能对比与选型建议
在中小数据量场景下,三种格式的选择建议:
- gzip:单数据流压缩的首选,HTTP 内容编码标准之一,解压速度快
- tar:只归档不压缩,适合需要保留权限、链接、修改时间的场景
- zip:同时归档和压缩,Windows 兼容性最好,适合文件下载
性能上,gzip 压缩和解压速度都很快。对于大文件,tar + gzip(.tar.gz)依然是 Linux 服务器上最常见的格式组合。zip 由于要维护中心目录信息,创建和读取的开销略大一些,但胜在兼容性好。
如果你需要更小的体积而不在乎速度,可以考虑 github.com/klauspost/pgzip 等第三方并行 gzip 实现,它能在多核 CPU 上获得数倍加速。
实战 FAQ
Q: 压缩后文件反而变大了怎么办?
A: 压缩对已经压缩过的数据或随机数据效果很差甚至反效果。图片、音频、视频通常不需要再 gzip。
Q: 如何压缩大文件不占用大量内存?
A: 使用 io.Copy 流式处理,不要一次性把整个文件读到内存再压缩。
Q: tar 和 zip 怎么选?
A: 需要保留 Unix 权限和符号链接用 tar;需要 Windows 兼容和随机访问用 zip。
Q: gzip.Writer 可以复用吗?
A: 不可以。每个 Writer 绑定一个输出流,压缩完要关闭。复用会生成格式错误的输出。
Q: 怎么判断一个文件是不是 gzip 格式?
A: 读前两个字节,gzip 魔数是 0x1f 0x8b。
buf := make([]byte, 2)
_, err := f.Read(buf)
if err == nil && buf[0] == 0x1f && buf[1] == 0x8b {
// gzip file
}
批量日志压缩实战
服务端每天产生大量日志,手动处理不现实。下面是一个批量的日志压缩工具示例:
func CompressLogFiles(pattern string, keepOriginal bool) error {
files, err := filepath.Glob(pattern)
if err != nil {
return err
}
for _, f := range files {
if strings.HasSuffix(f, ".gz") {
continue
}
gzFile := f + ".gz"
if err := GzipFile(gzFile, f); err != nil {
log.Printf("compress %s failed: %v", f, err)
continue
}
if !keepOriginal {
if err := os.Remove(f); err != nil {
log.Printf("remove %s failed: %v", f, err)
}
}
log.Printf("compressed: %s -> %s", f, gzFile)
}
return nil
}
这个工具可以集成到定时任务中,每天凌晨压缩前一天的日志文件,既节省磁盘空间,又保留可读性。
zip 解压并安全检查
处理用户上传的 zip 文件时需要格外小心,避免路径穿越和压缩炸弹攻击:
func SafeUnzip(src string, destDir string, maxSize int64) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
clean := filepath.Clean(f.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("invalid path in zip: %s", f.Name)
}
target := filepath.Join(destDir, clean)
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
return fmt.Errorf("path traversal detected: %s", f.Name)
}
if f.FileInfo().IsDir() {
os.MkdirAll(target, os.ModePerm)
continue
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
limit := io.LimitReader(rc, maxSize)
data, err := io.ReadAll(limit)
if err != nil {
return err
}
if err := os.WriteFile(target, data, f.Mode()); err != nil {
return err
}
}
return nil
}
这个函数严格限制了解压路径,防止恶意 zip 覆盖系统文件。同时还限制了每个文件的解压大小,避免内存被占满。
压缩级别基准测试
不同压缩级别对速度和压缩比的影响可以用基准测试量化:
func BenchmarkGzipDefault(b *testing.B) {
data := bytes.Repeat([]byte("hello world "), 10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
gz, _ := gzip.NewWriterLevel(&buf, gzip.DefaultCompression)
gz.Write(data)
gz.Close()
}
}
func BenchmarkGzipBestSpeed(b *testing.B) {
data := bytes.Repeat([]byte("hello world "), 10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
gz, _ := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
gz.Write(data)
gz.Close()
}
}
运行 go test -bench=. -benchmem 可以看到不同级别的耗时和内存分配差异。通常 BestSpeed 比 BestCompression 快 3-5 倍,但压缩比低 10%-20%。
更多的实战 FAQ
Q: 为什么 zip 解压后中文文件名乱码?
A: Windows 上的 zip 工具常使用 GBK 编码文件名,而 Go 默认按 UTF-8 处理。可以用 golang.org/x/text/encoding/simplifiedchinese 做编码转换。
Q: 并发压缩多个文件要注意什么?
A: 每个文件用独立的 gzip.Writer,不要多个 goroutine 共享同一个 Writer。如果文件很多,可以使用 sync.WaitGroup 并发处理,但要控制并发度避免耗尽文件句柄。
Q: HTTP 服务端如何发送 gzip 压缩的响应?
A: 最简方式是检测 Accept-Encoding: gzip 请求头,然后设置 Content-Encoding: gzip,用 gzip.Writer 包装 ResponseWriter 写入响应体。
Q: tar 归档如何保留 symbol link?
A: 使用 tar.FileInfoHeader 时传入 link 目标作为第二个参数:tar.FileInfoHeader(fi, linkTarget),然后设置 header.Typeflag = tar.TypeSymlink。
Q: 内存中直接解压 zip 文件怎么做?
A: 用 bytes.NewReader 包装 []byte,然后传给 zip.NewReader:
r := bytes.NewReader(zipData)
zr, err := zip.NewReader(r, int64(len(zipData)))
这种方式适合处理小 zip 文件,不需要先写入磁盘。
大文件分块压缩与校验
当文件超过内存限制时,需要用流式方式处理。结合 io.CopyBuffer 和自定义缓冲区可以显著提升大文件压缩性能。这里还给压缩结果加上 SHA256 校验和,便于后续验证文件完整性:
func GzipFileWithHash(dstPath, srcPath string) (string, error) {
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return "", err
}
defer dst.Close()
gz := gzip.NewWriter(dst)
defer gz.Close()
hash := sha256.New()
mw := io.MultiWriter(gz, hash)
buf := make([]byte, 32*1024)
if _, err := io.CopyBuffer(mw, src, buf); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
这个函数在压缩的同时计算 SHA256,压缩完成后返回校验和。接收方可以用同样的方式重新计算并与原始校验和比对,确认传输过程中文件没有被损坏或篡改。缓冲区设为 32KB 是一个经验值,实际应用中可以根据文件大小和 I/O 特性调整。
并发压缩多个文件
实际生产环境需要压缩的日志文件往往不止一个。下面是一个带并发度控制的安全批量压缩工具:
func BatchGzip(files []string, maxWorkers int) error {
var wg sync.WaitGroup
semaphore := make(chan struct{}, maxWorkers)
for _, f := range files {
if strings.HasSuffix(f, ".gz") {
continue
}
wg.Add(1)
semaphore <- struct{}{}
go func(file string) {
defer wg.Done()
defer func() { <-semaphore }()
gzFile := file + ".gz"
if err := GzipFileWithLevel(gzFile, file, gzip.BestSpeed); err != nil {
log.Printf("compress %s failed: %v", file, err)
return
}
log.Printf("compressed: %s", gzFile)
}(f)
}
wg.Wait()
return nil
}
使用 semaphore 限制并发 goroutine 数量,避免同时打开过多文件句柄导致系统资源耗尽。maxWorkers 通常设置为 runtime.NumCPU() 或更小值,具体取决于磁盘 I/O 带宽。
流式 HTTP 响应 tar.gz 下载
有时候需要让用户下载整个目录的内容,而非单个文件。这时可以动态生成 tar.gz 直接写入 HTTP 响应:
func downloadDirHandler(w http.ResponseWriter, r *http.Request) {
dir := "/var/reports/daily"
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="reports.tar.gz"`)
gz := gzip.NewWriter(w)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, _ := filepath.Rel(dir, path)
header, _ := tar.FileInfoHeader(info, "")
header.Name = filepath.ToSlash(rel)
tw.WriteHeader(header)
f, _ := os.Open(path)
defer f.Close()
io.Copy(tw, f)
return nil
})
if err != nil {
log.Printf("tar walk error: %v", err)
}
}
这个实现支持流式输出,不需要先把整个 tar.gz 写到磁盘再发送,内存占用极小,即使目录有数十 GB 也能正常工作。注意压缩后的响应大小无法预先得知,所以无法设置 Content-Length。
go1.21 新增 compress/gzip 功能
从 Go 1.21 开始,gzip.Writer 支持通过 SetHeader 自定义 gzip 头部的一些字段。这在需要与旧系统兼容时很有用:
func GzipWithCustomHeader(w io.Writer, data []byte, filename string) error {
gz := gzip.NewWriter(w)
defer gz.Close()
gz.Header.Name = filename
gz.Header.ModTime = time.Now()
gz.Header.Comment = "Generated by Go"
_, err := gz.Write(data)
return err
}
自定义 Name 字段后,解压工具(如 Linux 的 gunzip)会自动恢复原始文件名,提升用户体验。
小结
Go 标准库已经提供了常见压缩和归档能力。gzip 负责压缩单个数据流,tar 负责把多个文件归档,zip 同时处理归档和压缩。它们都围绕 io.Reader 和 io.Writer 设计,可以自然组合。
处理压缩文件时,要记得关闭 writer,给错误加上下文,面对不可信输入时限制解压大小和文件路径。选择压缩级别时权衡 CPU 和带宽。批量压缩时注意并发控制和文件句柄管理。只要把数据流想清楚,Go 写这类后台工具会很顺手。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。