📘 第六章 HTTP首部
📄 一句话概括

Age 告诉客户端:这份响应在缓存里已经待了多少秒。它只由缓存添加,源服务器自己不会发。 多级缓存时它是累加的。

📝 本节和前面几节的关系

上一节讲资源能不能切,这一节讲副本有多旧。 缓存能省往返,代价就是「你拿到的是旧东西」,Age 就是这个代价的刻度。

一、只有缓存才会加这一行

源服务器刚生出来的响应,年龄就是 0,没必要说。所以你在源站抓包,几乎永远看不到 Age; 一看到它,说明中间至少有一个人替你存过。 这也解释了一个常见困惑:本地测试时怎么都不出现 Age,一上 CDN 就有了。 不是你的代码问题,是路径上多了缓存。

二、和 max-age 一起算剩多久

最简的算法是:还能用多久 = max-age − Age。Demo 里拿 max-age=600 配五组 Age: | Age | max-age | 判定 | |---:|---:|---| | 0 | 600 | 新鲜,还能用 600 秒 | | 100 | 600 | 新鲜,还能用 500 秒 | | 599 | 600 | 新鲜,还能用 1 秒 | | 600 | 600 | 正好到期 | | 650 | 600 | 过期,必须回源 | 严格的算法还要加上「本机收到时刻减去 Date」这一段,因为 Age 只统计到缓存发给你那一刻。 RFC 7234 里把它拆成四项,还要处理时钟不同步,实际实现大多用上面这个简化式。 要注意 Age 和 Date 不重复计算:Date 是源服务器生出来的时刻, Age 是「从那个时刻到现在过了多久」的合计,两者相加才是完整的账。

三、多级缓存时是累加的

Demo 里搭了两层代理,走三步:直接问内层(Age 0)、睡 2 秒后问外层让它去问内层(Age 2)、 再睡 1.2 秒问外层(Age 3)。 第三行是 3 而不是 1,因为内层那份在第 2 步时已经 2 秒了,外层把这个 2 存下来当底数, 自己又放了 1 秒,报出去就是 2+1。 整个过程中源服务器只被访问 1 次,内层被访问 2 次,外层被访问 2 次。 这就是缓存的账本:每一次命中都在 Age 上记一笔,回源次数却不变。

四、动手 Demo:两级缓存,Age 从 0 累加到 3

先做一个只报 max-age=600 的源服务器,再套两层各带一份副本的代理。 每层都把自己这份「放了多久」写进 Age,客户端只看最后一行的数。

def strip_age(raw):
    base, keep = 0, []
    for ln in raw.split(b'\r\n\r\n', 1)[0].decode('latin-1').split(u'\r\n'):
        if ln.lower().startswith(u'age:'):
            base = int(ln.split(u':', 1)[1])   # 上游报的,留着当底数
        else:
            keep.append(ln)
    return keep, base

def outer(raw, st):
    if u'copy' not in st:
        st[u'copy'] = X.ask(p1, X.req(u'GET', u'/doc', u'127.0.0.1'))
        st[u'birth'] = time.time()
    keep, base = strip_age(st[u'copy'])
    keep.append(u'Age: %d' % (base + int(time.time() - st[u'birth'])))
    return (u'\r\n'.join(keep) + u'\r\nConnection: close\r\n\r\n').encode('utf-8') + X.body_of(st[u'copy'])

实跑输出:

--------------------------------------------------------------------
Age:这份副本在缓存里放了多少秒
--------------------------------------------------------------------
  场景:源服务器给的 `Cache-Control: max-age=600`,
  缓存把副本放了不同时长,然后把 Age 报给客户端。

  Age      max-age    判定
  0        600        新鲜,还能用 600 秒
  100      600        新鲜,还能用 500 秒
  599      600        新鲜,还能用 1 秒
  600      600        正好到期
  650      600        过期,必须回源

  客户端自己算的公式是:还剩 = max-age − Age − (收到时刻 − Date)。
  第二项是本机时钟和源服务器时钟的差,Age 已经把缓存那一段算进去了,
  所以两者不重复。

  两级缓存时,Age 是累加的。真跑一遍,中间各睡一段时间:
    第 1 步 直接问内层           Age: 0
    第 2 步 睡 2 秒后问外层      Age: 2(外层没有,去问内层)
    第 3 步 再睡 1.2 秒问外层    Age: 3(外层自己有,直接给)
    源服务器总共被访问 1 次
    内层代理总共被访问 2 次
    外层代理总共被访问 2 次

  第 3 步那行不是 1 而是 3,因为它不在一个地方变老的:
  内层那份出生在第 1 步,到第 2 步时它已经 2 秒了,这个 2 写进 Age;
  外层把这个 2 存下来当底数,自己在第 2 步到第 3 步之间又放了 1 秒,
  于是报给客户端的是 2+1=3。Age 从来不是「某一个人的年龄」,
  而是「这份副本从出生到现在,一共被转了多久」的合计值。

第 1 步直接问内层,Age 是 0——副本刚出生。 第 2 步睡 2 秒后问外层,外层手上没有,去问内层,拿到 Age: 2。 第 3 步再睡 1.2 秒问外层,外层这次自己有,直接把存的 2 加上自己的 1,报 Age: 3。 三个计数也对得上:源服务器 1 次、内层 2 次、外层 2 次。 每多一层缓存,Age 就多一段,回源却一次都不多。

30 秒实验

  1. 把第 2 步的 2 秒改成 5 秒,看第 3 步会不会变成 6。
  2. 把 max-age 改成 2,看 Age 3 那一行是不是该判过期。
  3. 想一想:如果两层缓存的时钟差了 10 秒,Age 会偏大还是偏小?

常见误区

  1. 「源服务器也会发 Age」——不会,Age 只由缓存添加。
  2. 「Age 就是 Date 到现在的时间」——Age 是逐跳累加的合计,不是单一差值。
  3. 「Age 超过 max-age 就该删副本」——是「不能直接给」,通常还得回源验证一次。
自测三问(点开对答案) 1. 为什么源站抓包看不到 Age? —— 因为它刚生成、年龄为 0,规范上只有缓存才加这一行。 2. 两级缓存各放了 2 秒和 1 秒,客户端收到的 Age 是多少? —— 3,Age 是逐跳累加的。 3. max-age=600、Age=650,这份副本还能直接用吗? —— 不能,已经过期,必须回源或者走协商验证。

Age 说「有多旧」,可「旧」到底按什么判?下一节的 ETag 给了另一条尺子:按内容判。

相关笔记