CDN 缓存命中率完全指南:从一次"加了 CDN 命中率却几乎为零"看懂为什么边缘节点是空的

2024 年我给一个内容网站接 CDN这个网站的首页和文章页都很慢图片 CSS JS 全压在源站一台机器上访问高峰期出口带宽直接打满方案很标准接一个 CDN 把静态资源交给 CDN 遍布各地的边缘节点去缓存让用户就近访问把源站的流量分出去第一版我做得很顺手把网站域名 CNAME 到 CDN 厂商给的地址DNS 一改本地测了测资源确实从 CDN 的节点加载了我心里很笃定 CDN 嘛就是把我的资源复制一份放到全国各地的边缘节点上用户访问时哪个节点近就从哪个拿接上去缓存命中率自然就高源站压力自然就降可等它一上线一串问题冒了出来第一种最先把我打懵翻 CDN 控制台缓存命中率只有百分之二三十大量请求标着 MISS 绕过边缘节点一路回到了源站第二种最难缠加了 CDN 有些用户反而觉得更慢了第三种最头疼我更新了一个 CSS 文件可不少用户刷新很多次看到的还是旧样式第四种最莫名其妙我发现登录用户的个人主页竟然也被 CDN 缓存了用户 A 打开自己的主页看到的却是用户 B 的昵称和头像我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 CDN 就是把源站的资源原样复制一份预先铺到全国各地的边缘节点上可这个认知是错的本文从头梳理为什么接上 CDN 命中率却上不去Cache-Key 怎么把缓存打得粉碎Cache-Control 如何决定缓存行为改了文件怎么让 CDN 不再返回旧的什么内容能缓存什么不能以及一些把 CDN 缓存做扎实要避开的工程坑

2024 年我给一个内容网站接 CDN——这个网站的首页和文章页都很慢,图片、CSS、JS 全压在源站一台机器上,访问高峰期出口带宽直接打满。方案很标准:接一个 CDN,把静态资源交给 CDN 遍布各地的边缘节点去缓存,让用户就近访问,把源站的流量分出去。第一版我做得很顺手:把网站域名 CNAME 到 CDN 厂商给的地址,DNS 一改,本地测了测,资源确实从 CDN 的节点加载了,我心里很笃定:CDN 嘛,就是把我的资源复制一份、放到全国各地的边缘节点上,用户访问时哪个节点近就从哪个拿,接上去缓存命中率自然就高、源站压力自然就降——这 CDN 稳了。可等它一上线,一串问题冒了出来。第一种最先把我打懵:翻 CDN 控制台,缓存命中率只有百分之二三十,大量请求标着 MISS,绕过边缘节点、一路回到了源站,源站的带宽压根没降下来。第二种最难缠:加了 CDN,有些用户反而觉得更慢了,比不接 CDN 的时候还慢。第三种最头疼:我更新了一个 CSS 文件,可不少用户刷新很多次,看到的还是旧样式,过了大半天才慢慢好。第四种最莫名其妙:我发现登录用户的个人主页竟然也被 CDN 缓存了——用户 A 打开自己的主页,看到的却是用户 B 的昵称和头像。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为CDN 就是把源站的资源原样复制一份,预先铺到全国各地的边缘节点上;只要把域名指过去,用户的每一次请求都会就近从边缘节点直接拿到资源,边缘节点上"有我的一切",命中是默认状态、回源才是极少数的意外。可这个认知是错的。CDN 的边缘节点,一开始是空的——它不会预先持有你的任何资源。只有当某个用户的请求第一次打到某个边缘节点、节点发现自己没有这份资源(这叫 MISS),它才会回源站把资源拉过来、存进自己的缓存;之后,同一个节点再收到"同样的请求",才算命中(HIT),才轮到那份缓存发挥作用。所以缓存命中率高不高,根本不取决于"你接没接 CDN",而取决于一件事:同样的请求,能不能在边缘节点上反复命中那同一份缓存。而决定"两个请求算不算同样的请求"的,是 Cache-Key;决定"CDN 敢不敢缓存、缓存多久"的,是源站返回的 Cache-Control 头。第一版对这两样东西一无所知——URL 里带着每个用户都不同的查询参数、源站不发一个像样的缓存头,于是边缘节点上的缓存要么算出来的 key 各不相同、永远等不来第二个人,要么 CDN 根本不敢留它,命中率必然趴在地上。所以 CDN 缓存,根上不是"把域名指过去"这一个动作,而是一整套工程:要让同类请求算出同一个 Cache-Key、要用 Cache-Control 明确地告诉 CDN 缓存策略、要在资源更新时让 CDN 及时换掉旧的、还要分清什么能缓存什么绝不能。本文从头梳理:为什么"接上 CDN"命中率却上不去,Cache-Key 怎么把缓存打得粉碎,Cache-Control 如何决定缓存行为,改了文件怎么让 CDN 不再返回旧的,什么内容能缓存什么不能,以及一些把 CDN 缓存做扎实要避开的工程坑。

