关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

CDN 也接了缓存也开了,源站出网带宽为什么还是降不下来:先看响应头里那几个字段

发布时间:2026-10-10

CDN 接上了、缓存也开了,源站的出网带宽却几乎没动。这是很多站点在接完加速之后最想不通的一件事:控制台上的命中率看着不低,源站那张出网流量图却还是平的,账单也跟着没下来。我见过太多次这种「两边对不上」的情况,最后查下来,问题几乎都不在 CDN 那一侧,而在源站自己吐出来的那几个响应头。说白了,中间层要不要帮你挡住请求,第一步看的是你给的响应头允不允许它挡,第二步才是你控制台里那几条缓存规则。源站不点头,CDN 想帮也帮不上。

第一,出网带宽的大头不是请求数,是重复回源。源站的出网字节等于每次回源时它吐出去的字节之和,决定这个数的是「回源次数 × 单次吐多少字节」,不是访问量本身。

第二,三层缓存读的不是同一批字段。浏览器私有缓存、共享缓存(CDN 与反向代理)、源站自身,各读各的。要让中间层真正生效,得给 s-maxage、public 这类只对共享缓存起作用的指令,只写 max-age 很多时候不够。

第三,几个指令的名字跟直觉是反的。no-cache 不是不缓存,它要求每次回源校验;no-store 才是真不存。immutable 让浏览器在有效期内连校验请求都别发;stale-while-revalidate 与 stale-if-error 允许用陈旧副本顶一下,专门削回源尖峰。

第四,命中率要看两个,跟带宽账单相关的是字节命中率。请求命中率很容易被一大堆小响应拉高,而真正吃带宽的是少数几个大响应。只盯着一个百分比下结论,多半会得出「已经很好了」的错觉。

第五,改一个响应头值不值,是可以估出来的。用「命中率提升幅度 × 单响应体大小 × 请求量」这三个数就能估出量级,但必须代入你自己的数据,本文只给方法不给结论数字。

账单疑问:带宽到底被谁吃掉了

先把这个疑问拆开。你看到的现象是:CDN 控制台显示有命中,可能还显示了一个不低的命中率百分比;源站的出网带宽曲线没有明显下降,或者只在刚接上去那两天掉了一点,之后又爬回去。于是自然产生两个猜测——要么 CDN 没在工作,要么源站有别的东西在吃带宽。

这两个猜测都有可能对,但更常见的是第三种情况:CDN 确实在工作,只是它挡住的那些请求,本来就不占多少字节;而真正吃字节的那些请求,它一份都没挡住。这就是「命中率不低、出网不降」的悖论来源。命中率是一个比值,比值高不代表分母里的关键部分被覆盖了。

还有一种更直接的翻车方式:源站压根没有给中间层授权。比如每个响应都带着 private,或者带着一个很宽的 Vary,或者干脆带着 Set-Cookie。这几种情况下,共享缓存会按规范拒绝缓存,或者缓存了但因为缓存键太细而永远命中不上。你在控制台里把缓存规则配得再漂亮,源站一句 private 就把门关死了。这一步的顺序千万别搞反:先看源站响应头,再看 CDN 配置。

所以排查的起点很明确——抓一个你认为最该被缓存的响应,把它的响应头完整看一遍。不是看命中率曲线,是看头。控制台上的百分比是结果,响应头才是原因。

出网带宽的大头不是请求数,是重复回源

先把「源站出网」这件事定义清楚。源站出网字节,等于一段时间内所有从源站发往外部(主要是发往 CDN 节点或反向代理)的响应字节之和。一个响应由响应头和响应体组成,其中响应体是大头——一个 2MB 的图片、一个 5MB 的安装包、一段视频切片,响应头在里面占不到千分之一。

于是这个量可以近似写成:源站出网量 ≈ 回源响应体大小之和。要把它降下来,只有两条路:减少回源次数,或者减小每次回源吐出的字节。第二条路通常没什么空间(响应体压缩能省一点,但量级有限),所以真正能动的是第一条。

回源次数里又有两种,它们的成本完全不同。一种是完整回源——源站返回一个 200,带着完整的响应体,字节全部从源站出去;另一种是校验回源——中间层拿着上次存的校验器问一句「变了吗」,源站回答「没变」,返回一个不带响应体的 304,只有几百字节。同样是「回源了一次」,前者可能是几兆,后者可能是几百字节,差三四个数量级。

