图片仓库瘦身:尺寸、格式与批量管线

把原图压在硬盘里,是把成本推给了以后的自己

写这篇文章的起因很简单:一台跑了五年的图片服务器报磁盘 92%,上去一看,/home/wwwroot/uploads 下面躺着 38 万张文件,总容量 210GB。其中 80% 是当年直接上传的原图——单反拍的、手机拍的、截图工具截的,平均每张 2MB 到 5MB,而站上实际展示的最宽尺寸只有 1200px。

这就是个人站图片存储最典型的形态:上传时"点了上传就完事",展示时靠 CSS 把 4000px 宽的图缩成 600px 框,浏览器照样要把几兆的字节下载下来再丢掉。等到几年后磁盘告急,才发现整个仓库没有统一的尺寸、没有统一的格式、也没有能批量重建的元数据。

这篇文章讲的不是"用某某工具压一下就完事",而是一套可以长期维护的图片处理管线:文件怎么组织、尺寸按什么定、格式怎么选、元数据要留哪些、批量处理脚本怎么写、怎么让旧图也能被逐步收口。前提假设是:你有一台自己的 Linux 服务器,图片存在本地磁盘或挂载的对象存储上,前端是 Nginx 或 Apache。不涉及任何付费服务。

一、先给现有仓库做一次体检

不要先动手压,先量。没有数字的优化,最后都会变成"感觉快了一点"这种没意义的结论。三个数字必须知道:总容量、尺寸分布、格式分布。

cd /home/wwwroot/uploads
# 1. 总容量与文件数
du -sh . ; find . -type f | wc -l

# 2. 格式分布(按容量排序)
find . -type f -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \
  -o -iname '*.gif' -o -iname '*.webp' | sed 's/.*\.//' | tr 'A-Z' 'a-z' \
  | sort | uniq -c | sort -rn

# 3. 找出 1MB 以上的"肥图"
find . -type f -size +1024k -exec ls -l {} \; | awk '{print $5, $9}' | sort -rn | head -30

# 4. 尺寸分布(需要 imagemagick)
find . -type f \( -iname '*.jpg' -o -iname '*.png' \) -print0 \
  | xargs -0 -P 4 identify -format '%w %h ' 2>/dev/null \
  | awk '{print $1}' | sort -n | uniq -c | sort -rn | head -20

第四步是关键。你会看到两类极端:一类是 8000px 以上的超大原图,一类是 100px 以下的小图标,中间正好是"可以统一"的地带。如果尺寸分布散成几十种,说明历史上有过多次换编辑器、换主题、换截图工具,这种仓库只能靠"按展示位重新出图"来收口,而不是逐张压缩。

再补一个成本估算:假设你要压的那部分有 200GB,用一台 2 核的机器跑,JPEG 重编码速度大约每秒 3 到 6 张(取决于尺寸),38 万张要跑十几到二十个小时。所以这件事必须能中断、能续跑,不能写成"一次跑完"的脚本。

二、尺寸按展示位定,不按原图定

最常见的错误是"统一压到 1920px 宽"。但一个内容站真正需要的宽度,取决于页面上实际要它多大,乘以设备像素比。计一遍就清楚了:正文区在 1440px 桌面下宽 780px,在 1920px 显示器上宽 860px 封顶;手机端 390px 宽、像素比 3,物理宽度 1170px。所以正文图的实际上限就是 1200px 左右,再大就是浪费。

我一般按四个档位出图,命名规则固定在文件名里:

  • -thumb:320px 宽,列表页、侧栏、搜索结果缩略图
  • -medium:800px 宽,正文内嵌图、手机横屏
  • -large:1200px 宽,正文主图、桌面端
  • -2x:2400px 宽,只为高像素比设备准备,且只在必要处提供

注意 -2x 不要无脑加到所有图。一张 2400px 宽的高质量 JPEG 通常 400KB 到 700KB,在手机流量下代价不小。我的做法是只在"文章封面 + 首屏图片"提供 2x,正文中部的图直接给 1200px,因为没有人会为了看清一张配图去放大。