问题背景

先把 CDN 缓存这件事说清楚。CDN(内容分发网络)在你的源站和用户之间,加了一层遍布各地的边缘节点。用户的请求不再直接打到源站,而是先打到离他最近的边缘节点。边缘节点收到请求,先查自己本地的缓存:有,就是命中(HIT),直接把缓存的内容返回给用户,源站毫不知情;没有,就是未命中(MISS),节点这时才回源站(回源)把资源取回来,一边返回给用户、一边按规则存进自己的缓存,留给后面的请求。缓存命中率,就是 HIT 占全部请求的比例——它是衡量一个 CDN 用得好不好的核心指标。

错误认知是:CDN 把资源预先铺满所有边缘节点,接上域名命中就是默认的。真相是:边缘节点的缓存是"按需、被动"填充的,而且每个节点各缓存各的;命中率取决于同类请求能不能算出同一个 Cache-Key、源站有没有用 Cache-Control 允许缓存。把这一点摊开,第一版的几类问题就都能解释了:

  • 命中率上不去:URL 带着各人不同的查询参数,Cache-Key 各不相同,缓存永远等不来第二个相同请求。
  • 加了 CDN 反而更慢:大量 MISS 下,请求要先到边缘节点、再回源站,链路比直连源站还长。
  • 改了文件还看到旧的:边缘节点上的旧缓存没到期、也没被刷新,继续返回旧内容。
  • 看到别人的私人页面:动态、个性化的响应被 CDN 当成静态资源缓存,A 的页面被 B 命中。

所以让 CDN 真正省源站、又不出错,核心不是"把域名指过去",而是一整套工程:统一 Cache-Key、用 Cache-Control 管缓存、做好缓存刷新、分清可缓存性。下面六节,就从第一版"接上域名就万事大吉"的想当然讲起。

一、为什么"接上 CDN"命中率却上不去

第一版我接 CDN,所做的全部事情,就是把域名 CNAME 过去,源站的 Nginx 配置一个字都没动。

# 反面教材:接了 CDN,但源站什么缓存头都没发

server {
    listen 80;
    server_name www.example.com;
    root /var/www/site;

    # 静态资源:就这么直接发出去,没有任何 Cache-Control
    location /static/ {
        # 没有 add_header Cache-Control ...
        # 没有 expires ...
        # 源站对 CDN 一个字都没交代
    }

    # 文章页:动态生成,也没区分
    location / {
        proxy_pass http://127.0.0.1:8000;
    }
}

# 我以为:域名 CNAME 到 CDN,CDN 自己就会聪明地
# 把 /static/ 下的东西缓存起来。
# 可源站不发 Cache-Control,CDN 根本不知道这东西
# 能不能缓存、该缓存多久 —— 很多 CDN 此时会选择"不缓存,每次回源"。

问题就藏在这里:接 CDN 不是"把域名指过去"这一个动作就完成了。CDN 的边缘节点收到一个请求、决定要不要把响应缓存下来,靠的是源站在响应里带的 Cache-Control 头。第一版的源站,对所有资源都不发这个头。CDN 拿到一个没有任何缓存指示的响应,它无从判断这东西是能缓存一年的图片、还是一秒都不能缓存的实时数据——保守的 CDN 此时往往选择"宁可不缓存",于是每个请求都老老实实回源,命中率自然趴在地上。

# 想知道一个请求到底命中没有,看 CDN 在响应里加的状态头
# (不同 CDN 头名不同:X-Cache / X-Cache-Status / CF-Cache-Status ...)

curl -sI https://www.example.com/static/app.css

# 看返回头里这一行,它告诉你命中了没有:
#   X-Cache: MISS from edge-node-shanghai   未命中,这一次回源了
#   X-Cache: HIT  from edge-node-shanghai   命中,边缘节点直接给的
#
# 第一版我一连 curl 很多次,每次都是 MISS ——
# 这说明边缘节点根本没把这个 CSS 存下来,
# 每一次请求,都穿过 CDN、回到了源站。