说白了,出网带宽不是被访问量吃掉的,是被「重复吐同一个字节」吃掉的。同一个 2MB 的包被一万个用户下载,如果中间层没缓存,源站就要吐 20GB;如果缓存住了,源站可能只吐一次 2MB,外加若干次几百字节的校验。这个差距才是账单上那个数字的来源。

顺带说一句容易被忽略的:尖峰。如果你的带宽计费按峰值类口径走,那么削尖峰的收益可能比削总量更直接。而尖峰往往恰好出现在缓存集体失效的那一刻——比如一次发版把所有资源的 max-age 同时归零,或者一个热门资源在某一秒刚好过期、几千个请求同时回源。这也是后面要讲 stale-while-revalidate 的原因。

三层缓存在读不同的字段:浏览器、共享缓存、源站

HTTP 缓存不是一个东西,是三层结构,它们读的字段有交集但并不重合。分不清这三层,是很多人改了半天没效果的根本原因。

第一层是浏览器私有缓存,或者说私有缓存。它在用户自己的设备上,服务的是这一个用户。max-age 它读,no-store 它读,immutable 主要影响它——immutable 的意思是「有效期内连用户主动刷新都别发校验请求」。这一层挡掉的请求,源站和 CDN 都看不见,是最便宜的一层。

第二层是共享缓存,也就是 CDN 节点、反向代理、企业出口网关这一类。它服务的是一批用户,一份副本可以被多个用户复用。这一层才是决定源站出网带宽的关键——因为它挡掉的请求,本来是会打到源站的。它读 max-age,也读 s-maxage(这个只有它读),还看 public / private(这两个本质是给它的允许或禁令)。

第三层是源站自己。不管前面两层怎么配,请求打过来,源站就得吐字节。所以源站自身也可以做一层缓存——应用内的结果缓存、本地反向代理的磁盘缓存、对象存储的冷热分层——目的不是减少回源次数(那是前两层的事),而是让「必须回源」的请求也不要每次都重新生成一遍、重新读一次盘。这一层不在本文重点里,但你要知道它在账上同样算数:源站内部把文件读出来再吐出去,吐的那部分才是出网。

三层分工清楚了,下面这些字段的针对性才有意义。private 是对共享缓存下的禁令,浏览器照存不误——所以「我明明设了 private,怎么浏览器还是有缓存」不是 bug,是设计如此。s-maxage 只给共享缓存看,浏览器完全无视它——所以你可以做到「浏览器 5 分钟,CDN 一天」这种组合。反过来,immutable 主要约束浏览器,对共享缓存的行为没有直接影响。

一句话记住:想让源站出网降下来,你要说服的是第二层;说服第二层的语言是 s-maxage 和 public,不是 max-age。

no-cache 不是不缓存:几个最容易搞混的指令

这一组指令的名字起得实在不友好,我按「它到底允许不允许存副本」和「用之前要不要问源站」两个维度来讲,比背定义好记。

no-cache:允许存,但每次用之前必须去源站校验一遍。它的字面意思骗了无数人。它真正的含义是「别直接用,先问」。所以一个带 no-cache 的响应,中间层照样会在本地留一份副本,只是每次有请求来都要拿着校验器去源站问一句。如果源站回 304,就把本地那一份发出去——响应体不用再传一遍。这就是为什么 HTML 入口用 no-cache 依然能省下大量带宽:改版立刻生效,而校验只花几百字节。

no-store:这个才是真不存,任何一层都不许留副本,包括浏览器。涉及敏感数据的响应用它。注意它的代价——用了它,每个请求都是完整的完整回源,出网字节一分都省不下来。所以不要把 no-store 当成「保险起见先加上」的选项,它是一条很贵的指令。

max-age=0 和 no-cache 的实际效果很接近(都是立刻过期、用前校验),但语义不同,某些中间层对两者的处理细节也有差异,别混着用让人看不懂。

must-revalidate:一旦过期,如果不能成功跟源站校验,就不许用陈旧副本凑合。它是给「源站失联时中间层能不能继续顶」这件事下的规则,跟 stale-if-error 恰好是一对相反方向的旋钮。

immutable:在有效期内,连用户按刷新、按 Ctrl+F5 这一类的重新加载都不要发条件请求。它对「URL 一变内容就变」的资源是完美搭配——文件名里带内容哈希的静态资源就该用它。反过来,如果内容会变而 URL 不变,用了 immutable 就是自找麻烦,用户在有效期内根本拿不到新版本。它的支持情况要看客户端,主流现代浏览器支持,老浏览器会忽略它,忽略掉只是退化成普通的长缓存,不会出错。

