
最近折腾了把对象存储从阿里云 OSS 迁到 Cloudflare R2,迁移前做了番审计,结果发现团队在缓存命中率上踩了个坑——这个坑本来半天就能修好,但如果没发现就迁移,等于花时间和钱去掩盖一个本来能解决的问题。这篇把审计方法和判断逻辑说清楚。
有篇讨论存储流出费用的帖子里有句话说得在理:动任何东西之前,先搞清楚你到底在服务多少不同的资源,有多少是从缓存来的。
因为有两块表在跑,计的是不同的流量。
假设你向用户交付了 5 TB 数据,命中率是 95%,那你的存储桶实际只发出约 250 GB。这个数字才是迁移能改变的。另外 4.75 TB 是边缘节点计费,换存储桶不会动它分毫。
阿里云加 CDN 这条路让人迷糊就在这儿:OSS 到 CDN 的传输免费,所以加了 CDN 只是把流出账单从 OSS 换到了 CDN 名下,并没有消除它。Cloudflare 的 CDN 带宽不按 GB 计费,不过到 2026 年中其服务条款对非企业计划下的大量非 HTML 内容有限制。
结论:存储迁移只是移动了源站流出那块表,所以估算节省空间前,量的应该是缓存未命中——而不是总下载量。
两个 curl 就够了。第一个填缓存,第二个应该命中:
BASE=$(printf 'https://%s' "cdn.example.invalid") # your edge hostname
for i in 1 2; do curl -sSI "$BASE/u/9f3ab2/avatar.webp" \
| grep -iE '^(cf-cache-status|x-cache|age|cache-control|vary):'
done
如果第二次响应还是 Miss from cloudfront 或者 cf-cache-status: MISS,说明缓存键不稳定,调 TTL 没救。每次请求 Age 头都重置为 0 也是同样症状,只是看起来温和点。
两个 curl 只能看一个对象。想看真实数字,得解析日志。CloudFront 访问日志是制表符分隔的,有个 #Fields: 头行,所以用列名映射而不是硬编码位置:
#!/usr/bin/env python3
"""Byte-weighted hit ratio and asset fan-out from CloudFront access logs."""
import gzip, sys
from collections import Counter HIT = {"Hit", "RefreshHit", "OriginShieldHit"}
requests_by_obj, bytes_by_obj = Counter(), Counter()
hit_bytes = miss_bytes = 0 for path in sys.argv[1:]: opener = gzip.open if path.endswith(".gz") else open with opener(path, "rt") as fh: fields = None for line in fh: if line.startswith("#Fields:"): fields = line.split()[1:] continue if line.startswith"#" or not fields: continue row = dict(zip(fields, line.rstrip("\n").split("\t"))) size = int(row.get("sc-bytes") or 0) requests_by_obj[row["cs-uri-stem"]] += 1 bytes_by_obj[row["cs-uri-stem"]] += size if row.get("x-edge-result-type") in HIT: hit_bytes += size else: miss_bytes += size total_bytes = hit_bytes + miss_bytes
if not total_bytes: sys.exit("no log rows parsed - check the file paths") uniq = len(requests_by_obj)
top = bytes_by_obj.most_common(50)
print(f"byte-weighted hit ratio : {hit_bytes / total_bytes:.1%}")
print(f"origin egress (misses) : {miss_bytes / 1e9:.2f} GB")
print(f"unique objects : {uniq:,}")
print(f"requests per object : {sum(requests_by_obj.values()) / uniq:.1f}")
print(f"top 50 objects : {sum(b for _, b in top) / total_bytes:.1%} of bytes")
Cloudflare Logpush(换行分隔的 JSON(JSON(JavaScript 对象简 Notation)))的话,同样的数字用 jq 加 awk 就能流出来:
zcat logs/*.log.gz \
| jq -r '[.CacheCacheStatus, .EdgeResponseBytes, .ClientRequestURI] | @tsv' \
| awk -F'\t' ' { bytes += $2; if ($1 == "hit") hits += $2 if (!($3 in seen)) { seen[$3] = 1; uniq++ } } END { printf "hit ratio : %.1f%%\nunique URIs: %d\nreq/URI : %.1f\n", 100 * hits / bytes, uniq, NR / uniq }'
两个注意点。sc-bytes 是发给客户端的字节数,所以分段下载和中断的下载会使它成为源站获取字节数的近似值——够用来做决策参考,但不够用来对账。数 hit 才是命中是故意保守的:revalidated 仍然会消耗一次源站往返。
结论:按字节加权的命中率才是唯一和钱挂钩的版本——99% 的请求命中率毫无意义,如果那 1% 的未命中都是你的视频文件的话。
很少是 TTL 太短——那是所有人第一个去查的。几乎总归是缓存键在同一个资源的多次请求间不稳定:
X-Amz-Signature 和 X-Amz-Date,每次生成都会变。如果查询参数在缓存键里,每个请求对边缘来说都是独立对象,命中率结构性地等于零。这是大坑。?v=<timestamp>、?_=<random>)。Vary: User-Agent 或 Vary: Accept,来自图片转换层,把一个对象拆成多个变体——同个资源用多个主机名也是同样效果。查一下你缓存策略里的查询字符串、请求头和 Cookie 包含列表,然后用剥离了参数的 URL 重新跑两个 curl 测试,确认是哪个在作怪。
预签名 URL 不能被缓存——你只能别在热路径上用它。管用的做法是基于路径的令牌加量化窗口,这样窗口内的每个请求生成的 URL 字节级一致:
import hashlib, hmac, time WINDOW = 3600 # seconds; also your worst-case revocation lag
def asset_path(key: str, secret: bytes) -> str: slot = int(time.time()) // WINDOW mac = hmac.new(secret, f"{key}:{slot}".encode(), hashlib.sha256) return f"/a/{slot}/{mac.hexdigest()[:16]}/{key}"
你的边缘(一个 Worker、Lambda@Edge 或你自己的源站)重新计算当前和上一个时间槽的 HMAC,拒绝其他任何东西。代价是实实在在的:吊销是粗粒度的,所以一个链接在你切断访问后最多还会有效一个完整窗口。适合头像和导出场景;敏感对象还是保留预签名 URL,承认这些字节永远都是源站流出。
结论:如果资源是通过轮换签名送达的,你的命中率不叫低——是结构性地等于零,这是 URL 设计问题,不是存储问题。
| 每个唯一对象的请求数 | 按字节加权的命中率 | 诊断 | 先修哪个 |
|---|---|---|---|
| 高(20+) | 高(90%+) | 正常运行 | 不用动——源站流出本来就很小 |
| 高(20+) | 低(不到 60%) | 缓存键不稳定或 TTL 太短 | 先修缓存问题;迁移只会掩盖 Bug |
| 低(1–3) | 低,而且高不上去 | 真正的长尾:一次性生成的文件 | 用没有流出费用的存储 |
| 整体低,字节集中在少数对象 | 混合 | 几个大文件占主导 | 那些文件设长 TTL;其余的不用管 |
第三行是人们容易忽略的。AI(人工智能)生成的资产——一份渲染好的 PDF、一张一次性图片、一次请求后再也没人看的导出——扇出接近 1。缓存它们几乎没意义:源站获取费用反正都要付,而且对象在下一次永远不来的请求之前就被清除了。想要托管版本的话,Cloudflare R2 是真正移除了流出计量表的方案,不像别家只是打折,所以长尾流量不再是可变成本。Backblaze B2 在免费流出额度内基本能做到同效果——通过 Bandwidth Alliance 到 Cloudflare 网络免费,而且只要读写量与存储量的比值不超过那个倍数就行。
结论:扇出接近 1 是唯一可靠地说明值得为流出迁移的信号,因为这是更好的缓存也改善不了的情况。
用脚本打印出的未命中字节——那是你实际为之付费的月源站流出。把这个数字用两种方式定价:按超出额度的每 GB 费用(OSS,以及超出免费额度的 B2),和零(R2,以及在免费额度内的 B2)。然后加上运维成本,因为扇出接近 1 时每个交付的对象大约就是一次源站 GET,而在 R2 上传侧的 Class A 写入才是更贵的那块表。存储费用通常只是零头,这就是为什么按每 GB 存储费用给供应商排名会排错。
在修缓存问题前后各用这个脚本跑一个月日志。按我的经验,修复先落地,数字动得足够大,迁移的理由要么变得明摆着,要么直接消失了。
结论:迁移决策就是一道减法——未命中字节乘以流出费率,减去你在新供应商那边要付的运维成本。
怎么检查 CDN 有没有在缓存我的 OSS 文件?
用 curl -sSI 请求同一个 URL 两次,看 x-cache(CloudFront)或 cf-cache-status(Cloudflare)头。第一次响应是未命中;如果第二次也是未命中,说明你的缓存键里有东西——查询字符串、Cookie 或 Vary 头——在两次请求间不一样。
在 OSS 前加 CDN 能消除流出费用吗?
不能,只是挪了位置。OSS 到 CDN 传输免费,所以存储桶的流出降到接近零,但 CDN 然后会向你收交付给客户端的数据流出。省钱靠的是缓存命中减少了源站获取,而不是 CDN 本身免费。
用户上传资产的合理命中率是多少?
共享资产如头像和缩略图,目标按字节加权算 90%+。对于扇出接近 1 的一次性生成文件,就算 30% 可能也是天花板——这时候命中率不是要解决的问题,流出定价模式才是。
迁移前先跑这个日志脚本。如果每个唯一对象的请求数高、但命中率不高,你有个缓存键 Bug——修掉预签名 URL 或者多出来的查询字符串,流出账单就下来了,不用换供应商。如果扇出真的接近 1,缓存已经没什么可给的了,零流出存储如 R2(或在免费额度内的 B2)才是正经答案。不管哪种情况,这两个数字花你一个下午,能告诉你实际上是在做两个项目里的哪一个。