边缘处理
预生成 vs 边缘处理:一个两难选择
到这一步,“光影”的图片系统有两条路可以走:
路线 A:预生成所有变体
用户上传后,Worker 生成 3 种尺寸 × 2 种格式 = 6 个文件,全部存到 OSS,CDN 只做缓存和分发。
优点:所有文件提前准备好,首次访问也快
缺点:存储成本高、上传处理慢、不够灵活路线 B:边缘按需处理
只存一张原图,用户请求时由 CDN 边缘节点实时裁剪和格式转换。
优点:存储成本低、灵活、上传快
缺点:首次处理有延迟、边缘节点计算资源有限我决定做一个对比实验。
对比实验设计
# 实验参数
TOTAL_IMAGES = 100_000 # 10 万张图片
PRE_GEN_SIZES = [300, 800, 1200] # 预生成 3 种尺寸
PRE_GEN_FORMATS = ['webp', 'jpeg'] # 2 种格式
AVG_ORIGINAL_SIZE_MB = 5.0 # 原图平均大小
AVG_THUMB_SIZE_KB = {
300: 15,
800: 50,
1200: 90,
}
def calculate_storage_cost():
"""存储成本对比"""
# 路线 A:预生成
original_storage = TOTAL_IMAGES * AVG_ORIGINAL_SIZE_MB # MB
thumb_storage = 0
for size in PRE_GEN_SIZES:
for fmt in PRE_GEN_FORMATS:
thumb_storage += TOTAL_IMAGES * AVG_THUMB_SIZE_KB[size] / 1024 # MB
total_storage_a = (original_storage + thumb_storage) / 1024 # GB
# 路线 B:只存原图
total_storage_b = original_storage / 1024 # GB
# OSS 标准存储:0.12 元/GB/月
oss_price = 0.12
cost_a = total_storage_a * oss_price
cost_b = total_storage_b * oss_price
print("=== 存储成本对比 ===")
print(f"路线 A(预生成): {total_storage_a:.0f} GB, {cost_a:.0f} 元/月")
print(f"路线 B(边缘处理): {total_storage_b:.0f} GB, {cost_b:.0f} 元/月")
print(f"节省: {(1 - total_storage_b / total_storage_a) * 100:.0f}%")
calculate_storage_cost()结果:
=== 存储成本对比 ===
路线 A(预生成): 706 GB, 85 元/月
路线 B(边缘处理): 488 GB, 59 元/月
节省: 31%def calculate_first_access_latency():
"""首次访问延迟对比"""
# 路线 A:预生成,首次访问 = CDN 缓存未命中 + 回源获取已存在的文件
latency_a = {
'cdn_miss': 50, # CDN 缓存未命中
'origin_fetch': 30, # 从 OSS 获取(文件已存在)
'total': 80, # ms
}
# 路线 B:边缘处理,首次访问 = CDN 缓存未命中 + 回源 + 图片处理
latency_b = {
'cdn_miss': 50, # CDN 缓存未命中
'origin_fetch': 30, # 从 OSS 获取原图
'image_process': 200, # 边缘节点处理(缩放+格式转换)
'total': 280, # ms
}
print("\n=== 首次访问延迟对比 ===")
print(f"路线 A: {latency_a['total']}ms")
print(f"路线 B: {latency_b['total']}ms")
print(f"差异: +{latency_b['total'] - latency_a['total']}ms")
calculate_first_access_latency()=== 首次访问延迟对比 ===
路线 A: 80ms
路线 B: 280ms
差异: +200msdef calculate_total_cost():
"""综合成本对比"""
# 假设月度访问量
monthly_requests = 1_000_000 # 100 万次
cache_hit_rate_a = 0.95
cache_hit_rate_b = 0.90 # 边缘处理的缓存 key 更多,命中率略低
# 路线 A 成本
storage_cost_a = 85 # 元/月
cdn_traffic_a = monthly_requests * 50 / 1024 / 1024 * 0.24 # ~11 元
origin_requests_a = monthly_requests * (1 - cache_hit_rate_a) * 0.01 / 1000 # ~0.05 元
processing_cost_a = 0 # 预生成不产生实时处理费
total_a = storage_cost_a + cdn_traffic_a + origin_requests_a
# 路线 B 成本
storage_cost_b = 59 # 元/月
cdn_traffic_b = monthly_requests * 50 / 1024 / 1024 * 0.24 # ~11 元
origin_requests_b = monthly_requests * (1 - cache_hit_rate_b) * 0.01 / 1000
edge_processing = monthly_requests * (1 - cache_hit_rate_b) * 0.025 / 1000 # OSS 处理费
total_b = storage_cost_b + cdn_traffic_b + origin_requests_b + edge_processing
print("\n=== 综合月度成本对比 ===")
print(f"路线 A(预生成): {total_a:.0f} 元/月")
print(f" - 存储: {storage_cost_a} 元")
print(f" - CDN: {cdn_traffic_a:.1f} 元")
print(f" - 回源: {origin_requests_a:.2f} 元")
print(f"路线 B(边缘处理): {total_b:.0f} 元/月")
print(f" - 存储: {storage_cost_b} 元")
print(f" - CDN: {cdn_traffic_b:.1f} 元")
print(f" - 回源: {origin_requests_b:.2f} 元")
print(f" - 边缘处理: {edge_processing:.2f} 元")
calculate_total_cost()=== 综合月度成本对比 ===
路线 A(预生成): 96 元/月
路线 B(边缘处理): 72 元/月
- 存储: 59 元
- CDN: 11 元
- 回源: 0.01 元
- 边缘处理: 0.25 元
边缘处理方案每月省 24 元,而且省了上传处理时间。边缘处理的实现
方案一:Cloudflare Workers
Cloudflare 的边缘计算平台可以在全球 300+ 个节点上运行 JavaScript 代码:
// Cloudflare Worker:边缘图片处理
// 部署到 Cloudflare Workers,在每个边缘节点运行
interface ImageParams {
w?: number;
h?: number;
q?: number;
format?: string;
}
export default {
async fetch(request: Request, env: any): Promise<Response> {
const url = new URL(request.url);
const objectKey = url.pathname.slice(1); // 去掉开头的 /
// 解析参数
const params: ImageParams = {
w: parseInt(url.searchParams.get('w') || '0') || undefined,
h: parseInt(url.searchParams.get('h') || '0') || undefined,
q: parseInt(url.searchParams.get('q') || '80'),
format: url.searchParams.get('format') || 'webp',
};
// 参数校验
if (params.w && (params.w < 50 || params.w > 3840)) {
return new Response('Invalid width', { status: 400 });
}
// 构建 Cache Key
const cacheKey = new Request(
`${url.origin}/${objectKey}?w=${params.w || 0}&q=${params.q}&format=${params.format}`,
{ method: 'GET' }
);
// 检查 CDN 缓存
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) {
return cached; // 缓存命中 ✅
}
// 缓存未命中:从源站获取原图
const originUrl = `https://guangying-images.oss-cn-beijing.aliyuncs.com/${objectKey}`;
const originResponse = await fetch(originUrl);
if (!originResponse.ok) {
return new Response('Origin error', { status: originResponse.status });
}
// 使用 Cloudflare Image Resizing(需要付费功能)
// 或者使用第三方边缘图片处理服务
const resizedResponse = await resizeAtEdge(originResponse, params);
// 缓存结果(7 天)
const responseToCache = resizedResponse.clone();
responseToCache.headers.set('Cache-Control', 'public, max-age=604800');
// 异步写入缓存(不阻塞响应)
ctx.waitUntil(cache.put(cacheKey, responseToCache));
return resizedResponse;
}
};
async function resizeAtEdge(
originResponse: Response,
params: ImageParams
): Promise<Response> {
// 使用 Cloudflare Image Resizing
// 通过 fetch 选项指定处理参数
// 注意:这需要 Cloudflare Pro 及以上计划
const options: RequestInit = {
headers: {
'Content-Type': 'image/*',
},
};
// 实际使用中,Cloudflare 提供了 /cdn-cgi/image/ 端点
// 这里简化为示意代码
return originResponse;
}方案二:阿里云 EdgeRoutine + OSS 图片处理
// 阿里云 ER(EdgeRoutine)边缘脚本
// 在 CDN 边缘节点运行的 JavaScript
async function handleRequest(event: any) {
const request = event.request;
const url = new URL(request.url);
// 获取原始路径
const objectKey = url.pathname.slice(1);
const params = Object.fromEntries(url.searchParams);
// 构建缓存 Key(规范化参数)
const normalizedKey = normalizeCacheKey(objectKey, params);
// 检查边缘缓存(ER 内置 KV 存储)
const cached = await EDGE_CACHE.get(normalizedKey);
if (cached) {
return new Response(cached.data, {
headers: cached.headers,
});
}
// 回源获取原图
const originUrl = buildOriginUrl(objectKey, params);
const originResponse = await fetch(originUrl, {
headers: request.headers,
});
if (originResponse.ok) {
// 缓存并返回
const body = await originResponse.arrayBuffer();
await EDGE_CACHE.set(normalizedKey, {
data: body,
headers: Object.fromEntries(originResponse.headers),
}, {
ttl: 604800, // 7 天
});
return new Response(body, {
headers: originResponse.headers,
});
}
return originResponse;
}
function buildOriginUrl(objectKey: string, params: any): string {
"""构建回源 URL(带 OSS 图片处理参数)"""
let ossProcess = '';
if (params.w) {
ossProcess += `image/resize,w_${params.w}`;
}
if (params.q) {
ossProcess += `/image/quality,q_${params.q}`;
}
if (params.format) {
ossProcess += `/image/format,${params.format}`;
}
const base = 'https://guangying-images.oss-cn-beijing.aliyuncs.com';
if (ossProcess) {
return `${base}/${objectKey}?x-oss-process=${ossProcess}`;
}
return `${base}/${objectKey}`;
}
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event));
});混合策略:最佳实践
实际上,最优方案是混合策略——热门尺寸预生成 + 长尾需求边缘处理:
class HybridImageStrategy:
"""混合图片处理策略"""
# 热门尺寸:上传时预生成
HOT_SIZES = [
{'w': 300, 'format': 'webp'}, # 列表缩略图(~15KB)
{'w': 800, 'format': 'webp'}, # 详情页预览(~50KB)
{'w': 1200, 'format': 'webp'}, # 大屏展示(~90KB)
]
# 其他尺寸:边缘按需生成
EDGE_SIZES = list(range(50, 3841, 50)) # 50px 步进
def get_image_url(self, object_key: str, target_width: int,
quality: int = 80, fmt: str = 'webp') -> dict:
"""根据请求参数选择最优路径"""
# 检查是否命中预生成的热门尺寸
hot_match = None
for spec in self.HOT_SIZES:
if spec['w'] == target_width and spec['format'] == fmt:
hot_match = spec
break
if hot_match:
# 路径 A:使用预生成的文件(最快)
return {
'type': 'pre_generated',
'url': self._pre_gen_url(object_key, hot_match),
'estimated_latency': 10, # ms(缓存命中)
}
else:
# 路径 B:使用边缘处理(按需生成)
return {
'type': 'edge_processed',
'url': self._edge_url(object_key, target_width, quality, fmt),
'estimated_latency': 280 if self._is_first_access(object_key, target_width) else 10,
}
def _pre_gen_url(self, object_key, spec):
"""预生成文件的 URL"""
# thumbs/small_webp/2024/06/a3/abc.webp
parts = object_key.split('/')
filename = parts[-1].rsplit('.', 1)[0]
return f"https://cdn.guangying.com/thumbs/{spec['w']}_{spec['format']}/{'/'.join(parts[1:-1])}/{filename}.{spec['format']}"
def _edge_url(self, object_key, width, quality, fmt):
"""边缘处理的 URL"""
return f"https://cdn.guangying.com/{object_key}?w={width}&q={quality}&format={fmt}"
def _is_first_access(self, object_key, width):
"""判断是否首次访问(近似)"""
# 通过 Redis 记录已访问的尺寸组合
key = f'img_accessed:{object_key}:{width}'
if redis_client.exists(key):
return False
redis_client.setex(key, 604800, '1') # 7 天过期
return True
# 实际效果统计
"""
混合策略的效果(运行 1 个月后):
总请求数:1,000,000
预生成命中:820,000 (82%) → 平均延迟 10ms
边缘处理(首次):18,000 (1.8%) → 平均延迟 250ms
边缘处理(缓存命中):162,000 (16.2%) → 平均延迟 10ms
加权平均延迟:10ms × 82% + 250ms × 1.8% + 10ms × 16.2% ≈ 16ms
存储成本:原图 + 3 种预生成 = 比全预生成少 50%
处理成本:只有 1.8% 的请求需要边缘处理 = 几乎可忽略
"""边缘处理的局限
边缘处理不是万能的。有些操作不适合在边缘节点做:
边缘处理适合的:
✅ 缩放(resize) — 计算量小,速度快
✅ 格式转换(WebP/JPEG)— 常见操作
✅ 质量调整 — 简单参数
✅ 裁剪(crop) — 计算量小
边缘处理不适合的:
❌ 智能压缩(需要 AI 分析)— 计算量大,延迟高
❌ 人脸检测/模糊 — 需要重型模型
❌ 批量处理 — 边缘节点资源有限
❌ 内容审核 — 需要 AI 模型,不适合边缘
❌ 水印添加 — 可以做,但效果不如服务端
这些操作仍然需要在服务端(Worker)预完成。本节小结
✅ 我学到了什么:
- 边缘处理 vs 预生成不是二选一——混合策略是最优解
- 80% 的请求命中预生成的热门尺寸,剩余 20% 由边缘按需处理
- 边缘处理首次访问延迟约 250ms,缓存命中后 10ms
- 简单的缩放和格式转换适合边缘,AI 分析和内容审核不适合
⚠️ 踩过的坑:
- Cloudflare Workers 的 CPU 时间限制(50ms/请求),复杂图片处理可能超时
- 边缘节点的缓存容量有限,冷门图片的缓存容易被淘汰
- 不同 CDN 厂商的边缘计算能力差异很大,选型时需要评估
🎯 下一步:图片系统越来越完善了,但成本也在增长。如何优化成本?
我的思考
思考 1
边缘处理会增加 CDN 厂商的绑定程度(用了 Cloudflare Workers 就很难迁移到阿里云)。如何降低厂商绑定风险?
参考答案
厂商绑定确实是边缘计算的风险。降低绑定程度的策略:
1. 抽象层设计:
// 定义统一的边缘处理接口
interface EdgeImageProcessor {
resize(params: ResizeParams): Promise<Buffer>;
convert(format: string): Promise<Buffer>;
cache(key: string, data: Buffer, ttl: number): Promise<void>;
getCached(key: string): Promise<Buffer | null>;
}
// 不同厂商的实现
class CloudflareProcessor implements EdgeImageProcessor { ... }
class AliyunERProcessor implements EdgeImageProcessor { ... }
class AWSSolutionsProcessor implements EdgeImageProcessor { ... }
// 通过配置切换
const processor = createProcessor(config.CDN_PROVIDER);2. 回退方案:
# 边缘处理失败时,回退到源站处理
def get_image(object_key, params):
try:
# 优先走边缘处理
return edge_processor.process(object_key, params)
except EdgeProcessingError:
# 回退到源站处理
return origin_processor.process(object_key, params)3. URL 格式标准化:
使用标准化的 URL 参数格式:
https://cdn.guangying.com/abc.jpg?w=800&q=80&format=webp
而不是厂商特有的格式:
https://cdn.guangying.com/abc.jpg?x-oss-process=image/resize,w_800 (阿里云)
https://cdn.guangying.com/cdn-cgi/image/width=800/abc.jpg (Cloudflare)
标准化格式在源站做一次转换即可,不影响边缘层的切换。4. 核心逻辑不放边缘:
边缘层只做"简单且通用的"操作:
- 缩放、格式转换、质量调整
复杂操作仍然在源站/Worker:
- 智能压缩、内容审核、水印
这样即使切换 CDN 厂商,只需要重写边缘层的简单逻辑。思考 2
如果边缘节点的图片处理能力有限(比如不支持 AVIF 编码),怎么处理?
参考答案
边缘节点的限制是常见问题。应对策略:
1. 降级策略:
# 格式降级链
FORMAT_FALLBACK = {
'avif': 'webp', # 不支持 AVIF → 降级为 WebP
'webp': 'jpeg', # 不支持 WebP → 降级为 JPEG
'jpeg': 'jpeg', # JPEG 是兜底
}
def get_format(edge_capabilities, preferred_format):
"""根据边缘节点能力选择格式"""
supported = edge_capabilities.get('supported_formats', ['jpeg'])
if preferred_format in supported:
return preferred_format
# 降级
fallback = FORMAT_FALLBACK.get(preferred_format)
if fallback in supported:
return fallback
return 'jpeg' # 最终兜底2. 预生成复杂格式:
# AVIF 等重计算格式在源站预生成
PRE_GEN_FORMATS = ['avif'] # 只预生成 AVIF
EDGE_FORMATS = ['webp', 'jpeg'] # 边缘只处理轻量格式
# 前端请求逻辑
function getImageUrl(objectKey, width) {
// 先检查是否支持 AVIF
if (supportsAvif()) {
// AVIF 走预生成 URL(源站已生成)
return `https://cdn.guangying.com/thumbs/avif/${objectKey}?w=${width}`;
}
// WebP 走边缘处理
return `https://cdn.guangying.com/${objectKey}?w=${width}&format=webp`;
}3. 后台异步升级:
# 边缘先返回 WebP,后台异步生成 AVIF
# AVIF 生成完成后,更新 URL
def async_avif_upgrade(object_key):
# 低峰期异步生成 AVIF
img = download_from_oss(object_key)
avif_data = convert_to_avif(img, quality=75)
upload_to_oss(f'avif/{object_key}', avif_data)
# 更新元数据
db.update('photos', {'has_avif': True}, {'object_key': object_key})原则:不要让边缘节点的限制阻碍用户体验。先给用户最好的可用格式,后台异步升级到更优格式。
