导航菜单

图片 CDN 实战

一个 URL 的多种形态

接入 CDN 后不久,我遇到了一个问题:同一个图片在前端需要展示不同尺寸。

<!-- 列表页:300px 宽 -->
<img src="https://cdn.guangying.com/thumbs/small_webp/2024/06/a3/abc.webp">

<!-- 详情页:800px 宽 -->
<img src="https://cdn.guangying.com/thumbs/medium_webp/2024/06/a3/abc.webp">

<!-- 大屏:1200px 宽 -->
<img src="https://cdn.guangying.com/thumbs/large_webp/2024/06/a3/abc.webp">

<!-- 手机:400px 宽 -->
<img src="https://cdn.guangying.com/thumbs/small_webp/2024/06/a3/abc.webp">

每种尺寸都需要预先生成、单独存储、各自缓存。100 万张原图 × 5 种尺寸 × 2 种格式 = 1000 万个文件。

有没有更优雅的方式?

有的——URL 参数化实时裁剪。让 CDN 在用户请求时动态生成需要的尺寸:

<!-- 只需要一张原图,URL 参数指定需要的尺寸和格式 -->
<img src="https://cdn.guangying.com/originals/2024/06/a3/abc.jpg?w=300&q=75&format=webp">
<img src="https://cdn.guangying.com/originals/2024/06/a3/abc.jpg?w=800&q=80&format=webp">
<img src="https://cdn.guangying.com/originals/2024/06/a3/abc.jpg?w=1200&q=85&format=webp">

CDN 收到这个请求后,从源站获取原图,按参数裁剪,缓存结果,返回给用户。

缓存 Key 设计

URL 参数化后,缓存 key 的设计变得至关重要——不同的参数组合会生成不同的缓存 key。

# 缓存 Key 的设计原则

"""
原则 1:不同的参数组合 = 不同的缓存 Key
  ?w=800&q=80 → Key A
  ?w=400&q=80 → Key B
  ?w=800&q=75 → Key C

原则 2:参数顺序不影响 Key
  ?w=800&q=80&format=webp
  ?format=webp&w=800&q=80
  → 应该是同一个 Key

原则 3:默认值不生成额外的 Key
  ?w=800(format 默认 webp)
  ?w=800&format=webp
  → 应该是同一个 Key

原则 4:非法参数不缓存
  ?w=99999 → 返回错误,不缓存
  ?w=800&hack=1 → 忽略未知参数
"""

class CacheKeyBuilder:
    """CDN 缓存 Key 构建器"""
    
    # 允许的参数及其默认值
    ALLOWED_PARAMS = {
        'w': None,        # 宽度,必填
        'h': None,        # 高度,可选
        'q': 80,          # 质量,默认 80
        'format': 'webp', # 格式,默认 webp
        'mode': 'fit',    # 裁剪模式:fit/fill/crop
    }
    
    # 允许的参数范围
    PARAM_CONSTRAINTS = {
        'w': (50, 3840),       # 宽度:50~3840px
        'h': (50, 3840),       # 高度:50~3840px
        'q': (1, 100),         # 质量:1~100
        'format': ['webp', 'jpeg', 'png'],
        'mode': ['fit', 'fill', 'crop'],
    }
    
    def build(self, original_path: str, params: dict) -> str:
        """构建规范化的缓存 Key"""
        # 1. 过滤非法参数
        filtered = {}
        for key, value in params.items():
            if key not in self.ALLOWED_PARAMS:
                continue  # 忽略未知参数
            filtered[key] = value
        
        # 2. 填充默认值
        for key, default in self.ALLOWED_PARAMS.items():
            if key not in filtered and default is not None:
                filtered[key] = default
        
        # 3. 校验参数范围
        for key, value in filtered.items():
            if key in self.PARAM_CONSTRAINTS:
                constraint = self.PARAM_CONSTRAINTS[key]
                if isinstance(constraint, list):
                    if value not in constraint:
                        raise ValueError(f"非法参数: {key}={value}")
                elif isinstance(constraint, tuple):
                    if not (constraint[0] <= int(value) <= constraint[1]):
                        raise ValueError(f"参数超范围: {key}={value}")
        
        # 4. 排序参数(保证顺序一致)
        sorted_params = sorted(filtered.items())
        
        # 5. 构建 Key
        param_str = '&'.join(f'{k}={v}' for k, v in sorted_params)
        cache_key = f'{original_path}?{param_str}'
        
        return cache_key