stale-while-revalidate=N:过期之后 N 秒内,中间层可以继续把陈旧副本发给用户,同时在后台异步去源站取新的。对用户来说没有等待,对源站来说把并发的 N 个回源合并成了一次。这一个指令是专门用来削回源尖峰的。

stale-if-error=N:回源失败(源站 5xx、连接超时)的时候,用陈旧副本顶 N 秒。它削的不是带宽,是可用性风险,但顺带也避免了「源站一抖、所有缓存集体失效、恢复后瞬间被打爆」这种二次灾害。

这几个指令不是互斥的,可以组合。比如 max-age=600, stale-while-revalidate=86400 是一个很常见的组合:十分钟之内直接用,十分钟之后到一天之内用旧的但后台更新,一天之后必须回源。

s-maxage 与 public:想让中间层生效必须给对的东西

s-maxage 里的 s 是 shared 的意思,它只对共享缓存生效。而且对共享缓存来说,它的优先级高于 max-age,也高于 Expires。这一条是本篇最重要的一句话——如果你想给「浏览器」和「CDN」设两个不同的新鲜期,只能靠它。

举个典型的组合:Cache-Control: max-age=300, s-maxage=86400。浏览器拿到这个头,看到 max-age=300,五分钟后就会来问一次;CDN 节点看到 s-maxage=86400,一天之内都直接用本地副本,一次都不回源。最终效果是:用户侧能比较快地拿到更新(五分钟级),源站侧一天才被问一次。这个组合对「内容会变但不频繁、且不需要秒级生效」的资源非常好用。

public 是明确放行。它的作用常被低估:在响应带有 Authorization 头一类情况下,共享缓存的默认行为是保守的,public 是你给它的明确授权——这份响应对所有人都一样,你可以存。注意 public 一般不需要显式写,如果已经给了 s-maxage 或较长的 max-age,多数实现会认为它是可共享的;但在你排查「为什么没缓存」的时候,显式写上 public 是排除嫌疑最省事的一招。

private 则相反,是给共享缓存下的禁令。浏览器可以存,中间层一份都不许留。带用户信息的响应必须用它,否则甲用户的个人数据可能被乙用户拿到——这是安全事故,不是性能问题。

而这里恰恰藏着「CDN 没效果」最常见的真相:源站自己在响应上带了 private,或者带了 no-store。很多 Web 框架在检测到会话、检测到登录态时会自动给响应加上这类头,而且是全站加的,包括那些明明是公开的静态资源。结果就是所有请求无一例外地回源。你在 CDN 控制台里配了缓存规则,看到命中率是 0,第一反应是 CDN 有问题——其实源站那句 private 把门关死了。

排查方法很简单:用命令行抓一次响应头,看有没有 private、有没有 no-store、有没有 Authorization。抓的对象要选你认为最该被缓存的那个 URL,比如首页里的主 JavaScript 文件。

Vary 用宽一次,命中率直接归零

Vary 这个头的语义是:这个响应不仅跟 URL 有关,还跟请求里的这几个头有关。它不是建议,它是缓存键的一部分。中间层存副本的时候,会把 Vary 里列的那些请求头的值一起记进键里;下次来请求,只有这些头的值也完全一样,才算命中。

问题就出在「取值有多少种」上。Vary: User-Agent 是最经典的杀手:User-Agent 的字符串有多少种?成千上万,而且同一款浏览器的不同小版本、不同操作系统补丁级别都会不一样。把 UA 塞进缓存键,等于给每个用户建一个独立的缓存副本——命中率会掉到接近零,而存储占用会暴涨。很多人写 Vary: User-Agent 的初衷只是想给移动端和桌面端返回不同的内容,结果是把整层共享缓存废掉了。

正确的做法不是靠 Vary 做内容协商。要么做 UA 归一化——在中间层把 UA 归成「移动端 / 桌面端 / 爬虫」三五个值,再按归一化后的值做键;要么干脆别区分,用响应式布局把差异消化在 CSS 里。如果一定要按设备返回不同 HTML,那至少要保证 Vary 的值域是可数的。

