导航菜单

起点

一张图片,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, ~15KB1200px, ~80KBWebP
小红书200px, ~10KB800px, ~60KBWebP/AVIF
微博300px, ~20KB1000px, ~100KBWebP
淘宝250px, ~8KB800px, ~50KBWebP

共同模式

  1. 绝不直接展示原图——列表用缩略图,详情用中等尺寸,原图只在用户主动请求时提供
  2. 全部用 WebP 或 AVIF——同等质量下比 JPEG 小 30%~50%
  3. 全部走 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 MB240 KB缩小 725 倍
首屏加载时间12 秒0.5 秒快 24 倍
带宽消耗174 MB/次240 KB/次节省 99.86%

但这远远不够

缩略图解决了”图片太大”的问题,但还有四个问题没解决:

  1. CDN:用户还是从我的服务器下载图片,异地用户依然慢
  2. 存储:每张原图 + 3 个缩略图,磁盘消耗在加速
  3. 审核:用户上传了什么内容,我一无所知
  4. 高可用:服务器挂了,所有图片都不可访问

凌晨 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
};

为什么不是越多越好?

  1. 存储成本:每多一种尺寸,存储量就增加一份。100 万张原图,5 种尺寸 = 600 万个文件。
100 万张图片 × (1 原图 + 5 缩略图) × 平均 50 KB = 300 GB
100 万张图片 × (1 原图 + 20 缩略图) × 平均 50 KB = 1.05 TB
  1. 处理时间:每张图片需要生成 N 种尺寸,上传接口变慢。
生成 1 种缩略图:~200ms
生成 5 种缩略图:~1s
生成 20 种缩略图:~4s
  1. 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 种常用缩略图,其他尺寸按需生成。

搜索