HTTP 缓存完全指南:从一次"发了新版用户还看旧页面"看懂强缓存与协商缓存

2023 年我给一个 Web 服务做 HTTP 缓存服务里既有页面也有 JS CSS 这些静态资源还有一批返回数据的 API 我想减轻服务器压力让用户打开得更快第一版我做得很顺手写一个统一的响应处理给每一个响应都加上一个 Cache-Control max-age 而且为了缓存效果好我把这个时间设得很长一整天本地我点开页面刷新看 Network 面板资源确实显示 from disk cache 不再发请求了我心里很笃定 HTTP 缓存嘛不就是加一个 max-age 让浏览器把响应存一份以后别再来问我时间设得越长缓存命中越多服务器越省事可等它一上线一串问题冒了出来第一种最先把我打懵我发布了一个新版本改了 JS 里的一个 bug 可用户的浏览器还在用一天前缓存的旧 JS bug 依旧在用户怎么按刷新都没用第二种最难缠我把一个返回当前登录用户信息的 API 也加上了 max-age 结果有用户反馈自己页面上显示的居然是别人的名字那个响应被中间的 CDN 缓存了一份第三种最头疼我想要一种可以缓存但每次用之前都跟服务器确认一下还新不新鲜的效果我以为该用 no-store 加上去之后发现缓存完全没了第四种最莫名其妙我给响应加了 ETag 以为这样浏览器就能问一下没变就别传了可抓包一看每次还是老老实实返回 200 把整个文件重传一遍我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 HTTP 缓存就是给响应加一个 max-age 让浏览器存一份在过期之前别来烦我可这个认知是错的本文从头梳理为什么给所有响应加个 max-age 会出事强缓存和协商缓存分别是什么 ETag Last-Modified 与 304 怎么配合那几个缓存指令为什么极容易用反内容哈希如何让资源更新真正可靠以及一些把 HTTP 缓存做扎实要避开的工程坑

2023 年我给一个 Web 服务做 HTTP 缓存——服务里既有页面、也有 JS/CSS 这些静态资源,还有一批返回数据的 API。我想减轻服务器压力、让用户打开得更快,自然就想到了缓存。第一版我做得很顺手:写一个统一的响应处理,给每一个响应都加上一个 Cache-Control: max-age,而且为了"缓存效果好",我把这个时间设得很长——一整天。本地我点开页面、刷新,看 Network 面板,资源确实显示"from disk cache"、不再发请求了,我心里很笃定:HTTP 缓存嘛,不就是加一个 max-age、让浏览器把响应存一份、以后别再来问我——时间设得越长,缓存命中越多,服务器越省事,这缓存稳了。可等它一上线,一串问题冒了出来。第一种最先把我打懵:我发布了一个新版本、改了 JS 里的一个 bug,可用户的浏览器还在用一天前缓存的旧 JS,bug 依旧在,用户怎么按刷新都没用——因为那个 max-age 还没到期,浏览器压根不会来取新的。第二种最难缠:我把一个返回"当前登录用户信息"的 API 也加上了 max-age,结果有用户反馈,自己页面上显示的居然是别人的名字——那个响应被中间的 CDN 缓存了一份,A 用户的数据被原样发给了 B 用户。第三种最头疼:我想要一种"可以缓存,但每次用之前都跟服务器确认一下还新不新鲜"的效果,我以为该用 no-store,加上去之后发现缓存完全没了、每次都全量重新下载——我把指令用反了。第四种最莫名其妙:我给响应加了 ETag,以为这样浏览器就能"问一下、没变就别传了",可抓包一看,每次还是老老实实返回 200、把整个文件重传一遍,那个 ETag 像是白加的。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为 HTTP 缓存就是"给响应加一个 Cache-Control: max-age,让浏览器把它存一份、在过期之前别再来烦我"这么一个动作;这个 max-age 设得越长,缓存命中率就越高,服务器就越省事;至于资源更新了怎么办、这个响应能不能被别人共用、缓存了之后还要不要跟服务器核对,这些都不重要,反正加一个够长的 max-age 就万事大吉了。可这个认知是错的。HTTP 缓存根本不是"存一份、到期前别问我"这么一个单向的动作,它是一整套协商机制。它分两层:一层是强缓存——浏览器拿着本地这份,自己判断它还新不新鲜,新鲜就直接用、连请求都不发;另一层是协商缓存——浏览器觉得可能不新鲜了,于是带着一个"凭证"去问服务器"我手里这份还能用吗",服务器要么回一个 304 说"能用、你接着用",要么回一个 200 把新的发给你。第一版只用了强缓存里最粗暴的一招 max-age,把缓存理解成了一件"设置一次、单向生效"的事;可它完全没有"协商"——没有让资源在更新时能被及时取到的机制,没有区分"这份响应能不能被共享缓存存下来",也没有处理那个让 304 真正生效的条件请求。所以用 HTTP 缓存,根上不是"加一个 max-age"这一个动作,而是一整套工程:要分清强缓存和协商缓存各自管什么;要用 ETag 或 Last-Modified 配合条件请求,把"没变就别重传"这件事落地;要分清 no-cacheno-storeprivatepublic 这几个极易用反的指令;要给静态资源用内容哈希文件名,让"更新"这件事从根上不依赖缓存过期;还要处理 Vary、认证响应这些最容易踩的工程坑。本文从头梳理:为什么"给所有响应加个 max-age"会出事,强缓存和协商缓存分别是什么,ETag/Last-Modified 与 304 怎么配合,那几个缓存指令为什么极容易用反,内容哈希如何让资源更新真正可靠,以及一些把 HTTP 缓存做扎实要避开的工程坑。

