⌨ 技术

博客全站资源优化实录:Nginx、媒体压缩与 191MB→64MB

现象:博客部署到服务器后,主页图片、博客列表封面、"动态"里的视频全都加载得特别慢,一张图要等好几秒,视频更是要点开等半分钟。 结论先行:不是 Nginx 配错了,是 5Mbps 的小水管 + 全站 191MB 未压缩的原图。两层问题叠一起,每一层都在帮倒忙。

一、排查:先测速,别猜

遇到"加载慢",第一步是把锅找准。F12 打开 Network 面板能看到每个请求的耗时和体积,但带宽到底是不是瓶颈,直接用 curl 从服务器拉一个大文件最直观:

curl -o NUL -w "SPEED=%{speed_download} B/s | TOTAL=%{time_total}s" \
  http://minthats.cn/uploads/moments/img_xxx.png

实测结果:下载速度 632 KB/s ≈ 5.4 Mbps——这就是服务器的全部带宽。一张 5.5MB 的 PNG 要独占管道 9 秒;首页十几张图排队共享这 632KB/s,每张都得等;这时候再有人点开一个 53MB 的视频,它会把水管独占 85 秒,期间全站所有图片集体卡死。

再盘点一下媒体文件的体积,问题就很清楚了:

类型 现状 问题
动态图片 4-6MB 的 PNG 手机照片存成 PNG,格式本身就是错的
文章封面 2-4MB 原图 jpg 上传时没有任何压缩
主页背景图 20 张 × 400-900KB 没压缩
动态视频 53MB / 26MB / 26MB 11 秒的视频 53MB,码率离谱

整个 uploads + static 加起来 191MB。5Mbps 的带宽配 191MB 的未压缩媒体,怎么都是慢的。

二、传输层:让 Nginx 干它该干的活

2.1 别用 Flask 自带服务器跑生产

app.run() 是开发服务器,单进程。生产上换 Gunicorn:

gunicorn -w 2 --threads 4 -b 127.0.0.1:5000 run:app

有个容易被忽略的坑:worker 数不能开大。博客用的是 SQLite,多进程并发写会出现 database is locked。所以用 2 个进程 + 4 个线程的组合——并发靠线程补,进程数压住,SQLite 才不会锁库。

2.2 静态资源直接由 Nginx 发

音乐、图片、上传文件原本全部经过 Python(Flask 的 send_file),每个下载都占着 gunicorn 的线程。改成 Nginx 直接发:

location /static/ {
    alias /www/wwwroot/minthats/static/;
    expires 7d;
    add_header X-Content-Type-Options nosniff;
}
location /uploads/ {
    alias /www/wwwroot/minthats/uploads/;
    expires 7d;
    add_header X-Content-Type-Options nosniff;
}
location /music-stream/ {
    alias /www/wwwroot/minthats/music/;
    expires 7d;
}

Nginx 发静态文件原生支持 HTTP Range,音乐进度条拖动秒响应,而且完全不过 Python 进程。

2.3 反代配置里藏着的三个坑

面板自动生成的反代配置能用,但有三个点必须手动改:

坑一:Host 头被写死。 模板里是 proxy_set_header Host 127.0.0.1:$server_port;,导致 Flask 收到的 Host 永远是 127.0.0.1:5000。而我的 sitemap 是用 request.url_root 动态生成域名的——提交给搜索引擎的 sitemap 全变成了 http://127.0.0.1:5000/xxx,SEO 直接报废。必须改成 proxy_set_header Host $host;

坑二:SSE 流式响应被缓冲。 AI 聊天用的是 SSE(Server-Sent Events),Nginx 默认开启 proxy_buffering,会把后端的流式输出攒到一起再发给浏览器——用户看到的现象就是"回答憋到最后一次性蹦出来"。要给这个接口单独关缓冲:

location /api/chat/stream {
    proxy_pass http://127.0.0.1:5000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 300s;
}

坑三:真实 IP 与 HTTPS 协议头。 访问统计、评论 IP、限流全靠取真实访客 IP,所以每一个反代 location 都要带 X-Real-IPX-Forwarded-For,漏一个那个通道的统计就全变成 127.0.0.1。上了 HTTPS 之后还要传 X-Forwarded-Proto $scheme;,Flask 才知道请求走的是加密连接。

顺带一提,取 IP 的逻辑在应用层也要防一手:只在「TCP 直连对端是内网/回环地址」时才信任 X-Real-IP 头,公网直连一律取对端真实地址——不然任何人伪造一个请求头就能污染统计、绕过限流。

三、媒体瘦身:191MB → 64MB

3.1 图片压缩策略(观感优先)

输入 处理 效果
JPEG 原图 重压 q85 渐进式 + 长边限 1920 6MB → 300KB 左右
无透明通道的 PNG 转 JPEG(文件名不变) 5.5MB → 365KB(-89%)
有真透明通道的 PNG 保留 PNG,仅 optimize 透明必须保,只能小赚
GIF / APNG 动图 跳过不碰 动画帧不能丢

