图片 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 resizedCDN 缓存策略配置
# 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])生产建议:
- 限制允许的宽度为有限集合(如 12 种)
- 前端只使用预定义的 URL 构建函数,不直接拼接参数
- CDN WAF 配置回源频率限制
- 可选:对 URL 参数加 HMAC 签名