问题背景

先把 HTTP 缓存这件事说清楚。浏览器向服务器要一个资源(一个页面、一个 JS 文件、一个 API 响应),这个来回是要花时间和带宽的。HTTP 缓存,就是让这个资源在第一次取到之后,被存在某个地方(浏览器本地、或者中间的 CDN、代理),之后再要同一个资源时,直接从近处拿,不必再千里迢迢去源服务器。它的全部规则,都写在响应头里——服务器在返回资源时,通过 Cache-ControlETagLast-Modified 这些响应头,告诉缓存"这东西能存多久、怎么判断它过期了、过期之后怎么核对"。第一版的错,不在于"用了缓存",而在于它只看见了 Cache-Control: max-age 这一个响应头,把它当成了 HTTP 缓存的全部,完全没意识到这套机制还有"协商"这另外一半。

错误认知是:HTTP 缓存就是加一个 max-age,时间越长越好,缓存了就别再来问。真相是:HTTP 缓存是一套协商机制,分强缓存(本地判断新鲜度)与协商缓存(带凭证问服务器);用对它,要配 ETag/Last-Modified、要分清各缓存指令、要靠内容哈希做更新。把这一点摊开,第一版的几类问题就都能解释了:

  • 发了新版用户还看旧的:静态资源用了长 max-age,又没换文件名,过期前浏览器根本不会来取新的。
  • A 用户看到 B 用户数据:带用户隐私的响应用了可被共享缓存的指令,被 CDN 存下后发给了别人。
  • 加了 no-store 缓存全没了:把"每次校验"误当成"完全不存",no-store 是彻底禁用缓存。
  • ETag 白加、304 不生效:服务端没处理 If-None-Match 条件请求,每次仍返回 200 全量。

所以让 HTTP 缓存真正可靠,核心不是"max-age 设得够长",而是一整套工程:分清强缓存与协商缓存、用条件请求让 304 生效、正确区分缓存指令、用内容哈希做版本失效。下面六节,就从第一版"给所有响应加个 max-age"的想当然讲起。

一、为什么"给所有响应加个 max-age"会出事

第一版我做 HTTP 缓存的代码,核心就是一个统一的响应钩子,给每一个出去的响应都贴上一个一天的 max-age。

# 反面教材:第一版 —— 给每一个响应,统统贴上一个超长的 max-age

from flask import Flask

app = Flask(__name__)

@app.after_request
def add_cache_header(resp):
    # 不管是 HTML、JS、CSS,还是返回用户数据的 API,
    # 一律加上同一个 Cache-Control:存一天,一天内别再来问。
    resp.headers['Cache-Control'] = 'max-age=86400'
    return resp

# 本地一测:刷新页面,Network 面板里资源显示 from disk cache,
# 不再发请求了 —— 看着缓存非常成功。
# 可一上线:发了新版,用户浏览器还在用一天前的旧 JS,
# 刷新也没用;返回用户信息的 API 被 CDN 缓存,
# A 用户的数据被发给了 B 用户。

问题就藏在这个"一视同仁"里。这段代码隐含了两个极其乐观的假设:一是所有资源的更新节奏都一样(所以可以用同一个 max-age);二是所有响应都是"公共的、谁拿到都一样"的(所以可以随便被任何缓存存下来)。可现实里,这两个假设没有一个成立。一个 JS 文件可能这个月都不变,一个 API 返回的用户数据却每次都不一样;一个公开的首页谁看都行,一个"我的订单"接口只能给特定用户看。用一刀切的 max-age 去套所有响应,等于把这些天差地别的资源,强行按同一套规矩对待。