这一节要建立的认知是:CDN 的边缘节点不是一个"预先装满了你所有资源的镜像仓库",而是一个"一开始空空如也、靠用户请求一点一点喂出来的缓存"——它持有什么、不持有什么,完全是被你的流量和你的缓存头塑造出来的,而不是接上 CDN 就自动成立的。第一版最深的想当然,是把 CDN 想象成了"源站的副本":以为我有的东西,边缘节点也都有,用户来取,就近给一份就行。可边缘节点根本不是副本。它是一个缓存,而缓存的本质是"按需、被动"——它不会预先去源站把东西全搬过来,它只在"有人来要、而它恰好没有"的时候,才回源取一次、顺便存一份。这意味着,边缘节点上有没有某份资源,取决于两件事:一,在此之前有没有人在这个节点上请求过它(没人请求过,节点就是空的);二,源站当初有没有通过 Cache-Control 允许它把这份资源存下来(源站不允许,节点取了也不留)。第一版两件事都没做对:它默认"节点上都有",所以从没关心过缓存头;结果源站不发缓存头,节点取了不留,每个请求都 MISS、都回源。所以接 CDN 的第一课,是丢掉"镜像仓库"这个幻觉,换成一个清醒的认知:边缘节点是一块需要你主动经营的缓存,你要做的所有工程,都是为了让这块缓存"存得下、留得住、反复命中"。而"反复命中"的前提,是同类请求得算出同一个 key——那就是下一节的 Cache-Key。

二、Cache-Key:决定"两个请求算不算同一个请求"

边缘节点缓存里存的每一份内容,都挂在一个 key 下面。一个请求进来,节点用某种规则从请求里算出一个 key,拿这个 key 去缓存里找:找到了就是命中。这个 key,就是 Cache-Key。它默认由请求的 URL(含查询参数)构成,但很多 CDN 还允许你把 Cookie、某些 Header 也算进去。命中率的头号杀手,就是 Cache-Key 被算得太"细"。

# 反面教材:这些 URL 在用户眼里是"同一个 CSS",
# 但默认 Cache-Key 把它们算成了四个完全不同的 key

# 用户实际请求到的 URL,带着五花八门的参数:
urls = [
    "/static/app.css?v=1001",
    "/static/app.css?v=1001&utm_source=weibo",     # 带了营销追踪参数
    "/static/app.css?v=1001&from=homepage",         # 带了来源参数
    "/static/app.css?v=1001&t=1716300000",          # 带了随机时间戳
]

# CDN 默认拿"完整 URL"当 Cache-Key:
# 上面四个 URL 字符串两两不同 -> 四个不同的 key
# -> 边缘节点上存了四份一模一样的 app.css
# -> 每一份都要单独回源一次,命中率被生生打成四分之一。

# utm_source、from、t 这些参数,对"返回哪个文件"
# 没有任何影响,却被算进了 key —— 这就是缓存被打碎。

解药是:在 CDN 算 Cache-Key 时,明确地告诉它"哪些查询参数才真正决定内容、哪些是噪声该忽略",同时,静态资源的 key 里绝对不能掺进 Cookie。

# 正解:Cache-Key 只保留真正决定内容的部分
# (这里用 Nginx 作为缓存层示意,CDN 控制台上是等价的配置项)

proxy_cache_path /data/cache levels=1:2 keys_zone=cdn:100m;

server {
    location /static/ {
        proxy_cache cdn;

        # 关键一:Cache-Key 只用"路径 + 真正决定内容的参数 v",
        # utm_source / from / t 这些参数,统统不进 key
        proxy_cache_key "$uri?v=$arg_v";

        # 关键二:把请求里的 Cookie 头摘掉再往下走,
        # 绝不让它有任何机会进入 Cache-Key
        proxy_set_header Cookie "";

        proxy_pass http://origin;
        add_header X-Cache $upstream_cache_status;
    }
}

# 每个登录用户的 Cookie 里都有独一无二的 session id,
# 一旦 Cookie 进了 Cache-Key,1 万个用户 = 1 万个不同的 key,
# 同一个 app.css 被存了 1 万份,命中率几乎是零。

这一节的认知是:Cache-Key 是缓存的"身份证"——它决定了两个请求在 CDN 眼里"算不算同一个请求";而命中率的高低,本质上就是你的 Cache-Key 设计得"粗"还是"细"。第一版从没意识到 Cache-Key 的存在,默认接受了 CDN 最"细"的那套规则:整条 URL、连带 Cookie 全算进 key。这套规则的问题在于,它把"字符串不同"等同于"内容不同"——可这两件事根本不是一回事。/static/app.css?v=1001/static/app.css?v=1001&utm_source=weibo,字符串明明不同,但它们要取的是同一个文件、内容一模一样;一个带 session Cookie 的请求和另一个带不同 session Cookie 的请求,要取的也还是同一个 app.css。CDN 默认的细粒度 key,把这些"内容相同"的请求,因为"字符串不同"而判成了不同请求,于是同一份内容在边缘节点上被存了无数份,每一份都要单独回源,命中率被稀释到接近零。设计 Cache-Key 的正确思路,是反过来问:在这个请求里,到底哪些部分真正决定了"返回什么内容"?对一个静态文件,答案通常只有"路径"和那个表示版本的参数;其余的查询参数、Cookie、大部分 Header,都和"返回什么"无关,就都不该进 key。把 key 里那些"因人而异、因来源而异、因时间而异"的噪声全部剔掉,只留下真正决定内容的那点东西,你才能让一万个用户的请求,命中边缘节点上同一份缓存。而 key 对上了只是"能命中"的前提,CDN 到底敢不敢缓存、缓存多久,还得看下一节的 Cache-Control。