Vary: Cookie 同样危险,Cookie 的值域比 UA 还散,尤其是里面塞了会话标识的时候。Vary: * 更干脆,等于告诉中间层「这个响应对任何请求头变化都敏感」,实际效果就是完全不缓存。

值得留下的是 Vary: Accept-Encoding,它通常是合理且必要的——你要按客户端支持的压缩算法返回 gzip 或 br,缓存里就得区分这两种副本。但要注意,如果中间层不规范化这个头,客户端写法上的细微差异同样会放大键的数量。好的 CDN 会自己做规范化,这一点属于服务商实现细节,需要你实际抓包验证。

另一个命中率杀手跟 Vary 同级,就是 Set-Cookie。多数共享缓存见到响应里带 Set-Cookie 就默认不缓存,或者需要你显式覆盖这个默认行为。而给静态资源下发 Set-Cookie 是最容易犯的低级失误——比如全站统一注入的统计脚本、比如一个写了「任何响应都要初始化会话」的中间件。一刀切下去,所有静态资源全部不缓存。

所以这一步的判断规则很具体:抓一个你认为该被缓存的响应,看它有没有 Set-Cookie,看它的 Vary 值域是不是可数的。这两处是命中率归零最集中的两个位置。

ETag 决定回源校验贵不贵:304 只需要几百字节

先讲清楚一件事:缓存「过期」不等于「重新下载」。这是很多人对缓存最大的误解。新鲜度(freshness)说的是「这份副本在多长时间内可以直接用、不用问」;过期之后进入的是校验(revalidation)阶段——拿着上次源站给的校验器去问一句「变了吗」,没变就回一个 304,不带响应体。

于是「过期」的代价有两种可能:要么几百字节(有校验器且内容未变),要么几兆字节(没有校验器,只能重新传一遍完整响应体)。差三四个数量级,全看你有没有给校验器。

校验器有两种。ETag 是实体标签,源站给每个版本一个标识;下次请求带上 If-None-Match: "那个值",源站比对,一致就回 304。Last-Modified 是最后修改时间,下次请求带 If-Modified-Since。两者可以一起给,同时存在时通常 ETag 优先。

ETag 分强弱。带 W/ 前缀的是弱校验器,表示「语义等价」而不是「字节完全一致」——比如 gzip 前后算弱等价。强校验器才能用在断点续传这类要求字节一致的场景。弱校验器在纯缓存校验里够用。

还有一个容易被忽略的成本:生成校验器本身要花资源。如果为了算 ETag 要把整个文件读一遍算哈希,那省下来的带宽可能被 CPU 和磁盘 IO 吃掉,尤其是大文件。常见的省事做法是直接用 mtime 加 size 组合,或者更好——让构建产物本身就带内容哈希,把哈希值直接写进响应头,连计算都省了。这一条正好跟下一节讲的「哈希文件名」衔接上:构建期算一次哈希,运行期零成本。

Last-Modified 的精度是秒级。同一秒内改了两次,第二次可能被认为是「未变更」,客户端拿到旧的。对秒级更新的内容不要只依赖它,用 ETag。

304 到底多小?它只有响应头,没有响应体,通常几百字节到 1KB 上下,具体取决于你这个站点的响应头有多大(Set-Cookie 多、自定义头多就会更大)。注意这是量级说明,不是实测值——你的实际大小要自己抓一次看。相比之下,一次完整回源要传整个响应体。所以「每次都校验」和「每次都重传」在账单上完全不是一回事,这就是 HTML 入口敢用 no-cache 的底气。

按资源类型下头:哈希静态、非哈希静态、HTML 入口、用户接口

一套响应头走天下,必然出问题。因为不同资源的「内容变更方式」和「能否被多个用户共享」完全不一样。我按四类分开讲,这是本篇最可以直接照着做的一部分。

第一类:文件名带内容哈希的静态资源。比如构建产物 app.3f2a9c.js、带哈希的图片。这类资源的特征是内容一变 URL 就变,所以「长缓存」对它是绝对安全的——用户不可能拿到旧内容,因为旧 URL 根本不会被新页面引用。给 max-age=31536000, immutable,共享缓存再叠一个长的 s-maxage 和 public。这一类应该做到几乎零回源。它是出网带宽优化里优先级最高的一类,因为这类资源往往体积大、请求量也大。