这一节要建立的认知是:Cache-Control: max-age 这个头,它表达的根本不是"请把它缓存起来",而是一个强得多、也危险得多的承诺——"在接下来这段时间里,这份响应不会变,你尽管放心地用,完全不必再来问我"。理解 max-age,关键是理解它是一张你单方面签发的"保质期承诺",一旦签出去,在到期之前你就再也没有反悔的余地了。第一版最深的想当然,是把 max-age 当成了一个"建议"——我建议你缓存一天,你缓存就行,要是我这边更新了,你应该会知道的吧?可 max-age 不是建议,它是一纸契约,而且是对浏览器极其有利、对你极其不利的一纸契约。你写下 max-age=86400,意思就是你向全世界所有的缓存(浏览器、CDN、代理)郑重承诺:这份响应在未来 86400 秒内绝对不会变。缓存收到这个承诺后,会做一件完全合理的事——在这 86400 秒里,它对这个资源的任何请求,都直接拿本地这份回应,根本不会再来打扰你的服务器。这正是缓存的全部意义,它确实省了服务器的力。可一旦你在这期间发布了新版本,灾难就来了:你这边代码是新的了,可全世界的缓存还死死抱着你那张"一天不变"的承诺书,它们没有任何理由、也没有任何机制会知道你已经变卦了。用户于是被锁死在旧版本里——他刷新?刷新也没用,因为浏览器一看 max-age 还没过期,连请求都不会发出去。这就是第一版"发了新版用户还看旧的"的全部根源:你不是忘了让浏览器更新,你是亲手签了一张"别来更新"的承诺书。而第二个假设——所有响应都能被随便缓存——错得更危险:max-age 默认是允许包括 CDN 在内的"共享缓存"存储的,于是一个返回用户隐私的 API,响应被 CDN 存了一份,下一个用户来请求,CDN 直接把上一个用户的数据发了过去。所以 max-age 绝不是一个能"一视同仁"贴给所有响应的东西。要用对它,你必须先回答两个问题:这个资源多久会变一次(决定 max-age 设多长、甚至该不该用 max-age),以及这个响应能不能被别人共用(决定它能不能进共享缓存)。而要回答好第一个问题,你得先看清,强缓存到底"强"在哪、它的边界在哪——这就是下一节。

二、强缓存:Cache-Control 的 max-age 与它的边界

第一版用的 max-age,属于 HTTP 缓存里的"强缓存"。强缓存的特点,就是上一节说的——浏览器自己判断新鲜度,在有效期内完全不和服务器通信。Cache-Control 这个头,除了 max-age,还有一组指令,都是在描述这个"强缓存"该怎么用。

# 强缓存:Cache-Control 的几个关键指令(以响应头的形式列出)

# 1) max-age:资源的"保质期",单位是秒。
Cache-Control: max-age=3600          # 1 小时内,浏览器直接用本地的

# 2) s-maxage:专门给"共享缓存"(CDN、代理)用的保质期,
#    它会覆盖 max-age —— 让 CDN 和浏览器有不同的有效期。
Cache-Control: max-age=60, s-maxage=3600

# 3) public / private:这份响应能不能被"共享缓存"存。
Cache-Control: private, max-age=600  # 只许浏览器自己存,CDN 不许存

# 4) no-cache:可以存,但每次用之前必须找服务器核对(见下一节)。
Cache-Control: no-cache

# 5) no-store:彻底别存,每次都重新完整请求。
Cache-Control: no-store

这里要特别说清楚 max-age 和过期之间的关系。在 max-age 规定的时间内,资源处于"新鲜"(fresh)状态,浏览器直接用本地副本,这次访问根本不产生网络请求——这是强缓存最大的价值。一旦超过 max-age,资源变成"陈旧"(stale)状态,但请注意,"陈旧"不等于"作废"、不等于"必须重新下载"——它只是意味着"浏览器不敢再自己做主了,得去问问服务器"。问的过程,就是下一节的协商缓存。

这一节的认知是:强缓存的本质,是一笔"拿确定性换通信"的交易——浏览器之所以能跳过那一整个网络往返、直接用本地副本,唯一的依据就是 max-age 给它的那句承诺"这段时间里东西不会变";所以强缓存的速度优势有多大,它的更新风险就有多大,这两者是同一枚硬币的两面,你不可能只要前者、不要后者。第一版只看见了强缓存"快"的那一面——零网络请求,资源秒开。可它没看见,这个"快"是有前提的:它建立在"浏览器在 max-age 内对服务器一无所知"这个事实上。浏览器不知道服务器更新了没有,它也不在乎,因为你用 max-age 明确告诉它"不必在乎"。于是强缓存的边界就非常清晰了:它只适合那些"你能对它的不变性打包票"的资源。什么资源你能打这个包票?一个内容哈希命名的 JS 文件——它的文件名就是它内容的指纹,内容只要变,文件名就变,那么对于 app.a1b2c3.js 这个具体的 URL,它的内容是物理上永不可能改变的,你给它设一年的 max-age 都毫不心虚(这正是第五节要讲的)。什么资源你打不了这个包票?一个 HTML 页面——你随时可能改它;一个 API 响应——它每次都可能不同。对这些你打不了包票的资源,用强缓存(尤其是长 max-age)就是在赌博,赌你在这段时间内不会更新,而第一版输掉了这场赌局。这就引出强缓存的边界:它不是用来"缓存一切"的,它是用来缓存那些"带版本、一旦发布就不再改变"的资源的。对于会原地更新的资源,你需要的不是强缓存的"别来问我",而恰恰相反——是一种"你可以缓存,但每次都来跟我核对一下"的机制。这,就是协商缓存。

三、协商缓存:ETag / Last-Modified 与 304

协商缓存,解决的是强缓存的死穴:资源会变,但你又不想每次都全量重传。它的思路是——浏览器可以缓存,但每次用之前(或者强缓存过期之后),带上一个能标识"我手里这份是哪个版本"的凭证,去问服务器:我这份还是最新的吗?如果是,服务器回一个 304 Not Modified、不带任何响应体——浏览器就接着用本地的;如果不是,服务器回 200 加上新的内容。这个凭证有两种,第一种是 Last-Modified,基于资源的最后修改时间。

