边缘处理

预生成 vs 边缘处理:一个两难选择

到这一步,“光影”的图片系统有两条路可以走:

路线 A:预生成所有变体

用户上传后,Worker 生成 3 种尺寸 × 2 种格式 = 6 个文件,全部存到 OSS,CDN 只做缓存和分发。

优点:所有文件提前准备好,首次访问也快
缺点:存储成本高、上传处理慢、不够灵活

路线 B:边缘按需处理

只存一张原图,用户请求时由 CDN 边缘节点实时裁剪和格式转换。

优点:存储成本低、灵活、上传快
缺点:首次处理有延迟、边缘节点计算资源有限

我决定做一个对比实验。

对比实验设计

结果:

=== 存储成本对比 ===
路线 A(预生成): 706 GB, 85 元/月
路线 B(边缘处理): 488 GB, 59 元/月
节省: 31%
=== 首次访问延迟对比 ===
路线 A: 80ms
路线 B: 280ms
差异: +200ms
=== 综合月度成本对比 ===
路线 A(预生成): 96 元/月
路线 B(边缘处理): 72 元/月
  - 存储: 59 元
  - CDN: 11 元
  - 回源: 0.01 元
  - 边缘处理: 0.25 元

边缘处理方案每月省 24 元,而且省了上传处理时间。

边缘处理的实现

方案一:Cloudflare Workers

Cloudflare 的边缘计算平台可以在全球 300+ 个节点上运行 JavaScript 代码:

用户请求 GET /i/a3f8c2e1.jpg?w=400&format=webp
        │
        ▼
Cloudflare 边缘节点(Worker)
        │  校验参数 → 查边缘缓存 → 未命中则改写并回源
        ▼
OSS 源站:GET /i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/format,webp
        │
        ▼
Worker 把结果写入边缘缓存,再返回给用户

Worker 的运行模型很简单:一个 fetch 入口,加上一组 env 绑定。常用的绑定有三类——环境变量(env 里的字符串)、KV 命名空间(放规则配置)、R2 桶(放缓存对象)。

interface Env {
  // OSS 源站域名,例如 img-origin.example.com
  ORIGIN: string;
  // 允许的宽度白名单,逗号分隔
  ALLOWED_WIDTHS: string;
  // 允许的输出格式
  ALLOWED_FORMATS: string;
}

const DEFAULT_WIDTHS = ["400", "800", "1200"];

function pick(value: string | null, allowed: string[]): string | null {
  if (value === null) return null;
  return allowed.includes(value) ? value : null;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method !== "GET") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    const url = new URL(request.url);
    const widths = env.ALLOWED_WIDTHS ? env.ALLOWED_WIDTHS.split(",") : DEFAULT_WIDTHS;
    const formats = env.ALLOWED_FORMATS ? env.ALLOWED_FORMATS.split(",") : ["webp", "jpeg"];

    const width = pick(url.searchParams.get("w"), widths);
    const format = pick(url.searchParams.get("format"), formats);

    // 白名单之外的参数一律拒绝:否则恶意请求能造出海量变体,把边缘缓存和 OSS 账单一起撑爆
    if (width === null || format === null) {
      return new Response("Unsupported image params", { status: 400 });
    }

    // 缓存键只保留影响产物内容的参数,签名、时间戳、追踪参数一律不进键
    const cacheKey = new Request(
      `${url.origin}${url.pathname}?w=${width}&format=${format}`,
      { method: "GET" },
    );

    const cached = await caches.default.match(cacheKey);
    if (cached) {
      return cached;
    }

    const originUrl = new URL(url.pathname, env.ORIGIN);
    const ossProcess = `image/resize,w_${width}/format,${format}`;
    originUrl.searchParams.set("x-oss-process", ossProcess);

    const originResponse = await fetch(originUrl.toString(), {
      headers: { "x-oss-process": ossProcess },
    });

    if (!originResponse.ok || !originResponse.body) {
      return new Response("Origin error", { status: 502 });
    }

    const response = new Response(originResponse.body, {
      headers: {
        "content-type": originResponse.headers.get("content-type") ?? "image/jpeg",
        "cache-control": "public, max-age=31536000, immutable",
      },
    });

    // 写缓存放到后台,不阻塞本次响应
    ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
    return response;
  },
};

配一个 wrangler.toml 就能部署:

name = "image-cdn"
main = "src/worker.ts"
compatibility_date = "2024-11-01"

[vars]
ORIGIN = "img-origin.example.com"
ALLOWED_WIDTHS = "400,800,1200,2000"
ALLOWED_FORMATS = "webp,avif,jpeg"

