site logo

Marico's space

你的 CDN 真的在缓存吗?迁移存储桶前,先审计命中率与资源扇出

服务器技术 2026-08-25 11:31:25 9

最近折腾了把对象存储从阿里云 OSS 迁到 Cloudflare R2,迁移前做了番审计,结果发现团队在缓存命中率上踩了个坑——这个坑本来半天就能修好,但如果没发现就迁移,等于花时间和钱去掩盖一个本来能解决的问题。这篇把审计方法和判断逻辑说清楚。

有篇讨论存储流出费用的帖子里有句话说得在理:动任何东西之前,先搞清楚你到底在服务多少不同的资源,有多少是从缓存来的。

为什么我的流出账单和下载量对不上?

因为有两块表在跑,计的是不同的流量。

  • CDN 数据流出:每个交付给浏览器的字节,不管缓存与否都算。
  • 源站流出:只有 CDN 从存储桶里抓回来的字节——也就是缓存未命中的部分。

假设你向用户交付了 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 太短——那是所有人第一个去查的。几乎总归是缓存键在同一个资源的多次请求间不稳定:

  • 预签名 URL(S3 Presigned URL)。OSS 预签名 URL 在查询字符串里带了 X-Amz-SignatureX-Amz-Date,每次生成都会变。如果查询参数在缓存键里,每个请求对边缘来说都是独立对象,命中率结构性地等于零。这是大坑。
  • 缓存破坏参数,来自图片组件或分析封装器(?v=<timestamp>?_=<random>)。
  • 转发的 Cookie。把会话 Cookie 加入缓存键,命中率就等于每个用户的重复下载率。
  • Vary: User-AgentVary: 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 存储费用给供应商排名会排错。

在修缓存问题前后各用这个脚本跑一个月日志。按我的经验,修复先落地,数字动得足够大,迁移的理由要么变得明摆着,要么直接消失了。

结论:迁移决策就是一道减法——未命中字节乘以流出费率,减去你在新供应商那边要付的运维成本。

FAQ

怎么检查 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)才是正经答案。不管哪种情况,这两个数字花你一个下午,能告诉你实际上是在做两个项目里的哪一个。