# 测试
builder = CacheKeyBuilder()

key1 = builder.build('/originals/2024/06/a3/abc.jpg', {'w': 800, 'format': 'webp', 'q': 80})
key2 = builder.build('/originals/2024/06/a3/abc.jpg', {'q': 80, 'w': 800, 'format': 'webp'})
key3 = builder.build('/originals/2024/06/a3/abc.jpg', {'w': 800})  # 默认 q=80, format=webp

print(key1)  # /originals/2024/06/a3/abc.jpg?format=webp&q=80&w=800
print(key2)  # /originals/2024/06/a3/abc.jpg?format=webp&q=80&w=800
print(key3)  # /originals/2024/06/a3/abc.jpg?format=webp&q=80&w=800
# 三个 Key 完全一致 ✅

URL 参数化实时裁剪

CDN 本身不提供图片处理能力,需要配置”回源重写 + 图片处理服务”:

方案一:OSS 图片处理

阿里云 OSS 自带图片处理能力,可以通过 URL 参数直接处理:

# OSS 图片处理 URL 格式
# https://cdn.guangying.com/object_key?x-oss-process=image/resize,w_800/quality,q_80/format,webp

class OSSImageProcessor:
    """利用 OSS 图片处理服务"""
    
    BASE_URL = 'https://cdn.guangying.com'
    
    def build_url(self, object_key: str, params: dict) -> str:
        """构建 OSS 图片处理 URL"""
        process_params = []
        
        # 缩放
        if 'w' in params:
            process_params.append(f"image/resize,w_{params['w']}")
        if 'h' in params:
            process_params.append(f"resize,h_{params['h']}")
        
        # 质量
        quality = params.get('q', 80)
        process_params.append(f"image/quality,q_{quality}")
        
        # 格式
        fmt = params.get('format', 'webp')
        process_params.append(f"image/format,{fmt}")
        
        process_str = '/'.join(process_params)
        return f"{self.BASE_URL}/{object_key}?x-oss-process={process_str}"
    
    # 示例输出:
    # https://cdn.guangying.com/originals/2024/06/a3/abc.jpg?x-oss-process=image/resize,w_800/quality,q_80/format,webp


# 前端使用
class ImageURLBuilder {
  private static BASE = 'https://cdn.guangying.com';
  
  static build(
    objectKey: string, 
    options: { width?: number; quality?: number; format?: string }
  ): string {
    const params: string[] = [];
    
    if (options.width) {
      params.push(`image/resize,w_${options.width}`);
    }
    
    const quality = options.quality || 80;
    params.push(`image/quality,q_${quality}`);
    
    const format = options.format || 'webp';
    params.push(`image/format,${format}`);
    
    return `${this.BASE}/${objectKey}?x-oss-process=${params.join('/')}`;
  }
  
  // 使用
  static thumbnail(objectKey: string): string {
    return this.build(objectKey, { width: 300, quality: 75 });
  }
  
  static preview(objectKey: string): string {
    return this.build(objectKey, { width: 800, quality: 80 });
  }
  
  static detail(objectKey: string): string {
    return this.build(objectKey, { width: 1200, quality: 85 });
  }
}

方案二:自建图片处理服务

OSS 图片处理虽然方便,但功能有限,且每张图片每次处理都要收费。如果需要更灵活的处理能力,可以自建:

# 自建图片处理服务(部署在源站或 CDN 回源链路上)
from flask import Flask, request, send_file
from PIL import Image
import io
import redis

app = Flask(__name__)
redis_client = redis.Redis()