# 协商缓存(一):Last-Modified —— 基于资源的"最后修改时间"

import os
from email.utils import formatdate, parsedate_to_datetime
from flask import request, Response

def serve_with_last_modified(filepath):
    mtime = os.path.getmtime(filepath)        # 文件最后修改时间
    last_modified = formatdate(mtime, usegmt=True)

    # 浏览器再次请求时,会把上次拿到的 Last-Modified
    # 原样放进 If-Modified-Since 请求头带回来。
    ims = request.headers.get('If-Modified-Since')
    if ims is not None:
        cached_time = parsedate_to_datetime(ims).timestamp()
        # 文件的修改时间没有比缓存的更新 —— 说明没变过
        if mtime <= cached_time:
            # 回一个 304,不带响应体 —— 浏览器接着用本地那份
            return Response(status=304)

    # 变了,或浏览器第一次来:回 200,带上完整内容和新的 Last-Modified
    with open(filepath, 'rb') as f:
        body = f.read()
    resp = Response(body, status=200)
    resp.headers['Last-Modified'] = last_modified
    resp.headers['Cache-Control'] = 'no-cache'   # 每次都来协商
    return resp

Last-Modified 有一个粒度问题:它基于时间,而 HTTP 的时间精度只到秒。如果一个文件在一秒内被改了两次,或者文件内容回滚了但修改时间变了,Last-Modified 就会判断错。更精确的凭证是第二种——ETag,它是资源内容的指纹(通常是内容的哈希),内容变它才变。

# 协商缓存(二):ETag —— 基于资源"内容本身"的指纹,比时间更可靠

import hashlib
from flask import request, Response

def serve_with_etag(filepath):
    with open(filepath, 'rb') as f:
        body = f.read()
    # ETag = 内容的哈希:内容只要变一个字节,ETag 就变
    etag = '"' + hashlib.md5(body).hexdigest() + '"'

    # 浏览器把上次的 ETag 放进 If-None-Match 带回来
    inm = request.headers.get('If-None-Match')
    if inm is not None and inm == etag:
        # 指纹完全一致 —— 内容一个字节都没变,回 304
        resp = Response(status=304)
        resp.headers['ETag'] = etag
        return resp

    # 指纹不一致,或第一次来:回 200,带上完整内容和 ETag
    resp = Response(body, status=200)
    resp.headers['ETag'] = etag
    resp.headers['Cache-Control'] = 'no-cache'
    return resp

这一节的认知是:协商缓存和强缓存,省下的根本不是同一样东西——强缓存省的是"一整个网络往返",代价是它彻底放弃了对新鲜度的掌控;协商缓存省的是"响应体的那一大坨字节",它仍然付出了一次"轻量问答"的网络往返,但换回了对新鲜度的完全掌控。把这两者看成"两个新鲜度档位",而不是"一个有用、一个没用",你才算真正理解了缓存。第一版"ETag 白加"的困惑,根子就在于没分清这两件事省的是什么。它给响应加了 ETag,以为加上就万事大吉,可它服务端的代码从来没有去读 If-None-Match 这个请求头、也从来没有返回过 304——它只是把 ETag 当成一个"装饰"挂在响应上。可 ETag 这个东西,它本身一点用都没有,它的全部价值,在于"下一次请求时被带回来、并被服务端拿去比对"这个闭环。这个闭环是这样转的:服务端第一次返回资源时,附上一个 ETag(内容指纹);浏览器存下资源,也存下这个 ETag;下一次浏览器要同一个资源,它会把存着的 ETag 放进 If-None-Match 请求头里带上;服务端收到请求,重新算一遍当前资源的 ETag,和带回来的那个一比——一样,就说明内容一个字节都没变,回一个轻飘飘的、不带响应体的 304,浏览器于是接着用本地副本;不一样,才回 200 把新内容全量发过去。你看,关键的动作全在服务端那个"比对"上,第一版恰恰漏掉了这一步,所以它的 ETag 永远只是单向地发出去、从来没有被"用"过,304 自然永远不会发生。Last-Modified 是同样的闭环,只不过凭证从"内容指纹"换成了"修改时间"、请求头换成 If-Modified-Since;它更轻便(不用读取整个文件算哈希),但精度只到秒、也对付不了"内容变了但时间没变、时间变了但内容没变"的情况,所以 ETag 通常更可靠。理解了协商缓存,你就有了应对"会更新的资源"的武器:HTML 页面、会变的接口,给它们用协商缓存,既享受了"没变就不重传"的省流量,又不会像强缓存那样把用户锁死在旧版本里。而要让协商缓存生效,你得用一个特定的指令去触发它,这就引出了下一节那几个最容易被用反的指令。

四、no-cache / no-store / private / public:别把指令用反

第一版那个"想要每次校验、结果用了 no-store 把缓存全关了"的问题,就出在这里。Cache-Control 里有四个指令,名字看起来都和缓存有关,含义却天差地别,而且极容易望文生义用反。

# 四个最容易用反的指令 —— 名字像,含义差很远

