导航菜单

边缘处理

预生成 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
差异: +200ms
def 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})

原则:不要让边缘节点的限制阻碍用户体验。先给用户最好的可用格式,后台异步升级到更优格式。

搜索