Go 压缩和归档入门:gzip、tar 和 zip 的基本用法与实战

本文详解 Go 标准库中 compress/gzip、archive/tar 和 archive/zip 的使用方式,涵盖日志压缩、文件打包、HTTP 响应流式输出等实战场景,附带性能对比和常见错误排查。

压缩和归档是很多后台工具的基础能力

服务端程序经常需要处理文件:日志按天压缩,报表打包下载,备份目录归档,上传的 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 写这类后台工具会很顺手。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练