Go 图片压缩为什么吃掉 200MB 内存?一次从标准库到 bimg 的优化实录
最近在用 Go + Gin 写一个服务端图片压缩接口,功能跑通了,结果一上压测就傻眼:一张 20MB 的图片,处理过程内存峰值居然冲到 200MB 以上。
几个并发直接把容器干 OOM 了。
这篇就记录下我怎么一步步把它从 200MB 砍到 40MB 以内的。
一开始的写法,错在哪?
最初的接口设计图省事,前端把图片转成 base64 塞进 JSON body,后端解析 base64 还原成文件,再用标准库 image/jpeg 压缩,最后返回下载链接。
代码大概长这样:
func compress(c *gin.Context) {
var req struct {
Image string `json:"image"` // 整张图的 base64
}
c.BindJSON(&req)
// 坑 1:整段 base64 一次性解码进内存
raw, _ := base64.StdEncoding.DecodeString(req.Image)
// 坑 2:标准库解码会把整张图解成未压缩位图
img, _, _ := image.Decode(bytes.NewReader(raw))
out, _ := os.Create("out.jpg")
defer out.Close()
jpeg.Encode(out, img, &jpeg.Options{Quality: 80})
c.JSON(200, gin.H{"url": "/out.jpg"})
}看着没毛病,对吧?我一开始也这么觉得。
直到 pprof 把真相摆我面前。
用 pprof 把内存吃在哪看清楚
光猜没用,得测。我在服务里挂了 net/http/pprof,再配一个进程级的内存采样,单请求跑一张 20MB 的图,盯着 heap profile 看。
import _ "net/http/pprof"
func main() {
go http.ListenAndServe("localhost:6060", nil) // pprof 端口
// ... gin 路由
}抓一次堆快照:
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap结果很直观,内存几乎全卡在两个地方。
第一处是 base64 解码。
JSON 里的 base64 字符串本身就比原图大约 33%,一张 20MB 的图,body 文本就有 27MB 左右。
DecodeString 又复制出一份 20MB 的字节切片。
光这一步,两份数据躺在内存里。
第二处更狠,是 image.Decode。
标准库解码 JPEG 时,会把压缩过的图还原成 RGBA 未压缩位图。
一张 4000×3000 的图,位图大小是 4000 × 3000 × 4 = 48MB,跟它本身压没压缩半毛钱关系。
再加上编码时的中间缓冲,几个 40MB 叠一块儿,200MB 就是这么来的。
说白了,问题压根不是压缩算法慢,是数据在内存里被反复复制 + 解成了巨大的位图。
解决思路:别让整张图躺内存里
定位清楚后,优化方向就两条。
第一,干掉 base64,改用 multipart 文件流。
文件上传走 multipart/form-data,Gin 能直接拿到一个流,边读边写到临时文件,内存里永远只留一小段缓冲。
func upload(c *gin.Context) {
file, _ := c.FormFile("image")
// SaveUploadedFile 内部是 io.Copy,按 32KB 缓冲流式落盘
dst := filepath.Join(os.TempDir(), file.Filename)
c.SaveUploadedFile(file, dst)
// 后续对 dst 这个临时文件做压缩
}这一步下来,27MB 的 base64 文本和那份解码副本就全没了。
如果你确实是需要传输 base64,也可以用流式解析的方式,具体可以让 AI 帮你实现,流式解析可以极大降低内存占用。
第二,换掉标准库,用 bimg。
标准库那套"解码成位图再编码"的路子,内存天然下不来。
我对比了几个库,最后选了 bimg,它底层是 C 写的 libvips。
libvips 用的是流式 + 分块处理,不会把整张图一次性解成位图,所以内存占用低得离谱。
import "github.com/h2non/bimg"
func compressFile(path string) error {
buf, err := bimg.Read(path) // 读临时文件
if err != nil {
return err
}
newImg, err := bimg.NewImage(buf).Process(bimg.Options{
Quality: 80,
Type: bimg.JPEG,
})
if err != nil {
return err
}
return bimg.Write("out.jpg", newImg)
}改完再压测,同样那张 20MB 的图,单请求内存峰值掉到 40MB 以内,差不多是原来的五分之一。并发跑起来容器也稳了。
数据对比
| 方案 | 20MB 图片单请求内存峰值 | 关键问题 |
|---|---|---|
| base64 + image/jpeg 标准库 | 200MB+ | 整体解码 + 位图膨胀 |
| multipart 流式 + 标准库 | 120MB 左右 | 位图膨胀还在 |
| multipart 流式 + bimg | ≤ 40MB | 基本没有大块复制 |
两步优化里,换 bimg 是主力,流式上传是辅助。
两个一起上效果最好。
bimg 怎么装?这步劝退了不少人
bimg 不是纯 Go 库,它依赖 libvips,所以必须开 CGO,还得在机器上装好 vips。
macOS 上直接:
brew install vipsUbuntu/Debian:
apt-get install -y libvips-devlinux:
dnf install -y libvips-dev这里简化了 vips 的安装,实际安装还挺麻烦,请自行找安装教程或咨询 AI。
编译时记得开 CGO(默认就是开的,但 Docker 多阶段构建里容易忘):
CGO_ENABLED=1 go build .我第一次在 Alpine 镜像里构建踩了个坑:Alpine 用的是 musl libc,装 vips 还得 apk add vips-dev,而且最终运行镜像里也要带上 vips 运行时库,不然启动直接报 error while loading shared libraries: libvips.so。
我是在宿主机编译,复制到容器内执行才会出现,建议直接用 docker 进行编译
折腾了快一个小时才整明白。
如果你用 Docker,建议直接找带 vips 的基础镜像省事。
常见问题
bimg 一定比标准库快很多吗?
不一定快"很多",但内存优势是碾压级的。
bimg 底层 libvips 处理大图时内存占用通常只有 ImageMagick 或标准库的几分之一。
速度上,小图差距不明显,图越大、批量越多,libvips 的流式优势越突出。
不想引入 CGO 和 vips,有纯 Go 方案吗?
有,但要权衡。
纯 Go 的库比如 disintegration/imaging、nfnt/resize 部署简单,可内存模型跟标准库一样,大图照样会解成位图。
如果你的图普遍不大(几百 KB 以内),纯 Go 够用;一旦要处理几十 MB 的大图,bimg 这类基于 libvips 的方案更稳。
base64 上传就一定不行吗?
也不是绝对不行。
如果图都很小(比如头像、缩略图,几十 KB),base64 那点开销无所谓,前端集成还方便。后端解析采用流式解析一样可以减少内存占用。
临时文件会不会堆满磁盘?
会,所以一定要清。
压缩完该删的删,可以用 defer os.Remove(tmpPath),或者跑个定时任务清理 os.TempDir() 下的过期文件。
我线上是处理完立刻删源文件,压缩结果走对象存储,本地不留。
写在最后
整件事的核心其实就一句话:别让一张完整的大图以"未压缩"的形态在内存里待着。
base64 整体解码和标准库的位图膨胀,是两个最容易踩的坑。
换成流式上传加 libvips,内存问题基本就解决了。
如果你也在做服务端图片处理,或者对 bimg、libvips 的配置有疑问,欢迎在评论区交流~~~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/go-image-compression-memory-optimization/
备用原文链接: https://blog.fiveyoboy.com/articles/go-image-compression-memory-optimization/