@app.route('/<path:object_key>')
def process_image(object_key):
    """图片处理服务:根据 URL 参数动态裁剪"""
    
    # 1. 构建缓存 Key
    try:
        cache_key = CacheKeyBuilder().build(object_key, dict(request.args))
    except ValueError as e:
        return str(e), 400
    
    # 2. 检查本地缓存(Redis 或磁盘)
    cached = redis_client.get(f'img_cache:{cache_key}')
    if cached:
        return send_file(io.BytesIO(cached), mimetype='image/webp')
    
    # 3. 从 OSS 获取原图
    original_data = oss_client.get_object(object_key)
    img = Image.open(io.BytesIO(original_data))
    
    # 4. 按参数处理
    width = int(request.args.get('w', img.width))
    height = int(request.args.get('h', 0))
    quality = int(request.args.get('q', 80))
    fmt = request.args.get('format', 'webp')
    mode = request.args.get('mode', 'fit')
    
    # 缩放
    if width < img.width:
        img = resize_image(img, width, height, mode)
    
    # 格式转换和质量调整
    buffer = io.BytesIO()
    if fmt == 'webp':
        img.save(buffer, 'WebP', quality=quality, method=4)
    elif fmt == 'jpeg':
        img = img.convert('RGB')
        img.save(buffer, 'JPEG', quality=quality)
    elif fmt == 'png':
        img.save(buffer, 'PNG', optimize=True)
    
    result_data = buffer.getvalue()
    
    # 5. 缓存结果(24 小时)
    redis_client.setex(f'img_cache:{cache_key}', 86400, result_data)
    
    # 6. 返回
    mimetype = f'image/{fmt}' if fmt != 'jpeg' else 'image/jpeg'
    return send_file(io.BytesIO(result_data), mimetype=mimetype)


def resize_image(img, target_width, target_height=0, mode='fit'):
    """图片缩放"""
    if mode == 'fit':
        # 保持宽高比,缩放到目标宽度内
        ratio = target_width / img.width
        new_height = int(img.height * ratio)
        return img.resize((target_width, new_height), Image.LANCZOS)
    
    elif mode == 'fill':
        # 填满目标尺寸,裁剪多余部分
        ratio = max(target_width / img.width, target_height / img.height)
        resized = img.resize(
            (int(img.width * ratio), int(img.height * ratio)),
            Image.LANCZOS
        )
        left = (resized.width - target_width) // 2
        top = (resized.height - target_height) // 2
        return resized.crop((left, top, left + target_width, top + target_height))
    
    elif mode == 'crop':
        # 中心裁剪
        ratio = target_width / img.width
        new_height = int(img.height * ratio)
        resized = img.resize((target_width, new_height), Image.LANCZOS)
        return resized

CDN 缓存策略配置

# CDN 缓存策略

class CDNCacheConfig:
    """CDN 缓存配置"""
    
    def get_cache_rules(self):
        """缓存规则配置"""
        return [
            # 规则 1:原图(不带处理参数)——缓存 30 天
            {
                'path': '/originals/*',
                'query_string': False,  # 忽略 URL 参数
                'ttl': 2592000,         # 30 天
            },
            
            # 规则 2:带图片处理参数的请求——缓存 7 天
            {
                'path': '/*',
                'query_string': True,   # 不同的 URL 参数 = 不同的缓存
                'vary_by': ['w', 'h', 'q', 'format', 'mode'],  # 只看这些参数
                'ignore_params': ['_', 't', 'rand'],  # 忽略这些参数
                'ttl': 604800,          # 7 天
            },
            
            # 规则 3:错误响应——缓存 1 分钟
            {
                'path': '/*',
                'status_code': [400, 404, 415],
                'ttl': 60,              # 1 分钟
            },
        ]
    
    def get_cache_headers(self, object_key, params):
        """生成 Cache-Control 响应头"""
        if 'x-oss-process' in params or 'w' in params:
            # 处理后的图片:7 天缓存
            return {
                'Cache-Control': 'public, max-age=604800, immutable',
                'Vary': 'Accept',  # 根据浏览器 Accept 头区分格式
            }
        else:
            # 原图:30 天缓存
            return {
                'Cache-Control': 'public, max-age=2592000, immutable',
            }

Vary 头的作用

# Vary: Accept 的作用
"""
浏览器 A 支持 WebP:Accept: image/webp,image/*
浏览器 B 不支持 WebP:Accept: image/jpeg,image/*

同一个 URL:https://cdn.guangying.com/originals/abc.jpg?w=800

CDN 需要根据 Accept 头返回不同格式:
  浏览器 A → WebP
  浏览器 B → JPEG

Vary: Accept 告诉 CDN:同一个 URL 可能有多个缓存版本,按 Accept 头区分。

⚠️ 注意:Vary 会降低缓存命中率(每个 URL 有多个缓存副本)
如果所有请求都走 ?format=webp 或 ?format=jpeg 参数来区分格式,
就不需要 Vary: Accept,缓存命中率更高。
"""

回源优化