q85 是"观感优先"的甜点位:肉眼基本看不出差别,体积却能砍一个数量级。

3.2 一个隐蔽的坑:假透明 PNG

第一轮压缩跑完,发现几张照片 PNG 只降了 40%,远远达不到预期。检查 alpha 通道才发现:这些图片是 RGBA 模式但 alpha 全是 255(完全不透明)——拍照软件把照片存成了带 alpha 通道的 PNG,实际上透明通道一个像素都没用上。

如果只看 mode == 'RGBA' 就判定"有透明要保留 PNG",就会把大量照片锁死在 PNG 格式里。正确的判定要看 alpha 的实际取值:

if img.mode in ('RGBA', 'LA'):
    amin, _ = img.getchannel('A').getextrema()
    has_alpha = amin < 255        # alpha 全 255 = 假透明,放心转 JPEG
else:
    has_alpha = img.mode == 'P' and 'transparency' in img.info

区分之后这些假透明 PNG 全部转 JPEG,压缩率直接从 -40% 跳到 -89%。而落地页的抠图人像、精灵图素材是真透明(alpha 从 0 到 255 都有),必须保留 PNG。

3.3 为什么文件名不能变

图片的 URL 存在数据库里(动态媒体表、文章封面字段),如果压缩时顺手把 img_xxx.png 改成 img_xxx.jpg,就得连数据库一起迁移。更稳的做法是:内容转成 JPEG,文件名保持 xxx.png 不变。浏览器渲染 <img> 时是按文件内容解码的,不看扩展名和 Content-Type,所以 URL 零改动、数据库零改动,覆盖文件即生效。新上传的图片则直接存成真正的 .jpg,以后不会再产生这种"名不副实"的文件。

3.4 视频压缩与 faststart

三个视频用 ffmpeg 重编码:

ffmpeg -i in.mp4 -c:v libx264 -preset medium -crf 26 \
  -vf "scale=w='min(iw,1920)':h=-2" -pix_fmt yuv420p \
  -c:a aac -b:a 96k -movflags +faststart out.mp4

效果:53MB → 8MB、26.9MB → 6.9MB × 2,1080p 60fps 的画质保留。

这里最关键的是 +faststart:MP4 的索引(moov atom)默认在文件末尾,浏览器必须下完整首歌……不对,是下完整个文件才能开始播放;faststart 把索引挪到文件开头,实现真正的边下边播。对于小带宽服务器,这个参数比压缩率本身还重要。

3.5 另一个坑:JPEG 的二次压缩

第一轮跑完发现问题修了 alpha 判定逻辑后要重跑,但已经压过的 JPEG 不能再过一遍 q85——JPEG 是有损压缩,每压一次都会累积画质损失。所以重跑时加了个 --png-only 开关跳过所有 JPEG,只处理 PNG。

四、治本:上传时自动压缩

批量压缩解决的是存量,防止复发要靠上传环节。给上传接口加了一层压缩:

data, ext = compress_image(file)   # 失败时返回原始字节,绝不阻断上传
name = 'cover_' + uuid.uuid4().hex + ext   # 扩展名用返回值

compress_image 的几条设计原则:

  1. 压缩永不导致上传失败——任何解析/压缩异常都返回原始字节兜底;
  2. 返回真实扩展名——PNG 照片转 JPEG 后扩展名要跟着变,文件名必须用返回的 ext 拼接;
  3. 长边超 1920 才缩,用 LANCZOS 插值,观感无损;
  4. 顺手抹掉 EXIF——不只是省体积,照片里的 GPS 定位信息也一起清掉了,隐私上很有必要。

五、效果

之前 之后
uploads/ 155.9MB 40.1MB
static/images/ ~35.6MB 23.9MB
全站媒体合计 ~191MB ~64MB
最大单图 6.2MB 365KB
最大视频 53MB(下完才能播) 8MB(边下边播)

同一张 5MB 的图,压缩后 365KB,在 632KB/s 的带宽下 0.6 秒拉完;视频从"独占管道 85 秒"变成 13 秒且不阻塞边播。

小结

  1. 优化前先实测(curl 测速 + F12 看单请求体积),别凭感觉猜瓶颈;
  2. 传输层的便宜要先捡:静态直发、关缓冲、正确传头,这些是零成本纯收益;
  3. 媒体是体积大头,PNG 存照片是最常见的"格式用错"事故,判透明要看 alpha 实际取值而不是看模式名;
  4. 动视频一定要 +faststart,小水管上它比压缩率更重要;
  5. 批量压缩救存量,上传压缩防增量,两个都得有;
  6. 改完记得强刷(Ctrl+F5)——expires 7d 的缓存会让老访客继续看旧图,别误以为优化没生效。

评论区

共 0 条
🍃 还没有评论,沙发虚位以待~
🚇

穿越虫洞?

虫洞会把你随机传送到另一位陌生博主的网站——
可能是技术大佬,也可能是诗歌爱好者,开盲盒的时间到了。

森林精灵