还有两个跟展示位直接相关的细节容易忽略。第一,<img> 上必须写 width 和 height 属性,值是图片真实像素的宽高比换算后的值,浏览器靠它提前占位,否则图片加载完会把正文顶下去,造成布局抖动。第二,同一条内容在列表页和详情页用的是不同尺寸,别偷懒让列表页直接引用 -large,一个 40 条列表的页面因此多下载几十兆是常事。

三、格式选择:留在 JPEG/PNG,还是全量转 WebP

2026 年的答案比三年前清晰得多。JPEG 对照片类内容仍然是"通用性最好的压缩格式",WebP 在同等视觉质量下通常再省 25% 到 35%,PNG 只适合"必须无损、必须带透明"的场景。

我的实际选择是:源文件全部保留(不删原图,见第六节),分发文件按类型分流。

  • 照片、截图、带渐变的大图:转 WebP,质量 78 到 82。再往上加质量,字节数涨得比画质快,肉眼看不出来。
  • 需要透明通道的小图标、logo:保留 PNG,但要跑一遍有损量化(pngquant --quality=70-85),通常能省 60% 到 70%,而且不影响 alpha。
  • 线框图、纯色块截图:PNG 的量化效果最好,别硬转 WebP,有时反而更大。
  • 动图:GIF 一律转 WebP 动图或直接改成视频,一个 5MB 的 GIF 转成 WebP 通常 800KB 以内。

批量转换用 cwebp 就够,不需要上任何图形界面:

# 单张:质量 80,保留元数据以外全部信息
cwebp -q 80 -m 6 -mt in.jpg -o out.webp

# 批量:只处理 1MB 以上的 JPEG,输出同名 .webp
find . -type f -iname '*.jpg' -size +1024k -print0 \
  | xargs -0 -P 4 -I{} sh -c 'cwebp -q 80 -m 6 -quiet "{}" -o "${1%.*}.webp"' _ {}

# 看效果:抽样 20 张比一下总体积
find . -name '*.webp' | head -20 | xargs du -cb | tail -1

-m 6 是最慢但压缩率最好的搜索档位,对服务器上离线批处理完全可以接受;-mt 开多线程;-P 4 让 xargs 并行四个进程,和机器核数对齐即可,别开太大,磁盘 IO 会先成为瓶颈。

关于 AVIF:压缩率确实比 WebP 再好 20% 左右,但编码速度慢一个数量级(同样一张图 WebP 要 30 毫秒,AVIF 要 1.5 秒),38 万张图跑下来就是天级。而且老版本浏览器的兼容处理要靠 <picture> 多源声明,模板改动量不小。我的建议是:新项目可以上,存量仓库先老老实实做 WebP,跑完这一轮再看要不要二次升级。

四、WebP 的分发:三条路,只有一条不出事

转出 WebP 之后,怎么让浏览器拿到它,有三个层次的做法,踩坑的难度递增。

第一条路,模板里写 <picture>。 最稳,也最可控:

<picture>
  <source srcset="/uploads/2026/11/photo-large.webp" type="image/webp">
  <img src="/uploads/2026/11/photo-large.jpg" alt="示例" width="1200" height="800" loading="lazy">
</picture>

不支持 WebP 的浏览器会跳过 source,直接走 img 的 JPEG。代价是每张图都要有 JPEG 兜底,磁盘上是两份。

第二条路,Nginx 按 Accept 头自动改写。 用 map 判断浏览器是否支持 WebP,命中就把后缀换掉。麻烦在于它和缓存强相关:CDN 或反向代理如果按 URL 缓存而不看 Vary: Accept,第一个访问者拿到什么格式,后面所有人就都拿到这个格式。如果第一个是支持 WebP 的浏览器,那么后来的老 Safari 会收到一个它解不了的 WebP,页面直接显示破图。要用这条路,必须在响应头里下发 Vary: Accept,并且确认 CDN 的缓存键包含该头——这两件事在租来的 CDN 上经常做不到。

