响应经过另一个缓存,不代表自动获得一份全新的新鲜度额度。缓存必须计入交付路径上已经积累的年龄,再增加自己的本地停留时间。否则,每一层看起来都只缓存六十秒,串在一起却可能把旧内容直接复用远超源站预期的时间。判断新鲜度需要明确时间来源,不能只把配置中的秒数当成最终交付保证。
先算清年龄
Age 估计从响应生成或成功验证以来经过的秒数,其中包括缓存停留与网络传输。收到的 Age 是计算当前年龄的输入,不是在本地存放期间保持不变的计数器。Date 提供响应生成时间;把这些信息联系起来时,时钟同步和实际测得的请求、响应时刻都可能影响结果。只截取一组响应头,不能看到它随后在缓存里又停留了多久。
RFC 9111 给出两条年龄估计路径:接收时刻减去 Date,负数归零,得到表观年龄;收到的 Age 加响应延迟,得到校正年龄值。保守算法取两者较大值,再加本地停留时间。例如请求时刻为一百秒,接收为一百零二秒,Date 为九十五秒,收到的 Age 为十秒,当前时刻为一百零七秒,表观年龄是七秒,校正值是十二秒,当前年龄最终为十七秒。只读原始 Age 就漏算了七秒。
新鲜度寿命也有优先顺序。共享缓存存在 s-maxage 时先用它,否则 max-age 优先于 Expires 减 Date;私有缓存忽略 s-maxage。没有显式过期信息时,在允许的条件下可能使用启发式寿命,但并不存在统一固定时长。只有适用的新鲜度寿命严格大于当前年龄,响应才是新鲜的;恰好相等时已经到达陈旧边界,而不是再多送一次的许可。
坏掉的样子
新鲜不代表适合任意请求。存储许可、网址与 Vary 匹配、验证要求仍然有效。即使寿命设得很长,no-cache 也要求复用前验证。ETag 是验证器,不是免于询问源站的许可。成功重验证可以更新保存的元数据而不重新传正文,但仅仅从另一个缓存收到响应,不能把年龄清零,否则每经过一层就会错误地延长直接复用期限。
提供陈旧响应需要另外的许可,不能把源站故障当成自动授权。must-revalidate 禁止过期后未经成功验证就复用,即使网络断开也不例外。stale-while-revalidate 可以允许在有界的陈旧窗口内一边返回旧内容、一边验证,但仍受适用限制约束,也不会把旧内容重新变成新鲜内容。RFC 9111 已废弃 Warning 字段,因此没有旧式 Warning: 110 不能作为新鲜度证据。
还要区分“存储保留多久”和“直接复用多久”。过期后保留正文,可能有助于重验证或受许可的陈旧窗口,所以缓存后端的淘汰期限可能长于新鲜度寿命。如果调用方直接把这个存储期限当成可返回旧内容的许可,就可能越过响应策略。缓存键仍存在和这次请求可以命中不是一回事,必须检查实际的复用决策。
快速验证
准备 Node.js 与 npm,本次实际使用 Node 26.0.0 和 http-cache-semantics 4.2.0。安装后用 NODE_PATH="$freshness_lab/node_modules" node 执行 JavaScript。程序覆盖的是库内测试时钟,不修改系统时间;输入是虚构元数据,没有网络请求,也不用等待真实时间流逝。只有依赖安装需要联网。
freshness_lab=$(mktemp -d)
npm install --prefix "$freshness_lab" --ignore-scripts --no-audit --no-fund --package-lock=false http-cache-semantics@4.2.0
const assert = require("node:assert/strict");
const CachePolicy = require("http-cache-semantics");
const start = Date.parse("2026-09-07T00:00:00Z");
let clock = start;
class TestPolicy extends CachePolicy {
now() { return clock; }
}
const request = { url: "https://example.test/news", method: "GET", headers: {} };
const headers = {
date: new Date(start).toUTCString(),
age: "20",
expires: new Date(start + 3600000).toUTCString(),
"cache-control": "public, max-age=60, s-maxage=30",
};
const shared = new TestPolicy(request, { headers }, { shared: true });
const single = new TestPolicy(request, { headers }, { shared: false });
assert.equal(shared.maxAge(), 30);
assert.equal(single.maxAge(), 60);
for (const [elapsed, sharedHit, privateHit] of [
[0, true, true], [9, true, true], [10, false, true], [40, false, false],
]) {
clock = start + elapsed * 1000;
assert.equal(shared.age(), 20 + elapsed);
assert.equal(shared.responseHeaders().age, String(20 + elapsed));
assert.equal(shared.satisfiesWithoutRevalidation(request), sharedHit);
assert.equal(single.satisfiesWithoutRevalidation(request), privateHit);
console.log("elapsed:", elapsed, "age:", shared.age(), "reuse:", sharedHit, privateHit);
}
clock = start;
const background = new TestPolicy(request, { headers: {
age: "20", "cache-control": "public, max-age=30, stale-while-revalidate=20",
} });
clock += 15000;
const decision = background.evaluateRequest(request);
assert.equal(background.timeToLive(), 15000);
assert.equal(background.satisfiesWithoutRevalidation(request), false);
assert(decision.response);
assert.equal(decision.revalidation.synchronous, false);
console.log("stale window: retain 15000 ms, serve and revalidate");
clock += 15000;
assert.equal(background.evaluateRequest(request).response, undefined);
assert.equal(background.evaluateRequest(request).revalidation.synchronous, true);
clock = start;
const oldDate = new TestPolicy(request, { headers: {
date: new Date(start - 3600000).toUTCString(),
"cache-control": "public, max-age=60",
} });
assert.equal(oldDate.age(), 0);
assert.equal(oldDate.satisfiesWithoutRevalidation(request), true);
console.log("library limitation: old Date alone does not increase age");
经过零、九、十和四十秒后,计算出的年龄依次是二十、二十九、三十和六十秒。两种模式最初都允许复用;共享模式恰好在三十秒年龄处停止,私有模式到六十秒停止。未来的 Expires 不会覆盖显式寿命。程序同时检查向下游返回的 Age,而不是只断言一个命中布尔值,避免把年龄传播这一半遗漏掉。
独立的陈旧窗口案例达到三十五秒年龄,即超过新鲜期五秒。剩余保留时间是一万五千毫秒,但验证仍然必要。库允许返回缓存响应,同时给出异步重验证决策;它自己不会执行那次网络请求。到五十秒年龄时,窗口许可结束,必须先同步验证。这个结果直接说明后端还能保留条目,不代表可以不经判断地直接使用它。
最后一项有意暴露实现限制:这个库计算年龄时刻意忽略服务器 Date,所以只有一个小时前的 Date、没有 Age 的响应,仍从零开始计龄。它采用收到的 Age 加本地停留时间,不计算实际响应延迟,也不执行上文保守算法中的表观年龄分支。因此,这些断言证明的是固定版本的决策行为,不是完整的 RFC 年龄算法一致性。实际中间层的时序和字段传播仍要另行验证,不能把库测试的通过误写成整条交付链的证明。
延伸阅读
来源
- RFC 9111:新鲜度,寿命优先级、当前年龄与陈旧响应限制,已核验 2026-09-07。
- MDN:Cache-Control,新鲜度与 stale-while-revalidate 指令,已核验 2026-09-07。
- http-cache-semantics 4.2.0,保留期限、重验证决策及文档明确说明的 Date 限制,已核验 2026-09-07。