图片 CDN 实战
接入 CDN 后不久,我遇到了一个问题:同一个图片在前端需要展示不同尺寸。
每种尺寸都需要预先生成、单独存储、各自缓存。100 万张原图 × 5 种尺寸 × 2 种格式 = 1000 万个文件。
有没有更优雅的方式?
有的——URL 参数化实时裁剪。让 CDN 在用户请求时动态生成需要的尺寸:
CDN 收到这个请求后,从源站获取原图,按参数裁剪,缓存结果,返回给用户。
URL 参数化后,缓存 key 的设计变得至关重要——不同的参数组合会生成不同的缓存 key。
CDN 本身不提供图片处理能力,需要配置”回源重写 + 图片处理服务”:
方案一:OSS 图片处理
阿里云 OSS 自带图片处理能力,可以通过 URL 参数直接处理:
GET https://guangying-img.oss-cn-beijing.aliyuncs.com/i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/quality,q_80/format,webp
x-oss-process 的语法是”动作/参数,参数_值”用斜杠串起来,每个动作以 image/ 开头。常用动作和参数:
| 动作 | 参数 | 含义 |
|---|---|---|
image/resize | w_400 | 指定输出宽度 |
image/resize | h_300 | 指定输出高度 |
image/resize | m_lfit / m_mfit / m_fill / m_pad / m_fixed | 缩放模式:等比缩放到给定范围内 / 等比缩放到范围内取小 / 裁剪填满 / 补边 / 强制指定尺寸 |
image/format | webp / avif / jpg / png | 输出格式 |
image/quality | q_80 | 有损质量因子,取值 1 到 100 |
image/crop | x_100,y_200,w_300,h_300 | 按坐标裁剪 |
image/rotate | 90 | 旋转角度 |
image/blur | r_3 | 模糊半径 |
image/watermark | t_sBpc,a_90 | 加文字水印,s 是字号、a 是透明度 |
image/auto-orient | 无 | 按 EXIF 信息自动摆正 |
参数可以组合,也可以用「图片样式」把一串参数固化下来。样式在控制台创建,或调用 PutBucketStyle 接口写入,之后 URL 只需要带一个样式名:
GET /i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/quality,q_80/thumb_400_webp
样式适合那些”每次都要用同一串参数”的场景——缩略图、头像、封面。好处是参数集中在一处,改一次全站生效;代价是排查问题时要先查样式定义才知道实际做了什么,所以样式名要有语义(thumb_400_webp),不要叫 style1。
直接验证一条处理指令是否生效:
curl -sI "https://guangying-img.oss-cn-beijing.aliyuncs.com/i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/format,webp" | grep -iE "content-type|content-length|x-oss-request-id"
content-type 应该是 image/webp,content-length 是处理后的大小(不是原图大小)。如果返回的是原图,通常是参数拼错了——最常见的是把 w_400 写成了 w=400,或者漏掉了动作前缀 image/。
在代码里调用,OSS SDK 的 process 参数就是拼这一串:
import oss2
endpoint = "https://oss-cn-beijing.aliyuncs.com"
bucket = oss2.Bucket(oss2.Auth("<AccessKeyId>", "<AccessKeySecret>"), endpoint, "guangying-img")
# 取一张 400 宽的 WebP 缩略图,直接落盘
process = "image/resize,w_400/quality,q_80/format,webp"
bucket.get_object_to_file(
"i/a3f8c2e1.jpg",
"a3f8c2e1_400.webp",
process=process,
)
四个必须提前知道的限制:
- 图片处理按调用次数计费,而且是”每个处理请求”计一次,不看处理复杂度。所以绝不能把 OSS 的处理接口直接暴露给终端用户——恶意请求打多少次,就计费多少次。正确做法是让 CDN 边缘先缓存,用户请求只打在边缘上。
- 原图必须放在 OSS 上。这个能力是 OSS 自己的功能,处理不了任意 URL 上的图片。
- 处理结果不会自动写回 OSS。OSS 处理完把结果直接返回给你,不会在桶里生成一个新对象。要长期保存,得自己再
put_object回去。 - 参数拼错不报错。写错参数时 OSS 通常返回原图(HTTP 200),而不是 4xx。所以验收不能只看状态码,要比对
content-type和content-length。
方案二:自建图片处理服务
OSS 图片处理虽然方便,但功能有限,且每张图片每次处理都要收费。如果需要更灵活的处理能力,可以自建:
| 能力 | OSS 图片处理 | 自建图片服务 |
|---|---|---|
| 处理算法 | 固定的一组内置动作 | 任意:智能压缩、感知裁剪、人脸检测、风格化 |
| 计费方式 | 按调用次数 | 按机器时长,是固定成本 |
| 自定义逻辑 | 不支持 | 支持:按用户等级、场景、业务规则选择参数 |
| 结果缓存 | 依赖 CDN 边缘缓存 | 服务内部可以再缓存一层 |
| 运维成本 | 无 | 要自己管机器、扩缩容、监控 |
| 首次处理延迟 | 20 到 200 ms | 30 到 300 ms(多一跳网络) |
什么情况下值得自建?三个信号:
- 需要的处理超出 OSS 的动作集合。比如按图片内容自适应质量、按人脸位置做智能裁剪、加智能水印,这些 OSS 都做不到。
- 调用量足够大,能摊薄固定成本。自建的核心优势是固定成本:处理机的月租是固定的,处理 10 万次和 100 万次,月租不变;而 OSS 是按次计费,量越大越贵。粗略地说,自建的成本在月处理量达到某个量级之后才开始占优——具体拐点要按自己的机器单价和 OSS 的处理单价实测。
- 需要把处理逻辑和业务规则耦合。比如”头像用无损、封面用有损、付费作品一律不降质”。
自建服务的形态很简单:CDN 边缘 → 图片服务 → OSS。边缘负责缓存和参数白名单,图片服务负责真正的变换。
用 Node.js 加 sharp 实现一个最小可用的处理服务。sharp 基于 libvips,处理速度和内存占用都比纯 JS 的方案好得多:
import sharp from "sharp";
const ALLOWED_WIDTHS = new Set([200, 400, 800, 1200, 2000]);
const ALLOWED_FORMATS = new Set(["webp", "avif", "jpeg"]);
export interface TransformQuery {
width: number;
format: "webp" | "avif" | "jpeg";
quality: number;
}
export function parseQuery(params: URLSearchParams): TransformQuery | null {
const width = Number(params.get("w"));
const format = params.get("format") ?? "webp";
const quality = Number(params.get("q") ?? "80");
if (!ALLOWED_WIDTHS.has(width)) return null;
if (!ALLOWED_FORMATS.has(format)) return null;
if (!Number.isInteger(quality) || quality < 1 || quality > 100) return null;
return { width, format: format as TransformQuery["format"], quality };
}
export async function transform(source: Buffer, query: TransformQuery): Promise<Buffer> {
const pipeline = sharp(source, { failOn: "none" }).resize({
width: query.width,
fit: "inside",
withoutEnlargement: true,
});
if (query.format === "webp") return pipeline.webp({ quality: query.quality }).toBuffer();
if (query.format === "avif") return pipeline.avif({ quality: query.quality }).toBuffer();
return pipeline.jpeg({ quality: query.quality, mozjpeg: true }).toBuffer();
}
sharp 的默认并发会按 CPU 核数自动伸缩,服务密集部署时通常还要手动收敛,否则一台 4 核机器上跑十几个进程会互相抢内存:
import os from "node:os";
import sharp from "sharp";
// 进程启动时执行一次:并发数按核数的一半取,给系统留出余量
sharp.concurrency(Math.max(1, Math.floor(os.cpus().length / 2)));
// 限制单张图片的输入像素数,防止超大图把内存打爆
sharp.cache({ memory: 50, files: 0, items: 100 });
自建方案有三个必须提前算清楚的账:
- 机器规格要按峰值选,成本按均值付。图片处理是典型的波峰波谷负载,压缩任务集中在用户上传后的几分钟里。所以要用弹性伸缩而不是固定机器:平时 1 台,队列积压超过阈值就扩,处理完再缩回去。算容量的时候按”峰值并发需要的机器数”算,算钱的时候按”月均用量”算。
- 原图下载是隐性的大头。处理机要把原图从 OSS 拉下来,这段流量和耗时经常被低估。跨地域访问尤其要注意——处理机和 OSS 同地域部署,回源走内网 endpoint,延迟和费用都低得多。
- 结果要落回 OSS 还是留在服务本地。自建服务把结果存在本地磁盘或 R2 上,短期省钱,但源站重启就丢,而且 CDN 边缘刷新后还得重新回源。生产环境通常还是写回 OSS:多一次写入,换来”结果可寻址、可分享、可长期缓存”。
常见的组合是:OSS 图片处理负责常规的缩放和格式转换(零运维),自建服务只处理 OSS 做不到的那部分(智能压缩、感知裁剪),两者由同一个参数白名单约束。
Vary 头的作用
Vary 头告诉缓存:“这个响应取决于哪些请求头”。它是内容协商的必需品,也是缓存命中率的隐形杀手。
典型场景是格式协商。同一个 URL,浏览器声明支持 AVIF,边缘就该返回 AVIF;老浏览器只声明 JPEG,边缘返回 JPEG:
Vary: Accept
有了这行头,边缘才知道”同一个 URL,对不同 Accept 的请求要分别缓存”。少了它,边缘会把发给 Chrome 的 AVIF 直接返回给 Safari,Safari 显示不出来。
代价是缓存被分裂。Accept 头的取值组合不止一种,不同浏览器、不同版本、不同系统发出的 Accept 字符串都不一样,边缘需要为每一种组合各存一份。常见情况下,一个 URL 会分裂成三四份缓存,命中率随之下降。
几个实践上的判断:
| 做法 | 缓存分裂程度 | 适用情况 |
|---|---|---|
Vary: Accept | 按 Accept 组合分裂 | 格式种类少、且确实需要按浏览器能力返回不同格式 |
格式写进 URL(/i/xxx_400.webp) | 按 URL 分裂,语义清晰 | 更推荐,缓存对象与请求一一对应 |
Vary: User-Agent | 近乎无限分裂 | 不要用,UA 组合几乎无穷,命中率会归零 |
Vary: Cookie | 按登录态分裂 | 不要用,会让未登录用户共享不到缓存 |
所以我的做法是:把格式写进 URL 路径,用 <picture> 让前端选 URL,而不是靠 CDN 嗅探 Accept 头。这样”哪个对象被缓存”和”哪个对象被请求”是同一件事,排查问题时也直观得多——看到 URL 就知道拿到的是什么格式。
Vary: Accept-Encoding 一般由 CDN 自动处理(gzip / br),业务侧不需要手动加。
验证方法也很简单:用不同的 Accept 头各请求一次同一个 URL,比较边缘返回的缓存命中标记和 Age 头。如果第二次请求的 Age 是 0,说明它没有命中同一份缓存——那就要检查 Vary 和缓存键的设置。
回源只在未命中时发生,但它决定了”最坏情况有多坏”。图片系统的回源优化通常包括这些项:
| 优化项 | 配置 | 解决什么问题 | 注意事项 |
|---|---|---|---|
| 回源 Host | 与源站期望的域名一致 | 源站的防盗链、签名校验按 Host 匹配 | 不一致会直接 403 |
| 回源协议 | 优先 HTTPS | 避免明文传输,也避免源站强制跳转 | 源站证书必须有效 |
| 回源跟随 301 | 开启 | 源站返回 301 时由节点自己跟随 | 目标地址要稳定,避免跳转链 |
| 回源鉴权 | 携带自定义头或带签名的回源 URL | 源站要求鉴权时仍然可用 | 鉴权参数不进缓存键 |
| 回源 Range | 开启按需取回 | 大文件、Range 请求不必整文件回源 | 需要源站支持 Range |
| 回源超时与重试 | 超时时间、重试次数、是否切备用源站 | 源站抖动时不把错误直接抛给用户 | 重试要设上限,避免放大故障 |
| 回源并发限制 | 设置回源 QPS 上限 | 缓存失效或恶意刷量时保护源站 | 限得太低会拖慢正常填充 |
| 降级响应 | 回源失败时返回占位图或 404 | 不把 5xx 直接暴露给用户 | 占位图也要短 TTL,避免错误被长期缓存 |
几条经验:
- 回源参数一律不进缓存键。签名、时间戳、追踪参数在边缘校验就好,进了缓存键就会把一份缓存撕成无数份。
- 回源失败要区别对待。源站 5xx 是”稍后重试”,源站 404 是”这张图不存在”,前者应该重试和降级,后者应该直接失败。混在一起处理会让真正的问题被掩盖。
- 回源链路要和源站一起压测。只压 CDN 不压源站,会得到一个”CDN 很快、源站一被回源就挂”的假象。
配合 CDN 的 URL 参数化,前端可以实现真正的响应式图片:
浏览器会自动选择最合适的尺寸——手机上加载 400px 版本,桌面端加载 1200px 版本。
发现内容有问题?
告诉我们哪里需要改进。