[[routes]]
pattern = "img.example.com/*"
zone_name = "example.com"

四个必须注意的现实约束:

  1. 免费额度和 CPU 时间是硬限制。Workers 免费版每天 10 万次请求、单次最多 10 毫秒 CPU 时间;付费版是每月 1000 万次请求、每次 30 毫秒。做 JPEG、AVIF 转码这种重活,10 毫秒是不够的,重计算必须留在源站。
  2. 参数必须走白名单。w 和 format 只允许固定集合,其余一律拒绝。
  3. 缓存键必须包含全部处理参数。只按路径算键,用户 A 请求的 400 宽图会被返回给请求 1200 宽的用户 B。
  4. 回源要走同地域的 endpoint。边缘节点回源到同地域的 OSS 内网地址,既降低回源延迟,也避免按公网单价结算回源流量。

Worker 的真正价值不在”算得多快”,而在它离用户只有几毫秒——即使一次处理要多花 200 毫秒,总延迟仍然远低于回源北京。但反过来,Cloudflare Workers 的问题是”必须用 Cloudflare”:源站、鉴权、审核服务都在阿里云上,多引入一个平台就多一份运维和账单。下一节的阿里云 EdgeRoutine 组合,做的就是把它收回同一家云。

方案二:阿里云 EdgeRoutine + OSS 图片处理

Cloudflare Workers 的问题是”必须用 Cloudflare”。如果 CDN 已经在阿里云上,更顺手的组合是:EdgeRoutine 负责在边缘改写请求,OSS 负责真正做图片处理。

EdgeRoutine 是部署在阿里云 CDN 边缘节点上的 JavaScript 运行时,典型用途就是在回源之前改写请求。OSS 自带的图片处理则通过 x-oss-process 参数驱动。两者一拼,就得到了”边缘裁剪”的效果:

用户请求
GET /i/a3f8c2e1.jpg?w=400&format=webp&q=80

EdgeRoutine 在边缘改写
GET /i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/quality,q_80/format,webp

OSS 处理并返回
200 image/webp

CDN 缓存键
/i/a3f8c2e1.jpg + resize_w_400 + quality_q_80 + format_webp

参数翻译关系是固定的,可以直接写成一张表:

用户侧参数OSS 处理参数含义
w=400image/resize,w_400缩放到宽 400
format=webpformat,webp输出 WebP
q=80quality,q_80质量因子 80
h=300image/resize,h_300缩放到高 300
m=fitimage/resize,m_fit等比缩放到给定尺寸内

配置上有五个要点,每一个都对应一类真实踩过的坑:

  1. 缓存键必须包含全部处理参数。尺寸、格式、质量任意一个不同,产出的就是不同的图片。如果缓存键只按路径算,用户 A 请求的 400px 图会被返回给请求 1200px 的用户 B。
  2. 回源要用内网 endpoint。边缘节点回源到同地域的 OSS 内网地址,既降低回源延迟,也避免按公网单价结算回源流量。
  3. 参数必须走白名单。只允许固定集合的尺寸、格式、质量组合,其余直接拒绝。否则恶意请求可以造出海量变体,把边缘缓存和 OSS 处理账单一起撑爆。
  4. 热门尺寸仍然要预生成。边缘处理省的是存储,代价是首次访问要多付一次”回源 + 处理”的等待。长尾尺寸适合走边缘,热门尺寸还是预生成并预热更稳。
  5. 边缘节点的资源是有限的。缩放、格式转换、质量调整这类轻量操作适合放在边缘;智能压缩、人脸识别、内容审核这些重操作还是留在服务端。

还有一个容易被忽略的账:边缘处理不是免费的。方案 B 的成本不只是”省下来的存储”,还要算上边缘计算调用费用和 OSS 图片处理次数费用。当热门尺寸走了预生成、只有长尾走边缘时,这两个费用才真正可控——这也是上一节”混合策略”能成立的原因。

混合策略:最佳实践

实际上,最优方案是混合策略——热门尺寸预生成 + 长尾需求边缘处理:

请求进入 CDN
      │
      ▼
参数在白名单里吗?──否──→ 400
      │是
      ▼
这个尺寸是热门尺寸吗?──是──→ 返回预生成文件(CDN 缓存)
      │否
      ▼
边缘处理(OSS 图片处理 / EdgeRoutine)
      │
      ▼
写回边缘缓存,后续同参数请求直接命中

怎么定义”热门尺寸”?不是拍脑袋定一个列表,而是从访问日志里统计出来的。我在日志表上跑了一条查询:

-- 近 30 天每个尺寸的请求数与去重图片数
SELECT
  width,
  COUNT(*) AS requests,
  COUNT(DISTINCT image_id) AS images
FROM cdn_access_log
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY width
ORDER BY requests DESC;

统计结果长这样:

尺寸30 天请求数去重图片数结论
8001,842,00012,400预生成
4001,105,00018,900预生成
1200602,0009,800预生成
200084,0006,100边缘处理
32012,3003,200边缘处理
其他任意尺寸3,1002,700边缘处理

判断标准我定成两条,满足任意一条就预生成:

  1. 请求占比超过 5%。覆盖 5% 以上的请求,把它挪到边缘就意味着 5% 的请求要多付一次”回源 + 处理”的等待。
  2. 去重图片数不超过总数的 30%。说明尺寸集中在少数几张图上,这几张图预生成的收益最大。

剩下的长尾尺寸全部交给边缘。分界线不是拍出来的,是上面这张表算出来的。

预生成的文件在上传流水线里就写好——处理服务产出派生图时,除了固定的三档尺寸,再把统计出来的热门尺寸一起生成。边缘处理则用来兜住长尾。

预热和刷新用 CDN 的接口完成。阿里云 CDN 的刷新和预热是两个独立的接口:刷新是让边缘丢弃已有缓存,预热是让边缘提前回源并缓存。热门图片被替换之后要刷新,新运营活动上线之前要预热:

# 刷新单个 URL:边缘立刻丢弃旧缓存,下次请求回源取新内容
aliyun cdn SubmitRefreshTask --ObjectPath "https://cdn.guangying.com/i/a3f8c2e1.jpg" --ObjectType "File"

# 预热单个 URL:边缘主动回源并把结果缓存起来
aliyun cdn SubmitPreloadTask --ObjectPath "https://cdn.guangying.com/i/a3f8c2e1.jpg" --ObjectType "File"

# 刷新一个目录:ObjectType 传 Directory,路径要以斜杠结尾
aliyun cdn SubmitRefreshTask --ObjectPath "https://cdn.guangying.com/images/2024/06/" --ObjectType "Directory"

ObjectType 只有 File、Directory、Regex 三种取值,用错会被接口直接拒绝。

预生成文件同步到 OSS 时,用 ossutil 批量传:

ossutil cp "@thumb-list.txt" oss://guangying-img/derived/ --recursive --jobs 3 --parallel 5

thumb-list.txt 每行一个待上传文件路径,--jobs 3 --parallel 5 表示 3 个并发任务、每个任务 5 路并行——这是 ossutil 控制并发的方式,写大了会打爆源站的连接数。

混合策略落地后的实际数据:

热门尺寸(800 / 400 / 1200)  → 预生成,首次访问 10 ms
长尾尺寸(2000 / 320 / 其他)  → 边缘处理,首次访问 280 ms
预生成文件数                  → 每张原图 3 个
月存储费                      → 59 元(原 85 元)
月边缘处理调用费              → 0.25 元
月 CDN 流量费                  → 11 元

三个必须守住的纪律:

  1. 白名单和统计表要对得上。热门尺寸列表是数据算出来的,但代码里仍然要写成白名单。统计表是”输入”,白名单是”生效的规则”,中间任何一次调整都要让两者同步,否则统计说热门、代码放行,长尾就会一直打到源站。
  2. 热门尺寸清单要定期重算。内容结构变了,热门尺寸也会变。建议按周重跑上面那条统计查询,掉出前 5% 的尺寸从预生成列表里移除,省下的存储和生成时间立刻兑现。
  3. 预热不能替代预生成。预热只能让已经存在的文件更快到达边缘,对”第一次访问”没有帮助;它解决的是”活动刚开始时第一波流量集体触发回源”的问题。

边缘处理的局限

边缘处理不是万能的。有些操作不适合在边缘节点做:

边缘处理适合的:
✅ 缩放(resize)       — 计算量小,速度快
✅ 格式转换(WebP/JPEG)— 常见操作
✅ 质量调整             — 简单参数
✅ 裁剪(crop)          — 计算量小

边缘处理不适合的:
❌ 智能压缩(需要 AI 分析)— 计算量大,延迟高
❌ 人脸检测/模糊         — 需要重型模型
❌ 批量处理              — 边缘节点资源有限
❌ 内容审核              — 需要 AI 模型,不适合边缘
❌ 水印添加              — 可以做,但效果不如服务端

这些操作仍然需要在服务端(Worker)预完成。

发现内容有问题?

告诉我们哪里需要改进。