边缘处理
到这一步,“光影”的图片系统有两条路可以走:
路线 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"
四个必须注意的现实约束:
- 免费额度和 CPU 时间是硬限制。Workers 免费版每天 10 万次请求、单次最多 10 毫秒 CPU 时间;付费版是每月 1000 万次请求、每次 30 毫秒。做 JPEG、AVIF 转码这种重活,10 毫秒是不够的,重计算必须留在源站。
- 参数必须走白名单。
w和format只允许固定集合,其余一律拒绝。 - 缓存键必须包含全部处理参数。只按路径算键,用户 A 请求的 400 宽图会被返回给请求 1200 宽的用户 B。
- 回源要走同地域的 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=400 | image/resize,w_400 | 缩放到宽 400 |
format=webp | format,webp | 输出 WebP |
q=80 | quality,q_80 | 质量因子 80 |
h=300 | image/resize,h_300 | 缩放到高 300 |
m=fit | image/resize,m_fit | 等比缩放到给定尺寸内 |
配置上有五个要点,每一个都对应一类真实踩过的坑:
- 缓存键必须包含全部处理参数。尺寸、格式、质量任意一个不同,产出的就是不同的图片。如果缓存键只按路径算,用户 A 请求的 400px 图会被返回给请求 1200px 的用户 B。
- 回源要用内网 endpoint。边缘节点回源到同地域的 OSS 内网地址,既降低回源延迟,也避免按公网单价结算回源流量。
- 参数必须走白名单。只允许固定集合的尺寸、格式、质量组合,其余直接拒绝。否则恶意请求可以造出海量变体,把边缘缓存和 OSS 处理账单一起撑爆。
- 热门尺寸仍然要预生成。边缘处理省的是存储,代价是首次访问要多付一次”回源 + 处理”的等待。长尾尺寸适合走边缘,热门尺寸还是预生成并预热更稳。
- 边缘节点的资源是有限的。缩放、格式转换、质量调整这类轻量操作适合放在边缘;智能压缩、人脸识别、内容审核这些重操作还是留在服务端。
还有一个容易被忽略的账:边缘处理不是免费的。方案 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 天请求数 | 去重图片数 | 结论 |
|---|---|---|---|
| 800 | 1,842,000 | 12,400 | 预生成 |
| 400 | 1,105,000 | 18,900 | 预生成 |
| 1200 | 602,000 | 9,800 | 预生成 |
| 2000 | 84,000 | 6,100 | 边缘处理 |
| 320 | 12,300 | 3,200 | 边缘处理 |
| 其他任意尺寸 | 3,100 | 2,700 | 边缘处理 |
判断标准我定成两条,满足任意一条就预生成:
- 请求占比超过 5%。覆盖 5% 以上的请求,把它挪到边缘就意味着 5% 的请求要多付一次”回源 + 处理”的等待。
- 去重图片数不超过总数的 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 元
三个必须守住的纪律:
- 白名单和统计表要对得上。热门尺寸列表是数据算出来的,但代码里仍然要写成白名单。统计表是”输入”,白名单是”生效的规则”,中间任何一次调整都要让两者同步,否则统计说热门、代码放行,长尾就会一直打到源站。
- 热门尺寸清单要定期重算。内容结构变了,热门尺寸也会变。建议按周重跑上面那条统计查询,掉出前 5% 的尺寸从预生成列表里移除,省下的存储和生成时间立刻兑现。
- 预热不能替代预生成。预热只能让已经存在的文件更快到达边缘,对”第一次访问”没有帮助;它解决的是”活动刚开始时第一波流量集体触发回源”的问题。
边缘处理不是万能的。有些操作不适合在边缘节点做:
边缘处理适合的:
✅ 缩放(resize) — 计算量小,速度快
✅ 格式转换(WebP/JPEG)— 常见操作
✅ 质量调整 — 简单参数
✅ 裁剪(crop) — 计算量小
边缘处理不适合的:
❌ 智能压缩(需要 AI 分析)— 计算量大,延迟高
❌ 人脸检测/模糊 — 需要重型模型
❌ 批量处理 — 边缘节点资源有限
❌ 内容审核 — 需要 AI 模型,不适合边缘
❌ 水印添加 — 可以做,但效果不如服务端
这些操作仍然需要在服务端(Worker)预完成。发现内容有问题?
告诉我们哪里需要改进。
