博客全站资源优化实录: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-IP 和 X-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 的几条设计原则:
- 压缩永不导致上传失败——任何解析/压缩异常都返回原始字节兜底;
- 返回真实扩展名——PNG 照片转 JPEG 后扩展名要跟着变,文件名必须用返回的 ext 拼接;
- 长边超 1920 才缩,用 LANCZOS 插值,观感无损;
- 顺手抹掉 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 秒且不阻塞边播。
小结
- 优化前先实测(curl 测速 + F12 看单请求体积),别凭感觉猜瓶颈;
- 传输层的便宜要先捡:静态直发、关缓冲、正确传头,这些是零成本纯收益;
- 媒体是体积大头,PNG 存照片是最常见的"格式用错"事故,判透明要看 alpha 实际取值而不是看模式名;
- 动视频一定要
+faststart,小水管上它比压缩率更重要; - 批量压缩救存量,上传压缩防增量,两个都得有;
- 改完记得强刷(Ctrl+F5)——
expires 7d的缓存会让老访客继续看旧图,别误以为优化没生效。
评论区
共 0 条