第二类:不带哈希的静态资源。比如 /static/logo.png、用户上传后没有改名的原图。这类资源的麻烦在于内容可能变而 URL 不变,长缓存会导致用户拿到旧版本。给中等长度的 max-age(分钟到小时量级),配合强校验器,共享缓存侧可以用 s-maxage 给得稍长一些——因为中间层的副本你至少还能主动刷新,而用户浏览器里的那份你刷不掉。要彻底解决,还是应该把它变成第一类:构建或上传时把哈希写进文件名。

第三类:HTML 入口。它是所有静态资源的引用者,必须能立刻更新,否则你把 JS 改了、用户拿到的还是引用旧 JS 的旧 HTML。给 no-cache(或者 max-age=0, must-revalidate),配上强校验器。这样每次都会校验,改版立刻生效,而校验只花几百字节——HTML 本体通常也就几十 KB,即使真的变了重传一次也不贵。想更快,可以再叠一小段 s-maxage 加 stale-while-revalidate,让 CDN 在几十秒到几分钟内直接用旧副本、后台更新,代价是改版有几分钟延迟,多数站点可以接受。

第四类:与用户相关的接口。带个人信息、带登录态的响应。给 private,或者敏感的直接 no-store,或者 private, max-age=很小的值。这一类绝不进共享缓存,这是安全红线不是性能选项。同时它也意味着这一类的出网字节一分都省不下来——所以你要做的是把「可共享的部分」从响应里拆出去,能公开的部分走前三类,只有真正跟用户绑定的那一小块走这一类。

分类之后还有一个检查动作:把这四类的 URL 在日志里分别统计请求量和平均响应体大小,按「响应体大小 × 请求量」排序。排在最前面的那一两个,就是你改响应头收益最大的地方。

收益怎么估:命中率、响应体大小、请求量这三个数

改一个响应头到底值不值,是可以估出来的,不需要靠感觉。给你一个能直接用的估算方法。

先定义两个命中率。请求命中率 = 1 −(回源请求数 ÷ 总请求数)。字节命中率 = 1 −(回源字节数 ÷ 分发总字节数)。这两个数的分母不一样,值也常常差得很远。

源站出网量那条公式是:源站出网量 ≈ 回源响应体大小之和。这里要再把回源拆成两类加权——完整回源按整个响应体算,校验回源按几百字节算。所以更实用的写法是:源站出网字节 ≈(完整回源次数 × 平均响应体大小)+(校验回源次数 × 平均 304 大小)。

于是改某一个资源的响应头,收益可以这么估:减少的出网字节 ≈ 该资源命中率提升幅度 × 单响应体大小 × 该资源请求量。三个数分别来自:改之前和改之后的命中率差、这个资源的平均响应体大小、这段时间内这个资源被请求了多少次。乘出来就是一个时间窗口内的字节数,换算成平均速率就能跟你的带宽曲线对上。

量级上可以这样理解:一个 2MB 的资源,如果原本命中率是零、改到接近全命中,那么每省下一次回源就省 2MB 出网,一万次请求就是 20GB 这个量级。反过来,一个 300 字节的接口响应,就算全命中,一万次请求也就省 3MB。所以优先级排序必须按「响应体大小 × 请求量」排,不能按请求数排。按请求数排会把你引向优化一堆小响应——请求数好看,账单不动。

再强调一遍边界:上面这些是估算方法和量级说明,不是实测结果。真实数字要把你自己日志里的三个数代进去算,换一个站点结论可能完全不同。本文不提供任何「改完之后从多少降到多少」的案例,因为没有你的数据,那个数字只能靠编。

最后提醒一下计费口径这件事。上面算出来的是出网字节量,而它怎么变成账单上的钱,取决于服务商的计费方式——是按 95 峰值计费还是按累计流量计费、端口带宽上限是多少、超出之后怎么算。这几个口径每个服务商都不一样,签约前必须逐条问清楚。像一万网络这样深耕 IDC 19 年(成立于 2007 年)的服务商,带宽计费口径与上限通常在合同里单列,问的时候把「峰值口径、计费周期、上限、超出处理」这四项问全,别只看单价。价格本身一律需实时询价,以签约时的最新报价为准。

改之前先量清楚:请求命中率和字节命中率不是一回事

这一节是全篇最该被执行的一节。动手改之前,先把数取出来。

要取的数有四个,都来自 CDN 或反向代理的日志:总请求数、回源请求数、回源字节数、分发总字节数(也就是发给最终用户的字节数)。前两个算请求命中率,后两个算字节命中率。