# no-store:真正的"完全不缓存"。响应不许被任何缓存存下来,
#   每一次请求都必须完整地打到源服务器。
#   用于:转账结果、含敏感信息且绝不能落地的响应。
Cache-Control: no-store

# no-cache:容易被误读成"不要缓存" —— 其实是"可以存,
#   但每次用之前必须先找服务器协商(走上一节的 ETag 流程)"。
#   用于:会更新的 HTML、需要实时性的接口。
Cache-Control: no-cache

# private:这份响应是"私有的",只有终端用户的浏览器能存,
#   中间的共享缓存(CDN、代理)一律不许存。
Cache-Control: private, max-age=600

# public:明确允许共享缓存存储 —— 哪怕响应带了
#   Authorization 头,也允许 CDN 缓存(默认情况下不允许)。
Cache-Control: public, max-age=86400

这里最致命的混淆,是 no-cache 和 no-store。第一版想要的"每次都和服务器核对一下",对应的恰恰是 no-cache——它允许浏览器把响应存起来,只是规定每次复用前必须走一遍协商。而 no-store 是另一个极端:它要求响应连存都不许存,每次都全量重新请求,这是为转账凭据这类绝不能在本地留痕的响应准备的。第一版把"每次校验"和"完全不存"搞混,用了 no-store,于是协商缓存的省流量效果一点都没享受到。

这一节的认知是:这四个指令之所以人人都会用反,是因为它们其实在回答两个完全不同的问题,而它们的名字把这两个问题搅在了一起——no-storeno-cache 回答的是"新鲜度"问题(这份响应要多新?),privatepublic 回答的是"可见性"问题(这份响应能给谁存?);把这两个维度拆开,这四个指令立刻就清楚了。第一版的混乱,源于它以为"缓存"是一个一维的滑块,从"完全不缓存"滑到"使劲缓存",这四个词只是滑块上的几个刻度。可它们根本不在一根轴上。第一根轴是新鲜度,问的是"复用这份响应前,要不要先确认它没过时"。这根轴上,no-store 在最极端的一头——它的意思是"这东西太敏感、太易变,根本不存,每次都要最新的";no-cache 在中间——"可以存,但每次复用前都必须协商核对";max-age 在另一头——"在保质期内,看都不用看,直接用"。第二根轴是可见性,问的是"这份响应,除了请求它的那个用户,还能不能给别人用"。这根轴上,private 的意思是"这是这个用户私有的,只能存在他自己的浏览器里,CDN、代理这些'公用'的缓存绝对不能碰";public 的意思是"这是公开的,谁存都行,共享缓存尽管缓"。一旦你把"新鲜度"和"可见性"拆成两个独立的问题,第一版的两个事故就都有了精确的解释。"用户隐私 API 被 CDN 发给别人",是可见性这根轴上的错——那个响应是 private 的,却被默认当成了能进共享缓存的,所以该用 private、甚至该用 no-store。"想每次校验却把缓存关了",是新鲜度这根轴上的错——把 no-cache 错认成了 no-store。所以面对一个响应,你要分两步问:它要多新鲜(no-store / no-cache / max-age 里选一个)?它能给谁(private / public 里选一个)?两个问题分别回答、再组合起来,你就再也不会用反了。而可见性这根轴上还有一个最隐蔽的坑,留到第六节细说。先看一个比指令更釜底抽薪的办法——内容哈希。

五、内容哈希与缓存失效:让该更新的资源真的能更新

前面四节都在讲"怎么用各种指令把缓存控制好"。但对于 JS、CSS 这类静态资源,有一个比指令更彻底的办法,能让"长缓存"和"更新及时"这对看似矛盾的目标同时达成——给文件名嵌入内容哈希。

# 内容哈希:用文件内容的指纹给文件命名,内容一变文件名就变

import hashlib, os, shutil

def build_with_content_hash(src_path, out_dir):
    with open(src_path, 'rb') as f:
        content = f.read()
    # 取内容哈希的前 8 位作为版本指纹
    digest = hashlib.sha256(content).hexdigest()[:8]

    name, ext = os.path.splitext(os.path.basename(src_path))
    # app.js -> app.3f9a1c7b.js
    hashed_name = f'{name}.{digest}{ext}'
    shutil.copy(src_path, os.path.join(out_dir, hashed_name))
    return hashed_name

# 关键效果:只要 app.js 的内容变了哪怕一个字节,
# 哈希就变,文件名就从 app.3f9a1c7b.js 变成 app.8d2e5f01.js
# —— 这是一个全新的 URL,浏览器缓存里根本没有它,
# 自然会去下载新的;旧文件名的缓存留着也无所谓,没人再请求它。

有了哈希文件名,HTML 里就要引用这个带哈希的名字。而 HTML 自身,恰恰是那个"不能用长缓存"的文件——它得用协商缓存,这样每次发版,用户总能拿到最新的 HTML,而最新的 HTML 里又指向了最新的、带哈希的静态资源。

# 把"会变的 HTML"和"带哈希的静态资源"用两套缓存策略

