Age 是从源站生成或成功验证响应以来,已经经过多少秒的估计值,包含缓存驻留和网络传输时间。它不是对象的创建时间戳,也不是访问次数,更不只是最后一台代理在磁盘上保存副本的时长。要判断这个数字意味着什么,必须同时查看响应的新鲜度策略和实际请求上下文,不能把年龄直接等同于是否允许复用。
合并估计时不能重复计时
RFC 9111 定义了接收响应时的两种年龄估计。表观年龄用接收缓存的时钟减去 Date,如果得到负数,就按零处理。校正年龄从收到的 Age 数值出发,再加上发出请求到收到响应之间的时间。缺少 Age 时,这个输入按零参与计算,但并不意味着应当丢掉基于 Date 的另一种估计。
当缓存链满足相关 HTTP 行为要求时,RFC 允许直接使用校正年龄;更保守的计算则取表观年龄与校正年龄的较大值,再加本地驻留时间。下面的实验采用后者。把这两种估计直接相加,会重复计算互相重叠的时间;而每经过一个缓存就把 Age 清零,又会丢掉上游已经积累的历史。
请求、接收和本地驻留时间应使用一致的测量方法,Date 比较还依赖时钟同步。因此结果仍然是估计,而不是精确秒表。生产实现需要恰当处理时钟和输出整数值。下面只使用固定的整秒 UTC 时间与合法头字段,展示指定计算步骤,不构成一套完整的 HTTP 缓存实现。
新鲜度是另一个判断
当前年龄为四十五秒,寿命为六十秒时仍在新鲜期;同样的年龄放在三十秒寿命下,就已经陈旧。相等时也算陈旧,因为新鲜要求年龄严格小于寿命。不过,这个比较本身并不授权复用。请求限制、缓存键、Vary、存储规则和要求验证的指令,都可能让一份年龄很小的响应仍然不能直接返回。
对于共享缓存,s-maxage 覆盖 max-age,其数值既可能缩短,也可能延长适用寿命。stale-while-revalidate 并不是把旧响应重新变成新鲜,而是在策略与实现允许的条件下,一边验证一边有限使用陈旧副本。应把“允许服务旧内容”视为另一种状态,不要把所有宽限期都混进新鲜期的定义。
坏掉的样子
Age 很大,不自动证明发布出了问题。缓存寿命为一年的指纹资产,即使年龄很大也可能仍然新鲜。正文没有改变的表示,还可以在后来成功通过验证,只更新元数据而不替换字节。此后 Age 对应的是较新的验证历史,不一定反映这些字节最初被作者写出的时间,不能据此计算内容存在了多久。
同一 URL 的每次请求也不保证 Age 单调递增。请求可能到达不同边缘、选择不同表示、遇到条目淘汰,或经过成功的重新验证。Age 为零也可能来自刚缓存的响应;没有这个头,同样不能单独证明一定由源站直接应答。应对照匹配的请求、正文身份、验证器、Date、缓存策略和服务商诊断字段一起判断。
出现错误复用时,先区分是身份边界错了,还是寿命判断错了。年龄算得再准确,也修复不了缓存键遗漏币种或授权条件的问题。同样,缩短源站 max-age 不会追溯通知那些仍被认为新鲜的既有副本。应查看真正 GET 请求链路上的响应头;HEAD 可能有不同的实现路径,也不能告诉你实际交付了哪份正文。
快速验证
用 Python 3.11 或更新版本执行代码;本次使用 Python 3.14.4。只依赖标准库,HTTP 日期由 email.utils 负责解析和格式化。所有时间都是合成输入,不发起网络请求,不修改系统时钟,也无需睡眠。实验保证请求、接收和观察时间有序,Age 输入是合法非负整数;它不承担通用的畸形头字段解析职责。
from datetime import timedelta
from email.utils import format_datetime, parsedate_to_datetime
base = parsedate_to_datetime("Thu, 01 Jan 2026 00:00:00 GMT")
def at(seconds):
return base + timedelta(seconds=seconds)
def current_age(headers, request_time, response_time, now):
date_value = parsedate_to_datetime(headers["Date"])
age_value = int(headers.get("Age", "0"))
assert request_time <= response_time <= now and age_value >= 0
apparent_age = max(0, (response_time - date_value).total_seconds())
response_delay = (response_time - request_time).total_seconds()
corrected_age_value = age_value + response_delay
corrected_initial_age = max(apparent_age, corrected_age_value)
resident_time = (now - response_time).total_seconds()
return corrected_initial_age + resident_time
def headers(date_seconds, age=None):
result = {"Date": format_datetime(at(date_seconds), usegmt=True)}
if age is not None:
result["Age"] = str(age)
return result
cases = [
("Date estimate dominates", headers(0, 5), 10, 12, 20, 20),
("upstream Age dominates", headers(0, 50), 10, 12, 20, 60),
("future Date is clamped", headers(30, 5), 10, 12, 20, 15),
("Age absent means zero input", headers(0), 10, 12, 20, 20),
("after modeled validation", headers(100, 0), 100, 102, 105, 5),
]
for name, fields, sent, received, now, expected in cases:
age = current_age(fields, at(sent), at(received), at(now))
assert age == expected
print(f"{name}: {age:g} seconds")
freshness_lifetime = 60
assert 45 < freshness_lifetime
assert not 60 < freshness_lifetime
print("lifetime=60: age 45 is fresh; age 60 is stale")
在第十二秒收到响应、第二十秒观察时,第一个案例返回二十,而不是二十七:基于 Date 的估计和校正估计描述了重叠时间,不能相加。把上游 Age 提高到五十后,请求延迟和本地驻留时间使最终结果为六十。即使 Date 在未来,表观年龄被截为零,上游年龄和已经经过的时间仍会让结果达到十五。
缺少 Age 的案例,仍然通过 Date 得到二十秒。模拟成功验证后的案例则使用新的响应元数据,得到五秒,而不是从先前生成时间起经过的一百零五秒。这并没有真的访问源站执行一次验证,只是在明确“已经成功验证”的前提下,检查后续元数据对应的年龄计算。
最后的断言把寿命为六十秒时的两个边界分开:四十五秒新鲜,六十秒陈旧。以上结果验证的是文中采用的算术步骤和边界,不是某个浏览器或 CDN 的实际 Age 生成逻辑,更不证明其路由、故障恢复或畸形字段处理行为。针对具体部署的结论,仍需要真实链路上的响应证据。
延伸阅读
来源
- RFC 9111:计算年龄,独立估计、请求延迟、驻留时间与保守校正,已核验 2026-09-07。
- MDN:Age,头字段语法和非负秒数,已核验 2026-09-07。