class OriginOptimizer:
    """回源优化策略"""
    
    # 策略 1:回源 URL 重写
    # CDN 收到:https://cdn.guangying.com/originals/abc.jpg?w=800&format=webp
    # 回源请求:https://guangying-images.oss-cn-beijing.aliyuncs.com/originals/abc.jpg?x-oss-process=...
    # 把前端友好的参数格式转成 OSS 格式
    
    def rewrite_origin_url(self, cdn_url, params):
        """回源时重写 URL"""
        object_key = cdn_url.split('cdn.guangying.com/')[1].split('?')[0]
        
        # 构建回源 URL
        origin_url = f"https://guangying-images.oss-cn-beijing.aliyuncs.com/{object_key}"
        
        if params:
            # 转换为 OSS 图片处理格式
            oss_params = self._convert_to_oss_format(params)
            origin_url += f"?x-oss-process={oss_params}"
        
        return origin_url
    
    def _convert_to_oss_format(self, params):
        """转换为 OSS 图片处理参数"""
        parts = []
        if 'w' in params:
            parts.append(f"image/resize,w_{params['w']}")
        if 'q' in params:
            parts.append(f"image/quality,q_{params['q']}")
        if 'format' in params:
            parts.append(f"image/format,{params['format']}")
        return '/'.join(parts)
    
    # 策略 2:回源 HTTP/2
    # 回源时使用 HTTP/2 多路复用,减少连接开销
    
    # 策略 3:回源 Prefetch
    # 预测用户可能请求的尺寸,提前回源缓存
    
    def prefetch_common_sizes(self, object_key):
        """预缓存常用尺寸"""
        common_sizes = [
            {'w': 300, 'q': 75, 'format': 'webp'},
            {'w': 800, 'q': 80, 'format': 'webp'},
            {'w': 1200, 'q': 85, 'format': 'webp'},
        ]
        
        for params in common_sizes:
            url = f"https://cdn.guangying.com/{object_key}"
            query = '&'.join(f'{k}={v}' for k, v in params.items())
            
            # 向 CDN 节点发起预热请求
            cdn_client.push_object_cache(f"{url}?{query}")

前端响应式图片

配合 CDN 的 URL 参数化,前端可以实现真正的响应式图片:

// 前端:根据屏幕宽度加载不同尺寸的图片
class ResponsiveImage {
  private static CDN_BASE = 'https://cdn.guangying.com';

  static getSrcSet(objectKey: string, maxWidths: number[] = [400, 800, 1200]): string {
    // 生成 srcset 属性
    return maxWidths
      .map(w => {
        const url = `${this.CDN_BASE}/${objectKey}?w=${w}&q=80&format=webp`;
        return `${url} ${w}w`;
      })
      .join(', ');
  }

  static getSizes(breakpoints?: { maxWidth: number; size: string }[]): string {
    // 生成 sizes 属性
    if (!breakpoints) {
      return '(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw';
    }
    return breakpoints
      .map(bp => `(max-width: ${bp.maxWidth}px) ${bp.size}`)
      .join(', ');
  }
}

// React 组件
function ResponsivePhoto({ objectKey, alt }: { objectKey: string; alt: string }) {
  return (
    <img
      src={`${ResponsiveImage.CDN_BASE}/${objectKey}?w=800&q=80&format=webp`}
      srcSet={ResponsiveImage.getSrcSet(objectKey)}
      sizes={ResponsiveImage.getSizes()}
      alt={alt}
      loading="lazy"
    />
  );
}

// 输出 HTML:
// <img
//   src="https://cdn.guangying.com/originals/abc.jpg?w=800&q=80&format=webp"
//   srcset="
//     https://cdn.guangying.com/originals/abc.jpg?w=400&q=80&format=webp 400w,
//     https://cdn.guangying.com/originals/abc.jpg?w=800&q=80&format=webp 800w,
//     https://cdn.guangying.com/originals/abc.jpg?w=1200&q=80&format=webp 1200w
//   "
//   sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
//   loading="lazy"
// >

浏览器会自动选择最合适的尺寸——手机上加载 400px 版本,桌面端加载 1200px 版本。

本节小结

我学到了什么

  • URL 参数化实时裁剪让一张原图满足所有尺寸需求,不再需要预生成所有变体
  • 缓存 Key 设计是 CDN 的核心——参数排序、默认值处理、范围校验
  • OSS 自带图片处理能力,可以直接在 URL 中指定处理参数
  • 前端 srcset + sizes 配合 CDN 参数化,实现真正的响应式图片