server {
    # HTML:绝不能强缓存,否则用户被锁在旧页面。
    # 用 no-cache:可以存,但每次都来协商,确保拿到最新版。
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
    }

    # 带内容哈希的 JS/CSS:文件名即版本,内容永不原地改变,
    # 放心给一年的强缓存,immutable 表示连协商都免了。
    location ~* \.[0-9a-f]{8}\.(js|css)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

这一节的认知是:第一版面对的"长缓存"和"更新及时"看似是一对你死我活的矛盾——缓存得越久,更新越不及时——可这个矛盾,只在"资源会原地更新"这个前提下才存在;内容哈希做的事,是釜底抽薪地拆掉这个前提:它让资源永远不原地更新,而是每次更新都变成一个全新的、URL 都不同的资源,于是矛盾的双方就再也碰不到一起了。第一版的整个困境,可以浓缩成一句话:它想缓存 app.js,但 app.js 这个 URL 的内容是会变的。所有的麻烦——长 max-age 锁死旧版、不敢用长缓存又怕没缓存效果——全都源于这个"同一个 URL,内容却会变"的事实。内容哈希的精妙之处,在于它根本不去"解决"这个矛盾,而是直接消灭矛盾产生的土壤:它让文件名等于内容的指纹,这样一来,app.3f9a1c7b.js 这个 URL 就和某一份确定的内容被永久焊死了——这个 URL 的内容,在物理上、在逻辑上,都永远不可能再变。一个内容永不改变的 URL,你对它的 max-age 该设多长?答案是越长越好,一年、甚至更长,再加一个 immutable 告诉浏览器"连过期后的协商都省了,它就是不会变"。而当你真的改了代码,构建出来的文件名会自动变成 app.8d2e5f01.js——这是一个浏览器从没见过的新 URL,它的缓存里压根没有这一项,所以浏览器必然会去下载新的。旧的 app.3f9a1c7b.js 还静静躺在某些用户的缓存里?无所谓,再也不会有任何新的 HTML 去引用它了,它就是一份无害的垃圾,等着被缓存自然淘汰。这里的关键配合是:静态资源用"哈希文件名 + 超长 immutable 强缓存",而引用它们的 HTML 入口文件,绝对不能强缓存,必须用协商缓存(no-cache),保证用户每次都能拿到最新的 HTML——最新的 HTML 里写着最新的哈希文件名,这条更新链就完整地、可靠地闭合了。这套"哈希资源 + 协商 HTML"的组合,是现代前端工程缓存的标准答案。理解了它,你就明白第一版的错不在"max-age 设得太长",而在于它把长 max-age 用在了一个不该用的对象(会变 URL 的资源)上。把六节的内容收一收,该给一个响应配哪种缓存,可以画成下面这张决策图:

[mermaid]
flowchart TD
A[要给一个响应定缓存策略] --> B{这份响应含用户隐私吗}
B -->|是 含隐私或敏感| C[private 或 no-store 禁止共享缓存]
B -->|否 是公开内容| D{它的 URL 内容会原地变吗}
D -->|不会 带内容哈希的资源| E[public 加超长 max-age 加 immutable]
D -->|会 如 HTML 或接口| F[no-cache 配 ETag 每次协商]
F --> G{服务端处理 If-None-Match 了吗}
G -->|没有| H[ETag 形同虚设 304 永不触发]
G -->|有| I[没变回 304 变了回 200]

六、把 HTTP 缓存做扎实,要避开的工程坑

前面五节讲清了 HTTP 缓存的核心:分清强缓存与协商缓存、用条件请求让 304 生效、别把指令用反、用内容哈希做版本失效。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是最隐蔽的:同一个 URL,可能因为请求头不同而需要返回不同的内容,这时候必须用 Vary 告诉缓存"按什么区分"。

# 坑一:同一个 URL 会因请求头不同而返回不同内容 —— 必须用 Vary

from flask import request, Response

def serve_compressed(body_plain, body_gzip):
    # 同一个 /page 这个 URL,浏览器支持 gzip 就返回压缩版,
    # 不支持就返回原文 —— 内容取决于 Accept-Encoding 请求头。
    ae = request.headers.get('Accept-Encoding', '')
    if 'gzip' in ae:
        resp = Response(body_gzip)
        resp.headers['Content-Encoding'] = 'gzip'
    else:
        resp = Response(body_plain)

    resp.headers['Cache-Control'] = 'max-age=3600'
    # 关键:告诉缓存"这个响应是随 Accept-Encoding 变化的",
    # 缓存要按这个头分开存。漏了 Vary,缓存可能把 gzip 版
    # 发给不支持 gzip 的客户端 —— 内容直接乱码。
    resp.headers['Vary'] = 'Accept-Encoding'
    return resp

第二个坑,是缓存和认证撞在一起。一个需要登录才能看的页面,响应里往往带着用户身份,这种响应一旦被共享缓存(CDN)存下,就会发生第一版那样的"串号"事故。

# 坑二:带认证的响应,绝不能进共享缓存

from flask import Response

def serve_user_dashboard(user):
    body = render_dashboard(user)         # 这里面有该用户的私密数据
    resp = Response(body)

    # 反面:用了 public,或只写 max-age(默认就允许共享缓存)
    # resp.headers['Cache-Control'] = 'public, max-age=600'  # 危险

    # 正解:带用户数据的响应,必须 private;
    # 涉及敏感操作的,直接 no-store 最保险。
    resp.headers['Cache-Control'] = 'private, no-store'
    return resp