再往细里拆一层,回源请求要分成两类统计:完整回源(源站返回 200,带响应体)和校验回源(源站返回 304,不带响应体)。日志里的状态码字段就能区分。这一步不能省,因为两者对带宽的贡献差三四个数量级,混在一起你根本看不出现在的问题在哪——比如你发现回源率 30% 很高,但拆开一看其中 28% 是 304,那真正的带宽问题其实不大,你要解决的是校验开销而不是传输开销。

为什么两个命中率必须分开看?设想一个站:一万个请求里有九千个是几百字节的小接口响应,一千个是 2MB 的大图。如果那一千个大图全部回源,而小接口全部命中,请求命中率是 90%,字节命中率大约是(9000×300B 命中)/(9000×300B + 1000×2MB)……算下来字节命中率只有百分之十几。控制台默认显示的多半是前者,你看着 90% 觉得挺好,账单却没动。跟带宽账单相关的那个数是字节命中率。

还有一点:出网带宽是速率不是总量。看曲线的时候要看单位时间的值,尤其看尖峰。如果计费按峰值类口径走,削尖峰比削总量更划算,这时候 stale-while-revalidate 的价值就体现出来了——它不减少总回源量多少,但把并发回源摊平了。反过来,如果按累计流量计费,那就要老老实实提字节命中率,尖峰不那么重要。所以「先量」这一步还要加一项:确认你的计费口径。

改完之后,回到这一步,用同一组指标、同一段时间长度、同样的采样方式再量一次。不要换指标对比——改之前看请求命中率、改之后看字节命中率,这种对比是自欺欺人。也不要只看一天,至少覆盖一个完整的业务周期,否则工作日和周末的差异会盖过你的改动效果。

四类资源的缓存头组合与预期行为

把上面四类资源的处理方式整理成下面这张表。用的时候按 URL 归类,逐类套,不要一套头打天下。表里「中间层是否缓存」一列指的是共享缓存(CDN 与反向代理)的行为,「出网量估算要点」一列是你在算收益时要代进去的数。

资源类型 推荐的缓存头组合 中间层是否缓存 回源触发条件 出网量估算要点
带内容哈希的静态资源、(构建产物、带哈希图片) public, max-age=31536000, immutable,共享缓存再叠 s-maxage=31536000 缓存,且有效期极长 基本只在副本被淘汰或首次访问时回源;immutable 让浏览器连校验请求都不发 收益估算的优先级最高:体积大、请求量大,命中率每提升一档都直接体现在出网字节上
不带哈希的静态资源、(固定路径图片、未改名上传文件) 中等 max-age(分钟到小时量级)+ 强校验器,共享缓存侧 s-maxage 可给得稍长 缓存,但有效期有限 新鲜期过后走条件请求;内容真变了才回 200 完整传输,未变则回 304 按「平均响应体大小 × 过期后的请求量」估;中间层副本可主动刷新,浏览器那份刷不掉
HTML 入口、(首页、详情页、入口文档) no-cache(或 max-age=0, must-revalidate)+ 强校验器,可叠短 s-maxage 与 stale-while-revalidate 缓存副本,但每次用前校验;叠了 s-maxage 则在短时间内直接用 每次请求都可能触发一次校验回源,但校验命中时只回 304,不带响应体 回源次数高但字节小;按「校验回源次数 × 304 大小 + 完整回源次数 × HTML 大小」估
与用户相关的接口、(登录态、个人信息、订单接口) private,敏感内容用 no-store,或 private, max-age= 很小的值 不缓存,这是安全红线不是性能选项 每个请求都完整回源,出网字节无法节省 不要试图在这类上省带宽;正确做法是把可公开部分拆出去走前三类

避坑:调缓存头最容易踩的四个坑

第一个坑:只看控制台的命中率就下结论。为什么坑——控制台默认显示的往往是一个百分比,而它多半是请求命中率,跟出网字节不对应。你看着数字上去了就以为搞定了,账单不动又不知道为什么。怎么避——把日志里的请求命中率和字节命中率都算出来,分开看,并且把回源拆成 200 和 304 两类。两个数朝着不同方向走是正常的,别只盯一个。