三、Cache-Control:源站怎么告诉 CDN 缓存策略

Cache-Key 解决"算不算同一个请求",Cache-Control 解决"这个响应到底能不能缓存、能缓存多久"。它是源站在响应头里发出的一个指令,CDN 和浏览器都听它的。第一版的源站根本不发这个头,CDN 只能靠猜。要让 CDN 行为可控,必须由源站明确地发出 Cache-Control。

# 源站按资源类型,明确地发出 Cache-Control

server {
    # 带版本号的静态资源:内容永不变(变了就换 URL),
    # 可以让 CDN 和浏览器都长期缓存
    location ~* \.(css|js|jpg|png|woff2)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # HTML 页面:内容会更新,CDN 可短暂缓存,但要能快速更新
    location ~* \.html$ {
        # s-maxage 只对 CDN 这类共享缓存生效:CDN 缓存 60 秒
        # max-age 对浏览器生效:设 0,让浏览器每次都来问 CDN
        add_header Cache-Control "public, s-maxage=60, max-age=0";
    }

    # 接口、个人页面:绝不能被任何共享缓存留存
    location /api/ {
        add_header Cache-Control "private, no-store";
    }
}

HTML 页面还有一个进阶技巧:缓存过期的那一刻,不该让一个倒霉用户原地等一次完整回源。stale-while-revalidate 就是为这个设计的。

# Cache-Control 进阶:stale-while-revalidate
# 让缓存过期的那一刻,用户也不用站着等回源

location ~* \.html$ {
    # CDN 缓存 60 秒;过期后的 600 秒内,
    # 仍然先把"过期的旧缓存"立刻返回给用户(用户不等),
    # CDN 自己在后台悄悄回源、把缓存换成新的
    add_header Cache-Control "public, s-maxage=60, stale-while-revalidate=600";
}

# 没有这个指令:缓存一过期,下一个倒霉的用户
# 就要原地等一次完整的回源。
# 有了它:那个用户拿到的是稍旧一点的内容(旧几秒),
# 但他不用等 —— 用一点新鲜度换响应速度,这笔交易通常很划算。

这一节的认知是:CDN 该缓存什么、缓存多久,这个决定权不在 CDN,而在源站——Cache-Control 就是源站手里那根唯一的指挥棒,你不挥它,CDN 要么瞎猜、要么干脆不缓存。第一版默认了一件危险的事:它以为 CDN 会"自己很聪明地"判断什么该缓存。可 CDN 并不知道你的业务——它没法分辨 /static/app.css 是一个一年都不会变的文件、还是 /api/balance 是一个一秒都不能旧的余额。这个区分,只有源站知道,也只有源站能通过 Cache-Control 告诉 CDN。这根指挥棒上有几个必须分清的刻度:public 和 private——public 才允许 CDN(一个被所有人共享的缓存)缓存,private 表示"这内容只属于某一个人,只有他自己的浏览器能存,CDN 不许碰";max-age 和 s-maxage——max-age 管浏览器,s-maxage 专门管 CDN 这类共享缓存,你常常想让 CDN 缓存、却不想让浏览器也缓存(这样内容一更新,CDN 一刷新所有人就都更新了),这时就靠 s-maxage 和 max-age 取不同的值;no-store 则是最硬的一条——任何缓存都不许留。第一版的根本问题,不是某个刻度调错了,而是它压根没握住这根指挥棒:它一个 Cache-Control 都不发,把"缓存策略"这个本该由业务来定的决定,丢给了 CDN 去猜。把每一类资源该用什么 Cache-Control 想清楚、并由源站明确地发出来,CDN 的行为才第一次变得可控。而其中最需要被管好的一件事,是缓存的更新——那是下一节的事。

四、缓存刷新:改了文件,怎么让 CDN 不再返回旧的

给静态资源设了很长的 max-age,命中率是上去了,但马上撞到第三个问题:文件更新了,边缘节点上的旧缓存还没到期,用户拿到的还是旧的。解决它有两条路。第一条,也是最该优先的——版本化 URL:别去"改"文件,而是让更新后的文件有一个全新的 URL。

# 缓存刷新法一:版本化 URL —— 内容一变,URL 就变

import hashlib

def versioned_url(filepath, url_path):
    # 用文件内容算一个哈希,塞进文件名
    with open(filepath, "rb") as f:
        digest = hashlib.md5(f.read()).hexdigest()[:8]
    # app.css  ->  app.a1b2c3d4.css
    name, ext = url_path.rsplit(".", 1)
    return f"{name}.{digest}.{ext}"