# 第一版的"A 用户看到 B 用户数据",就是因为这种响应
# 被 CDN 当成公共内容缓存了 —— private 能挡住 CDN,
# no-store 则连浏览器本地都不留,最干净。

还有几个坑值得点一下。其一,缓存策略变更要趁早——如果一个资源已经带着长 max-age 发出去了,你事后想缩短它,是没有办法的,那些已经缓存的客户端会一直用到原定的过期时间;所以拿不准的资源,宁可一开始就给短 max-age 或用协商缓存。其二,POST 等非幂等请求默认不被缓存,别试图去缓存它们;缓存只适合 GET 这类安全、幂等的请求。其三,max-age=0no-cache 效果接近但不完全相同——max-age=0 是"立刻过期、然后按常规流程(可能协商)处理",语义上不如直接写 no-cache 清晰,要表达"每次都协商"就老老实实写 no-cache。下面把强缓存和协商缓存集中对照一下:

强缓存 与 协商缓存 怎么选

  维度            强缓存                  协商缓存
  ------------------------------------------------------------
  靠什么生效      Cache-Control max-age   ETag 或 Last-Modified
  有没有网络请求  有效期内完全没有        每次都有一次轻量问答
  省下了什么      省掉整个网络往返        省掉响应体的传输
  适合的资源      带内容哈希 永不变的     会原地更新的 HTML 接口
  更新及时性      差 过期前锁死旧版       好 服务端一变就能拿到

  原则 HTTP 缓存是协商机制 不是单向的存一份
       会变的资源用协商缓存 不变的资源才用长强缓存

这一节这几个坑,串起来是同一个意思:HTTP 缓存的正确性,从来不是由"你在响应里写了什么"单方面决定的,而是由"你的响应""千变万化的请求"和"你控制不了的中间缓存"三者共同决定的——你必须站在整条链路上看它,而不是只盯着自己手写的那一个响应头。第一版把 HTTP 缓存当成一件"我说了算"的事:我在响应里写一个 Cache-Control,缓存就该乖乖照办。可这一节的每个坑都在说,不是的。Vary 这个坑,暴露的是"请求"这一侧的复杂性:你以为一个 URL 对应一份内容,可实际上,同一个 URL 会因为 Accept-EncodingAccept-Language 这些请求头的不同而返回不同的东西;缓存如果不知道这件事,就会拿一个请求的响应去回应另一个本不该匹配的请求——你必须用 Vary 显式地把"我这个响应是随哪些请求头变化的"告诉缓存。认证那个坑,暴露的是"中间缓存"这一侧的失控:你的响应从服务器出发,到用户的浏览器之间,可能穿过 CDN、穿过各级代理,这些共享缓存你一个都管不到,你唯一能做的,是用 privateno-store 这些指令明确地、严厉地告诉它们"这份不许你存";你一旦忘了,它们就会"好心地"把一个用户的隐私缓存下来发给所有人。缓存策略不可撤回那个坑,暴露的是"时间"这一维度:你今天发出去的每一个 max-age,都是一个泼出去就收不回的承诺,它会在客户端那里一直生效到过期为止,你没有任何后悔药。把这些坑串起来,你对 HTTP 缓存的心态就该彻底变了:它不是一个你单向下达的指令,它是你和无数请求方、无数中间缓存之间的一份需要字斟句酌的契约。你写下的每一个缓存头,都要想清楚——它面对各种各样的请求还成立吗?它经过那些你看不见的共享缓存还安全吗?它在未来那段不可撤回的有效期里还正确吗?想清楚这三问,你的 HTTP 缓存才算真的做扎实了。

关键概念速查

概念 说明
HTTP 缓存 把取到的资源存在浏览器或中间节点,再次请求时就近获取,省时间省带宽
强缓存 浏览器凭 max-age 自行判断新鲜度,有效期内完全不与服务器通信
协商缓存 浏览器带凭证问服务器资源是否变化,未变回 304,变了回 200
Cache-Control 控制缓存行为的核心响应头,含 max-age、no-cache、private 等指令
max-age 强缓存的保质期秒数,期内浏览器直接用本地副本不发请求
ETag 资源内容的指纹,内容变它才变,是协商缓存最可靠的凭证
Last-Modified 资源最后修改时间,作协商缓存凭证,精度只到秒不如 ETag
304 Not Modified 协商缓存命中时的响应,不带响应体,浏览器接着用本地副本
no-cache 与 no-store 前者可存但每次须协商,后者彻底禁止存储,二者极易用反
内容哈希文件名 文件名嵌入内容指纹,内容一变文件名即变,实现可靠的缓存失效