第三条路,内容协商模块。 部分发行版带 ngx_http_image_filter_module 或者第三方的 WebP 协商模块,能在请求时实时转码。实时转码会消耗 CPU,在个人站上跑几十个并发就会把机器压满,而且转出来的结果不可缓存到磁盘时等于每次访问都要算一遍。这条路通常只在"图片量巨大、存不下多份"时才值得。

我的实际选择是第一条路。理由很朴素:多存一份 JPEG 的成本是磁盘,磁盘现在很便宜;而出一次"破图事故"的成本是当天的流量和读者的信任。模板里多写一个 source 标签,是这三条路里唯一不需要担心缓存键、不需要担心 CDN 行为的做法。

五、移除元数据与颜色配置,能白捡 5% 到 15%

手机拍的照片里藏着 GPS 坐标、机型、拍摄参数,某些编辑软件还会塞进几百 KB 的缩略图预览和不带压缩的 ICC 配置文件。这些对网页展示毫无用处,却是实打实的字节。

# 看一张图里有什么
identify -verbose photo.jpg | head -40
exiftool photo.jpg | head -30

# 两条命令:去掉全部元数据 + 转 sRGB
mogrify -strip -colorspace sRGB photo.jpg

# 或者用 jpegtran 无损处理,不重编码画质不掉
jpegtran -copy none -optimize -progressive in.jpg > out.jpg

# PNG 无损优化
optipng -o5 -strip all image.png
pngquant --quality=70-85 --strip --force --output out.png image.png

jpegtran 是无损的:它不重新做 DCT 变换,只重排熵编码、去掉元数据,所以画质绝对不变,通常能省 3% 到 10%。mogrify -strip 会重新编码,画质有极小损失,但能顺手把不是 sRGB 的图片矫正过来——这一点很重要,很多从设计软件导出的图带的是 Adobe RGB,在浏览器里会显得偏灰偏暗,用户会以为你的图有问题。

一个容易忽略的坑:-strip 会连 ICC 一起删掉。如果原图是 Adobe RGB 而你没做 -colorspace sRGB 转换就直接删 ICC,颜色会明显变淡。所以顺序必须是"先转 sRGB,再 strip",不能反过来。

六、源文件不删:EXIF 里的拍摄时间是重建的唯一线索

很多人第一步就是"压完把原图删了腾空间",这是我最不推荐的做法。原因有两条。

第一,压缩参数会过时。三年前 80 质量的 WebP 在当时看起来无可挑剔,但今天你可能想改成 AVIF,或者发现某个尺寸切错了。没有源文件,就只能拿着已经损失过一轮的图再压一轮,画质是累积损失的。

第二,EXIF 里的 DateTimeOriginal 是唯一可靠的"这张照片是什么时候拍的"的证据。mtime 会被 cp、rsync、解压、上传工具改掉,很多图库里 mtime 全是上传当天,等于没有时间信息。一旦原图被删,照片的时间线就永久错乱了。

如果磁盘实在紧张,我的折中方案是:把源文件挪到一块便宜的大盘或者挂载的对象存储上,用 rclone 同步,服务器上只留分发用的 -large 和 -medium。这样磁盘压力解决了,重建能力还在。同步命令长这样(只上行,覆盖不删远端):

rclone sync /home/wwwroot/source-images remote:zz1984-src --transfers 8 --checkers 16 --progress --exclude '*.tmp'

注意 rclone sync 默认会删除目标端多余的文件,如果你在同一块存储上还放别的东西,一定用 --filter 限定范围,或者换成 rclone copy。这条命令在写进 cron 之前,务必先加 --dry-run 跑一遍看它打算做什么。

七、让旧图逐步收口:按热度分批,而不是按时间