第二个坑:Vary 和 Set-Cookie 从来没查过。为什么坑——这两处是命中率归零最集中的位置,而且往往不是你主动写的,是框架、中间件、统计脚本悄悄加上的。一个 Vary: User-Agent 就能让整层共享缓存形同虚设,而控制台上你可能只看到命中率低,看不出原因。怎么避——对你认为最该被缓存的三五个 URL 逐个抓响应头,专门看 Vary 的值域是不是可数的、有没有多余的 Set-Cookie、有没有 private 或 no-store。

第三个坑:长缓存给到了不带哈希的资源。为什么坑——长缓存的前提是「内容一变 URL 就变」。如果资源路径固定而内容会更新,你给一年 max-age,改版之后用户在一年内拿不到新版本,而且你连刷都刷不掉(浏览器那份副本你无法远程清除)。这不是性能问题是线上事故。怎么避——先确认构建产物有没有内容哈希,没有就先改构建,再谈长缓存。顺序不能反。

第四个坑:stale-while-revalidate 想当然。为什么坑——这个指令不是 HTTP 的核心标准字段,中间层不认识就会直接忽略它。你加上了,以为削掉了尖峰,实际上一点效果都没有,还可能因为「看起来已经处理过了」而不再去追真正的原因。怎么避——上之前先用一个明确的测试确认你的中间层支持它:故意让一个资源过期,观察在 stale-while-revalidate 窗口内回源是不是只发生了一次而不是每次都发生。同理,immutable 也要确认客户端支持情况。

还有一个补充性的提醒:源站头和 CDN 控制台规则冲突的时候谁生效,取决于服务商的实现策略——有的以源站头为准,有的允许控制台覆盖。这个默认行为必须向你的服务商确认,不要两边各配一套然后靠猜。

源站出网带宽降不下来,最该先查的几个问题

CDN 控制台的缓存规则都开了,为什么源站出网一点没降?先别怀疑 CDN,先抓源站的响应头。最常见的原因是源站自己下了禁令——private、no-store、Vary 过宽、或者带了 Set-Cookie,这几种情况下共享缓存按规范就不该存,或者存了也命中不上。控制台规则是「允许缓存」,源站响应头是「不许缓存」,后者优先级更高。抓三五个你认为最该被缓存的 URL,把响应头完整看一遍,通常一眼就能找到原因。

no-cache 和 no-store 到底该选哪个?看你要的是「每次都要最新」还是「绝对不能留副本」。no-cache 允许存,但每次用之前去源站校验一次,内容没变就回 304,只花几百字节——HTML 入口用它最合适,既能立刻改版又几乎不花带宽。no-store 是任何层都不许留副本,每个请求都完整回源,出网字节一分省不下来,只该用在真正敏感的响应上。把它当保险选项随手加,是最贵的错误之一。

为什么跟带宽相关的是字节命中率,不是请求命中率?因为源站出网字节等于回源响应体大小之和,跟请求次数不是线性关系。一个 2MB 的资源回源一次,等于几千个几百字节的小请求全部回源。所以请求命中率很容易被一大堆小响应拉高,而真正吃带宽的大文件可能一次都没命中。两个数都算,跟账单对的是字节那个。控制台默认显示哪个,你要自己去确认。

s-maxage 和 max-age 同时写会冲突吗?不会冲突,它们服务的是不同层。max-age 浏览器和共享缓存都读,s-maxage 只有共享缓存读,而且对共享缓存来说 s-maxage 优先于 max-age 和 Expires。所以同时写是有意的设计:浏览器按 max-age 走,CDN 按 s-maxage 走,两边拿到不同的新鲜期。比如 max-age=300, s-maxage=86400 就是「用户侧五分钟更新一次,源站一天才被问一次」。

源站什么缓存头都不给,中间层会怎么处理?会走启发式过期——规范里给了一种在没有明确新鲜期信息时的推算方式,常见的实现是拿 Last-Modified 距今时间的某个比例当作新鲜期,典型取值是百分之十,并且多数实现会设一个上限。这套猜测各家实现不完全一致,所以不能依赖它。它的实际后果是:你的资源可能被缓存了,但缓存多久你不知道,改版什么时候生效你也不知道。这就是为什么必须显式给 Cache-Control——不给不等于不缓存,等于把决定权交给了别人的猜测逻辑。

改成带哈希的文件名,是不是就能随便给长缓存了?基本可以,但要满足一个前提:哈希必须是内容哈希,且 URL 里确实带上了它。这样内容一变 URL 就变,用户不会拿到旧版本,长缓存才是安全的。要注意别把「每次构建生成的时间戳」当哈希用——时间戳每次构建都变,即使内容没变也会让用户重新下载一遍,反而浪费。另外 HTML 入口本身不能这么做,它必须保持路径稳定,所以它走 no-cache。

