用 R2 存博客图片:把出网流量费打到零
对象存储最贵的地方从来不是存储费,而是出网流量。R2 的定价模型里没有这一项,这让我把博客的图片成本压到了每月不到一毛钱。
先算一笔账
假设博客有 500 张图片,平均 300KB,总共 150MB。每月被访问 20 万次(对个人博客来说这已经很夸张了)。
| 项目 | 传统对象存储(典型价格) | Cloudflare R2 |
|---|---|---|
| 存储 150MB | ≈ $0.003 | 免费额度内 |
| 出网流量 60GB | ≈ $5.4 | $0 |
| 请求 20 万次 | ≈ $0.001 | 免费额度内 |
| 合计 | ≈ $5.4/月 | ≈ $0/月 |
差的那 5 美元全在「出网流量」这一栏。所以选对象存储的时候,先看流量单价,再谈别的。
R2 的定价结构里出网流量是免费的,代价是你得接受它的 API 和生态比 S3 稍微窄一点。对博客来说,完全够。
创建并绑定存储桶
wrangler r2 bucket create blog-media
然后在配置里绑定。注意 R2 的绑定名习惯上叫 BUCKET:
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "blog-media"
上传:从表单到 R2
Worker 里拿到 File 对象之后,直接 put 进去就行:
export async function POST({ request, locals }) {
const form = await request.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return new Response('缺少文件', { status: 400 });
}
// 用时间 + 随机串做前缀,避免文件名冲突和目录条目过多
const now = new Date();
const prefix = 'uploads/' + now.getUTCFullYear() + '/' + String(now.getUTCMonth() + 1).padStart(2, '0');
const ext = (file.name.split('.').pop() || 'bin').toLowerCase();
const key = prefix + '/' + crypto.randomUUID() + '.' + ext;
await locals.runtime.env.BUCKET.put(key, file.stream(), {
httpMetadata: {
contentType: file.type || 'application/octet-stream',
cacheControl: 'public, max-age=31536000, immutable',
},
});
return Response.json({ key });
}
三个细节值得单独说:
file.stream()而不是await file.arrayBuffer()。前者是流式上传,不用把整个文件读进 Worker 的内存;后者对大文件会明显吃内存。Worker 的内存上限是 128MB,一张 20MB 的原图用arrayBuffer()也不是不行,但没必要。crypto.randomUUID()。Worker 里原生可用,不需要引任何库。cacheControl: immutable。因为这些 key 是唯一的、内容永不改变,可以放心让浏览器缓存一年。这个设置能显著减少回源请求,也顺便降低了 R2 的读操作次数。
读取:用自己的路由代理
R2 桶默认不公开访问。有两种方式把对象暴露出去:
方案 A:绑定自定义域名。 在 R2 设置里把桶挂到 media.yourdomain.com,Cloudflare 会直接返回对象,不消耗 Worker 请求额度。这是最省的做法,推荐。
方案 B:用 Pages Function 做代理。 好处是权限可控(比如给附件加登录校验),坏处是每次访问都会消耗一次 Worker 调用。
// src/pages/media/[...key].ts
export async function GET({ params, locals, request }) {
const key = Array.isArray(params.key) ? params.key.join('/') : params.key;
const object = await locals.runtime.env.BUCKET.get(key);
if (!object) return new Response('Not Found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
// 支持条件请求,命中 304 时省掉一次完整的响应传输
if (request.headers.get('if-none-match') === object.httpEtag) {
return new Response(null, { status: 304, headers });
}
return new Response(object.body, { headers });
}
writeHttpMetadata 会把上传时存的 contentType 和 cacheControl 回写到响应头,所以上传和下载两端的行为是一致的——不用在读取时再判断一次 MIME。
这个博客用的是方案 B,因为我还想让 /media/private/* 这类路径可以被登录状态保护起来。
删除:别忘了同时删 R2 和 D1
删文章的时候,文章正文里的图片引用不会自动清掉。粗暴的做法是遍历正文、提取 、逐个删 R2 对象。但如果同一张图被多篇文章引用,就会删错。
我的做法是删除时不清理,而是留一个「孤儿对象」巡检脚本,定期列出 R2 里超过 30 天且没有任何文章正文引用的对象,人工确认后再批量删。听起来土,但比线上误删图片安全得多。
# 列出所有对象
wrangler r2 object list blog-media --prefix "uploads/2026/09/"
大文件的限制
R2 单个对象最大 5TB,但通过 Worker 上传时受请求体大小限制:免费版 100MB,Pro 版 100MB。Pages Functions 的实际经验值也差不多在这个量级。
如果你要传视频或者大压缩包,正确做法是走 预签名 URL 直传,让浏览器直接和 R2 交互,绕过 Worker:
// 服务端:生成一个 15 分钟有效的直传地址
const url = await createPresignedUrl(...) // 需要自己实现 AWS SigV4 签名
这个博客的图片都在几 MB 以内,走 Worker 中转足够了,所以我没实现预签名。但如果你要做播客或者视频站,这是必须补上的一环。
小结
R2 最有价值的不是「便宜」,而是它改变了成本模型:存储费可以忽略、请求费可以忽略、流量费为零。这意味着你可以放心地在博客里放高清图,而不用像以前那样先把图片压到 100KB 以下再上传。
架构上的选择也就变得简单了:图片进 R2,正文进 D1,两者用 key 关联。 数据层不存二进制,是这个博客从一开始就定下的规矩。