起点
一张图片,8.7 MB
2024 年 6 月的一个晚上,我上线了我的摄影社区——“光影”。
这是一个很简单的内容平台:摄影师上传作品,其他用户浏览、点赞、收藏。我用了一周时间开发,技术栈也很朴素:
# 最简实现:用户上传图片,直接存到服务器磁盘
from flask import Flask, request, jsonify
import os
import uuid
app = Flask(__name__)
UPLOAD_FOLDER = '/var/www/photos'
@app.route('/api/upload', methods=['POST'])
def upload_image():
file = request.files['image']
# 生成唯一文件名
filename = f"{uuid.uuid4().hex}.{file.filename.split('.')[-1]}"
filepath = os.path.join(UPLOAD_FOLDER, filename)
# 直接保存原图
file.save(filepath)
return jsonify({
'url': f'/photos/{filename}',
'status': 'ok'
})
@app.route('/photos/<filename>')
def serve_image(filename):
"""直接从磁盘读取图片返回"""
filepath = os.path.join(UPLOAD_FOLDER, filename)
return send_file(filepath)上线第一天,我自己上传了 3 张风景照试了试,感觉不错。
第二天,我的摄影师朋友小李上传了 5 张他用索尼 A7R5 拍的夜景——每张 8.7 MB,分辨率 6000×4000。
我点开其中一张,等了 12 秒才看到画面。
“这也太慢了吧?“小李在微信上跟我说,“我在图虫上传同样的照片,秒开。”
我知道问题出在哪里——8.7 MB 的原图,我的 5 Mbps 带宽服务器,传输就要十几秒。但我当时觉得不是什么大问题,用户不多嘛,忍忍就好。
第三天,小李把平台链接分享到了一个 3000 人的摄影群。
3000 人同时看一张 8.7 MB 的图片
那天晚上 8 点,我在吃外卖,手机突然弹出一条告警:
[2024-06-15 20:03:22] ALERT: Server CPU 98%
[2024-06-15 20:03:25] ALERT: Memory usage 89%
[2024-06-15 20:03:28] ALERT: Outbound bandwidth saturated: 5Mbps
[2024-06-15 20:03:31] ALERT: Nginx 502 Bad Gateway我放下筷子,打开电脑。
一群摄影师,正在同时浏览那些 8.7 MB 的图片。每个人点开一张,服务器就要传输 8.7 MB。3000 人同时点:
# 灾难现场的计算
concurrent_users = 3000
avg_image_size_mb = 8.7
total_bandwidth_needed = concurrent_users * avg_image_size_mb # = 26,100 MB ≈ 25.5 GB
# 我的服务器带宽
server_bandwidth_mbps = 5 # 5 Mbps
server_bandwidth_mbs = 5 / 8 # = 0.625 MB/s
# 理论上服务完这波请求需要的时间
time_to_serve = total_bandwidth_needed / server_bandwidth_mbs # = 41,760 秒 ≈ 11.6 小时11.6 小时。我的服务器需要将近 12 个小时才能把这一波图片传完。
实际上,Nginx 在第 3 分钟就 502 了。
灾后复盘
那天晚上,我在笔记本上写下了问题清单:
问题 1:图片太大
- 原图直出,没有任何压缩
- 一张 8.7 MB 的图片在网页上只需要显示 800px 宽
- 用户在手机上看,甚至只需要 400px
问题 2:没有缩略图
- 列表页直接加载原图
- 20 张图的列表页 = 174 MB
- 用户还没看到内容就跑了
问题 3:没有 CDN
- 所有用户都从我的单台服务器下载
- 北京的服务器,广州的用户延迟 60ms+
- 海外用户更惨,200ms+
问题 4:磁盘在尖叫
- 第一天 3 张图,25 MB
- 第二天 5 张图,43 MB
- 第三天 50 张图,400 MB
- 按这个速度,一个月后磁盘就满了
问题 5:没有内容审核
- 摄影师上传了什么?我不知道
- 如果有人上传违规内容,我是平台方,我要负责五个问题,每一个都可能让我这个刚上线的平台直接关门。
但说实话,我一点也不沮丧。因为我知道,这些问题本质上都是同一个问题的不同面——如何高效地存储、处理和分发图片。
我开始研究:别人是怎么做的
凌晨 2 点,我打开电脑,开始研究业界方案。
我先看了看图虫——小李说在那里上传照片秒开。我用浏览器开发者工具抓了一下:
# 图虫的图片加载策略
1. 列表页
- 加载缩略图:300x200, WebP 格式, 约 15 KB
- 懒加载:滚动到可视区域才加载
- 20 张图 × 15 KB = 300 KB,秒开
2. 详情页
- 先加载中等尺寸:1200x800, WebP 格式, 约 80 KB
- 用户点击"查看原图"时才加载全尺寸:6000x4000, JPEG, 约 5 MB
3. 图片 URL 策略
- 缩略图:https://cdn.tuchong.com/xxx_w300.webp
- 中等图:https://cdn.tuchong.com/xxx_w1200.webp
- 原图:https://cdn.tuchong.com/xxx_original.jpg
- URL 里直接带处理参数,按需生成然后我看了看小红书、微博、淘宝——策略都类似:
| 平台 | 列表缩略图 | 详情页大图 | 格式 | CDN |
|---|---|---|---|---|
| 图虫 | 300px, ~15KB | 1200px, ~80KB | WebP | ✅ |
| 小红书 | 200px, ~10KB | 800px, ~60KB | WebP/AVIF | ✅ |
| 微博 | 300px, ~20KB | 1000px, ~100KB | WebP | ✅ |
| 淘宝 | 250px, ~8KB | 800px, ~50KB | WebP | ✅ |
共同模式:
- 绝不直接展示原图——列表用缩略图,详情用中等尺寸,原图只在用户主动请求时提供
- 全部用 WebP 或 AVIF——同等质量下比 JPEG 小 30%~50%
- 全部走 CDN——用户就近访问,延迟降到 10ms 级
这三个策略,对应着图片系统的三个核心需求:
缩略图 + 格式转换 → 解决"图片太大"的问题(压缩优化)
多尺寸适配 → 解决"加载太慢"的问题(按需处理)
CDN 分发 → 解决"传输延迟"的问题(就近访问)我的第一个决定:先做缩略图
凌晨 3 点,我做了一个决定:先解决最紧急的问题——图片太大。
方案很简单:用户上传图片后,自动生成多个尺寸的缩略图。
from PIL import Image
import os
THUMBNAIL_SIZES = {
'small': (300, 200), # 列表页缩略图
'medium': (800, 600), # 详情页展示
'large': (1200, 900), # 大屏展示
}
def generate_thumbnails(image_path, output_dir):
"""为一张图片生成多个尺寸的缩略图"""
img = Image.open(image_path)
results = {}
for size_name, (max_width, max_height) in THUMBNAIL_SIZES.items():
# 保持宽高比缩放
img_resized = img.copy()
img_resized.thumbnail((max_width, max_height), Image.LANCZOS)
# 保存为 WebP 格式
output_path = os.path.join(
output_dir,
f"{os.path.splitext(os.path.basename(image_path))[0]}_{size_name}.webp"
)
img_resized.save(output_path, 'WebP', quality=80)
file_size = os.path.getsize(output_path)
results[size_name] = {
'path': output_path,
'size': f"{img_resized.width}x{img_resized.height}",
'file_size_kb': file_size / 1024,
}
return results
# 测试一下效果
result = generate_thumbnails('/var/www/photos/night_photo.jpg', '/var/www/photos/thumbs/')
# 输出:
# original: 6000x4000, 8700 KB
# small: 300x200, 12 KB ← 比 original 小 725 倍!
# medium: 800x533, 45 KB ← 比 original 小 193 倍
# large: 1200x800, 95 KB ← 比 original 小 92 倍725 倍。一张 8.7 MB 的原图,缩放到 300px 宽并转成 WebP 后,只有 12 KB。
我更新了上传接口:
@app.route('/api/upload', methods=['POST'])
def upload_image():
file = request.files['image']
# 保存原图
filename = f"{uuid.uuid4().hex}.jpg"
filepath = os.path.join(UPLOAD_FOLDER, filename)
file.save(filepath)
# 生成缩略图
thumbs = generate_thumbnails(filepath, os.path.join(UPLOAD_FOLDER, 'thumbs'))
return jsonify({
'original': f'/photos/{filename}',
'small': f'/photos/thumbs/{filename}_small.webp',
'medium': f'/photos/thumbs/{filename}_medium.webp',
'large': f'/photos/thumbs/{filename}_large.webp',
'status': 'ok'
})列表页改用 small 缩略图:
<!-- 之前:加载原图,每张 8.7 MB -->
<img src="/photos/abc123.jpg">
<!-- 现在:加载缩略图,每张 12 KB -->
<img src="/photos/thumbs/abc123_small.webp">效果立竿见影:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| 列表页 20 张图总大小 | 174 MB | 240 KB | 缩小 725 倍 |
| 首屏加载时间 | 12 秒 | 0.5 秒 | 快 24 倍 |
| 带宽消耗 | 174 MB/次 | 240 KB/次 | 节省 99.86% |
但这远远不够
缩略图解决了”图片太大”的问题,但还有四个问题没解决:
- CDN:用户还是从我的服务器下载图片,异地用户依然慢
- 存储:每张原图 + 3 个缩略图,磁盘消耗在加速
- 审核:用户上传了什么内容,我一无所知
- 高可用:服务器挂了,所有图片都不可访问
凌晨 4 点,我关上电脑,躺在床上。脑子里浮现出一张图片系统需要解决的问题全貌:
用户上传图片
│
├── 存到哪里? → 存储方案
├── 怎么压缩? → 压缩优化
├── 内容安全吗? → 内容审核
├── 怎么快速分发给用户? → CDN 加速
└── 成本怎么控制? → 成本优化每一个环节都值得深入研究。
我翻了个身,定了个早上 8 点的闹钟。明天开始,我要系统地解决这些问题。
我决定从这个方向开始研究:图片到底是什么?不同的图片格式有什么区别?
只有理解了图片的本质,才能做出正确的技术决策。
本节小结
✅ 我学到了什么:
- 绝不在网页上直接展示原图——列表用缩略图,详情用中等尺寸
- WebP 格式同等质量下比 JPEG 小 30%~50%
- 缩略图可以将图片大小缩小数百倍
⚠️ 还需要解决的问题:
- 图片格式选择(JPEG、PNG、WebP、AVIF 怎么选?)
- 图片存储方案(本地磁盘 vs 对象存储)
- CDN 加速原理和配置
- 内容审核方案
- 存储成本优化
🎯 下一步: 深入了解图片格式的基础知识,为后续的压缩优化和存储设计打下基础。
我的思考
思考 1
为什么同样是 300px 宽的缩略图,WebP 格式比 JPEG 小这么多?背后的压缩原理是什么?
WebP 和 JPEG 都是有损压缩,但 WebP 使用了更先进的压缩技术:
JPEG 的压缩方式:
- 将图片分成 8×8 的小块
- 对每个小块做离散余弦变换(DCT)
- 量化时丢弃高频细节(人眼不敏感的部分)
- 编码方式较老,效率有限
WebP 的改进:
- 使用帧内预测(从相邻已编码的块预测当前块,只编码残差)
- 支持自适应量化(平坦区域用低质量,纹理区域用高质量)
- 更高效的熵编码(算术编码 vs JPEG 的霍夫曼编码)
- 可选的无损压缩模式(JPEG 不支持)
实际效果对比(同等主观质量下):
一张 800×600 的风景照片:
JPEG quality=80: 85 KB
WebP quality=80: 52 KB ← 小 39%
AVIF quality=80: 38 KB ← 小 55%一句话总结:WebP 的压缩算法更聪明,它能更精准地判断”哪些数据可以扔掉而不被人眼察觉”。
思考 2
如果让你设计一个图片服务,你会为用户生成多少种尺寸的缩略图?为什么不是越多越好?
常见方案:3~5 种尺寸
// 推荐的尺寸配置
const THUMBNAIL_PRESETS = {
// 列表/网格页
thumb: { width: 200, quality: 70 }, // ~8 KB
// 卡片/预览
small: { width: 400, quality: 75 }, // ~20 KB
// 详情页展示
medium: { width: 800, quality: 80 }, // ~50 KB
// 大屏/桌面
large: { width: 1200, quality: 85 }, // ~90 KB
// 高清查看
xlarge: { width: 1920, quality: 85 }, // ~150 KB
};为什么不是越多越好?
- 存储成本:每多一种尺寸,存储量就增加一份。100 万张原图,5 种尺寸 = 600 万个文件。
100 万张图片 × (1 原图 + 5 缩略图) × 平均 50 KB = 300 GB
100 万张图片 × (1 原图 + 20 缩略图) × 平均 50 KB = 1.05 TB- 处理时间:每张图片需要生成 N 种尺寸,上传接口变慢。
生成 1 种缩略图:~200ms
生成 5 种缩略图:~1s
生成 20 种缩略图:~4s- CDN 缓存效率:每种尺寸都是独立的缓存 key,尺寸越多,缓存命中率越低。
更好的方案:动态裁剪
不再预生成所有尺寸,而是在 URL 参数中指定需要的尺寸,CDN 边缘节点按需生成:
https://img.example.com/photo/abc123?w=300&q=75&format=webp
https://img.example.com/photo/abc123?w=800&q=80&format=webp这样只需存储 1 张原图 + 2~3 种常用缩略图,其他尺寸按需生成。