避坑清单

  1. 不要给所有响应一刀切加 max-age:资源更新节奏不同,会变的资源会被锁死在旧版。
  2. 不要给会变的 URL 设长 max-age:过期前浏览器不会来取新版,刷新也没用。
  3. 不要把 no-store 当成"每次校验":要每次协商用 no-cache,no-store 是彻底不缓存。
  4. 不要只挂 ETag 不处理 If-None-Match:服务端不比对,304 永远不会触发。
  5. 不要给带用户隐私的响应用 public:必须 private,敏感的直接 no-store。
  6. 不要忘记 Vary:响应随 Accept-Encoding 等请求头变化时,缺 Vary 会发错内容。
  7. 不要强缓存 HTML 入口文件:它必须用协商缓存,否则用户拿不到新版本。
  8. 不要给静态资源原地改内容:用内容哈希文件名,让更新等于换一个 URL。
  9. 不要试图缓存 POST 等非幂等请求:缓存只适合 GET 这类安全幂等的请求。
  10. 不要以为缓存策略能随时收回:已发出的 max-age 不可撤回,拿不准就先设短。

总结

回头看第一版那个"给所有响应统一加一天 max-age"的方案,它的失控很典型。它不在某一行代码,而在一个对 HTTP 缓存的根本误解:以为缓存就是"加一个 max-age、让浏览器存一份、过期前别来问我"这么一个单向动作,时间设得越长越好。真相是,HTTP 缓存是一套协商机制——它分强缓存和协商缓存两层,max-age 只是强缓存里最粗暴的一招;它还分私有和共享,一个响应能不能被 CDN 存下来事关重大。第一版只用了 max-age 这一招、还一视同仁地套给所有响应,于是发了新版用户还看旧的、用户隐私被 CDN 发给别人、ETag 加了等于没加,全都顺理成章。

而把 HTTP 缓存做对,工程量并不小。它不是"加个 max-age"那么简单,而是要分清强缓存与协商缓存各自适合什么资源、要用 ETag 或 Last-Modified 配合条件请求把 304 真正跑通、要分清 no-cache 与 no-store 与 private 与 public 这几个极易用反的指令、要给静态资源用内容哈希文件名让更新可靠地失效、还要处理好 Vary 和认证响应这些工程坑。一套真正可靠的 HTTP 缓存,是这些环节一个不少地拼起来的。

这件事其实很像从图书馆借书。第一版的做法,像是借书时跟管理员说"这本书我借一年",然后一年之内绝不再露面。如果这是一本内容永不变的书,那没问题。可如果这本书出了修订版、勘误了重要错误,你还抱着一年前那本旧的、完全不知情——这就是长 max-age 锁死旧版本。聪明的借法是怎样的?第一,真正不会再变的书(带版本号的那一版),你借多久都行(这就是内容哈希资源用超长强缓存)。第二,可能会出修订版的书,你别一借一年,而是借期短一点,到期时打个电话问管理员"这本有新版吗"——他说"没有,你接着看"(这就是 304),或者"有,我给你寄新的"(这就是 200),这通电话很短,比你重新跑一趟图书馆省事多了(这就是协商缓存)。第三,有些是别人的私人笔记本,只能借给你一个人,绝不能转借、更不能放进公共书架(这就是 private 和 no-store);而公共的图书,放进公共书架人人可取(这就是 public)。第四,你跟管理员说的借期,一旦说出口就改不了了,所以拿不准的书,宁可先说个短借期(这就是缓存策略不可撤回)。一本书能不能既借得久、又不耽误你看到最新版,靠的从来不是"借期一刀切地定长",而是你懂不懂得对不同的书,用不同的借法。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地测缓存,无非是点开页面、按按刷新,看 Network 面板里资源是不是 from cache——是,你就觉得"缓存成功了"。可你本地根本测不到那些真正会出事的场景:你不会在本地"发布一个新版本"再用一个"max-age 还没过期的浏览器"去访问,所以你撞不见"用户被锁在旧版本";你本地压根没有 CDN、没有中间代理,所以你看不到一个 private 响应被共享缓存"串号"发给别人;你本地通常只有自己一个用户、一种浏览器,所以 Vary 漏了也不会有人拿到乱码。真正会把问题撑爆的,是上线后的真实环境:真实的发布会一次次进行,把你那个长 max-age 的赌注一次次兑现成"用户看到旧版";真实的链路里一定有 CDN 和代理,会忠实地把你没标 private 的隐私响应缓存下来共享出去;真实的用户带着各种浏览器、各种语言偏好、各种 Accept 头而来,把你漏掉的每一个 Vary 都变成一次内容错乱。这些场景,你本地一个都模拟不到。所以如果你正在给一个服务配 HTTP 缓存,别等用户拿着旧版本来报 bug、别等有人在自己页面上看到别人的名字,才回头怀疑你那个统一的 Cache-Control。在写下第一个缓存头时就想清楚:这个资源会不会变、它该用强缓存还是协商缓存、它含不含隐私能不能进共享缓存、它会不会随请求头变化要不要加 Vary——把"让浏览器缓存一份"和"让缓存在真实的发布、CDN 和多样请求下依然正确"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

向量数据库选型完全指南:从一次"向量库把服务拖到 OOM"看懂为什么不能随便挑一个

2026-5-22 23:33:18

技术教程

RAG 知识库质量完全指南:从一次"知识库越塞越多回答反而越差"看懂为什么文档质量决定一切

2026-5-22 23:56:20

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索