直接用zip.Writer+http.ResponseWriter高并发下载易崩溃,因未解耦打包与HTTP响应:内存峰值达GB级、fd超限、连接池打满;应改用预生成临时ZIP+流式ServeContent,并限制文件数、缓冲大小及路径安全校验。

直接用 http.ServeFile 或 zip.Writer 在高并发下打包并响应下载,大概率触发 OOM、文件描述符耗尽或 HTTP 超时——因为默认没做流控、内存复用和连接复用。
为什么 zip.Writer + http.ResponseWriter 并发一高就崩
每个请求都新建一个 zip.Writer 并遍历文件列表、读取内容、写入响应体,看似简单,实则三重隐患:
- 内存:若打包含上百个文件,每个文件读进内存再压缩,峰值内存可能达 GB 级;
io.Copy不加缓冲时还会频繁小块分配 - 文件句柄:
os.Open每个源文件都占一个 fd,100 并发 × 50 文件 = 5000+ fd,远超 Linux 默认 1024 限制 - HTTP 连接:未配置
http.Transport的 client(如用于校验或回调)会快速打满空闲连接池,导致后续请求卡在dial tcp
根本问题不是 zip 本身慢,而是没把「打包」从「HTTP 响应生命周期」里解耦出来。
用临时文件 + 流式 http.ServeContent 替代内存打包
不把整个 zip 加载进内存,也不让 goroutine 长时间阻塞在 WriteHeader 后;改用预生成 + 流式服务,既可控又可缓存:
立即学习“go语言免费学习笔记(深入)”;
- 收到打包请求后,先生成唯一任务 ID(如
pack_20260615_xxx),异步写入临时目录(如/tmp/packs/pack_20260615_xxx.zip) - 用
os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)创建文件,再套zip.NewWriter(bufio.NewWriterSize(f, 1,避免小 write syscall - 每个待打包文件用
os.Open→io.CopyN(zw, f, size)流式读写,读完立刻f.Close()释放 fd - 写完关闭
zw.Close()和文件句柄,再用http.ServeContent提供下载:它自动处理Range、If-None-Match、Content-Length,且不缓冲整个文件
示例关键片段:
func servePack(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
path := fmt.Sprintf("/tmp/packs/%s.zip", id)
f, err := os.Open(path)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer f.Close()
<pre class="brush:php;toolbar:false;">fi, _ := f.Stat()
http.ServeContent(w, r, id+".zip", fi.ModTime(), f)}
并发控制必须落在「打包任务调度层」,而非 HTTP handler
HTTP handler 只负责查状态、返回文件或 302 重定向;真正耗资源的打包动作要排队、限速、可取消:
- 用带缓冲 channel 模拟信号量:
packSem := make(chan struct{}, 5),每启动一个打包 goroutine 前packSem - 任务入队后立即返回 202 Accepted + Location: /status?id=xxx,前端轮询或长连接监听完成事件
- 打包失败时,记录错误到结构体(如
{ID, Err: "open xxx: permission denied"}),不 panic,不阻塞其他任务 - 临时文件加 TTL:用
filepath.Join(os.TempDir(), "packs"),并配 cron 清理 >24h 未访问的 .zip
别在 handler 里 time.Sleep 等打包完成——用户等不起,连接早断了。
大目录打包前务必校验路径与权限,防遍历与越权
用户传来的路径(如 dir=/var/log)若不做净化,filepath.WalkDir 可能递归扫出敏感文件,甚至触发 open /proc/self/mem: permission denied 导致 panic:
- 用
filepath.Clean+ 白名单根目录比对:if !strings.HasPrefix(cleaned, allowRoot) { http.Error(w, "forbidden", http.StatusForbidden); return } - 跳过符号链接:
if info.Type()&os.ModeSymlink != 0 { continue } - 单文件大小加硬限制:
if info.Size() > 100 (跳过 >100MB 的文件,防 zip bomb) - 总文件数也设上限:
if fileCount > 10000 { break },避免无限遍历
最易被忽略的是:zip 文件内路径可被恶意构造为 ../../../etc/passwd,必须用 zip.FileHeader.Name 做二次 sanitize,只保留相对路径。


