38 万张图不可能一天全部处理完,也没必要。真正影响体验的是"最近有人在看的图",而不是"2019 年发的一篇没人读的文章里的配图"。

我的分批依据是访问日志。从 Nginx access.log 里把图片请求按 URL 聚合,取最近 30 天请求量前 3000 的图片作为第一批:

# 取最近 30 天日志里被请求最多的图片路径
zcat -f /var/log/nginx/access.log* 2>/dev/null \
  | awk '$7 ~ /\.(jpg|jpeg|png|gif)$/ {print $7}' \
  | sed 's/?.*//' | sort | uniq -c | sort -rn | head -3000 \
  | awk '{print $2}' > /tmp/hot-images.txt

# 逐行处理,跳过已存在的 webp,可反复运行
while read -r p; do
  src="/home/wwwroot${p}"
  [ -f "$src" ] || continue
  out="${src%.*}.webp"
  [ -f "$out" ] && continue
  cwebp -q 80 -m 6 -quiet "$src" -o "$out"
done < /tmp/hot-images.txt

这个脚本的价值在于它天然幂等:[ -f "$out" ] && continue 让中断之后可以直接重跑,已经转好的不会重复处理。用 nohup 或者 systemd-run 挂到后台,跑完一轮再跑下一批(把 head -3000 改成 tail -n +3001 | head -3000)。

第二批按"最近 180 天内发布、且文章页有访问"筛选,第三批才是全量。实际经验是:跑完前两批,磁盘占用能降 40% 到 50%,而用户感知到的加载速度变化已经基本到位。第三批可以拖到下次磁盘告急再说。

八、四个踩过的坑

坑一:把透明 PNG 转成 JPEG 变成黑底。 批量脚本里如果按"图片就转 JPEG"的粗暴规则跑,带 alpha 的 logo 和图标会全部变成黑底或白底方块。判据很简单:identify -format '%A' 输出 Blend 或 True 就说明有透明,这类必须保留 PNG 或者转 WebP(WebP 支持 alpha)。

坑二:CMYK 图片在浏览器里颜色全反。 印刷来源的 JPEG 是 CMYK 色彩空间,浏览器不支持,会显示成一片诡异的负片色。批量处理时顺手 identify -format '%[colorspace]' 检一遍,CMYK 的一律 -colorspace sRGB 转过来。这个坑在"从印刷厂素材里挑图"的站长身上命中率很高。

坑三:并行开太多把 IO 打满,站点卡住。 在生产和站点同机的环境里跑批处理,xargs -P 16 会把磁盘读写占满,MySQL 的查询延迟立刻飙升。正确做法是 -P 不超过核数的一半,并且用 ionice -c 3 -p $$ 把整个进程降成空闲优先级。

坑四:文件名带空格或中文,脚本直接断。 find ... | xargs 遇到空格就断了,必须用 -print0 配 xargs -0,或者在 while read 的写法里改用 IFS= read -r。中文文件名还有另一个问题:转出来的 .webp 在上传回某些对象存储时,需要 URL 编码,否则 404。

九、小结:图片管线的价值在于"能重跑"

回头看这次收拾 210GB 的过程,真正省下 90GB 的不是某一次压缩,而是三件事:尺寸按展示位定死了、分发格式统一成 WebP 加 JPEG 兜底、处理脚本写成了幂等可以随时重跑。

前两件事决定了"以后新上传的图不会再把问题攒起来",第三件事决定了"以前的图可以慢慢收"。没有第三件,任何一次"全量压缩"都会因为中途出错而半途而废——38 万张图里断在第 20 万张,你既不知道哪些处理过,也不敢重跑。

如果只让我记一句话:图片优化的目标不是"把文件压小",而是"建立一条任何时候都能重来一遍的管道"。磁盘会满、格式会过时、CDN 的规则会变,只有可以重跑的脚本和没被删掉的源文件,才让下一次调整的成本可控。

Last modification:November 7th, 2026 at 09:29 pm

Leave a Comment