⚠️ 踩过的坑

  • URL 参数顺序不一致会导致缓存未命中——必须规范化
  • Vary: Accept 会降低缓存命中率——建议用 URL 参数明确指定格式
  • 首次访问某个尺寸组合会回源(较慢),需要预热常用尺寸

🎯 下一步:URL 参数化需要每次回源都处理图片,有没有更高效的方式?CDN 边缘处理——让离用户最近的节点直接处理图片。

我的思考

思考 1

URL 参数化实时裁剪和预生成缩略图各有什么优缺点?什么场景下应该选择哪种方案?

参考答案

两种方案的核心对比:

              预生成缩略图               URL 参数化实时裁剪
──────────────────────────────────────────────────────────────
存储成本      高(N×M 个文件)           低(1 个原图)
首次访问      快(已存在)               慢(需要处理)
灵活性        低(固定尺寸)              高(任意尺寸)
上传处理时间   慢(生成所有变体)          快(只存原图)
缓存命中后    一样快                     一样快
复杂度        低                         中
适合场景      尺寸固定、访问频繁          尺寸多变、长尾需求多

推荐策略:混合使用

# 混合策略
HYBRID_STRATEGY = {
    # 热门尺寸:预生成(80% 的流量)
    'pre_generated': [
        {'w': 300, 'format': 'webp'},    # 列表缩略图
        {'w': 800, 'format': 'webp'},    # 详情页预览
    ],
    
    # 其他尺寸:按需生成(20% 的流量)
    'on_demand': {
        'min_width': 50,
        'max_width': 3840,
        'step': 50,                      # 宽度步进 50px
    },
}

# 预生成 2~3 种最常用的尺寸,保证高频访问快速响应
# 其他尺寸走 URL 参数化按需生成

对于”光影”,我最终选择了:

  • 3 种常用尺寸预生成(thumb/small/medium)
  • 其他尺寸按需生成(通过 CDN URL 参数)
  • 这样 80% 的请求命中预生成的缓存,20% 按需生成

思考 2

如果有人恶意请求大量不同尺寸的图片(如 ?w=50, ?w=51, ?w=52…),会消耗大量 CDN 回源带宽和服务器计算资源。怎么防范?

参考答案

这是”参数爆破攻击”——通过制造大量不同的 URL 参数来绕过缓存、消耗源站资源。

防护策略

class ParameterBlastDefense:
    """参数爆破攻击防护"""
    
    # 策略 1:限制允许的参数值
    ALLOWED_WIDTHS = [50, 100, 150, 200, 300, 400, 600, 800, 1000, 1200, 1600, 1920]
    
    def validate_params(self, params):
        width = int(params.get('w', 0))
        if width not in self.ALLOWED_WIDTHS:
            return False  # 不允许的宽度
        return True
    
    # 策略 2:参数归一化(把 w=51 归一化为 w=50)
    def normalize_width(self, width):
        """把任意宽度归一化到最近的允许值"""
        for allowed in self.ALLOWED_WIDTHS:
            if width <= allowed:
                return allowed
        return self.ALLOWED_WIDTHS[-1]  # 最大值
    
    # 策略 3:CDN WAF 规则
    WAF_RULES = {
        # 限制单个 IP 每分钟的回源次数
        'origin_rate_limit': '100/minute',
        # 限制单个 IP 请求的唯一 URL 数
        'unique_url_limit': '50/minute',
    }
    
    # 策略 4:签名 URL(最安全)
    def sign_url(self, path, params, secret):
        """给 URL 加签名,防止篡改"""
        import hmac, hashlib
        
        # 按参数排序
        sorted_params = sorted(params.items())
        param_str = '&'.join(f'{k}={v}' for k, v in sorted_params)
        
        # HMAC 签名
        message = f'{path}?{param_str}'
        signature = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()[:16]
        
        return f'{message}&sign={signature}'
    
    # 验证签名
    def verify_url(self, path, params):
        """验证 URL 签名"""
        sign = params.pop('sign', None)
        if not sign:
            return False
        
        expected = self.sign_url(path, params, SECRET)
        return hmac.compare_digest(sign, expected.split('sign=')[1])

生产建议

  1. 限制允许的宽度为有限集合(如 12 种)
  2. 前端只使用预定义的 URL 构建函数,不直接拼接参数
  3. CDN WAF 配置回源频率限制
  4. 可选:对 URL 参数加 HMAC 签名

搜索