304 到底能省多少,值得为它专门配校验器吗?值得一配,因为成本极低。304 不带响应体,只有响应头,通常几百字节到 1KB 上下,具体多大取决于你自己的响应头有多大。相比之下完整回源要传整个响应体。所以哪怕命中率一点没变,只要把「每次都完整回源」变成「每次只校验」,出网字节就能降好几个数量级。配校验器的成本也很低——构建期把内容哈希写进响应头就行,运行期零开销。注意别在生成 ETag 的时候去读整个文件算哈希,那样省下的带宽会被 CPU 和磁盘 IO 吃掉。

改完响应头多久能看到效果?取决于两件事:中间层里已经存在的旧副本什么时候过期,以及用户浏览器里那份什么时候过期。中间层的副本你可以主动刷新,通常几分钟内生效;浏览器那份你无法远程清除,只能等它自己过期。所以一次大改的收益曲线通常是阶梯状的——先降一部分,再随着旧副本逐步过期慢慢降到位。量效果的时候至少覆盖一个完整业务周期,别只对比改之前的一天和改之后的一天。

出网带宽这道题,答案多半在响应头里

回到开头那个账单疑问。我的立场很明确:CDN 接上了而源站出网不降,第一嫌疑人是源站自己的响应头,不是 CDN 的缓存开关。排查顺序应该是固定的——先抓响应头看有没有 private、no-store、过宽的 Vary、多余的 Set-Cookie;再把日志里的总请求数、回源请求数、回源字节数、分发字节数取出来,把请求命中率和字节命中率分开算,并且把回源拆成 200 和 304 两类;然后按「哈希静态 / 非哈希静态 / HTML 入口 / 用户接口」四类分别下头,优先级按「响应体大小 × 请求量」排;最后回到同一组指标再量一次。这个顺序里最容易走歪的一步是跳过测量直接改头——改完不知道有没有效,或者用请求命中率去对比字节账单,越改越糊涂。至于收益,用「命中率提升幅度 × 单响应体大小 × 请求量」估一个量级就够了,别去找什么现成的百分比,那些数字换一个站点就完全不成立。最后别忘了账单那一头:出网带宽到底是按峰值类口径还是按累计流量计费、端口上限多少、超出之后怎么算,这几项要在签约前向服务商逐条确认;像一万网络这样深耕 IDC 19 年(成立于 2007 年)的服务商,这类口径通常在合同里单列,问全了再签,价格一律以实时询价和签约时的最新报价为准。

本文关于 HTTP 缓存字段与回源量估算的技术出处

本文涉及的 Cache-Control 各指令(max-age、s-maxage、public、private、no-cache、no-store、must-revalidate、immutable)的语义与适用范围,出自 IETF 关于 HTTP 缓存的规范文档(RFC 7234 及后续修订版本)与关于 immutable 扩展字段的相关规范;stale-while-revalidate 与 stale-if-error 的定义出自 RFC 5861,属于扩展字段,是否被中间层采纳取决于具体实现,需实际验证;条件请求机制(If-None-Match、If-Modified-Since)、304 状态码语义、ETag 强弱校验器的区分,出自 HTTP 条件请求相关规范(RFC 7232 及后续修订版本);Vary 作为缓存键组成部分的行为、以及无明确新鲜期信息时的启发式过期推算方式,同样出自 HTTP 缓存规范;Set-Cookie 与共享缓存可缓存性之间的关系,出自 HTTP 状态管理相关规范中关于可缓存性的约定。文中给出的命中率公式、出网量估算方法与排查顺序属于通用工程实践,其中的量级说明均为方法性描述,需要代入你自己的日志数据计算,不构成任何实测结果或性能承诺,文中亦不含任何客户案例与厂商排名。涉及带宽计费口径、带宽上限、峰值计费或流量计费方式以及价格的部分,需向服务商逐条确认,以实时询价与签约时的最新报价为准。更多行业技术内容可参见 https://www.idc10000.net/ 。


上一篇:规则堆到三百条之后没人敢删:访问控制该按访问关系重写,而不是按 IP 继续加

下一篇:给数据盘加静态加密之前先想清楚密钥丢了谁来救:性能代价远没有救援流程致命