# 内容没变:哈希不变 -> URL 不变 -> 一直命中老缓存。
# 内容一变:哈希变了 -> URL 变成全新的一个
#          -> 对 CDN 来说这是个从没见过的新资源,
#             第一次请求 MISS、回源、缓存;旧 URL 自然没人再访问。

# 这就是为什么静态资源可以放心设 max-age=31536000(一年):
# 你根本不需要"刷新"它 —— 要更新,就换一个 URL。

版本化 URL 适合 CSS、JS、图片这种"产物文件"。可有些 URL 是变不了的——网站首页就是 /,你不可能给首页换个地址。这类 URL 更新了,就得用第二条路:主动调用 CDN 的刷新(purge)接口,告诉 CDN"这个 URL 的缓存作废了"。

# 缓存刷新法二:URL 变不了的,发布后主动调 CDN 的刷新接口

import requests

def purge_cdn(urls):
    # 各家 CDN 都有这样一个刷新 API,发布流程结束后调它
    resp = requests.post(
        "https://api.cdn-provider.com/v1/purge",
        headers={"Authorization": f"Bearer {get_cdn_token()}"},
        json={"urls": urls},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()["task_id"]

# 部署脚本的最后一步:把这次更新到的、URL 又没法变的页面
# 一次性提交给 CDN 刷新
purge_cdn([
    "https://www.example.com/",
    "https://www.example.com/about.html",
])

# 刷新不是瞬间完成的,全网边缘节点生效要几秒到几分钟;
# 注意:get_cdn_token 要从环境变量 / 密钥管理里读,
# 绝不要把 CDN 的密钥硬写进代码。

这一节的认知是:CDN 缓存的"更新"有两种根本不同的思路——"让旧缓存失效"和"让新内容换一个身份";第一版只知道前者,而真正可靠、可扩展的,是后者。"让旧缓存失效"是大多数人的第一反应:文件改了,就想办法把 CDN 上那份旧的"删掉"或"刷新"。这个思路能用,但它有两个软肋。一是它有延迟和不确定性:刷新指令要传达到全网成百上千个边缘节点,这中间有几秒到几分钟的窗口,窗口里不同地区的用户,有的看到新的、有的还看到旧的。二是它依赖一个"额外的、可能失败的动作":你得记得在发布后调刷新接口,接口可能超时、可能漏调,一旦漏了,旧缓存就会一直挂到自然过期。"让新内容换一个身份",也就是版本化 URL,是一个聪明得多的思路:它根本不去碰旧缓存,而是让更新后的文件带着内容哈希、变成一个全新的 URL。对 CDN 来说,新 URL 是个素未谋面的新资源,天然就会回源拿最新的;而旧 URL,因为页面上引用的地方已经全部换成新 URL 了,自然再没有人访问,它挂在边缘节点上慢慢过期,不影响任何人。这个思路把"更新"从一个"主动失效旧的"动作,变成了"内容和 URL 一一对应"的一个静态属性——没有延迟窗口、没有可能漏掉的接口调用。所以正确的策略是分层的:能换 URL 的资源(CSS/JS/图片),一律用版本化 URL,并放心给它们设一年的强缓存;换不了 URL 的(首页这类),才退而求其次,用 purge 接口,并接受它的延迟。把"更新缓存"想成"尽量让内容换身份、不得已才去失效",你才能既敢上强缓存、又不怕更新。而无论缓存怎么更新,有一类内容是从一开始就不该进 CDN 缓存的——那是下一节的事。

五、什么能缓存、什么不能:静态、动态与个性化

前面解决的都是"怎么缓存得更好"。但还有一个更前置的问题:这个响应,到底该不该进 CDN 缓存?第一版栽的第四个跟头——用户 A 看到用户 B 的主页——根子就在这里。响应可以分成三类。第一类是纯静态资源(CSS、JS、图片),对所有人都一样,放心缓存。第二类是动态但公共的内容(文章页、商品页),对所有人也一样,只是会更新,可短时间缓存。第三类是个性化内容,因人而异,绝不能进 CDN 这个公共缓存。

# 按"可缓存性"给三类内容配不同策略

server {
    # 第一类:纯静态资源 —— 强缓存,配合版本化 URL
    location ~* \.(css|js|jpg|png|woff2)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # 第二类:公共的动态页(文章 / 商品) —— CDN 短缓存
    location ~* ^/(article|product)/ {
        add_header Cache-Control "public, s-maxage=60";
    }

    # 第三类:个性化 / 私密内容 —— 一个字节都不许 CDN 缓存
    location ~* ^/(user|account|cart|api)/ {
        add_header Cache-Control "private, no-store";
    }
}

# 第一版的灾难:/user/profile 这种个性化页面,
# 没设 no-store,被 CDN 当成普通页面缓存了 ——
# A 第一个访问,他的主页被存进边缘节点;
# B 再访问 /user/profile,Cache-Key 一样,直接命中了 A 的页面。

个性化内容里有一个尤其隐蔽的情况:同一个 URL,对未登录用户是公共的、可缓存的,对登录用户却是个性化的。比如文章页,游客看到的是纯内容,登录用户的页眉上却带着他自己的头像和昵称。这种 URL 不能简单地"缓存"或"不缓存",要按"有没有登录"分流。

# 同一个 URL,登录用户和游客要区别对待

map $cookie_sessionid $bypass_cache {
    default   0;     # 没有 session(游客):走缓存
    "~.+"     1;     # 带 session(登录用户):跳过缓存,直接回源
}

server {
    location ~* ^/article/ {
        proxy_cache cdn;
        # 登录用户:bypass 置 1,这次请求不读缓存
        proxy_cache_bypass $bypass_cache;
        # 且登录用户的响应,也不准存进缓存
        proxy_no_cache $bypass_cache;
        proxy_pass http://origin;
    }
}

# 游客访问 /article/123:走 CDN 缓存,命中率高、源站轻松。
# 登录用户访问同一个 /article/123:跳过缓存回源,
# 拿到带自己头像的那一版 —— 两类人互不污染。

这一节的认知是:决定一个响应能不能进 CDN 缓存,要问的不是"它是不是静态文件",而是"它对所有人是不是都一样"——CDN 缓存是一个被所有用户共享的公共空间,任何"因人而异"的东西放进去,都会被错误地分发给别人。第一版判断可缓存性的标准,模模糊糊是"静态 vs 动态":CSS、图片这种"文件"是静态的、可缓存,页面是动态的、不可缓存。可这个标准是错位的。真正该问的标准只有一个:这个响应,是不是"对谁都一样"。用这个标准重新看,事情就清楚了:CSS、JS、图片对谁都一样,能缓存;一篇文章的正文,虽然是"动态生成"的,但它对所有访客也都一样,所以同样能缓存(这恰恰是 CDN 能帮动态网站省下大量源站压力的关键)。而真正不能进 CDN 的,是那些"因人而异"的响应——用户的个人主页、购物车、账户余额。它们的危险,不在于"动态",而在于"私人":CDN 缓存是一块所有用户共用的公共空间,你把 A 的私人页面放进去,它就挂在某个 Cache-Key 下面,B 只要算出同一个 key,就会命中 A 的页面。这已经不是"缓存没刷新"的小毛病,而是实打实的数据越权。所以给每一类响应做决定前,先问那个唯一的问题:它对所有人都一样吗?一样的,放心交给 CDN;只要"因人而异",哪怕只有页眉上一个头像的差别,也必须用 no-store 或登录态分流,把它彻底挡在公共缓存之外。把"可缓存性"从"静态还是动态"扭正成"公共还是私人",CDN 才不会变成一个泄露隐私的分发器。

把一个请求到达 CDN 边缘节点之后,它怎么一步步决定命中、回源还是绕过缓存,这个流程画出来就是下面这张图:

[mermaid]
flowchart TD
A[请求到达边缘节点] --> B{响应可缓存吗 看 Cache-Control}
B -->|no-store 不可缓存| C[直接回源 不存缓存]
B -->|可缓存| D[按规则算出 Cache-Key]
D --> E{这个 key 在缓存里吗}
E -->|不在 即 MISS| F[回源取资源 按 TTL 存入缓存]
E -->|在 即 HIT| G{缓存还在 TTL 有效期内吗}
G -->|已过期| F
G -->|仍有效| H[直接返回缓存 不回源]

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

前面五节讲清了 CDN 缓存的核心:Cache-Key、Cache-Control、缓存刷新、可缓存性。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是命中率上来之后最容易出事的:回源风暴。

# 坑一:缓存集中过期的瞬间,会有一波"回源风暴"

# 场景:一个热门资源,TTL 到期的那一刻,
# 成百上千个边缘节点几乎同时发现自己缓存过期,
# 同时回源 —— 源站瞬间被打挂。

server {
    location /static/ {
        proxy_cache cdn;

        # 防线一:回源合并 —— 同一个资源同时有 N 个请求要回源时,
        # 只放一个真的回源,其余请求就地等它的结果
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;

        # 防线二:回源失败 / 源站抖动时,
        # 宁可返回一份过期的旧缓存,也别把错误透传给用户
        proxy_cache_use_stale error timeout updating;

        proxy_pass http://origin;
    }
}

# 再加一层 origin shield(中间父节点):让全国的边缘节点
# 不直接回源,而是先回到一个中间父层,由它统一回源 ——
# 源站面对的就从"成百个边缘节点"收敛成"几个父节点"。

第二个坑,是 Vary 头。它用对了是必要的,用错了是又一台"缓存粉碎机"。

# 坑二:Vary 头用错,会把缓存悄悄打碎

# Vary 告诉 CDN:这个响应"随某个请求头而变",
# 那个头取值不同,就得当成不同的缓存来存。

# 反面:Vary 了 User-Agent —— 用户的 UA 几乎人人不同,
#       等于给每一种 UA 都存一份缓存,命中率暴跌
add_header Vary "User-Agent";        # 不要这样

# 正面:只 Vary 真正会改变响应内容、且取值很少的头。
#       比如按需返回 gzip / br 压缩,就 Vary Accept-Encoding
#       (取值就那么两三种,缓存最多分两三份,完全可接受)
add_header Vary "Accept-Encoding";   # 这样才对

还有几个坑值得点一下。其一,CDN 命中率不能只看一个总数,要分资源类型看——静态资源的命中率本该在 95% 以上,如果某类资源命中率异常低,多半是它的 Cache-Key 或 Cache-Control 出了问题,而总数会把这种局部问题平均掉、藏起来。其二,别忘了 CDN 还是一道成本的闸门:回源流量是要单独计费的,命中率每低一个百分点,都是源站带宽和回源费用实打实的增加。其三,HTML 页面缓存要格外小心,它常常是"半公共半私人"的,上缓存前一定要确认页面里没有混入任何用户态的内容。下面把 CDN 缓存的几个关键开关集中对照一下:

CDN 缓存的几个关键开关对照

  开关 / 概念      管什么                     用错的后果
  --------------------------------------------------------------
  Cache-Key        两个请求算不算同一个       带了无关参数 Cookie 命中率趋零
  Cache-Control    能不能缓存 缓存多久        不发就靠猜 个性化内容会被缓存
  max-age          浏览器缓存时长             设太长 更新后用户长期看到旧的
  s-maxage         CDN 缓存时长               专门管 CDN 与浏览器分开调
  版本化 URL       内容更新                   不做就得依赖 purge 有延迟会漏
  no-store         私人内容不进缓存           漏设 A 的私人页面会命中给 B

  原则:CDN 缓存是一块共享的公共缓存 ——
        让对所有人都一样的内容反复命中 把因人而异的内容彻底挡在外面

这一节这几个坑,串起来是同一个意思:CDN 不是一个"接上就只有好处"的加速开关,而是在你的源站之前,新增了一层有自己行为、自己故障模式、自己计费规则的基础设施——你享受它带来的命中,就得一并接手它带来的复杂度。第一版把 CDN 当成一个纯粹的、单向的好处:接上去,流量就少了、速度就快了,没有代价。但这一节的每个坑,都是这层新基础设施的一个"代价侧"。回源风暴告诉你:缓存会集中过期,而过期的瞬间,这层基础设施会把压力反过来汇聚到源站,你得用回源合并、origin shield 去削平它。Vary 用错告诉你:这层基础设施有自己的一套规则(Vary、Cache-Key),你不理解它就会亲手把命中率砸了。分类型看命中率、关注回源计费告诉你:这层基础设施有自己的可观测性和成本账,你不去看,就管不住它。把 CDN 理解成"新增的一层基础设施",而不是"一个加速开关",你的心态就对了:你不会再指望"接上就好",而会像对待源站、对待数据库一样,去配置它、监控它、为它的故障模式做预案。CDN 的价值是真实而巨大的——它能把绝大部分流量挡在源站之外;但这份价值,要靠你认真经营这层基础设施才能拿到,而不是 CNAME 一改就自动到账。

关键概念速查

概念 说明
CDN 在源站和用户之间加一层遍布各地的边缘节点,就近响应请求
边缘节点 CDN 的缓存节点,缓存按需被动填充,每个节点各缓存各的
缓存命中率 HIT 占全部请求的比例,衡量 CDN 用得好不好的核心指标
回源 边缘节点未命中时向源站请求资源的过程,回源流量单独计费
Cache-Key 决定两个请求算不算同一个的指纹,默认由 URL 构成
Cache-Control 源站发出的缓存指令,决定能否缓存及缓存时长
max-age / s-maxage 分别控制浏览器和 CDN 的缓存时长,可取不同值
版本化 URL 内容变则文件名哈希变,用换 URL 代替刷新缓存
purge 主动调 CDN 接口让指定 URL 缓存失效,有秒级到分钟级延迟
回源风暴 缓存集中过期时大量节点同时回源,需回源合并与 origin shield 削峰

避坑清单

  1. 不要以为接上 CDN 命中率就高:边缘节点缓存是按需填充的,要主动经营。
  2. 不要让无关查询参数进 Cache-Key:utm、来源之类参数会把缓存打碎。
  3. 不要让 Cookie 进静态资源的 Cache-Key:每人一个 key,命中率归零。
  4. 不要不发 Cache-Control:源站不发,CDN 只能瞎猜或干脆不缓存。
  5. 不要给会更新的资源设超长 max-age 又不版本化:用户会长期看到旧的。
  6. 不要只靠 purge 刷新:能换 URL 的资源一律用版本化 URL。
  7. 不要把个性化页面交给 CDN:务必用 no-store,否则会串号、泄露隐私。
  8. 不要忽视回源风暴:用回源合并、origin shield 削平集中过期的尖峰。
  9. 不要随便 Vary User-Agent:取值太多会把缓存打碎,只 Vary 必要的头。
  10. 不要只看总命中率:要分资源类型看,局部问题会被总数平均掉。

总结

回头看第一版那个"把域名 CNAME 过去就完事"的 CDN 接入,它的失败很典型。它不在某一行配置,而在一个对 CDN 的根本误解:以为 CDN 是源站的一份预先铺好的镜像,接上域名,命中就是默认状态。真相是,CDN 的边缘节点一开始是空的,缓存靠用户请求按需、被动地填充;一个请求能不能命中,取决于它能不能算出和别人一样的 Cache-Key,取决于源站有没有用 Cache-Control 允许缓存。第一版 URL 带着各人不同的参数、源站一个缓存头都不发,于是缓存被打得粉碎、CDN 不敢留存,命中率必然趴在地上,源站的压力一点没省。

而把 CDN 缓存做对,工程量并不小。它不是"改一条 DNS"那么简单,而是要把无关参数和 Cookie 踢出 Cache-Key、要让源站按资源类型明确发出 Cache-Control、要用版本化 URL 把"更新"变成"换地址"、要给换不了 URL 的内容备好 purge、要用 no-store 和登录态分流把个性化内容彻底挡在公共缓存外、还要用回源合并和 origin shield 防住回源风暴、要分资源类型监控命中率。一套真正能省源站又不出错的 CDN 缓存,是这些环节一个不少地拼起来的。

这件事其实很像一家连锁便利店,在各个小区门口开了分店,而商品都来自城外一个总仓库。第一版的想法是"分店开了,顾客要什么就有什么"。可新分店的货架一开始是空的——它不会预先把总仓库的货全搬一份过来。顾客来要一样东西,分店没有(MISS),才派人去总仓库取一趟、顺便在货架上多摆几个,下一个顾客再要同样的,才能直接从货架拿(HIT)。所以分店能不能帮总仓库分担,关键有几个。第一,得让顾客的需求"对得上货架上的货":要是每个顾客都用一句独一无二的话来描述同一瓶水,店员就没法把他们的需求归到同一个货位上——这就是 Cache-Key 要统一。第二,总仓库得给每种货标好"能不能在分店卖、能摆多久":生鲜两天就下架,罐头能摆一年——这就是 Cache-Control。第三,商品换了新包装,聪明的做法不是满城跑去各分店把旧包装撤下来,而是直接给新包装一个新条码,旧条码自然没人再买——这就是版本化 URL。第四,有些东西是某个顾客的私人寄存物,它绝不能上公共货架,否则就会被另一个人取走——这就是个性化内容必须 no-store。一家分店能真正帮总仓库分流,靠的从来不是"开了分店"这个动作本身,而是把货架怎么摆、货怎么标、怎么换新、什么不能上架,这一整套都经营对了。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地测,要么压根没接 CDN、直连源站,要么自己一个人反复刷同一个 URL——你刷第二次,缓存命中了,看着很美;你也只有一个人、一个 Cookie,撞不出"每人一个 Cache-Key"的惨状;你测的那点时间里,缓存还没到期,更新延迟、回源风暴一个都遇不到。你会觉得"CDN 嘛,域名一指就行了"。真正会把问题撑爆的,是上线后的真实流量:成千上万的用户,带着五花八门的 utm 参数、各不相同的 session Cookie 访问同一个资源,把你那个细粒度的 Cache-Key 撞成无数个、命中率打到地板;真实的发布会让你撞上更新延迟;真实的热点资源集中过期会让你撞上回源风暴;真实的登录用户会让你撞上个性化内容被缓存的越权。这些场景,你本地一个都模拟不到。所以如果你正在给一个网站接 CDN,别等命中率报表难看、别等用户投诉"改了还是旧的"或者"看到了别人的信息",才回头怀疑你的接入方式。在改那条 DNS 之前就想清楚:我的同类请求能不能算出同一个 Cache-Key、我的源站发没发 Cache-Control、我的资源更新了怎么让 CDN 知道、我有没有个性化内容会漏进公共缓存——把"接上 CDN"和"让 CDN 真的高命中、不出错地分担源站"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

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

LLM 语义缓存完全指南:从一次"缓存命中率几乎为零"看懂为什么不能用字符串匹配

2026-5-22 22:37:30

技术教程

RAG 检索重排序完全指南:从一次"向量检索答案却总不对"看懂为什么 top-K 不能直接喂模型

2026-5-22 22:57:34

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