# 保哥笔记 — 缓存与CDN > 本分片含 13 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:缓存与CDN **生成**:2026-09-12 16:00:11 CST --- ## 每次发完文章都清一遍Redis,直到我去数了数那个库里有几个键 - URL:https://zhangwenbao.com/multilayer-cache-purge-order-and-receipt.html - 分类:缓存与CDN - 发布:2026-07-31 | 更新:2026-07-31 - 摘要:实测一台生产服务器的四层缓存:常清的库一直是空的、命令行重置对进程池零影响、失败命令照样打印成功。附清理顺序推演、全清成本账与三层作证清单。 - 关键词:Redis,服务器运维,缓存 > **TLDR**:摘要:每次发完文章都要跑一遍清缓存流程,其中一条是清那个键值库的0号库。跑了大半年,这次顺手数了一下:0号库里一直是0个键。真正装着东西的是1号库的2159个键和2号库的8377个键,而那两批键的前缀属于同一台机器上另外两个站点,跟本站毫无关系。同一轮排查里还钉死了第二件事:命令行里那句字节码缓存重置返回 bool(false),执行前后进程池那边的重启计数和命中数一个数字都没变。两件事的失败模式完全一样——不报错、不抛异常、脚本继续往下打印“已完成”。 > 摘要:每次发完文章都要跑一遍清缓存流程,其中一条是清那个键值库的0号库。跑了大半年,这次顺手数了一下:0号库里一直是0个键。真正装着东西的是1号库的2159个键和2号库的8377个键,而那两批键的前缀属于同一台机器上另外两个站点,跟本站毫无关系。同一轮排查里还钉死了第二件事:命令行里那句字节码缓存重置返回 bool(false),执行前后进程池那边的重启计数和命中数一个数字都没变。两件事的失败模式完全一样——不报错、不抛异常、脚本继续往下打印“已完成”。 发完文章清缓存,这是每个自建站的人都有的收尾动作。命令通常是从某篇教程里抄来的,抄完就再没看过,因为它从来不报错。 不报错这件事本身就值得怀疑一下。这次我把整条清理链路拆开量了一遍,量出来的东西比预想的难看:四层缓存里,我常年在清的那一层是空的;另一层我以为清掉了,其实那条命令连执行都没执行成功。 下面的数字全部来自2026年7月31日对一台真实生产服务器的实测,包括我自己那台。 ## 我清了半年的那个键值库,里面为什么一直是零个键? 先把这台机器上的缓存分层摆出来。一个典型的自建站,从里往外依次是:字节码缓存(PHP把源码编译成的中间产物)、对象缓存(数据库查询结果,通常放在内存键值库里)、全页缓存(渲染好的整页HTML,放在磁盘上)、然后才是CDN和浏览器。 ## 四个库,三个满的,一个空的 把内存键值库的每个逻辑库都数一遍: 逻辑库 | 键数 | 键名前缀 | 属于谁 | 0号库 | 0 | — | 没有任何东西 | 1号库 | 2159 | 某站点前缀加下划线 | 同机的另一个站 | 2号库 | 8377 | 另一套前缀 | 同机的第三个站 | 3号库 | 1 | 某缓存插件的指标键 | 同上 | 我的收尾脚本里那行命令,清的是0号库。0号库常年是0个键。这条命令跑了大半年,每次都成功,每次都清了个寂寞。 更尴尬的是另外两个库里那10536个键。它们的前缀清清楚楚地写着是另外两套系统的对象缓存,跟本站的程序完全不是一码事。如果当初我抄的教程里写的是清全部库而不是清0号库,我早就把隔壁两个站的缓存清光了,而且大概率至今都不知道。 ## 为什么本站一个键都没有 原因很简单,也很容易被自己骗过去:本站根本没有启用对象缓存。装的那批插件里没有一个把查询结果往内存键值库里写,程序层面用的是文件缓存和全页缓存。 那为什么站点看起来一点问题都没有?因为真正挡在前面的是全页缓存那一层。绝大多数请求根本没进到PHP,nginx直接把磁盘上那份现成的HTML甩了出来,压根没有机会去查数据库,自然也就不需要对象缓存。 这就是整件事最危险的地方:一个从来没生效过的缓存层,和一个一直在正常工作的缓存层,从站点表现上完全区分不开。你清一个空库,站点很快;你不清那个空库,站点还是很快。没有任何信号会告诉你这条命令是多余的。 ## 先搞清楚东西在哪,再谈清 逻辑库这个设计本身就是个坑。SELECT命令的官方文档 (https://redis.io/docs/latest/commands/select/)说明了它的语义:一个实例里有多个用编号区分的逻辑库,默认连到0号,而这些库共享同一个实例的内存和配置,只是键空间互相隔离。文档里还专门提到,在集群模式下这个特性是不支持的。 换句话说,多个站共用一个键值实例、靠库号隔离,是一种约定式的隔离——没有权限边界,没有防护,全靠每个人都记得自己该用哪个号。而清库命令本身对库号毫不敏感,FLUSHDB的文档 (https://redis.io/docs/latest/commands/flushdb/)说得很直白,它删除当前所选库的全部键。选错号,删的就是别人的。 所以第一条判据是:动手清之前先数键。三条命令就能问清楚:数一下每个库有多少键、随机抽几个键名看前缀、确认自己的程序到底往哪个库写。这三步花不到一分钟,能省掉半年的无用功,也能避免一次误删。对象缓存那一层的具体配置和命中率该怎么读,Redis对象缓存怎么给WordPress提速?object cache原理与运维实战 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html)那篇拆得更细。 ## 顺手量到的另一件事:淘汰策略从来没生效过 既然连上了,把这个实例的运行指标一起拉了出来,又撞见一处配置与现实的落差: 指标 | 实测值 | 已用内存 | 11.70MB | 内存上限 | 4.00GB | 淘汰策略 | 按最近最少使用淘汰全部键 | 命中次数 | 990878 | 未命中次数 | 89966 | 过期删除的键 | 41641 | 因内存不足被淘汰的键 | 0 | 内存上限设了4GB,实际用了11.7MB,占用率0.29%。而因内存不足被淘汰的键数是 0——这个实例从启动到现在,从来没有因为装不下而扔掉过任何一个键。 这意味着那条淘汰策略配置一次都没有被执行过。它配成什么都一样,因为触发它的前提条件从未出现。而绝大多数教程会花很大篇幅讲这几种策略怎么选,讲得都对,只是对这台机器毫无意义。 命中率倒是个有用的数字:990878除以总请求数得到 91.7%,不算差。另外那41641个过期删除的键说明有效期机制在正常工作——键是自己到点走的,不是被挤走的,这是健康的样子。 这一节想说的其实跟缓存无关:一份配置是不是合理,不能只看它写得对不对,还得看它有没有被执行过。没被执行过的正确配置,和写错了的配置,在系统表现上一样看不出来。 ## 命令行里那句重置为什么返回false还不报错? 第二件事比第一件更普遍,因为它出现在几乎每一份部署脚本里。 ## 前后两次读数,一个字都没变 字节码缓存的重置函数,在命令行里跑一次: php -r 'var_dump(@opcache_reset());' bool(false) 返回 false。不是异常,不是警告,就是一个安静的false,而绝大多数脚本写的是 php -r 'opcache_reset();'——连返回值都不看。 为了确认它到底有没有影响到真正干活的那一侧,我在网站目录里临时放了一个探针脚本,通过HTTP请求读取进程池里的状态。执行重置前后各读一次: 指标 | 重置前 | 命令行重置后 | 是否启用 | true | true | 已缓存脚本数 | 4230 | 4230 | 命中次数 | 415623 | 415623 | 未命中次数 | 4231 | 4231 | 命中率 | 98.99% | 98.99% | 已用内存 | 245.8MB | 245.8MB | 手动重启计数 | 1 | 1 | 七个指标,一个都没动。最能说明问题的是最后一行——手动重启计数专门用来记录重置被调用了几次,它停在1上纹丝不动,说明那句命令根本没碰到这个进程池。 ## 原因是两套完全独立的实例 查一下配置就明白了: opcache.enable => On => On opcache.enable_cli => Off => Off 字节码缓存的配置项手册 (https://www.php.net/manual/en/opcache.configuration.php)里,enable_cli 这一项的默认值就是关闭。而 重置函数的手册页 (https://www.php.net/manual/en/function.opcache-reset.php)写明它的作用是重置整个缓存的内容——问题在于“整个”指的是当前这个进程能看到的那份缓存。 命令行跑的PHP和进程池里的PHP是两个完全独立的进程,各自有各自的共享内存段。命令行那边压根没开缓存,所以它连一份可重置的东西都没有,函数老老实实返回false;而进程池那边的4230个脚本、245.8MB内存,站在命令行的角度是完全够不着的。 这个坑的杀伤力在于它的普遍程度。“部署完跑一句重置”几乎是所有PHP部署教程的标准收尾,而在默认配置下,这一句在命令行里执行等于什么都没做。 ## 这台机器上根本不需要那句命令 还有个反转。同一份配置里还有两行: opcache.validate_timestamps => On => On opcache.revalidate_freq => 60 => 60 启用了时间戳校验,间隔60秒。意思是改完文件之后最多60秒,进程池会自己发现文件变了并重新编译,不需要任何人手动干预。 所以这条链路上同时存在两个错误认知:一是以为那句命令生效了(其实没有),二是以为需要它(其实不需要)。两个错误刚好互相抵消,于是站点一直正常,也就一直没人去查。 真正需要手动重置的场景只有一种:关掉时间戳校验以换取性能。那种配置下改完文件必须显式重置,而且必须在进程池内部执行——要么通过一个HTTP端点触发,要么直接重载进程池。字节码缓存这一层的完整调优思路,PHP OPcache字节码缓存怎么调才能让站点真正快起来 (https://zhangwenbao.com/php-opcache-bytecode-cache-tuning-preload-jit-hit-rate.html)那篇讲过取舍。 ## 清了里层页面还是旧的,问题出在顺序吗? 这是清缓存里最反直觉的一件事:清理的顺序必须跟读取的顺序相反,否则你会亲手把旧内容重新固化一遍。 ## 只清里层,什么都不会发生 做一组对照。先把三个页面预热到全页缓存里,然后只清对象缓存那一层,全页缓存原封不动,再打这三个URL: 阶段 | 全页缓存文件数 | 首字节(秒) | 预热完成(热态) | 4 | 0.2299 / 0.2337 | 清完对象缓存 | 4(没变) | 0.2125 / 0.6817 | 再看对象缓存键数 | 仍然是0,说明这几个请求根本没走到那一层 | 首字节几乎没有变化,全页缓存文件数一个没少。你清了里层,但请求压根到不了里层——它在外层就被拦住返回了。 反过来,把全页缓存清掉再打同样的URL: 页面 | 清空后第1次(冷) | 第2次 | 第3次 | 冷热比 | 页面A | 0.9898 | 0.2339 | 0.2334 | 4.24 | 页面B | 0.8756 | 0.2135 | 0.2301 | 4.10 | 页面C | 1.2327 | 0.2319 | 0.2332 | 5.32 | 冷启动比命中慢4到5倍,这才是请求真正落回PHP的样子。 ## 顺序错了会发生什么 把这两组数据合起来推演一下,就能看清顺序为什么要紧。 假设你改了内容,然后先清外层(全页缓存)、再清里层(对象缓存)。这两条命令之间总有个时间差,哪怕只有一秒。在这一秒里,任何一个访客或者爬虫敲进来,会发生什么? - 外层已经空了,请求穿透到PHP。 - PHP去查对象缓存——里层还没清,它拿到的是旧数据。 - PHP用旧数据渲染出一个页面,返回。 - nginx把这个用旧数据渲染的页面写进全页缓存。 - 你这才执行第二条命令,把里层清了。 结果是:里层干净了,外层刚刚被灌进去一份新鲜出炉的旧内容,而且带着完整的有效期。你的清缓存动作,亲手把旧数据的寿命续了一轮。 而按正确顺序(先里层后外层)就不会有这个窗口:里层清完之后,外层还挡着,这段时间里没有任何请求能穿透进来触发重建;等外层也清掉时,里层已经是空的,重建出来的必然是新内容。 所以规则可以压成一句话:清理顺序必须与读取链路相反,从最里层往最外层清。读取是从外往里穿透,清理就得从里往外剥。 ## 完整的顺序长什么样 把这台机器上的四层按正确顺序排开: 顺序 | 层 | 动作 | 是否必需 | 1 | 字节码缓存 | 重载进程池,或依赖时间戳校验自动过期 | 只在关闭时间戳校验时必需 | 2 | 对象缓存 | 清对应库号(先确认是哪个号) | 只在真的启用了才必需 | 3 | 全页缓存 | 删缓存目录,或用清理模块按URL精准删 | 必需 | 4 | CDN与边缘 | 调接口刷新,或等有效期自然到点 | 看有没有挂CDN | 浏览器那一层不在这个列表里,因为你清不了别人的浏览器——那一层只能靠响应头里的指令提前约定好行为,事后无法补救。这也是为什么HTML的 max-age 通常要设成0:你唯一能确保事后可清的,是自己这一侧。反向代理层的清理和过期怎么配,Nginx proxy_cache反向代理缓存怎么配 (https://zhangwenbao.com/nginx-proxy-cache-reverse-proxy-upstream-cache-purge-stale.html)那篇有更细的参数。 ## 一次全清到底扔掉了什么? “清缓存”听起来是个免费动作。量一下就知道它不免费。 ## 304MB,955个页面 执行清空之前,全页缓存目录的状态: 指标 | 数值 | 缓存文件数 | 955 | 占用磁盘 | 304MB | 最老的一份 | 约70分钟前生成 | 最新的一份 | 执行清理前2秒生成 | 一条 rm -rf 下去,955变成0。955份已经渲染好的HTML全部作废,每一份都是之前某个访客用真实等待时间换来的。 清完之后打了3个URL,目录里只回来4个文件。这个数字很直观地说明了重建是多么缓慢的过程:缓存是被访客一个一个填回来的,不是自己长出来的。 ## 重建的账该算在谁头上 量一下重建成本:清空之后连续请求5个页面,总耗时 1691毫秒,平均每个页面338毫秒。 按这个速率往外推,把955个页面全部重建需要大约 323秒的纯服务器处理时间。但这323秒不是服务器在后台默默还清的——它被拆散成955份,分给了955个倒霉的访客,每人多等0.3到1秒。 而这955个页面本身也说明了点别的:全站文章一千七百多篇,缓存里只有955份是热的,约占56%。剩下四成多的页面本来就是冷的,谁撞上谁买单,这跟清不清缓存没关系,是长尾内容的固有成本。 ## 什么时候该全清,什么时候不该 由此得到一条相当实用的判据: - 改了单篇内容——只清那一个URL。用清理模块按键精准删,代价接近零。 - 改了模板或全局配置——必须全清,因为影响面覆盖所有页面,没有别的办法。 - 不确定影响面——先想清楚再动手。全清一次的代价是300多秒的用户等待被摊到几百个人头上,不是免费的。 - 调试时反复全清——换个思路,用带随机参数的地址绕过缓存来验证,别拿生产缓存当调试环境。 全页缓存的键怎么设计才能做到按URL精准清理,Nginx fastcgi_cache全页缓存怎么配 (https://zhangwenbao.com/nginx-fastcgi-cache-fullpage-php-wordpress-purge-microcache.html)那篇有完整配置。 ## 我以为在测冷启动,其实已经被别人预热了 前面那张冷热对照表里,其实还有一个页面没列进去,因为它的数据自相矛盾:清空缓存之后第一次请求,首字节 0.2332 秒,跟热态的0.2358、0.2338、0.2342几乎一模一样。 按理说清空之后第一次必然是冷启动,应该在0.9秒上下。它没有。 唯一的解释是:在我执行清空和发出第一个请求之间的那一秒里,别人已经把它请求过一遍了。可能是爬虫,可能是真实访客,也可能是某个监控。那个人替我承担了冷启动的代价,等我的测量请求到达时,缓存已经重建好了。 这条对做测量的人是个硬提醒:在一个有真实流量的生产站上,你没有办法独占一个干净的观测窗口。清空到测量之间的每一毫秒,都可能有别人在改变你要观测的状态。 补救办法有三个,按可靠性排:用一个带随机参数、别人不可能撞上的地址来测;把清空和测量之间的间隔压到最小;或者干脆多测几组,用中位数把这类污染筛掉。我这次是第三种——四组里三组正常、一组被污染,所以那一组的结论只能弃用。 ## 后台更新和陈旧回退,会把你的清理动作推迟多久? 还有两个开关会让“清缓存”这件事的效果变得不那么立竿见影,而它们通常是默认配置的一部分,没人专门去开。 ## 两个开关,一个共同后果 这台机器的全页缓存配置里有这么两行: fastcgi_cache_background_update on; fastcgi_cache_use_stale error timeout updating invalid_header http_500 http_503; 第一行的意思是:缓存过期之后,先把过期的那份发给用户,同时在后台悄悄发起一个请求去更新它。第二行的意思是:在列出的那几种情况下(后端出错、超时、正在更新、返回500或503),允许继续使用过期的缓存。 两个开关的出发点都是好的——它们让用户永远不用站着等重建,也让后端偶发抖动不至于变成用户可见的错误页。代价是:内容更新的可见时刻,被推迟了整整一轮。 具体推迟多久取决于谁先到。如果过期后第一个到达的请求触发了后台更新,那么这个人拿到的是旧的,下一个人才拿到新的。换句话说,永远有至少一个用户会看到你以为已经更新掉的内容。 ## 状态头六种取值,各说明什么 正因为有这些机制,那个缓存状态头就不只有MISS和HIT两种值。服务器模块的官方文档里定义了完整的取值表,实际排查时会遇到的是这几个: 取值 | 含义 | 看到它该想什么 | MISS | 没命中,这次是真的走了后端 | 清理生效了,或这个页面第一次被访问 | HIT | 命中了有效期内的缓存 | 正常状态 | EXPIRED | 命中了但已过期,这次重新取了 | 有效期偏短,或访问太稀疏 | UPDATING | 过期了,正在后台更新,先给你旧的 | 你看到的是旧内容 | STALE | 后端出问题,回退给你过期的那份 | 后端可能正在故障 | BYPASS | 规则命中,这次绕过缓存 | 检查绕过条件是不是写宽了 | 验收清理效果时,只有MISS才算数。看到UPDATING或STALE说明你拿到的还是旧内容,这时候下结论说“清了没用”是冤枉的——再打一次通常就正常了。 看到BYPASS则要往另一个方向查:这个页面根本没在缓存里,你清什么都不会有变化。常见原因是绕过条件里包含了登录状态、特定的查询参数或者某些Cookie,而你恰好带着这些东西在测。用一个干净的客户端重测,是排除这种误判最快的办法。 ## 配置文件里写着3600,发出去的为什么是300? 这是这轮排查里最意外的一条,属于顺手撞见的。 ## 十二处硬编码,全都没生效 在服务器配置目录里搜一下缓存指令,找到 12处硬写的这一行: add_header Cache-Control "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400" always; 而线上实际发出去的响应头是: cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400 3600变成了300,差12倍。 原因是配置里还有两行用变量的写法,位置更靠后、作用域更具体,于是它赢了。真正生效的值藏在那个变量的定义里,而不是那12处看起来言之凿凿的硬编码里。 这条的教训跟清缓存本身无关,但同源:配置文件里写着什么,跟线上实际发出去什么,是两个必须分别验证的事实。唯一可靠的确认方式是发一个真实请求把响应头打出来看——这类沉默的配置差异该怎么系统性地查,curl -I查了一圈说这站没配缓存头,换成GET之后那五个头全在 (https://zhangwenbao.com/curl-head-get-response-header-diagnosis-seo.html)那篇专门讲了工具本身的坑。 ## 还有一处更早就该发现的矛盾 同一份配置里,全页缓存的有效期是这么写的: fastcgi_cache_valid 200 301 302 5m; fastcgi_cache_valid 404 1m; 服务器自己只缓存 5分钟,却通过响应头告诉CDN缓存60分钟(那12处硬编码的本意)。RFC 9111对s-maxage的定义 (https://www.rfc-editor.org/rfc/rfc9111.html)写明它专门给共享缓存用,并且会覆盖max-age,也就是说CDN认的是这个值。 两个数字不一致会导致一个尴尬局面:源站上那份5分钟就过期了,CDN那份还要留55分钟。这段时间里,你在源站上做的任何清理动作,对已经到达CDN的那一份都毫无作用。这也是为什么清缓存必须把CDN那一层单独列进流程,而不是清完源站就以为完事了。CDN层的有效期分层该怎么设计,CDN边缘缓存到底怎么配才不踩坑?回源、TTL分层与缓存键实战 (https://zhangwenbao.com/cdn-edge-caching-strategy-ttl-cache-control-purge-origin-shield.html)那篇有一套现成的分层方案。 ## 怎么证明缓存真的清掉了? 前面所有的坑都指向同一个根因:清理动作没有回执。命令跑完打印一行“已完成”,那行字是脚本自己写的,不是缓存告诉你的。 ## 那条命令为什么在脚本里静默失败 顺着这个思路,我把自己的收尾脚本又查了一遍,发现了第三个坑,而且这次是彻底的静默失败。 非交互方式远程执行命令时,环境变量里的可执行路径只有四个目录: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin 而这台机器上那个键值库的客户端程序装在一个自定义目录下,不在这四个里面。于是: $ redis-cli -n 0 DBSIZE bash: redis-cli: command not found # 退出码 127 $ /www/server/redis/src/redis-cli -n 0 DBSIZE 0 # 退出码 0 关键在下面这一步。当这条命令被分号串在一长串收尾命令里时: $ redis-cli -n 0 FLUSHDB; echo "缓存已清完毕" bash: redis-cli: command not found 缓存已清完毕 命令失败了,成功提示照常打印。因为分号只表示“接着执行下一条”,它不看上一条的退出码。整个批处理返回0,日志里一片祥和。 这个坑跟前面那个字节码重置返回false的坑,是同一个失败模式的两个实例:失败被吞掉,脚本继续,输出看起来一切正常。它们能潜伏这么久,唯一的原因就是没有人去验证结果。 ## 三层各自的作证命令 回执必须来自被清理的对象本身,而不是执行者的自述。三层各有各的作证方式: 层 | 清理前后各跑一次 | 合格的回执 | 对象缓存 | 数当前库的键数 | 数字从N变成0 | 全页缓存 | 数缓存目录的文件数 | 数字从N变成0 | 字节码缓存 | 通过HTTP读进程池里的手动重启计数 | 计数加1,已缓存脚本数归零 | CDN | 请求一次看边缘状态头 | 从HIT变成MISS | 最后一行本站是现成的。清空全页缓存后连打四次同一个URL,把状态头一起记下来: 第几次 | 缓存状态头 | 首字节(秒) | 1 | MISS | 0.4858 | 2 | HIT | 0.2340 | 3 | HIT | 0.2331 | 4 | HIT | 0.2337 | 第一次MISS、耗时0.4858秒,之后三次全部HIT、稳定在0.233秒左右。这四行是无可辩驳的证据链:清理确实生效了(第一次真的没命中),重建确实发生了(第二次开始命中),效果确实存在(差2.1倍)。 ## 没有状态头的话,先把它加上 本站能这么方便地作证,是因为配置里有这么一行: add_header X-FastCGI-Cache $upstream_cache_status always; nginx的FastCGI模块文档 (https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html)里定义了这个变量的取值,常见的有MISS、HIT、EXPIRED、BYPASS、UPDATING、STALE等。把它挂成一个响应头,成本几乎为零,收益是此后所有的缓存问题都变成了一个可以一眼看穿的事实,而不是靠掐表猜。 如果你的站现在没有这一行,我的建议是把它当成清缓存流程的第0步:先让缓存能说话,再谈怎么清它。浏览器和CDN那一侧的对应信号,浏览器HTTP缓存头怎么配?让回头客秒开又不犯改了不更新的事故 (https://zhangwenbao.com/http-browser-cache-control-etag-expires-cache-headers.html)那篇也整理过一份。 ## HTML和图片是两套缓存,清法为什么不同? 同一个站里,两类资源走的是完全不同的缓存策略,因此“清缓存”对它们意味着完全不同的动作。 ## 一张图,一份HTML 把同一个页面和它里面的一张封面图,响应头并排放: 响应头 | HTML页面 | 封面图 | Cache-Control | public, max-age=0, s-maxage=300, stale-while-revalidate=86400 | public, max-age=31536000, immutable | ETag | 无 | 有 | Last-Modified | 无 | 有 | Vary | Accept-Encoding | 无 | 浏览器缓存时长 | 0秒 | 一年,且声明不可变 | 差别是刻意设计的,而且设计得对。HTML随时可能更新,所以浏览器端设成0、只让共享缓存留5分钟;图片一旦上传就不会变,所以给一年并声明不可变,浏览器连条件请求都不用发。 ## 但它带来一个后果 immutable 这个指令的含义是“在有效期内我保证不变,你连问都不用问”。后果是:这张图一旦被用户缓存,你在服务器上把它换掉也没用,那一年之内这个用户看到的都是旧图。 所以图片这一层的“清缓存”根本不是清,而是换名字——改文件名或者加内容指纹,让它变成一个全新的URL。这是唯一可靠的办法。 顺带说一下HTML那边没有ETag和Last-Modified的事。这对动态页面是合理的(每次生成的字节可能都有细微差别,发ETag反而会造成误判),代价是无法用条件请求省下重复传输。这笔账在抓取量大的站上不小,但它跟清缓存是两个独立的话题,多层缓存对首字节和抓取的联合影响,TTFB怎么优化才不白费:多层缓存如何同时左右Core Web Vitals与Google抓取 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)那篇算过完整的账。 ## 保哥的清缓存四步与作证清单 把这一轮踩出来的东西压成一套可以直接照抄的流程。 ## 四步 - 先摸清有哪几层,各在哪。数一下每个键值库有多少键、看键名前缀是谁的、确认全页缓存目录在哪、确认字节码缓存是不是开着时间戳校验。这一步只做一次,做完写进文档。 - 从最里层往最外层清。字节码 → 对象 → 全页 → CDN。顺序反了会把旧内容重新固化。 - 每清一层,立刻取回执。前后各数一次,数字必须变。脚本自己打印的成功提示一律不算。 - 用真实请求做最后验收。打一次目标URL,看缓存状态头是不是MISS,再打一次看是不是变回HIT。这两次的组合能同时证明“清掉了”和“又建好了”。 ## 作证清单 检查项 | 合格标准 | 不合格时最可能的原因 | 对象缓存键数 | 清理后为0 | 清错了库号,或本来就没启用 | 全页缓存文件数 | 清理后为0 | 路径写错,或权限不足 | 字节码手动重启计数 | 加1 | 在命令行执行的,没碰到进程池 | 缓存状态头 | 先MISS后HIT | 没配状态头,或请求被绕过 | 页面上那处改动 | 肉眼可见 | CDN那层没清 | 命令退出码 | 全部为0 | 路径没写全,命令根本没执行 | ## 三条给自动化脚本的硬要求 第一,所有命令写全路径。非交互环境的可执行路径跟你登录后看到的不是一回事,装在自定义目录里的程序一律找不到。这一条能挡掉本文里的第三个坑。 第二,用 && 而不是分号串联。分号不看退出码,前一条炸了后一条照跑;换成 &&,任何一步失败整条链就停在那里,比事后翻日志强得多。 第三,把回执打进日志,而不是打成功提示。日志里应该出现的是“键数2159 → 0”“文件数955 → 0”这样的数字对,而不是“缓存已清理完毕”。前者无法伪造,后者只是个字符串。 这三条加起来的成本,大概是给收尾脚本多写十几行。而它们要挡的,是本文里那三个各自潜伏了大半年的坑——清了个空库、重置了个不存在的缓存、命令根本没执行。三个坑的共同点是都不会报错,所以它们只能被主动验证发现,等不到自己暴露的那一天。 顺带一提,做完这轮排查我把自己的收尾脚本改了:那条清0号库的命令直接删掉了,因为它清的东西根本不存在;那条命令行重置也删掉了,因为时间戳校验会替我干;剩下的全页缓存清理换成了全路径写法,后面跟一条数文件数的命令做回执。脚本短了两行,可信度高了不止两倍。 ## 常见问题解答 ## 怎么快速判断我的站到底启没启用对象缓存? 两步。第一步数键:连上键值库,逐个库数键数并抽样看键名前缀,如果没有一个前缀跟你的程序对得上,那就是没启用。第二步看程序:查一下配置文件里有没有指向键值库的连接参数,以及有没有装对应的缓存插件。两步结论一致才可信——我这次就是只看了插件列表以为装了,实际上装的那批插件里没有一个往键值库写东西。 ## 直接把所有库都清掉不是更省事吗? 非常不建议,尤其在共享服务器上。清全部库的命令会把这个实例里所有逻辑库的键一次删光,而多个站共用一个实例、靠库号隔离是很常见的部署方式。本文实测的这台机器上就有另外两个站的一万多个键躺在1号和2号库里,一条命令下去它们全没了,而且对方大概率查不出是谁干的——因为他们的站不会报错,只会突然变慢一阵子。 ## 清完缓存网站变慢了,是不是清错了? 不是,这是正常现象,而且是可预期的。本文实测的冷热比是4到5倍,也就是说清完之后头几分钟,每个第一次访问的页面都会慢4到5倍。缓存是被访客一个一个填回来的,955个页面全部重建需要大约323秒的服务器处理时间,这些时间会摊到几百个访客头上。如果你的站流量大、页面多,建议在低峰期清,或者改用按URL精准清理而不是全清。 ## 为什么改了内容之后,有的用户看到新的有的看到旧的? 最常见的原因是漏清了某一层,而不同用户命中的层不同。典型场景:你清了源站,但CDN那份还在有效期内,于是命中CDN的用户看到旧的、回源的用户看到新的。第二常见的是浏览器缓存——如果HTML的有效期没设成0,老访客手上那份要等自然过期。第三种是共享缓存和私有缓存的有效期设成了不一致的值,本文实测的这台机器上就有这个问题:配置里写着一小时,实际发出去的是五分钟。 ## 命令行里那句重置既然无效,正确的做法是什么? 看你为什么要重置。如果时间戳校验是开着的(多数默认配置都是),那就什么都不用做,改完文件最多等一个校验间隔就自动生效了。如果关掉了校验以换性能,那就必须让重置发生在进程池内部:要么放一个受保护的HTTP端点、部署时请求一次,要么直接重载进程池。判断自己属于哪种情况,查一下配置里时间戳校验那一项是开还是关,一行就够。 ## 按URL精准清理和全清,日常该用哪个? 日常绝大多数情况用精准清理。判据很简单:看这次改动的影响面。改了一篇文章的正文,影响面就是那一个URL加上可能引用它的列表页,精准清几个键就够了。改了模板、导航、全局样式或者任何会出现在每个页面上的东西,影响面是全站,那就只能全清。介于两者之间的(比如改了某个分类的描述)可以按前缀批量清。真正要避免的是养成“出问题先全清一遍”的习惯,那等于每次都让几百个访客替你的不确定性买单。 ## 权威参考资料 ## 决定SEO收录哪一版页面的不是你的配置,是10分钟前路过的那个德国用户 - URL:https://zhangwenbao.com/vary-header-cache-fragmentation-googlebot-version-seo.html - 分类:缓存与CDN - 发布:2026-07-31 | 更新:2026-07-31 - 摘要:实测缺少Vary时缓存串版本:德语用户灌入后中英法用户与Googlebot全拿德语页;230个URL里Vary写Accept-Language的仅3.5%。附边缘归一化解法。 - 关键词:技术SEO,SEO,CDN缓存 > **TLDR**:摘要:在实验台上先用一个德语浏览器请求一次某个页面,之后不管来的是中文用户、英文用户、法语用户还是Googlebot,nginx全部返回HIT,给出的都是那份德语页面。整个过程没有任何一条配置写错,上游确实按语言返回了不同内容,缺的只是一个Vary响应头。而现网230个URL里,Vary里写了Accept-Language的只有3.5%,写了User-Agent的2.2%,下发了Cookie却没在Vary里声明Cookie的高达95.7%。更麻烦的是把Vary补上也不轻松:20个不同的Accept-Language取值只对应3种真实内容,缓存却老老实实存了20份副本。这篇拆的是同一个地址下“给谁哪一版”这个决定,最后到底是谁在做。 > 摘要:在实验台上先用一个德语浏览器请求一次某个页面,之后不管来的是中文用户、英文用户、法语用户还是Googlebot,nginx全部返回HIT,给出的都是那份德语页面。整个过程没有任何一条配置写错,上游确实按语言返回了不同内容,缺的只是一个Vary响应头。而现网230个URL里,Vary里写了Accept-Language的只有3.5%,写了User-Agent的2.2%,下发了Cookie却没在Vary里声明Cookie的高达95.7%。更麻烦的是把Vary补上也不轻松:20个不同的Accept-Language取值只对应3种真实内容,缓存却老老实实存了20份副本。这篇拆的是同一个地址下“给谁哪一版”这个决定,最后到底是谁在做。 上一篇 (https://zhangwenbao.com/page-content-jitter-baseline-seo-diff-false-positive.html)把噪声量出来之后,剩下的问题才好谈:在那136个连抓三次字节完全一致的URL上,改动请求里的东西,到底能让多少个页面吐出不一样的内容? 答案比传说中的小得多。但真正有意思的不是这个比例,而是这一小撮分岔,最后是由谁来仲裁的。 ## 请求里带的那点东西,真能改变返回的页面吗? 口径先说清楚:只在三次基线指纹完全一致的稳定URL上做,噪声地板归零,测到的差异全部可归因。每个变体都是一个全新会话,只改一个变量。 改动 | 样本 | 响应不同 | 占比 | 最终URL变了 | html lang变了 | canonical变了 | 第二次请求带上服务器下发的Cookie | 136 | 3 | 2.2% | 1 | 1 | 1 | Accept-Language换成zh-CN | 136 | 15 | 11.0% | 6 | 7 | 6 | Accept-Language换成de-DE | 136 | 14 | 10.3% | 7 | 9 | 7 | 完全不发Accept-Language | 136 | 7 | 5.1% | 2 | 2 | 2 | 换成Googlebot的User-Agent | 124 | 14 | 11.3% | 1 | 1 | 1 | 三条读法。 第一,Cookie的效应小得出人意料。只有2.2%。原因不难理解:站点在首访下发的那批Cookie,绝大多数是给统计和风控用的会话标识,服务端拿到它并不会改变页面内容——真正会改内容的偏好(语言、币种、地区)通常要等用户主动选过一次才写进去。爬虫从不选,所以它永远看到的是那个“还没选过”的默认版本,而这个版本和一个第一次来的真人看到的完全一样。这一点上,无状态其实帮了忙。 第二,语言这一维的分岔最大,而且它是唯一一个连canonical都跟着变的。换成zh-CN,136个里有15个内容不同,其中6个连规范网址都指向了另一个地址。这意味着同一个URL在Google眼里可能归属两个不同的规范页——取决于抓它的那次请求头长什么样。 第三,Googlebot的UA本身就能撬动11.3%。这个数字要小心解读:绝大多数不是有意的cloaking,而是站点的设备判别、机器人防护、或者干脆是给爬虫准备的简化版页面。但从结果上看,八分之一的稳定页面,会因为你自称是谁而给出不同的字节。 ## 这些分岔为什么在URL和canonical里一个字都看不见? 把上面三条合起来,会得到一个别扭的局面。 搜索引擎认的是URL。一个URL对应一个页面,这是整套索引体系的地基。同一个页面有11种URL写法都返回200 (https://zhangwenbao.com/url-path-normalization-case-slash-duplicate-seo.html)那篇讲的是这条地基的一边——一份内容可能有一堆地址;这篇讲的是另一边——一个地址底下可能有一堆内容。 而后一种情况比前一种更难发现,原因很简单:决定给你哪一版的那几个变量,一个都不在地址栏里。 - Accept-Language是请求头,不进URL; - Cookie是请求头,不进URL; - User-Agent是请求头,不进URL; - 出口IP连头都不是,它在TCP那一层。 于是你把这个URL复制给同事,同事打开看到的可能是另一个版本,而你俩都会以为对方看错了。按IP自动跳转语言版本 (https://zhangwenbao.com/international-seo-ip-redirect-geolocation-language-switcher-ux.html)那类做法至少还诚实——它跳走了,地址栏会变,你能看见。而按请求头分内容不跳转的做法,从地址上完全看不出发生过任何事。 HTTP协议对这件事是有安排的,就是Vary响应头。它的作用是告诉所有中间缓存:“这个响应的内容取决于请求里的这几个字段,你存它的时候得把这几个字段一起当成键。”RFC 9111把这条写成了强制要求: > "When a cache receives a request that can be satisfied by a stored response and that stored response contains a Vary header field, the cache MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request." 注意这句话的结构:缓存的义务是由响应里那个Vary字段触发的。没有Vary,就没有这条义务,缓存有权把存下来的那一份发给任何人。 ## 不写Vary,缓存到底会发生什么? 这个问题在文档里能读到答案,但读到和看到是两回事。我在服务器上另起了一套nginx,配置极其普通:一个上游按Accept-Language返回三种语言的内容,前面挂一层proxy_cache,用的是教程里最常见的那三行。上游不发Vary。 然后按顺序请求: 顺序 | 请求的Accept-Language | X-Cache | 拿到的版本 | 1 | de-DE | MISS | | 2 | zh-CN | HIT | | 3 | en-US | HIT | | 4 | fr-FR | HIT | | 第一个德国用户来过之后,这个URL在边缘上就变成德语的了。中文用户、英文用户、法国用户拿到的全是德语页面,而且每一次都是HIT——响应快得很,只是内容是别人的。 把上游改成显式发一个Vary: Accept-Language,同一套缓存配置,结果立刻变对: 请求的Accept-Language | X-Cache | 拿到的版本 | zh-CN | MISS | | en-US | MISS | | de-DE | HIT | | 一个响应头的差别,从“所有人拿德语版”变成“各拿各的”。这里最值得记的一点是:缺Vary这件事,在源站上完全测不出来。你直连上游,每种语言都正确;只有在边缘那一层、而且必须有另一个语言的人先你一步到达,问题才会显形。这跟Nginx配置每次reload都通过、Googlebot每8次抓取却有1次撞在301上 (https://zhangwenbao.com/nginx-config-silent-seo-side-effects-audit.html)是同一个家族的毛病——配置本身没错,错的是它在多个人共用的那一层上的行为。 ## 那把Vary写上不就完了? 如果这么简单,就不会有95.7%的站没写了。把Vary写上,账单会立刻寄过来。 我把Accept-Language放进proxy_cache_key(效果与Vary等价,且更容易观察缓存对象数),然后拿20个真实浏览器会发出的Accept-Language取值各请求一次: 指标 | 数值 | 发出的不同Accept-Language取值 | 20 | 实际返回的内容种类 | 3(en / zh / de) | 缓存目录里生成的对象数 | 20 | 20份副本,3种内容,命中率0。而这20个取值不是我瞎编的,全是真实浏览器会发的东西: > en-US,en;q=0.9/en-GB,en;q=0.9/en;q=0.9/en-US,en;q=0.8/en-US,en;q=0.9,zh-CN;q=0.8/zh-CN,zh;q=0.9/zh-TW,zh;q=0.9/de-DE,de;q=0.9/de-AT,de;q=0.9/de-CH,de;q=0.9…… de-DE、de-AT、de-CH三个取值返回的是完全相同的德语页面,缓存却认认真真存了三份。用户装了几个语言、装的顺序、系统区域设置的细微差别,都会造出一个新的键。 这就是Vary这条规则的真实成本:它按字符串精确匹配,而不是按语义匹配。RFC 9111连“字段不存在”这种情况都规定得很死: > "If (after any normalization that might take place) a header field is absent from a request, it can only match another request if it is also absent there." 也就是说,一个不发Accept-Language的客户端,会在缓存里占据一个独立的、谁也共用不了的分片。这个细节等一下讲Googlebot的时候还会用上。 所以现实里的选择是三选一,没有一个是白拿的: 做法 | 内容正确性 | 缓存命中率 | 适用场景 | 按请求头分内容,不写Vary | 错,随机串版本 | 高 | 没有,这是纯粹的bug | 按请求头分内容,写Vary | 对 | 崩,按取值数分片 | 取值空间很小的头,比如Accept-Encoding | 每个版本一个独立URL,不按请求头分 | 对 | 高 | 多语言站的标准答案 | 在边缘做归一化,把高熵头压成少数几档 | 对 | 较高 | 有边缘计算能力 (https://zhangwenbao.com/edge-seo-cdn-worker-no-deploy-implementation.html)的站 | 第三行才是多语言站该走的路,也是hreflang那一整套机制 (https://zhangwenbao.com/hreflang-implementation-return-tags-x-default-canonical-mistakes.html)存在的前提——每个语言版本得有自己独立、稳定、能直接链的URL,搜索引擎才有东西可收。按请求头在同一个URL下换内容,本质上是让搜索引擎去索引一个薛定谔的页面。 第四行是个折中,值得多说一句:在CDN边缘把Accept-Language解析出主语言、压成en/zh/de三档,再拿这三档去做缓存键。20个取值就收敛成3个分片,命中率回来了,内容也对。代价是你得在边缘上写一小段逻辑,而且缓存键的设计 (https://zhangwenbao.com/cdn-edge-caching-strategy-ttl-cache-control-purge-origin-shield.html)从此变成需要维护的东西。 ## 现网到底写成了什么样? 230个URL的Vary头统计如下: Vary里出现的字段 | URL数 | 占比 | accept-encoding | 193 | 83.9% | rsc(Next.js的React Server Components) | 39 | 17.0% | next-router-state-tree | 37 | 16.1% | next-router-prefetch | 37 | 16.1% | cookie | 25 | 10.9% | accept | 14 | 6.1% | accept-language | 8 | 3.5% | origin | 7 | 3.0% | user-agent | 5 | 2.2% | content-language | 2 | 0.9% | 完全没有Vary头 | 19 | 8.3% | 这张表读下来有三件事。 一是accept-encoding一枝独秀。83.9%的站写了它,因为不写就会出压缩事故——把gzip的字节发给不支持gzip的客户端,页面直接是乱码。内容编码协商 (https://zhangwenbao.com/content-encoding-negotiation-gzip-brotli-crawl-budget-seo.html)那一维之所以人人都写对,是因为写错了会立刻挨骂。 二是排在第二三四位的全是框架自动加的。rsc、next-router-state-tree这几个是Next.js自己发的,开发者没手写过一个字。Vary这件事,现网的绝大多数正确案例都不是人做对的,是框架和中间件替人做的。 三是需要人自己判断的那几维,覆盖率都在个位数。accept-language3.5%、user-agent2.2%。而前面测出来,语言这一维真的能撬动11.0%的内容变化,UA能撬动11.3%。会分岔的比例,是声明了分岔的比例的三到五倍。 保哥把这条总结成一句话:凡是写错了会立刻在用户屏幕上炸出来的,现网覆盖率就高;凡是写错了只会静静地让一部分人拿到别人的内容的,覆盖率就低。压缩协商属于前者,语言协商属于后者。这跟工程师认不认真没关系,跟反馈回路的长短有关系——响应头这一层的SEO机制 (https://zhangwenbao.com/http-response-headers-seo-x-robots-cache-vary-canonical-mechanism.html)之所以年年都要重讲,也是因为它整体上属于后者。 Cookie这一维的缺口最夸张。230个URL里116个首访就下发了Cookie,而这116个里111个的Vary没写Cookie,占95.7%。好在前面测出Cookie真正改变内容的只有2.2%,所以这个缺口大部分是无害的——但这属于运气好,不属于做对了。 ## 上游一发Set-Cookie,nginx就再也不缓存了? 这一节是整篇里最反直觉的一块。 先看普查数字:230个URL里116个在首访就下发了Cookie,占50.4%(首页比例更高)。每站Cookie条数中位2条、p90是6条,最多的是sephora.com的20条。这些Cookie后续每次请求都要回传,回传字节中位209、p90是1090,最多2967字节。 Cookie属性方面顺带记一下现状,因为这几个数很少有人量过:HttpOnly出现在56.9%、SameSite=None在42.2%、SameSite=Lax在32.8%、SameSite=Strict只有4.3%。而Partitioned(也就是分区Cookie)、__Host-前缀、__Secure-前缀,三个各自都只出现在2个URL上,占1.7%。业界讨论了好几年的这几样东西,现网首页上基本等于没有。SameSite那一整套 (https://zhangwenbao.com/samesite-cookie-payment-return-session-loss.html)倒是铺开了,毕竟浏览器直接改了默认值,不跟就出事故。 现在关键问题来了:这一半会下发Cookie的页面,放在nginx的proxy_cache后面会怎么样? 实验台上打三次,结果是三次全部MISS。不是命中率低,是一次都不缓存。 nginx官方文档把这条写得干干净净: > "If the header includes the "Set-Cookie" field, such a response will not be cached." 这是个非常合理的默认值——Set-Cookie意味着这个响应里有专属于某一个人的东西,缓存下来发给别人显然是灾难。但把它和50.4%这个普查数字放在一起,结论就相当刺眼了: 现网有一半的页面,在标准的nginx反向代理缓存配置下,一次都缓存不了。而站点自己往往并不知道——你配了proxy_cache (https://zhangwenbao.com/nginx-proxy-cache-reverse-proxy-upstream-cache-purge-stale.html),nginx -t通过了,重载成功了,日志里也没有任何异常,只是命中率永远是0。这跟PHP在响应头里替你写了一行1981年的日期 (https://zhangwenbao.com/php-session-cache-limiter-nocache-cdn-audit.html)是同一类故障:缓存没生效,而不生效这件事本身不产生任何错误。 顺带说,这条对fastcgi_cache那一套 (https://zhangwenbao.com/nginx-fastcgi-cache-fullpage-php-wordpress-purge-microcache.html)同样成立,PHP只要调了session_start()就会发Set-Cookie,整页缓存当场失效。很多人装完全页缓存插件发现“怎么没快多少”,根子在这儿。 ## 教程里那句proxy_ignore_headers,代价是什么? 发现命中率为0之后,搜索引擎会很快把你引向一条广为流传的解法。nginx确实提供了这个开关,文档里列得明明白白: > "Disables processing of certain response header fields from the proxied server. The following fields can be ignored: "X-Accel-Redirect", "X-Accel-Expires", "X-Accel-Limit-Rate" (1.1.6), "X-Accel-Buffering" (1.1.6), "X-Accel-Charset" (1.1.6), "Expires", "Cache-Control", "Set-Cookie" (0.8.44), and "Vary" (1.7.7)." 加上proxy_ignore_headers Set-Cookie;,命中率立刻上来了。我在实验台上打三次,第一次MISS,第二三次HIT。皆大欢喜。 然后我看了一眼这三次响应里的Set-Cookie值: 第几次 | X-Cache | 响应里的Set-Cookie | 1 | MISS | d86pref=4c030e5c9ac8c1c5; path=/; HttpOnly | 2 | HIT | d86pref=4c030e5c9ac8c1c5; path=/; HttpOnly | 3 | HIT | d86pref=4c030e5c9ac8c1c5; path=/; HttpOnly | 三个不同的访客,拿到了同一个Cookie值。 作为对照,默认配置下同样打三次,三个人拿到的是961f8a95…、b29783ae…、81f55adf…三个不同的值——那才是正常的。 proxy_ignore_headers Set-Cookie做的事情,是让nginx忽略这个头对缓存决策的影响,但并不把这个头从响应里删掉。于是第一个访客的那份响应连同他的Set-Cookie一起被存进了缓存,之后每一个HIT都把这个头原样发出去。如果这个Cookie恰好是会话标识,你就得到了一个所有人共用同一个会话的站点。 更要命的是那个开关名字里还能加Vary。proxy_ignore_headers Vary;这行的意思是“上游让我按语言分片,我不听”——把本文第三节那个德语版事故,从“忘了配”升级成“主动配出来的”。 正确的做法不是忽略,是分情况: - 如果这个Cookie对页面内容没影响(统计、埋点、A/B分桶标识),用proxy_hide_header Set-Cookie把它从缓存的那一份里摘掉,而不是无视它; - 如果这个Cookie会影响内容(登录态、地区、币种),那这个页面本来就不该进共享缓存,该走proxy_no_cache按Cookie存在与否绕开; - 如果只是想给匿名访客提速,判别条件应该写在请求侧——请求里没有登录Cookie才走缓存,有就直接回源。 保哥见过的最惨一次,是某个站为了提升命中率把这行加在了全站location /上,上线三小时后客服收到用户反馈说“我登进去看到的是别人的订单”。这类事故的可怕之处在于,它在压测里测不出来——压测工具不带Cookie,也不看别人的Cookie。 ## Googlebot在这套机制里站在哪个位置? 把前面几节的机制拼起来,Googlebot的处境就清楚了。它有三个特征,每一个都有官方出处。 第一,它不带Cookie。Google在A/B测试的官方指南里写: > "Googlebot generally doesn't support cookies. This means it will only see the content version that's accessible to users with browsers that don't accept cookies." 第二,它不发Accept-Language。这条写在本地化自适应页面的文档里: > "the crawler sends HTTP requests without setting Accept-Language in the request header." 第三,它的出口IP不固定在一个国家。同一份文档里: > "Googlebot crawls with IP addresses based outside the USA, in addition to the US-based IP addresses." 把这三条和缓存机制叠起来,会得到几个不太舒服的推论。 推论一:在写了Vary: Accept-Language的站上,Googlebot独占一个缓存分片。因为RFC规定“字段不存在只能匹配字段也不存在”,而Googlebot从不发这个头。这个分片没有任何真人会命中,所以它多半是冷的——Googlebot每次抓取都在为自己单独回源一次。好的一面是它拿到的内容至少是干净的默认版。 推论二:在没写Vary的站上,Googlebot拿到的是别人的版本。我在实验台上验了这一条:先让德语用户灌一次缓存,再用Googlebot的UA去抓,结果是HIT,内容。而同一个Googlebot直连上游(绕过缓存),拿到的是默认的。 同一个爬虫,走缓存拿德语、直连拿英语。哪一版进索引,取决于它那一次请求落在哪个边缘节点、以及那个节点上碰巧是谁先到的。 再叠上第三条——它的IP分布在多个国家——事情就更热闹了:一个做了地理分发的CDN,Googlebot从法兰克福节点抓和从弗吉尼亚节点抓,命中的是两个完全独立的缓存池,里面躺着的可能是两批不同人灌进去的内容。 保哥拿这条去解释过一个一直查不明白的现象:某个客户的德语站,Search Console里偶尔会冒出几个页面的索引内容是英文的,重新提交之后又好了,过一阵子再犯。查源站查不出问题,查hreflang也全对。后来发现根子在边缘——那批URL的Vary里没有Accept-Language,而德语站的默认语言恰好是英文。Googlebot从哪个节点来、那个节点上前一个访客是谁,决定了这次抓到的是德语还是英文。这种问题没法靠“重新提交”修,因为它不是内容错了,是内容不确定。 推论三:这类问题在日志里几乎无法归因。你的源站日志里只会看到Googlebot偶尔来了一次,看不到它在边缘拿到了什么。日志分析 (https://zhangwenbao.com/log-analyzer-crawl-budget-googlebot-guide.html)能告诉你抓了多少次、抓了哪些URL,告诉不了你每一次抓到的是哪一版。要查这个,只能在边缘那一层打日志,或者用模拟Googlebot的UA (https://zhangwenbao.com/useragent-generator-ua-string-bot-simulation-seo-guide.html)从多个地理位置各测一遍——而这又回到上一篇 (https://zhangwenbao.com/page-content-jitter-baseline-seo-diff-false-positive.html)那件事:测之前得先知道这个页面自己抖不抖,否则你分不清“两个节点给的不一样”和“这个页面本来就每次都不一样”。 最后补一句关于Client Hints的。有一套新机制叫客户端提示,服务器发一个Accept-CH响应头,浏览器在后续请求里就会带上屏幕宽度、设备型号这类信息。230个URL里发Accept-CH的有12个(5.2%),发Critical-CH的只有2个(0.9%)——ebay、walmart、gap、nike、paypal、moz、vercel这几家。 这套机制对爬虫天然失效,原因和Cookie一模一样:它要求客户端有“后续请求”这个概念,而爬虫每一次抓取都是第一次。服务器发出去的那句邀请,从来没被回过话。所以任何依赖高熵Client Hints来决定页面内容的逻辑,对搜索引擎而言就是一段死代码——它永远走的是那个“什么提示都没拿到”的分支。 ## 这笔账该怎么配才两头都不亏 把整篇的实测收成一张可执行的表。 如果你的站…… | 该做什么 | 为什么 | 按Accept-Language在同一个URL下换语言 | 改成每个语言一个独立URL,配hreflang;短期内至少先把Vary: Accept-Language补上 | 不补Vary会串版本;补了会把缓存打成20份。独立URL是唯一两头都对的解 | 按UA给爬虫和用户不同内容 | 先确认是有意还是无意;无意的(设备判别、防护页)尽快统一,有意的对照cloaking的判定口径 (https://zhangwenbao.com/render-compare-bot-user-cloaking-detection-guide.html)自查 | 实测11.3%的稳定页面会因UA分岔,多数站自己不知道 | 首访就下发Cookie,且配了proxy_cache | 查一下命中率是不是0。是的话,用proxy_hide_header把无关Cookie摘掉,别用proxy_ignore_headers | nginx默认见Set-Cookie就不缓存;忽略它会把第一个人的Cookie发给所有人 | 用了CDN且有多地区流量 | 在边缘把高熵请求头归一化成少数几档,再做缓存键 | 20个取值只对应3种内容,归一化能把命中率救回来 | 什么都没按请求头分 | 确认Vary里只有Accept-Encoding,别让框架偷偷加东西 | 多一个Vary字段就多一层分片,白白掉命中率 | 想验证自己有没有问题 | 同一个URL用3种Accept-Language、带与不带Cookie、浏览器与Googlebot两种UA各测一遍,比指纹 | 10分钟能测完,但要先量过基线抖动,否则结论不可信 | 我拿自己这个站跑了一遍:首页在en-US、zh-CN、de-DE三种Accept-Language下,以及换成Googlebot的UA,四次拿到的都是同一枚指纹b9711033b7b9;Vary里只有Accept-Encoding;没有Set-Cookie。这不是优化出来的,是“什么都没按请求头分”的自然结果。 一个页面只有一个版本,这件事在今天已经算是一种奢侈了——但它换来的是:任何人、任何缓存、任何爬虫,在任何时候看到的都是同一份东西。做技术SEO时最难排的那类问题,几乎全部来自这个前提不成立。 ## 常见问题解答 ## 不写Vary,Google会不会因此判我cloaking? 不会。cloaking的定义是有意针对爬虫身份返回不同内容,而缺Vary造成的串版本对所有人一视同仁——德国用户先到,中文用户和Googlebot拿到的是同一份德语页。它不是作弊,是缓存事故。真正的危害在别处:Googlebot可能把一个语言版本的内容记在了另一个URL名下,导致索引里的内容和落地页对不上,以及规范网址判定不稳定。 ## Vary里能不能写User-Agent? 技术上可以,实际上基本不该。UA的取值空间是所有请求头里最大的之一,浏览器版本号每几周就滚一次,按它分片等于让缓存彻底失效。现网也印证了这一点:230个URL里只有5个(2.2%)在Vary里写了UA。如果你确实按UA分内容(比如给移动端一套模板),正确的做法是要么响应式一套HTML,要么移动端用独立URL,而不是靠Vary撑着。 ## Googlebot不发Accept-Language,那我的多语言站它怎么抓? 它抓到的是你的默认语言版本。这正是Google反复建议“每个语言版本要有独立URL”的原因——只有独立URL,爬虫才有办法访问到非默认的那些版本。如果你的德语内容只存在于“发了de-DE请求头才出现”的那个状态里,那它在索引里就是不存在的。这一点和按IP自动跳转是同一个坑的两种表现。 ## proxy_hide_header和proxy_ignore_headers到底差在哪? 差在动作对象。proxy_ignore_headers Set-Cookie是让nginx在做缓存决策时不理会这个头,但这个头仍然留在响应里,会跟着缓存副本一起发给后面每一个人。proxy_hide_header Set-Cookie是把这个头从发给客户端的响应里删掉。想安全地缓存一个带无关Cookie的页面,两个通常要一起用:ignore让它能进缓存,hide保证进去的那份不带别人的身份。 ## 只有50.4%的页面下发Cookie,是不是说另一半可以放心缓存? 下发Cookie只是最容易被发现的那道障碍。另一半还得看Cache-Control里有没有no-store、private,PHP有没有自动写no-cache,以及有没有Vary把分片打碎。判断一个页面能不能被共享缓存住,得把这几样一起看。最省事的验证办法是直接看命中率,连打三次看X-Cache,比逐条读配置快得多。 ## Client Hints对SEO有没有影响? 目前基本没有正向影响,只有负向风险。因为爬虫没有“后续请求”,服务器发出去的Accept-CH永远等不到回话,爬虫拿到的一定是“什么提示都没有”那个分支的内容。如果你的页面在这个分支上退化得很厉害(比如图片全部按最小尺寸出、组件不渲染),那搜索引擎看到的就是那个退化版本。现网发Accept-CH的只有5.2%,暂时不是普遍问题,但用了的站值得单独验一遍。 ## 怎么最快确认自己的缓存有没有在串版本? 三步。第一步,直连源站,用两种Accept-Language各请求一次,看内容是否不同——如果相同,说明你压根没按语言分内容,后面不用查了。第二步,如果不同,看响应头里有没有Vary: Accept-Language,没有就是确定的隐患。第三步,通过CDN请求,先用一种语言灌一次,再换另一种语言请求,看X-Cache是不是HIT、内容是不是变成了第一种语言的。第三步能复现,就是实锤。 ## 权威参考资料 ## 做SEO算抓取预算,发现服务器把没改过的页面对爬虫重发了几百遍 - URL:https://zhangwenbao.com/conditional-request-304-etag-crawl-budget-seo.html - 分类:缓存与CDN - 发布:2026-07-30 | 更新:2026-07-30 - 摘要:实测条件请求为什么不生效:动态页发了ETag没人判决、手写Last-Modified与内部判决值不是一个、压缩会换掉验证器。含560组nginx判决矩阵与85站现网普查。 - 关键词:技术SEO,抓取预算,服务器运维,HTTP缓存 > **TLDR**:摘要:Google在2024年底自己公布了一个数:十年前有0.026%的抓取请求能走缓存,今天这个数字是0.017%。也就是说爬虫每来一万次,能省下正文的不到两次。保哥在一台真实nginx上把16种服务器配置乘21种条件请求写法跑了560组,又对85个线上站点的218个URL逐个发条件请求,结果是——不是站长不配ETag,是配了以后有七八种方式让它悄悄失效:动态页发了ETag没人判决、手写的Last-Modified和服务器内部判决用的根本不是一个值、开个压缩就把验证器换掉、代理层顺手把请求头吞了。这些故障有一个共同点,浏览器里全都看不出来,因为浏览器压根不会发那个请求。 > 摘要:Google在2024年底自己公布了一个数:十年前有0.026%的抓取请求能走缓存,今天这个数字是0.017%。也就是说爬虫每来一万次,能省下正文的不到两次。保哥在一台真实nginx上把16种服务器配置乘21种条件请求写法跑了560组,又对85个线上站点的218个URL逐个发条件请求,结果是——不是站长不配ETag,是配了以后有七八种方式让它悄悄失效:动态页发了ETag没人判决、手写的Last-Modified和服务器内部判决用的根本不是一个值、开个压缩就把验证器换掉、代理层顺手把请求头吞了。这些故障有一个共同点,浏览器里全都看不出来,因为浏览器压根不会发那个请求。 先看一段日志。 这是一个内容站的访问日志,抽出了同一个URL在30天里被Googlebot命中的记录,只保留了状态码和响应体积两列: 66.249.66.1 - [02/Jun] "GET /guide/shipping-policy.html" 200 184203 66.249.66.1 - [04/Jun] "GET /guide/shipping-policy.html" 200 184203 66.249.66.1 - [07/Jun] "GET /guide/shipping-policy.html" 200 184203 66.249.66.1 - [09/Jun] "GET /guide/shipping-policy.html" 200 184203 ... (30天共47次,全部200,全部184203字节) 这个页面在这30天里一个字也没改过。 47次乘184 KB,8.6 MB。单看一个URL不算什么,但这个站有4万多个页面,绝大多数是同一种形态:内容长期不动,爬虫按自己的节奏反复来。整站算下来,一个月里被重复搬运的字节数是三位数的GB。 这些字节不是白花的——它们是从抓取预算里扣的。 爬虫在你这里花掉的每一秒连接时间、每一个字节的下行带宽,都不会再花在别的URL上。 你那批新上线的商品页迟迟不被收录,很可能不是因为爬虫不想来,而是因为它的额度已经花在把同一份没变过的HTML搬了47遍上。 这件事本来有解,而且是HTTP协议里最老的那批解法之一:条件请求。爬虫把上次拿到的凭证带上,服务器一比对,没变就回一个不带正文的304,两边都省。 问题在于,这条路今天基本是断的。 ## 抓取预算漏掉的到底是页面数,还是字节数? 先把口径对齐。很多人讲抓取预算,讲的是“爬虫今天抓了多少个URL”,于是优化动作全落在减少URL上——删死链、收参数页、治分面导航。这些都对,筛选过滤产生的海量URL确实会拖垮抓取预算 (https://zhangwenbao.com/faceted-navigation-seo-crawl-budget-index-control.html),Google抓取预算优化的那份实操清单 (https://zhangwenbao.com/google-crawl-frequency-optimization-guide-2026.html)里也有大半篇幅在讲怎么少给爬虫喂URL。但它只是分子那一半。 Google在大型站点抓取预算管理那份文档 (https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget)里把分母写得很直白。它把抓取预算拆成两个东西:一个是crawl capacity limit(抓取容量上限,文档里又叫hostload),另一个是crawl demand(抓取需求)。前者的定义是This limits the total amount of time your server spends holding connections open for Google. 注意这句话的单位——是时间,不是页数。它限制的是你的服务器为Google把连接保持打开的总时长。 同一份文档接着说明这个上限怎么浮动。往上走的条件是: > If the site responds consistently and its response times (including latency and Time-to-First Byte) remain stable or improve, the limit goes up. 往下掉的条件是站点变慢,或者返回5xx与429这类信号——the limit goes down and Google crawls less。 所以抓取预算真正的计价单位是“服务器为爬虫忙了多久”。一个184 KB的200,和一个175字节的304,在这个账本上完全不是一个量级。 ## Google对这件事的正式建议只有一句话 还是那份文档,在优化清单里明写着:Support 304 (Not Modified) HTTP status codes. If a page hasn't changed since Google last crawled it, returning a 304 code tells Google to reuse the cached version, saving your server bandwidth and resources. 一句话,没有展开。而实际要把这句话落到位,中间隔着七八个能让它失效的环节——这篇剩下的篇幅基本都在讲那七八个环节。 ## 0.017%:这条路今天有多窄 2024年12月,Google搜索中心发了一篇叫Crawling December: HTTP caching (https://developers.google.com/search/blog/2024/12/crawling-december-caching)的博文,开头第一段就报了个数: > 10 years ago about 0.026% of the total fetches were cacheable, which is already not that impressive; today that number is 0.017%. 十年前万分之二点六,今天万分之一点七。而且原文自己都加了一句“已经很不像样了”。 保哥第一眼看到这个数字,以为是没人在乎条件请求。但但把线上站点实际扫一遍之后,结论不太一样:愿意配的人不少,配对的人不多。两者中间隔着的东西,才是这篇文章要拆的。 ## 为什么这类故障在浏览器里永远看不见 这是整件事最反直觉的地方,也是它长期没人发现的原因。 浏览器拿到一个带Cache-Control: max-age=3600的响应,一小时之内再需要这个资源,它根本不会联网——直接从本地缓存拿,连条件请求都不发。只有在缓存过期之后,它才会带上凭证去问一次。而对一个正常访问的用户来说,绝大多数资源在过期之前就已经用完了。 爬虫不一样。爬虫没有“页面停留”这个概念,它按自己的重访节奏回来,回来时手里那份副本往往早就过了新鲜期。于是条件请求这条路,用户几乎不走,爬虫每次都走。你在开发者工具里把网络面板翻烂也测不出问题,因为你测的那个角色根本不走这条路。 这条经验后面还会反复出现,先记下来:凡是只有爬虫会触发的路径,它的故障率跟你在浏览器里的观感完全无关。 ## “我配了ETag”这句话,有多大概率是假的? 为了把这句话验到底,本文在一台服务器上起了一个独立端口的nginx实例(1.28.3,不碰生产配置),把16种配置形态各开成一个location,再用21种条件请求写法逐个打过去,一共560组应答。下面的每一个数字都来自那张表。 先看基线。一个128 KB的HTML静态文件,nginx出厂配置: HTTP/1.1 200 OK Content-Type: text/html Content-Length: 127997 Last-Modified: Wed, 29 Jul 2026 17:17:57 GMT ETag: "6a6a35c5-1f3fd" Accept-Ranges: bytes 两个验证器都有,白给的。nginx的etag指令默认就是on,静态文件的Last-Modified直接取文件修改时间。把这个ETag原样带回去问一次: HTTP/1.1 304 Not Modified Date: Wed, 29 Jul 2026 17:37:41 GMT Last-Modified: Wed, 29 Jul 2026 17:17:57 GMT ETag: "6a6a35c5-1f3fd" 两次的完整字节账是这样的: 响应 | 响应头 | 响应体 | 合计 | 200 OK | 236 B | 127,997 B | 128,233 B | 304 Not Modified | 175 B | 0 B | 175 B | 省了99.86%。这是条件请求生效时的样子,也是本文后面所有对照的基准线。 顺带把这个状态码在SEO语境里的位置摆正一下:304不属于301、302、404和410那组需要你做取舍的状态码 (https://zhangwenbao.com/http-status-codes-seo-atlas-redirect-410-decision.html)——那几个是在回答“这个URL还算不算数”,而304回答的是“这份内容要不要重传”,它不改变任何索引层面的判断,只改变一次抓取的成本。 如果你的站是一堆静态HTML,恭喜,你什么都不用做就已经在这条线上了。问题是绝大多数站不是。 ## 动态页的默认形态:一个验证器都没有 把同样这份HTML交给PHP输出,代码就是最朴素的那种: 3. When If-None-Match is present, evaluate the If-None-Match precondition: … if false for GET/HEAD, respond 304 (Not Modified) 4. When the method is GET or HEAD, If-None-Match is not present, and If-Modified-Since is present, evaluate the If-Modified-Since precondition… 请注意第4步那半句加粗的条件。规范的意思是:只要请求里有If-None-Match,就完全不许再看If-Modified-Since。同一份文档在13.1.2节还解释了为什么——entity tags are presumed to be more accurate than date validators,实体标签被认为比日期验证器更准。 ## nginx用的是“两个都得点头” 实验台上这一组只有两行,但结论很硬。用真实的ETag(匹配)配上一个2001年的If-Modified-Since(按exact语义算作“变了”): 请求带的两个头 | RFC要求 | nginx实测 | ETag不匹配 +Last-Modified匹配 | 200 | 200 ✓ | ETag匹配+If-Modified-Since说旧了 | 304 | 200 ✗ | 第二行是偏离。nginx把两个条件当成了“与”——两个都得点头才给304,任何一个说“变了”就回全文。规范要求的是“有ETag就只看ETag”。 这个偏离的方向是保守的(宁可多发一次全文,也不会发错版本),所以从正确性上讲它不危险。但从抓取预算上讲,它正好把Google那条“两个都设上”的建议变成了一个陷阱:你按建议把两个都设上了,反而多出一条能让304失效的路径。 ## 现网44.6%的服务器也是这么干的 这条如果只在实验台上成立,那它只是nginx的一个脾气。于是把同一组请求原样打到线上——218个URL里有74个同时给了ETag和Last-Modified,可以做这个测试: 测试 | 规范要求 | 现网合规率 | ETag不匹配 +Last-Modified匹配 | 200 | 70/74=94.6% | ETag匹配 +If-Modified-Since说旧了 | 304 | 41/74=55.4% | 第二行:44.6%的服务器把已经匹配上的ETag判决否掉了,返回全文。这不是nginx一家的脾气,是相当一片实现的共同选择。 落到操作上,这条给出了一个反常识的建议:如果你的ETag是靠得住的(内容哈希),那么把Last-Modified一起发出去,在将近一半的服务器上是在给自己添乱。Google的“两个都设上”是站在客户端立场说的——它希望不管遇到哪种服务器都有得用;而站在你自己的抓取预算立场,先把一个做对,比两个都做半拉子强。 ## 为什么开个压缩就把验证器换掉了? 这一节是本批最出乎意料的部分。 压缩和条件请求看起来是两件事:一个管“传多少”,一个管“传不传”。但它们在同一个响应上碰头,而碰头的地方是ETag。 ETag标识的是“表示”(representation),不是“资源”。同一个URL的gzip版和未压缩版,在HTTP的语义里是同一个资源的两个不同表示——所以严格说,它们的ETag应该不一样。各家实现对这句“应该”的执行力度,差别极大。 ## 实验台上的三种下场 同一个128 KB的文件,三种压缩配置,拿到三个不一样的ETag: 配置 | 不压时的ETag | 要gzip时的ETag | 变了吗 | 不开压缩 | "6a6a35c5-1f3fd" | "6a6a35c5-1f3fd" | 没变 | gzip on(运行时压) | "6a6a35c5-1f3fd" | W/"6a6a35c5-1f3fd" | 值一样,被标成弱的 | gzip_static on(发预压件) | "6a6a35c5-1f3fd" | "6a6a35c5-51a1" | 换了一个 | 中间那行是nginx的聪明做法:值保持不变,只在前面加个W/标成弱验证器。RFC 9110规定If-None-Match必须用弱比较 (https://www.rfc-editor.org/rfc/rfc9110.html#name-if-none-match)——A recipient MUST use the weak comparison function when comparing entity-tags for If-None-Match——弱比较会忽略W/前缀,所以拿未压缩版的ETag在压缩请求下回问,仍然给304。实测确认了这一点。 第三行就不行了。gzip_static发的是磁盘上另一个文件(.gz),ETag是按那个文件的属性算的,尾巴上那个51a1正是预压件的字节数。两个ETag完全无关,弱比较也救不回来: 场景 | gzip on | gzip_static on | 拿未压缩版ETag,在gzip请求下回问 | 304 | 200(白下一次全文) | 拿gzip版ETag,在未压缩请求下回问 | 304 | 200 | ## 现网抓到了四种形态 把这个测试铺到线上。218个URL里有109个能同时拿到未压缩版和压缩版两个ETag,其中59个=54.1%的ETag在压缩前后不一样。而且形态相当有辨识度: 未压缩版 | 压缩版 | 做法 | "0db679ca7776c5b9…" | W/"0db679ca7776c5b9…" | 值不变,标成弱(nginx这一路) | "260c5-5d4b907971c29" | "260c5-5d4b907971c29-gzip" | 尾巴上贴一个后缀 | "14a03e4e…-ssl" | "14a03e4e…-ssl-df" | 贴另一种后缀 | "5d2873ae3fcdc1:0" | "80c6b139e3fcdc1:0" | 整个值换掉,看不出关系 | 只有第一种能被弱比较救回来,后面三种都救不回来。实测的最终损失率是:拿未压缩版的ETag在压缩请求下回问,35/109=32.1%的URL直接给200。 三分之一。而这三分之一里,没有任何一个站长做错了什么——他们只是开了压缩。 ## 连带效应:压缩之后Range请求也没了 还有一个副作用是顺手测出来的。运行时压缩是流式的,nginx没法在压缩流上定位字节区间,于是Accept-Ranges直接从响应头里消失: 配置+请求编码 | 裸Range请求 | 带If-Range的Range请求 | 不压缩 | 206 | 206 | gzip on+ 要gzip | 200(全量) | 200(全量) | brotli on+ 要br | 200(全量) | 200(全量) | gzip_static on+ 要gzip | 206 | 206 | 顺便说,If-Range这条规范上要求强比较,弱ETag根本不能用于断点续传——所以哪怕Range还在,一个被W/标弱了的ETag也拿不到206。对大文件(超大sitemap、PDF目录、媒体文件)来说,这意味着任何一次中断都得从头再来。 ## 代理和CDN在中间做掉了什么? 前面几节的故障都发生在源站。真实站点在源站和爬虫之间还隔着一层甚至几层,那一层也会动手。 ## 改一个字,两个验证器一起消失 最干净的一个例子是sub_filter——在代理层做正文替换,用来注入统计代码、改域名、加个横幅,运维圈里非常常见。实验台上给一个正常带ETag的上游套一层sub_filter: 响应头 | 不套sub_filter | 套上sub_filter | ETag | "6a6a35c5-1f3fd" | 没有 | Last-Modified | 有 | 没有 | 条件请求 | 304 | 永远200 | 这个行为本身是对的——正文被改过了,上游那个ETag已经不能代表实际发出去的内容,留着才危险。但代价是这个location下的所有URL永久失去条件请求能力,而配置里没有任何一处提到这件事。 ## 请求头在入口就被吞掉 另一种更难查:响应里ETag好好的,条件请求却永远不命中。原因是中间层把If-None-Match请求头去掉了——WAF、某些反代默认配置、部分“防缓存穿透”策略都会这么做。 实验台上模拟了这个形态:响应头里ETag: "6a6a35c5-1f3fd"照发,客户端原样带回来问,结果是200。你在响应侧怎么检查都是对的,问题在请求侧,而请求侧没有任何回显。 自查方法很简单:在源站开一条只记$http_if_none_match的日志,看爬虫来的时候这个字段是不是空的。这跟Nginx配置里那些reload能过、却在抓取上出事的静默副作用 (https://zhangwenbao.com/nginx-config-silent-seo-side-effects-audit.html)是同一类问题——配置校验永远不会告诉你这件事。 ## 304自己也会丢东西 RFC 9110的15.4.5节 (https://www.rfc-editor.org/rfc/rfc9110.html#name-304-not-modified)对304有一条硬要求: > The server generating a 304 response MUST generate any of the following header fields that would have been sent in a 200 (OK) response to the same request: Content-Location, Date, ETag, and Vary; Cache-Control and Expires. 意思是200里有的这几个头,304里必须也有。实验台上开了gzip_vary on之后: 200 响应: Vary: Accept-Encoding ✓ 304 响应: (没有 Vary) ✗ nginx的304把Vary丢了。这条的后果落在共享缓存上:缓存收到304之后要用其中的头去更新已存条目,Vary一丢,它就可能把某个编码版本当成通用版本复用,最后给不支持该编码的客户端发了压缩内容。 线上的93个304样本里,这个问题的规模是可测的: 304里带了这个头 | 占比 | Date | 100.0% | ETag | 94.6% | Cache-Control | 76.3% | Vary | 53.8% | Last-Modified | 36.6% | Set-Cookie | 26.9% | 其中“200里有Vary、304里没有”的确认样本是8个。最后一行是个意外收获:四分之一的304响应里带着Set-Cookie。规范说得很直接——A 304 response is terminated by the end of the header section; it cannot contain content or trailers,同时建议不要发多余的表示元数据。往一个“什么都没变”的响应里塞一个新Cookie,对共享缓存是纯粹的麻烦。 好消息是,93个样本里带响应体的304是0个。这条底线大家还是守住了。 ## 现网到底配成了什么样? 实验台能穷举,但穷举出来的分布不代表真实世界。所以保哥又扫了一遍线上:164个域名里有85个能正常抓到首页,对每个站取四类URL——首页、一个深层内容页、robots.txt里声明的sitemap、页面上第一个静态资源,一共218个返回200的测量点。 ## 越是没人管的那类URL,配得越好 URL类型 | 样本 | 有ETag | 有Last-Modified | 两个都没有 | 首页 | 85 | 35.3% | 29.4% | 48.2% | 深层内容页 | 29 | 55.2% | 37.9% | 34.5% | sitemap | 66 | 56.1% | 59.1% | 27.3% | 静态资源(CSS/JS) | 38 | 73.7% | 84.2% | 13.2% | 合计 | 218 | 50.9% | 49.1% | 33.9% | 这张表的形状很说明问题:覆盖率最高的是静态资源,最低的是首页。 而这个顺序恰好跟“有没有人为它做过决策”成反比。静态资源的验证器是Web服务器自动给的,没人碰过,所以86.8%都有;首页是被改得最多的那个页面——挂了个人化模块、加了A/B测试、起了会话、套了三层中间件——每一次改动都可能把验证器碰掉,最后48.2%什么都不剩。 顺带一提,111个带ETag的URL里有33.3%是弱ETag。弱ETag对条件请求完全够用,但正如上一节说的,它不能用于Range。 ## 按站点看:四分之一的站完全吃不到 把测量点按站归总,每个站落进四档之一: 站点状态 | 站数 | 占比 | 测到的URL全都能拿到304 | 27 | 31.8% | 一部分能,一部分不能 | 36 | 42.4% | 一个验证器都没有 | 14 | 16.5% | 给了验证器,却一个304都拿不到 | 8 | 9.4% | 最后那一档是本文真正的主角:这8个站是配过的——响应头里有ETag或Last-Modified,检测工具会给它们打勾——但拿这些值回去问,没有一个能换来304。前面几节讲的每一种失效方式,都会把一个站送进这一档。 加上完全没配的14个,25.9%的站点在条件请求这件事上收益为零。另外42.4%是半拉子。 ## 按服务器软件分组:一个偏科得很明显的分布 把每个站按响应头里的Server归类,同时看两件事——能不能拿到304,以及给不给压缩: Server | 测量点 | 有验证器 | 能拿到304 | 给压缩 | cloudflare | 72 | 63.9% | 50.0% | 100.0% | nginx | 32 | 56.2% | 56.2% | 90.6% | netlify | 12 | 75.0% | 66.7% | 83.3% | vercel | 11 | 72.7% | 54.5% | 81.8% | AmazonS3 | 8 | 100.0% | 100.0% | 100.0% | Microsoft-IIS | 4 | 100.0% | 100.0% | 100.0% | 看第一行和最后两行的对比。 纯对象存储和传统Web服务器直出的场景,条件请求是100%——因为它们本来就只是把磁盘上的文件按规矩发出去,没有任何一层来得及把验证器弄丢。而经过了一层现代边缘网络之后,同一件事掉到了50%。 更值得琢磨的是同一行里那两个数的落差:压缩100%,条件请求50%。 这两件事都是省字节,为什么一个做满了一个只做了一半?因为压缩是边缘节点可以单方面完成的——内容在我手上,压不压我说了算,不需要问任何人。而条件请求要求边缘节点知道“源站那份东西到底变没变”,这是一次跨层的确认。同样是省字节,那件在自己这一层就能做完的,做到了100%;那件需要跟上一层对齐的,做到了一半。 ## 怎么改才真的省下抓取预算? 把前面所有实测收敛成可执行的动作。顺序是按“投入产出比”排的,不是按重要性。 ## 第一步:先确认你现在在哪一档 不用装任何工具,三条命令: # 1) 看有没有验证器 curl -sI -H 'Accept-Encoding: identity' https://你的域名/某个页面 | grep -i 'etag\|last-modified' # 2) 把拿到的 ETag 原样带回去问 curl -s -o /dev/null -w '%{http_code}\n' \ -H 'If-None-Match: "刚才那个值"' https://你的域名/某个页面 # 3) 换成浏览器/爬虫的真实编码偏好再问一次 curl -s -o /dev/null -w '%{http_code}\n' \ -H 'Accept-Encoding: gzip, deflate, br' \ -H 'If-None-Match: "刚才那个值"' https://你的域名/某个页面 第2条给304、第3条给200,就是本文那个32.1%的压缩换ETag问题。两条都给200,往下看第二步。 测的时候记得把首页、一个商品或文章页、sitemap各测一遍——前面那张表已经说明,同一个站的这三类URL可能落在完全不同的档上。 ## 第二步:按站型对号入座 你的形态 | 症状 | 动作 | 静态站/对象存储/纯CDN托管 | 通常已经是304 | 只需确认压缩没把ETag换掉 | 动态CMS,响应头里什么都没有 | 永远200 | 上页面级缓存(fastcgi_cache/proxy_cache/整页缓存插件),验证器会跟着一起来 | 动态CMS,有ETag但回问给200 | 验证器没人判决 | 要么在应用里自己判决并exit,要么在Web服务器侧开缓存让它接管 | 有Last-Modified但回问给200 | 多半是exact语义或手写值 | 去掉手写的add_header;确实需要就改if_modified_since before | 套了sub_filter/正文改写 | 验证器整体消失 | 把改写移到构建期或应用层,别放在代理层 | ETag在压缩前后不一样 | 三分之一的回问白费 | 优先用运行时压缩而不是gzip_static;用CDN的话确认它的缓存键与ETag策略 | ## 第三步:三段可以直接抄的配置 nginx静态资源,把默认行为坐实: location /static/ { etag on; # 默认就是 on,写出来是为了防止被上层关掉 if_modified_since before; # 默认是 exact,改成 before 更宽容 add_header Cache-Control "public, max-age=604800"; } 动态页交给nginx接管判决(PHP侧一行都不用改): fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=SITE:64m inactive=60m; location ~ \.php$ { fastcgi_pass unix:/run/php-fpm.sock; include fastcgi_params; fastcgi_cache SITE; fastcgi_cache_key "$request_method$host$request_uri"; fastcgi_cache_valid 200 10m; fastcgi_cache_bypass $cookie_session_id; # 登录态绕开 add_header X-Cache $upstream_cache_status; } 或者在应用层自己做完整判决——注意要用弱比较(忽略W/前缀),并且命中之后必须exit,一个字节正文都不能发: Our recommendation is that you require a cache refresh on significant changes to your content; if you only updated the copyright date at the bottom of your page, that's probably not significant. 只改了页脚的版权年份,不算实质变化,不该让缓存失效。 但绝大多数实现的ETag是整页响应体的哈希。页脚年份改一个字符,哈希就变,全站每一个URL的ETag同时失效——爬虫下一轮回来,一次304也拿不到。同理还有:随机排序的“相关推荐”、带时间戳的调试注释、每次渲染都变的CSRF令牌、轮播的广告位。 所以真正把收益做满的做法是:别拿整页哈希当ETag,拿内容版本当ETag。 文章的更新时间、商品的版本号、模板的构建号,三者拼一起做哈希,页脚年份改了它不动,正文改了它才动。 这跟改修改日期能不能骗过Google那件事 (https://zhangwenbao.com/modified-timestamp-truthful-update-fake-refresh-signal-mechanism.html)是一体两面:那边讲的是别把没变的说成变了,这边讲的是别让没变的看起来变了。 另外,Google在同一篇里还建议顺手把Cache-Control的max-age配上,用来帮爬虫判断多久该回来问一次——Set the value of the max-age field to the expected number of seconds the content will be unchanged.这一项跟浏览器侧那套缓存头怎么配 (https://zhangwenbao.com/http-browser-cache-control-etag-expires-cache-headers.html)用的是同一组指令,只是收益方向不同:那边省的是回头客的加载时间,这边省的是爬虫的抓取额度。 ## 一个诚实的边界 最后保哥想把这件事的天花板说清楚,免得预期跑偏。 Google那篇缓存博文通篇没有提排名,也没有提抓取预算,它的落脚点是perhaps also want to potentially save a few bucks on your hosting bill——省点服务器钱。真正把304和抓取预算挂上钩的是另一份文档(大型站点抓取预算管理),而那份文档开头就限定了适用范围:百万级URL、或者一万级URL但内容变化极快的站。 如果你的站只有几百个页面,把304配对了也不会让你多收录一个URL——爬虫本来就抓得完。 这件事的收益是随站点规模非线性放大的:URL越多、内容越静、爬虫回访越频繁,它越值得做。 反过来,一个小站花两天折腾这个,不如去把孤岛页面的内链补上 (https://zhangwenbao.com/orphan-pages-seo-detection-internal-link-repair-mechanism.html)。 ## 常见问题解答 ## 返回304会不会影响排名或者收录? 304本身不是排名信号,Google的缓存文档里对排名一个字没提。它的作用是让同样的抓取额度覆盖更多URL。所以它影响的不是“这个页面排第几”,而是“你有多少页面来得及被抓到、被更新”。对几百个页面的小站,这个差别接近于零;对几十万URL的电商站,它决定新品多久进索引。 ## 我用了CDN,是不是就不用管这件事了? 不是。本次普查里经过主流边缘网络的72个测量点,压缩覆盖率100%,条件请求只有50%。CDN能替你把“压多少”这件事做满,因为那是它自己一层就能决定的;但“变没变”这件事它必须跟源站对齐,只要源站不给验证器,边缘也编不出来。 ## ETag和Last-Modified只配一个的话,配哪个? 配ETag。Google的原话是We strongly recommend using ETag,理由是它的值没有格式约束、不会写错。而且本文实测了另一个理由:现网44.6%的服务器在两个都收到时会把它们当成“与”关系,一个说变了就全文重发——单配一个可靠的ETag,反而比两个都配半拉子稳。 ## 开了gzip会不会让条件请求失效? 取决于用哪种压缩方式。运行时压缩(gzip on)只是把ETag标成弱验证器,值不变,弱比较照样命中;预压缩(gzip_static on)发的是磁盘上另一个文件,ETag完全不同,回问必然200。现网实测54.1%的URL在压缩前后ETag不一样,其中32.1%会因此白下载一次全文。 ## 为什么我在浏览器开发者工具里看不到这个问题? 因为浏览器在缓存有效期内根本不发条件请求,直接用本地副本;过期了才会问一次。而爬虫按自己的重访节奏回来,回来时手上那份几乎总是过期的。这条路用户基本不走,爬虫每次都走,所以在浏览器里怎么测都是正常的。要看真相,得去服务器日志里筛爬虫UA的状态码分布。 ## 怎么快速判断整站的304覆盖情况? 在访问日志里按UA筛出Googlebot,统计状态码分布。健康的内容站应该能看到相当比例的304;如果连续30天全是200,而这些页面并没有天天更新,那这部分抓取额度就是白花的。这件事跟用日志分析工具读Googlebot的抓取与预算浪费 (https://zhangwenbao.com/log-analyzer-crawl-budget-googlebot-guide.html)是同一套方法,只是这次要看的那一列是状态码而不是URL。 ## 把If-Modified-Since改成before模式安全吗? 对内容型页面通常安全,它的语义是“你手上那份只要不比我旧,就当没变”。需要留意的是会回滚的场景:如果你把内容回退到了旧版本,文件修改时间反而变早了,客户端手上那份“更新”的副本会被判定为有效,从而拿不到新内容。有回滚流程的站,改这个之前先确认部署方式会不会把mtime往回调。 ## 权威参考资料 ## Redis对象缓存怎么给WordPress提速?object cache原理与运维实战 - URL:https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html - 分类:缓存与CDN - 发布:2026-05-08 | 更新:2026-06-02 - 摘要:Redis对象缓存怎么给WordPress提速?对象缓存与页面缓存CDN的区别、object-cache.php drop-in接入、PhpRedis前置条件、命中率监控、多站key隔离与缓存失效的完整运维实战。 - 关键词:Redis,WordPress,缓存优化 > **TLDR**:摘要:独立站后台点哪儿都转圈、商品列表加载半天、流量一上来数据库就喘——很多人第一反应是“加CDN”“上页面缓存”,结果发现登录用户和动态页面该慢还是慢。问题往往出在没人管的那一层:对象缓存(object cache)。WordPress每打开一个页面,背后是几十上百次数据库查询,默认这些查询结果用完即扔,下个请求重头再来。Redis对象缓存做的,就是把这些查询结果存进内存,让重复的查询不再反复砸数据库。保哥这篇把Redis对象缓存从原理到落地讲清楚:对象缓存到底缓存什么、和页面缓存/CDN有什么区别、WordPress默认缓存为什么不够用、object-cache.php这个drop-in是怎么接进去的、装它需要哪些前置条件、怎么判断真生效了和命中率高不高、它对TTFB和数据库负载意味着什么、Redis和Memcached怎么选、多站共用一个Redis的key隔离坑、缓存什么时候失效,最后是运维里那些容易栽的跟头。 > 摘要:独立站后台点哪儿都转圈、商品列表加载半天、流量一上来数据库就喘——很多人第一反应是“加CDN”“上页面缓存”,结果发现登录用户和动态页面该慢还是慢。问题往往出在没人管的那一层:对象缓存(object cache)。WordPress每打开一个页面,背后是几十上百次数据库查询,默认这些查询结果用完即扔,下个请求重头再来。Redis对象缓存做的,就是把这些查询结果存进内存,让重复的查询不再反复砸数据库。 保哥这篇把Redis对象缓存从原理到落地讲清楚:对象缓存到底缓存什么、和页面缓存/CDN有什么区别、WordPress默认缓存为什么不够用、object-cache.php这个drop-in是怎么接进去的、装它需要哪些前置条件、怎么判断真生效了和命中率高不高、它对TTFB和数据库负载意味着什么、Redis和Memcached怎么选、多站共用一个Redis的key隔离坑、缓存什么时候失效,最后是运维里那些容易栽的跟头。 ## 对象缓存到底缓存什么?它和页面缓存、CDN是一回事吗? 先把概念掰清楚,因为太多人把这几样混在一起,结果该优化的没优化。这三层缓存缓的东西、解决的问题完全不同,互相补位而非替代。 页面缓存(page cache)缓的是整个页面的最终HTML。第一个访客打开页面,服务器辛辛苦苦生成好HTML,页面缓存把它整张存下来,后面的访客直接拿现成的HTML,连PHP都不用跑。它快是真快,但有个硬伤:对登录用户、购物车、个性化内容这种“千人千面”的动态页面基本用不上——你总不能把A用户的购物车缓存给B看。 CDN缓的是静态资源和(配置得当时)页面HTML,把它们分发到离用户近的边缘节点,解决的是“物理距离”和“源站压力”的问题,保哥在Cloudflare缓存与回源率优化 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html)里专门讲过它的决策逻辑。 对象缓存缓的是更底层的东西:PHP运行过程中产生的中间数据,主要是数据库查询结果和一些复杂计算的结果。WordPress生成一个页面时,会反复问数据库“这篇文章的内容是什么”“这个用户有什么权限”“这个选项的值是多少”。对象缓存把这些问答的结果存进内存,下次再问同样的问题,直接从内存拿,不再走数据库。它的杀手锏正是页面缓存搞不定的场景——登录态、动态页面、后台操作,照样能加速,因为这些场景下数据库查询依然密集。 ## WordPress默认不是有缓存吗?为什么还要Redis对象缓存? 这是关键认知。WordPress本身确实自带对象缓存机制(WP_Object_Cache类),wp_cache_*那一组函数就是干这个的。但默认情况下,它是非持久的——缓存数据只在当前这一次请求的生命周期里存在,请求一结束,内存里的缓存就清空了。 这意味着什么?意味着默认的对象缓存只能在“同一个页面渲染过程中”避免重复查同一条数据,一旦这个请求结束、下一个访客来了,所有查询又得从数据库重新来过。它解决了单次请求内的重复,却完全没法跨请求复用。对于高流量、查询密集的站点,数据库依然被反复砸。 Redis对象缓存要做的,就是把这个“用完即扔”变成“持久存储”。它把WordPress的对象缓存后端从默认的内存(请求级)换成Redis(一个独立的、常驻的内存数据库)。这样一来,A访客触发的查询结果存进了Redis,B访客来时如果要问同样的问题,直接命中Redis里的结果——缓存跨请求、跨访客复用,数据库的重复查询量断崖式下降。WordPress官方文档里把这种通过drop-in插件启用的持久化缓存,明确列为Redis、Memcached这类方案的用武之地。 一句话总结:默认对象缓存是“一次性”的,Redis对象缓存是“持久共享”的。对内容不怎么变、访客以游客为主的小博客,差别可能不明显;但对查询密集、有大量登录用户、动态内容多的电商和会员站,这个差别是质变。 ## Redis对象缓存是怎么接进WordPress的?object-cache.php这个drop-in是什么? 理解了为什么要用,再看它怎么接进去,机制其实很优雅。WordPress留了一个官方的扩展口子,叫drop-in。在wp-content目录下放一个特定文件名的文件,WordPress启动时会自动加载它,用它替换掉某个核心组件的默认实现。对象缓存的drop-in文件名就是object-cache.php。 当wp-content/object-cache.php存在时,WordPress就不再用默认的非持久对象缓存,转而用这个文件里定义的实现。Redis对象缓存插件(社区最主流的是Till Krüss维护的Redis Object Cache)干的核心活,就是往wp-content里安装这个object-cache.php drop-in——它拦截标准的wp_cache_*调用,把读写转发到Redis。 所以装这类插件,启用后台通常有个“Enable Object Cache”的按钮,点一下它就把drop-in文件铺好;停用时再把drop-in移除,WordPress自动退回默认缓存。也可以用WP-CLI操作,命令更适合自动化部署: # 启用对象缓存(铺设 object-cache.php drop-in) wp redis enable # 查看状态、连接是否正常 wp redis status # 停用(移除 drop-in,退回默认缓存) wp redis disable 这个设计的好处是对业务代码零侵入:你的主题和插件还是照常调wp_cache_get、wp_cache_set,根本不知道底层换成了Redis,drop-in在中间无声地把活接了过去。这也是为什么换缓存后端不需要改一行业务代码。 ## 装Redis对象缓存需要哪些前置条件? 很多人装了插件却报“连不上Redis”,根因是前置条件没备齐。Redis对象缓存这套,需要三样东西到位,缺一不可。 第一,服务器上要装并运行Redis服务(redis-server)。Redis是个独立的内存数据库进程,得先把它跑起来,监听本地端口(默认6379)。这一步在你自己的服务器上要手动装,用的是托管主机/面板的话往往一键可开。 第二,PHP要有连接Redis的扩展。最主流、性能最好的是PhpRedis(一个用C写的PHP扩展,装好后phpinfo里能看到redis模块);另一种是纯PHP实现的Predis库,不用装扩展但性能略逊。Redis Object Cache插件两者都支持,优先用PhpRedis。没有任何一种,插件就没法和Redis通信。 第三,wp-config.php里配置Redis的连接信息(地址、端口,必要时密码和数据库编号)。本机Redis通常默认值就能连,但多站隔离、远程Redis、带密码的Redis就必须显式配。 // wp-config.php 中的常见 Redis 配置 define( 'WP_REDIS_HOST', '127.0.0.1' ); define( 'WP_REDIS_PORT', 6379 ); define( 'WP_REDIS_DATABASE', 0 ); // 如 Redis 设了密码 define( 'WP_REDIS_PASSWORD', 'your-strong-password' ); 三样齐了,再去后台或用WP-CLI启用,状态显示Connected就通了。保哥的经验是:排查“连不上”,按这三层倒着查——先确认Redis进程在跑(redis-cli ping返回PONG),再确认PHP有redis扩展,最后核对连接配置,十有八九能定位。 用宝塔、cPanel这类面板或托管主机的,前两步往往面板里点几下就装好了(装Redis服务、给对应PHP版本勾上redis扩展),不用手敲命令,但要注意一个常见坑:面板装的Redis扩展要装到你站点实际使用的那个PHP版本上。保哥遇到过有人服务器装了多个PHP版本,把redis扩展装到了7.4,站点却跑在8.1,结果插件死活报连不上,折腾半天才发现是版本对错了号。装完去对应PHP版本的phpinfo里确认redis模块在,最稳妥。 ## 怎么判断对象缓存真的生效了、命中率高不高? 装上不等于用好。保哥见过不少站,插件显示“已启用”,实际命中率低得可怜,缓存形同虚设。要看真实效果,得盯几个指标。 最直接的是命中率(hit ratio)。Redis Object Cache插件后台会显示缓存的命中和未命中次数,命中率=命中÷(命中+未命中)。健康的站点这个值通常很高(八九成以上)。如果命中率很低,可能是缓存频繁被清、或者大量查询根本没走缓存,得排查。 也可以直接问Redis自己。用redis-cli连进去看运行信息,keyspace_hits和keyspace_misses两个值就是命中和未命中的累计次数,used_memory看缓存占了多少内存,info stats能看到一整组运行指标。 redis-cli ping redis-cli info stats | grep keyspace redis-cli info memory | grep used_memory_human redis-cli dbsize 命中率上不去,保哥见过几个典型根因,可以挨个排。一是缓存被频繁清空——某个插件或定时任务动不动就全量flush对象缓存,缓存还没攒热就被清光,命中自然低。二是Redis内存太小、淘汰太凶,存进去的key还没来得及被复用就被挤出去了,这种要么加内存要么调淘汰策略。三是大量查询压根没走缓存,可能是某些数据被标记为不缓存、或插件没用标准的wp_cache_*接口而是直接裸查数据库,对象缓存对它无能为力。定位时把这三条对照着看,往往能找到症结。 更要看的是业务侧的真实变化:开缓存前后,后台操作(登录、改商品、看订单)顺不顺、首字节时间有没有降、数据库的查询量和负载有没有明显下来。保哥的判断标准很朴素——开了之后该快的地方真的快了、数据库压力真的小了,才算生效;只看插件显示“已连接”就以为万事大吉,是自我安慰。 ## 对象缓存能解决慢的问题吗?它对TTFB和数据库负载意味着什么? 能解决一部分,而且常常是最被忽略的那部分。要理解它的作用,得先知道一个动态页面慢在哪。当页面缓存命不中(登录用户、动态内容),服务器必须实时跑PHP生成页面,这个过程的大头开销之一就是数据库查询——几十上百次查询累加起来,是首字节时间(TTFB)的重要构成。 对象缓存通过把重复查询的结果挪到内存,直接砍掉了大量数据库往返。结果是两个:一是TTFB下降,因为生成页面时不用再傻等数据库逐条返回;二是数据库负载下降,同样的流量下数据库要处理的查询少了一大截,数据库不再是瓶颈,整站的并发承载能力随之上去。TTFB这个指标怎么受多层缓存影响、又怎么反过来牵动Core Web Vitals和抓取预算,保哥在TTFB多层缓存优化那篇 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)里有完整拆解,对象缓存正是其中作用于“动态页面生成”这一层的关键一环。 但要给个清醒的预期:对象缓存不是万灵药。如果你的慢是因为前端资源太重、图片没压缩、JS阻塞渲染,那是另一层的问题,对象缓存帮不上;如果慢是因为某个插件写了极其低效的查询,对象缓存能缓解重复部分,但治本还得去优化那个查询本身。它擅长的是“消灭重复的数据库查询”,对症时效果立竿见影,不对症时硬上也没用。 所以保哥的方法论是:先定位慢在哪一层。页面缓存解决游客的静态页,对象缓存解决动态页和后台的数据库重复查询,CDN解决距离和源站压力,前端优化解决渲染。对象缓存是这套组合里专治“数据库被反复砸”的那一味药,用在刀刃上。 ## Redis和Memcached怎么选?PhpRedis和Predis有什么区别? 先说后端:Redis还是Memcached。两者都能做WordPress的持久对象缓存后端,性能在大多数场景下都够用。区别在于Redis功能更丰富——它支持更多数据结构、支持持久化(能把内存数据落盘,重启不全丢)、支持更精细的内存淘汰策略,生态和工具也更成熟。Memcached更纯粹,就是个简单的键值缓存,极简场景下够用。保哥的默认建议是选Redis,功能和灵活性更好,社区主流插件的支持也更完善,除非你有特别理由非Memcached不可。 再说PHP侧的连接方式:PhpRedis还是Predis。这俩都是让PHP和Redis通信的“桥”,但实现不同。PhpRedis是用C语言写的PHP扩展,需要在服务器上安装编译,性能最好,是首选。Predis是纯PHP写的库,通过Composer引入即可,不用装扩展,部署更省事,但性能比PhpRedis差一截,适合没法装扩展的受限环境。 Redis Object Cache插件对两者都支持,会自动检测用哪个。保哥的取舍很简单:自己能控制服务器、能装扩展的,上PhpRedis拿最佳性能;用的是受限的虚拟主机装不了扩展,再退而求其次用Predis。别为了省那一点安装功夫在高流量站上用Predis,PhpRedis是C扩展、Predis是纯PHP,两者的性能差距在高并发压力下会被明显放大,等扛不住了再换更折腾。 ## 多站点共用一个Redis,key前缀不隔离会出什么事? 这是个真实又隐蔽的坑,做多站的人尤其要警惕。一台服务器上跑了好几个WordPress站,图省事让它们都连同一个Redis实例、同一个数据库编号,又没设区分的key前缀——结果就是几个站的缓存key撞在一起,互相覆盖、互相污染。 保哥真遇到过:一个客户在同一服务器跑了A、B两个独立站,共用Redis没做隔离,结果A站后台改了个设置,B站 (https://zhangwenbao.com/bilibili-seo-search-recommendation-ranking-guide.html)前台跟着出现莫名其妙的错乱数据,排查半天才发现是两站的缓存key冲突,A写的值被B读走了。这种问题极难定位,因为它表现得毫无规律。 隔离的办法有两种,可以叠加用。一是给每个站设不同的key前缀,Redis Object Cache插件支持用WP_REDIS_PREFIX定义;二是给每个站分配不同的Redis数据库编号(WP_REDIS_DATABASE,Redis默认有0到15共16个库)。 // A 站 wp-config.php define( 'WP_REDIS_PREFIX', 'siteA:' ); define( 'WP_REDIS_DATABASE', 0 ); // B 站 wp-config.php define( 'WP_REDIS_PREFIX', 'siteB:' ); define( 'WP_REDIS_DATABASE', 1 ); 设了前缀,每个站的key都带上自己的命名空间,互不干扰;用不同数据库编号则在更粗的粒度上做了物理隔离。保哥的建议是多站环境下两个一起用,宁可隔离得过度,也别让缓存串味——这种污染一旦发生,排查成本远高于事先配好隔离的那点功夫。 ## 缓存数据什么时候失效?会不会出现脏数据? 缓存的本质是“用一份可能过时的副本换速度”,所以“什么时候让副本失效、换上新数据”是缓存最核心的问题。处理不好就会出脏数据——前台显示的还是改之前的旧内容。 WordPress的对象缓存大体靠两套机制保证新鲜。一是写操作主动失效:当你改了文章、改了选项、更新了商品,WordPress会主动把相关的缓存key删掉或更新,下次读时自然回源数据库拿新值。主流的对象缓存drop-in都正确实现了这套失效逻辑,所以正常用通常不会有持久的脏数据。二是过期时间(TTL):可以给缓存项设存活时长,到点自动失效,给缓存兜个底。 那什么情况下会出脏数据?常见的是几类:插件写得不规范,改了数据却没正确触发缓存失效;或者人为用了不当的缓存配置(比如给本该实时的动态数据设了过长的TTL);再或者多站key污染(上一节那个坑)导致读到了别人的数据。保哥处理脏数据的第一反应是先wp redis flush或redis-cli flushdb清掉对象缓存看问题是否消失——如果清完就正常了,基本能确认是缓存层的失效问题,再去查是哪个环节没正确失效。 这里要拎清一个边界:对象缓存的flush清的是WordPress的查询结果缓存,和整页缓存、CDN缓存是不同层的清理。改了内容前台不更新,要按层排查——是对象缓存没失效,还是页面缓存/CDN还留着旧HTML,别一股脑全清了还定位不到根因。Magento那边用Redis做缓存也是同理,分层清理的思路是相通的,保哥在Magento性能调优那篇 (https://zhangwenbao.com/magento-2-performance-tuning-indexer-cache-redis-varnish-production-mode.html)里讲缓存层级时也强调过这点。 ## 对象缓存运维有哪些容易踩的坑? 最后把高频运维坑集中拎出来,对照自查。 第一,Redis内存爆了没设淘汰策略。Redis是内存数据库,缓存数据越存越多,内存会满。必须给它设maxmemory上限和淘汰策略(对象缓存场景通常用allkeys-lru,内存满时淘汰最久没用的key)。不设的话,内存撑满后Redis可能拒绝写入甚至出问题,缓存反而成了故障源。 # redis.conf 关键配置 maxmemory 512mb maxmemory-policy allkeys-lru 第二,Redis挂了拖垮整站。如果对象缓存的实现没做好降级,Redis一旦宕机或连不上,每次wp_cache调用都去连一个连不上的Redis、反复超时,页面会变得极慢甚至打不开。成熟的drop-in应在Redis不可用时自动降级回默认缓存,保证站点还能跑。部署时要确认这点,并对Redis的存活做监控。 第三,缓存命中率假高或假低没人看。装完就不管了,命中率早就掉到地板也不知道。应把命中率、Redis内存使用、连接状态纳入日常监控,定期瞄一眼,异常早发现。 第四,把对象缓存当成页面缓存的替代。两者不是二选一,是配合:页面缓存挡游客的静态页,对象缓存优化动态页和后台。只上对象缓存不上页面缓存,游客访问静态内容还是每次跑PHP;反过来只上页面缓存,登录用户和后台照样慢。该上的层都要上。 第五,改了Redis配置或重启没考虑缓存丢失的瞬时冲击。重启Redis(尤其没开持久化时)会清空所有对象缓存,重启后第一波请求全部未命中、集体回源数据库,可能出现短暂的负载尖峰(缓存击穿/雪崩的一种)。高流量站做这类操作要挑低谷,或者用支持持久化的配置让重启后缓存还在。把这些重活安排进维护窗口,是基本素养。 ## 常见问题解答 ## 对象缓存和页面缓存只上一个行不行,非得都上吗? 最好都上,因为它们解决的是不同场景,不是二选一的关系。页面缓存缓的是整张HTML,专治游客访问的静态页面——直接给现成HTML,连PHP都不跑,对游客极快;但它对登录用户、购物车、个性化内容这种动态页面基本失效。对象缓存缓的是数据库查询结果,专治动态页面和后台操作——这些场景页面缓存帮不上,而对象缓存能砍掉大量重复查询,让生成动态页变快、数据库压力下降。如果你的站以游客浏览静态内容为主,页面缓存收益最大;如果有大量登录用户、动态交互、后台操作频繁(典型如电商和会员站),对象缓存的价值就凸显出来。理想状态是两层叠加:页面缓存接住游客,对象缓存兜住动态和后台,再加CDN分发,各司其职。只上一层,总有一类场景被漏掉。 ## 我的站流量不大,上Redis对象缓存有必要吗? 看站点类型而非单纯看流量。流量小、内容以静态博客文章为主、访客几乎都是游客的站,默认的页面缓存往往就够了,Redis对象缓存带来的提升可能不明显,还多了一个要维护的Redis服务,性价比一般。但如果你的站虽然流量不大,却是动态密集型——比如WooCommerce电商、会员站、有大量登录用户和实时交互,那对象缓存的价值和流量大小关系没那么大,因为它优化的是每个动态请求里的数据库查询,哪怕请求总量不高,单个请求的查询负担重,对象缓存也能让后台和动态页明显变顺。保哥的判断是:纯静态小博客可以缓一缓,先用好页面缓存;动态交互多、后台操作频繁、或者已经感觉到数据库吃力的站,哪怕流量中等也值得上。先定位你的慢和压力来自哪里,再决定,别盲目跟风装一堆缓存。 ## 启用Redis对象缓存后,改了内容前台不更新,是缓存的锅吗? 有可能,但要分层排查别冤枉它。改了内容前台不更新,可能是对象缓存没正确失效,也可能是整页缓存或CDN还留着旧HTML——这是不同层的缓存。排查顺序建议:先确认是哪一层。如果是登录后台看到的也没更新,更可能是对象缓存层;如果只是游客看到的前台页面旧、你自己登录后台数据是新的,那大概率是页面缓存或CDN还缓着旧HTML。针对对象缓存,可以先清一次(wp redis flush或对应清理)看问题是否消失,消失了就说明是它没及时失效,再查是哪个插件改数据没触发失效。正常情况下主流对象缓存drop-in的失效逻辑是健全的,持久脏数据多半来自写得不规范的插件、不当的TTL配置、或多站key没隔离导致的污染。关键是按层定位,别一上来把所有缓存全清了,那样虽然暂时正常了,却没找到根因,下次照犯。 ## Redis如果挂了,我的网站会跟着挂吗? 取决于对象缓存实现有没有做好降级,这点务必在部署时确认。理想情况下,成熟的对象缓存drop-in(比如主流的Redis Object Cache)在检测到Redis连不上时,会自动降级回WordPress默认的非持久缓存,站点照常运行,只是少了持久缓存的加速、数据库压力回升,但不会挂。最怕的是实现没做降级:Redis一宕机,每次缓存调用都去连那个连不上的Redis、反复等待超时,页面会被拖得极慢甚至打不开,缓存层反而成了单点故障。所以两件事要做:一是选用有降级机制的成熟方案并验证降级确实生效(可以测试性地停掉Redis看站点是否还能访问);二是对Redis做存活监控,挂了能第一时间告警处理。Redis本身也建议配置持久化和合理的内存淘汰策略,提升它自身的稳定性。把缓存当成锦上添花的加速层来设计,而不是让整站命悬于它,是稳妥的架构心态。 ## 同一台服务器跑了好几个WordPress站,能共用一个Redis吗? 能共用,但必须做好隔离,否则会出缓存串味的隐蔽故障。多个站连同一个Redis实例本身没问题,省资源,关键是别让它们的缓存key撞在一起。隔离有两个手段,建议叠加用:一是给每个站设不同的key前缀(WP_REDIS_PREFIX),让每个站的缓存key带上自己的命名空间,互不干扰;二是给每个站分配不同的Redis数据库编号(WP_REDIS_DATABASE,默认有0到15共16个库),做更粗粒度的物理隔离。不做隔离的后果很难缠:A站写的缓存可能被B站读走或覆盖,前台冒出毫无规律的错乱数据,排查极其困难,因为现象飘忽不定。保哥的经验是多站环境宁可隔离得过度——配前缀加分库是举手之劳,而一旦发生污染,定位和善后的成本高得多。如果几个站规模都很大、互相影响内存,也可以干脆各跑各的Redis实例彻底隔开。 ## 权威参考资料 ## Service Worker离线缓存怎么做?Cache API与PWA缓存策略实战 - URL:https://zhangwenbao.com/service-worker-cache-api-offline-pwa-strategies.html - 分类:缓存与CDN - 发布:2026-05-06 | 更新:2026-05-06 - 摘要:从注册、install、activate到fetch拦截,手把手讲Service Worker离线缓存:Cache API核心方法、四五种缓存策略按资源类型选型、App Shell预缓存、缓存更新坑,以及它对Googlebot抓取的真实影响。 - 关键词:PWA,Service Worker,缓存与CDN > **TLDR**:摘要:Service Worker是跑在浏览器里、独立于页面的一段脚本,它能拦下页面发出的每一个网络请求,决定是走网络、读缓存还是两者结合。它和Nginx页面缓存、Redis对象缓存、CDN那几层最大的不同,是缓存逻辑由你用JavaScript写死在客户端,连断网都能出页面。保哥这篇把生命周期(注册、install、activate、fetch)、Cache API的几个核心方法、四五种常见缓存策略怎么选、版本更新怎么不把用户卡在旧页面,以及它对Googlebot抓取意味着什么,一次讲透。代码能抄走就用,坑也都标了出来。 > 摘要:Service Worker是跑在浏览器里、独立于页面的一段脚本,它能拦下页面发出的每一个网络请求,决定是走网络、读缓存还是两者结合。它和Nginx页面缓存、Redis对象缓存、CDN那几层最大的不同,是缓存逻辑由你用JavaScript写死在客户端,连断网都能出页面。 保哥这篇把生命周期(注册、install、activate、fetch)、Cache API的几个核心方法、四五种常见缓存策略怎么选、版本更新怎么不把用户卡在旧页面,以及它对Googlebot抓取意味着什么,一次讲透。代码能抄走就用,坑也都标了出来。 先把一个误会说清楚:很多人一听“缓存”,脑子里只有服务器那几层——Nginx的fastcgi_cache把整页HTML存起来、Redis把数据库查询结果存起来、Cloudflare在边缘节点存一份。这些都对,但它们有个共同点:都发生在请求离开浏览器、到达服务器或CDN之后。 Service Worker不一样。它住在浏览器里,是页面和网络之间的一道可编程关卡。请求还没出门,它就先拦下来问一句:这个要不要走网络?要不要直接给缓存里的旧货?这意味着哪怕用户的网断了、信号烂到只剩一格,你的站点照样能把页面渲染出来。这一篇就专门讲它这一层,和服务器端那几层是泾渭分明的两件事。 ## Service Worker到底是什么?和服务器端缓存差在哪? 用一句话概括,Service Worker是一个注册到浏览器、在后台独立运行、能拦截网络请求的脚本。它不挂在某个页面的生命周期上——页面关了它还能活着(比如处理推送通知),页面没开它也能被唤醒。正因为脱离了页面主线程,它里面不能碰DOM,所有跨页通信都得靠消息传递。 它和服务器端缓存的分工,保哥喜欢用“四层各管一段”来理解。浏览器的 HTTP缓存头(Cache-Control、ETag) (https://zhangwenbao.com/http-browser-cache-control-etag-expires-cache-headers.html)是最被动的一层,浏览器按响应头里的规则自动存、自动用,你只能配规则不能写逻辑。CDN(比如 Cloudflare的边缘缓存 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html))在网络中间替你挡掉大量回源。服务器上的 Nginx fastcgi_cache把PHP生成的整页存成静态 (https://zhangwenbao.com/nginx-fastcgi-cache-fullpage-php-wordpress-purge-microcache.html)。这三层都不需要你写代码,配置好就生效。 Service Worker是唯一一层“可编程”的缓存。命中不命中、命中了走不走网络更新、断网了拿什么兜底,全是你用JavaScript一行行写出来的。HTTP缓存头你只能说“缓存一小时”,Service Worker你能说“先给缓存里的旧版本秒开,同时偷偷去后台拉新版本存起来下次用”。这种精细到每个请求的控制力,是它和前面几层的本质区别,也是它能做到真正离线可用的根本原因。 还有一个绕不开的硬门槛:Service Worker只能在HTTPS下注册(localhost例外,方便本地开发)。这不是建议,是浏览器的强制规定。理由也好理解——它有改写网络响应的能力,要是能在不安全的连接上被注入,那就是个现成的中间人攻击工具。 ## Service Worker的生命周期怎么走?register、install、activate讲清楚 一个Service Worker从“被写出来”到“开始干活”,要走一条固定的路:注册 → 下载 → install → activate → 接管请求。这条路上每一步都有坑,搞懂了后面的缓存策略才有地方落脚。 第一步是在页面里注册。注意这段代码是写在普通页面脚本里的,不是写在Service Worker文件里: // 写在页面的主脚本里 if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker.register("/sw.js", { scope: "/" }) .then((reg) => console.log("注册成功,作用域:", reg.scope)) .catch((err) => console.error("注册失败:", err)); }); } 这里有个高频踩坑点——作用域(scope)。Service Worker只能控制它所在路径及以下的请求。你把 sw.js 放在 /js/sw.js,它默认就只能管 /js/ 下面的东西,根目录的页面它够不着。所以约定俗成把Service Worker文件放在网站根目录,让它能接管整站。想放深一点又要管根目录,得靠服务器响应头 Service-Worker-Allowed 放宽,多数人不知道这个,白白折腾半天。 注册成功后浏览器会下载这个脚本并触发 install 事件。这是预缓存的黄金时机——把页面骨架、关键CSS、JS、logo这些“无论如何都得有”的资源一次性塞进缓存: const CACHE_NAME = "site-shell-v1"; const PRECACHE_URLS = [ "/", "/offline.html", "/css/app.css", "/js/app.js", "/img/logo.svg", ]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) ); }); event.waitUntil() 是个关键动作,它告诉浏览器“install还没完,等我这个Promise兑现了再说”。不写它,浏览器可能在缓存还没塞完就认为安装结束,预缓存就成了薛定谔的猫。 接着是 activate。这一步最重要的活儿是清理旧缓存。每次你改了缓存内容、把版本号从v1升到v2,旧的v1缓存就该被扫掉,不然用户的硬盘会被一代代旧缓存慢慢撑爆: self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all( keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) ) ) ); }); 这里要特别提醒一个生命周期的“延迟接管”特性:默认情况下,新的Service Worker装好后不会立刻接管已经打开的页面,它会进入“等待(waiting)”状态,要等所有用旧版本的标签页都关掉,新版本才上岗。这个设计是为了避免页面用着一半版本逻辑突然被换掉。但它也带来了那个经典抱怨——“我明明改了代码刷新了,怎么还是旧的”。这个问题怎么治,后面专门有一节讲。 ## Cache API怎么用才能把离线缓存管明白? Service Worker自己不存东西,真正存缓存的是Cache API,全局对象叫 caches。它本质上是一个键值仓库,键是Request对象,值是Response对象。注意它和HTTP缓存是两套独立系统——Cache API完全不理会响应头里的Cache-Control,你存进去就一直在,什么时候删全凭你的代码说了算。这点MDN文档里写得很直白,也是新手最容易混淆的地方。 常用的方法就那么几个,记熟了基本够用: - caches.open(name):打开(不存在就新建)一个命名缓存,返回这个cache对象。 - cache.put(request, response):手动把一对请求-响应存进去,最灵活,适合在fetch里拿到响应后顺手缓存。 - cache.add(url) / cache.addAll([urls]):传URL进去,浏览器自己去抓取再存,预缓存清单最常用 addAll。 - caches.match(request):跨所有命名缓存找匹配的响应,找不到返回undefined,这是取缓存的主力。 - cache.delete(request) 和 caches.keys():删条目、列出所有缓存名字,清理时用。 有个细节值得单独拎出来:Response对象是“一次性”的,它的body是个流,读一次就空了。所以你想既把响应返回给页面、又存一份进缓存,必须先用 response.clone() 克隆一份。忘了克隆,要么页面拿到空响应,要么缓存存进去个空壳,这是写fetch拦截时最常见的低级错误: self.addEventListener("fetch", (event) => { event.respondWith( fetch(event.request).then((networkResponse) => { const copy = networkResponse.clone(); // 先克隆 caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); return networkResponse; // 原件给页面 }) ); }); 还有个匹配上的细节值得知道:caches.match() 默认拿整个Request(含查询参数)当键去比对,所以 /list?page=1 和 /list?page=2 会被当成两条完全不同的缓存。要是你希望忽略查询参数、把它们当同一条处理,得传 { ignoreSearch: true } 选项。带时间戳、带随机参数的URL如果不管,很容易把缓存撑出一堆几乎一样的副本,白白占掉存储配额,这点在缓存第三方脚本、广告资源时尤其要留神。 ## 几种缓存策略怎么选?Cache First还是Network First? 所谓缓存策略,说白了就是在fetch事件里,你决定“先问网络还是先问缓存、问不到怎么兜底”的那段逻辑。web.dev那本《Offline Cookbook》把常见套路总结得很全,落到实战里就这么几种,按资源类型对号入座即可。 Cache First(缓存优先):先查缓存,命中就直接返回,没命中才走网络并顺手存起来。适合那些“几乎不变”的资源——带哈希指纹的CSS/JS、字体、logo。它的好处是秒开且省流量,代价是更新得靠换文件名触发。 // Cache First event.respondWith( caches.match(event.request).then((cached) => { return cached || fetch(event.request).then((res) => { const copy = res.clone(); caches.open(CACHE_NAME).then((c) => c.put(event.request, copy)); return res; }); }) ); Network First(网络优先):先走网络,拿到新内容就用并更新缓存,网络挂了才退回缓存。适合对“新鲜度”敏感的内容——商品价格、库存、新闻列表。它保证联网时永远是最新的,断网时还有个旧版本兜底,不至于白屏。 Stale-While-Revalidate(先旧后新):这是体验最讨喜的一种。立刻把缓存里的旧版本返回去让页面秒开,同时在后台偷偷发请求拉新版本存起来,下次访问就是新的了。代价是用户这次看到的可能是上一版,适合那些“稍微旧一点也无所谓但要快”的内容,比如头像、非关键的配置数据。 // Stale-While-Revalidate event.respondWith( caches.open(CACHE_NAME).then((cache) => cache.match(event.request).then((cached) => { const fetching = fetch(event.request).then((res) => { cache.put(event.request, res.clone()); return res; }); return cached || fetching; // 有旧的先给旧的,后台照样更新 }) ) ); 另外还有两种极端:Network Only(只走网络,适合不能缓存的接口,比如下单、支付回调)和 Cache Only(只读缓存,适合明确预缓存过、保证存在的资源)。真实项目里很少全站用一种策略,更常见的是按请求类型分流:HTML文档用Network First,静态资源用Cache First,API数据看情况用Stale-While-Revalidate或Network Only。 Network First还有个实战增强很值得加——超时兜底。纯Network First在网络极慢(不是断网,是龟速)时会傻等服务器响应,那种体验比直接断网还煎熬。老练的写法是给网络请求套一个两三秒的超时,一旦超时就主动退回缓存,让用户先看到旧内容也别干等转圈。这种“网络优先但限时”的变体,在移动端弱网、跨境高延迟的场景下特别管用,保哥给做跨境的客户配PWA时几乎都会默认加上,实测能把弱网下的“感知卡顿”砍掉一大截。 保哥之前帮一个做户外装备的独立站排查“断网就白屏”的问题,根子就是他们图省事全站套了Cache First,连商品库存接口都被缓存死了,用户看到的库存永远是第一次访问时的快照,下单老是超卖。改成按类型分流——页面壳Cache First、库存价格Network First——白屏没了,超卖也治住了。策略选错比不选还麻烦,这是真金白银的教训。 ## App Shell和预缓存清单怎么设计? App Shell(应用外壳)是PWA里一个很实用的模型:把页面里“每一页都长一样”的部分——顶部导航、底部页脚、侧边栏、基础样式——抽出来当成一层稳定的骨架预缓存掉,内容区再按需从网络或缓存填进去。这样第二次访问时,外壳直接从缓存秒出,用户感觉“唰”地就开了,剩下的内容慢慢补。 设计预缓存清单有几条经验。第一,清单要克制,只放真正的关键路径资源,别把整站几百个文件全塞进去——install阶段 addAll 是“全有或全无”,里头任何一个URL抓取失败,整个install就失败,Service Worker装不上。第二,清单里的每个资源最好带版本指纹(文件名带哈希),这样配合Cache First既能长缓存又能在内容变了时自然失效。第三,准备一个 offline.html 兜底页,当用户彻底断网又访问了没缓存过的页面时,至少给个体面的“当前离线”提示,而不是浏览器那个难看的恐龙。 还要分清预缓存和运行时缓存这两个概念,混淆它们是后期缓存失控的根源。预缓存是install阶段一次性塞进去的固定清单,是离线兜底的地基,内容稳定、随版本号整体更新;运行时缓存是用户实际访问过程中、由fetch拦截按策略动态存下来的内容,比如用户翻过的某篇文章页、看过的某张图片。这两类最好用不同的缓存名分开管理——预缓存跟着版本号走,运行时缓存设个数量上限定期淘汰最老的,避免它随用户浏览无限膨胀吃掉配额。要是把两者混在一个缓存里,清理时极容易误删掉本该常驻的外壳资源,离线兜底跟着崩。 清单的版本管理直接绑在缓存名字上是最省事的做法——site-shell-v1、site-shell-v2。版本号一变,activate里的清理逻辑就会把旧缓存扫掉,新清单重新预缓存。手动维护清单容易漏,规模大了建议上Workbox这类工具自动生成预缓存清单(它会扫描构建产物、自动算指纹、自动注入清单),但原理还是这一套,工具只是把体力活自动化了。 ## 缓存更新和版本控制怎么做才不会让用户卡在旧版本? 这是Service Worker的头号疑难杂症,也是保哥被问得最多的问题:明明发了新版本,部分用户怎么死活还是旧页面?根源就在前面提过的“延迟接管”——新Service Worker装好后默认在旁边等着,非要等所有旧标签页关掉才上岗。可现在的人谁还关标签页?几十个标签页挂着,旧Service Worker就一直赖着不走。 想让新版本尽快接管,有两个动作要成对使用。self.skipWaiting() 让新Service Worker跳过等待、装完立刻激活;clients.claim() 让它激活后立刻接管所有已打开的页面,而不是等下次导航: self.addEventListener("install", (event) => { self.skipWaiting(); // 别在旁边等了,直接上 }); self.addEventListener("activate", (event) => { event.waitUntil(clients.claim()); // 立刻接管现有页面 }); 但这俩也不是无脑加就好。skipWaiting 会让页面在没刷新的情况下,前后两半请求由不同版本的Service Worker处理,万一新旧版本的缓存结构、接口契约对不上,可能出现样式错乱、接口报错。所以更稳妥的做法,是检测到有新版本就绪时弹个提示条——“有新版本,点此刷新”——让用户主动触发刷新,而不是偷偷换。要快还是要稳,看你的站点能不能接受瞬间的版本不一致。 另外一个常被忽略的点:浏览器对Service Worker文件本身(sw.js)也会做HTTP缓存。要是你的服务器给 sw.js 配了长缓存,浏览器可能拿着旧的Service Worker文件不更新,你改了半天它压根没下载新的。所以约定俗成给Service Worker文件单独配 Cache-Control: no-cache,让浏览器每次都去校验有没有更新。这个坑很隐蔽,排查“代码改了不生效”时一定要先看这里。 ## Service Worker对SEO和抓取有什么影响? 做技术的容易只盯着功能,但保哥做了二十多年SEO,必须提醒一句:Service Worker用得不对,是能伤到收录的。最核心的一条原则——首屏内容绝对不能依赖Service Worker才能出来。 原因是Googlebot抓取页面时,基本上是“全新访客”的身份,没有任何已注册的Service Worker,更不会等你的离线缓存生效。它要的是服务器直接吐出来的、首次请求就完整的HTML。如果你的页面把关键内容藏在“等Service Worker接管后再从缓存渲染”的逻辑里,爬虫第一次来看到的就是个空壳,收录自然出问题。 所以Service Worker是“锦上添花”的体验增强层,渲染兜底必须靠服务器端渲染(SSR)或静态生成(SSG)扛住。关于Service Worker和PWA对抓取索引的具体影响机制,保哥单独写过一篇拆解,想深挖的可以去看。 第二个角度是性能。Google早就把页面速度、Core Web Vitals当成排名信号,而Service Worker配合缓存策略,恰恰能显著拉低回头客的LCP(最大内容绘制)。这一点和服务器端的缓存优化是同向用力的——你想把 TTFB和整体加载速度压到底 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html),多层缓存得协同工作,Service Worker是离用户最近、最后那一道。它管不了首次访问的TTFB(那是服务器的事),但回头客的体验它能托起来。 说到底,Service Worker在SEO这件事上的定位很清楚:它优化的是“已经认识你的人”再次访问的体验,对“第一次来的爬虫”几乎没有正面贡献,用错了反而帮倒忙。把这条边界守住,剩下的就是放开手脚优化体验了。 ## 实战里踩过哪些坑? 把这些年趟过的雷集中列一下,照着避能省不少头发: - 非HTTPS注册失败:本地用localhost没问题,一上测试环境用了IP或HTTP域名,注册直接静默失败,控制台还不一定有明显报错,先查协议。 - scope没覆盖到目标路径:Service Worker文件位置决定能管的范围,要管全站就放根目录,别放 /assets/ 里还纳闷为什么首页不受控。 - 缓存爆掉存储配额:浏览器给每个站点的存储是有上限的,无脑缓存大文件、视频,容易触发配额上限导致写入失败。该清的旧缓存一定在activate里清干净。 - HTML被长缓存死:和HTTP缓存一个道理,HTML文档别用Cache First长缓,否则用户永远停在某个旧版本。文档类走Network First或Stale-While-Revalidate。 - 第三方资源的不透明响应(opaque response):跨域且没开CORS的资源(比如某些第三方CDN的图片)缓存进去是opaque的,你读不到状态码,没法判断是不是真的成功,还特别占配额,缓存第三方资源要当心。 - 忘了用DevTools的Application面板调试:Chrome开发者工具的Application → Service Workers能看注册状态、强制更新、模拟离线,Cache Storage能直接看缓存了什么。不会用这个面板,调Service Worker等于盲人摸象。 - 开发时被自己的缓存骗:调试时勾上DevTools里的“Update on reload”和“Bypass for network”,否则你会被自己写的缓存逻辑反复戏弄,改了代码看不到效果。 Service Worker这东西,原理不复杂,但它运行在一个“脱离页面、有自己生命周期、还能拦网络”的特殊语境里,新手最容易栽在生命周期和缓存克隆这些机制细节上。把本文这几节的机制吃透,配合DevTools反复观察,基本就能驾驭它了。 ## 常见问题解答 ## Service Worker和HTTP缓存(Cache-Control)有什么区别?该用哪个? 两者是独立的两套系统,不是二选一,而是配合用。HTTP缓存是浏览器按响应头自动管理的,你只能配规则、改不了逻辑,且对每个资源生效;Service Worker配合Cache API是你用JavaScript完全掌控的可编程层,能做到断网出页面、按策略精细分流。一般做法是:常规静态资源交给HTTP缓存头管就够了;需要离线可用、需要复杂缓存策略(比如先旧后新)、需要断网兜底时,才上Service Worker。web.dev有专门一篇讲两者如何协同,建议读一读。 ## 为什么我改了Service Worker代码,刷新后还是旧的? 两个最常见原因。一是“延迟接管”机制——新版本装好后默认在waiting状态等所有旧标签页关闭才激活,你只刷新没关页面,旧的还赖着。可以用 skipWaiting() 加 clients.claim() 让它尽快接管,或在DevTools里手动点“skipWaiting”。二是 sw.js 文件本身被HTTP长缓存了,浏览器压根没下载到新文件,给它配 Cache-Control: no-cache 即可。 ## Service Worker会拖慢首次访问吗?对新用户有好处吗? 首次访问时Service Worker还在注册和预缓存,那一次基本享受不到加速,甚至预缓存还会占用一点带宽。它的价值几乎全在“回头客”身上——第二次起,缓存生效,加载速度和离线能力才显现。对搜索引擎爬虫这种“永远的首次访客”更是几乎没有正面作用,所以千万别让首屏内容依赖它渲染。 ## 必须用Workbox这类库吗?手写Service Worker行不行? 完全可以手写,本文的代码就是原生写法,小站点手写反而更可控、包更小。Workbox的价值在于把预缓存清单生成、缓存策略封装、版本管理这些重复劳动自动化了,项目大、资源多、构建流程复杂时能省很多体力,但它底层用的还是本文讲的这套机制。建议先手写理解原理,再决定要不要上工具。 ## Service Worker能缓存API接口数据吗?会不会导致数据不更新? 能缓存,但要挑策略。读多写少、新鲜度要求不高的接口(比如分类列表、配置)适合Stale-While-Revalidate,秒出旧的同时后台更新。新鲜度敏感的(价格、库存、订单状态)要用Network First,联网时永远拿最新,断网才退缓存。绝对不能缓存的(下单、支付、登录态相关)直接用Network Only。把不该缓存的接口也缓存了,是导致“数据不更新”事故的最常见原因,按接口性质分流是关键。 ## 权威参考资料 ## CDN边缘缓存到底怎么配才不踩坑?回源、TTL分层与缓存键实战 - URL:https://zhangwenbao.com/cdn-edge-caching-strategy-ttl-cache-control-purge-origin-shield.html - 分类:缓存与CDN - 发布:2026-04-09 | 更新:2026-04-09 - 摘要:面向独立站运营讲CDN边缘缓存策略:边缘节点回源原理、s-maxage与max-age区别、边缘TTL与浏览器TTL分层、缓存键规范化剔除utm、stale-if-error兜底、按URL与标签精准purge、分层缓存防回源风暴,附实战案例与翻车现场。 - 关键词:CDN,缓存优化,运维,网站性能 > **TLDR**:摘要:很多人给独立站套了CDN,以为流量一接过去网站就会自动变快、源站压力就会自动降下来,结果一看后台,缓存命中率才五成出头,源站照样被打得喘不过气,更新了内容用户半天还看到旧版。问题几乎都出在同一个地方:CDN接进来了,但边缘缓存的策略没配对,CDN沦为一个只会转发请求的中转站,根本没把“缓存”这件最值钱的事做好。边缘缓存的逻辑其实不复杂:CDN在全球各地放了一堆边缘节点,用户的请求就近被最近的节点接住,如果这个节点缓存里有现成的内容就直接吐回去、根本不碰你的源站,这才是CDN让网站变快、给源站减压的核心。配好它的关键,是想清楚什么该缓存、缓存多久、缓存键怎么定、内容更新了怎么精准失效。保哥这篇不绑定某一家CDN,讲的是适用于Cloudflare、CloudFront、Fastly等任何CDN的通用边缘缓存策略:边缘节点怎么接住请求、Cache-Control怎么指挥CDN、边缘TTL和浏览器TTL的区别、缓存键怎么悄悄拖垮命中率、TTL怎么分层、源站挂了怎么用陈旧内容兜底、内容更新后怎么精准purge、命中率怎么提、回源风暴怎么防,最后给一个把命中率从五成提到九成的真实案例和几个翻车现场。 > 摘要:很多人给独立站套了CDN,以为流量一接过去网站就会自动变快、源站压力就会自动降下来,结果一看后台,缓存命中率才五成出头,源站照样被打得喘不过气,更新了内容用户半天还看到旧版。问题几乎都出在同一个地方:CDN接进来了,但边缘缓存的策略没配对,CDN沦为一个只会转发请求的中转站,根本没把“缓存”这件最值钱的事做好。 边缘缓存的逻辑其实不复杂:CDN在全球各地放了一堆边缘节点,用户的请求就近被最近的节点接住,如果这个节点缓存里有现成的内容就直接吐回去、根本不碰你的源站,这才是CDN让网站变快、给源站减压的核心。配好它的关键,是想清楚什么该缓存、缓存多久、缓存键怎么定、内容更新了怎么精准失效。 保哥这篇不绑定某一家CDN,讲的是适用于Cloudflare、CloudFront、Fastly等任何CDN的通用边缘缓存策略:边缘节点怎么接住请求、Cache-Control怎么指挥CDN、边缘TTL和浏览器TTL的区别、缓存键怎么悄悄拖垮命中率、TTL怎么分层、源站挂了怎么用陈旧内容兜底、内容更新后怎么精准purge、命中率怎么提、回源风暴怎么防,最后给一个把命中率从五成提到九成的真实案例和几个翻车现场。 保哥先讲个真见过的事。一个做外贸的独立站,老板花钱上了CDN,满心以为网站从此飞快。跑了一阵发现源站负载没怎么降,打开后台一看缓存命中率只有48%——意思是超过一半的请求CDN都没缓存,老老实实回源去问了一遍。再细查,原因哭笑不得:源站给几乎所有响应都带了Set-Cookie,CDN一看带cookie的响应,按默认规则当成“因人而异的私人内容”就不缓存了,于是CDN形同虚设。 这事的根子不在CDN本身,而在没人去理解和配置边缘缓存的规则。所以这一篇,保哥按“边缘缓存怎么工作、CDN怎么判断缓存什么、TTL怎么设、缓存键怎么定、更新了怎么失效、出问题怎么查”这条真实链路,把CDN边缘缓存策略讲清楚,让你套了CDN之后是真的快、真的给源站减压,而不是花钱买了个摆设。 ## CDN的边缘缓存到底是怎么工作的?请求是怎么被边缘节点接住的? 要把边缘缓存配对,先得看清一个请求在CDN体系里走的完整路径。保哥用一次普通的访问来拆解。 当一个用户访问你的网站,请求并不会直接打到你那台源服务器上,而是先被CDN的DNS解析引导到离用户地理位置最近的边缘节点(业内也叫PoP,接入点)。CDN在全球部署了成百上千个这样的节点,靠Anycast技术让同一个IP在不同地区指向不同的就近节点,所以一个中国用户和一个德国用户访问你的站,接住他们的是各自附近的不同节点。光是这个“就近接入”,就已经把网络延迟砍掉一大截。 接住请求后,边缘节点做的第一件事是查自己的本地缓存:如果缓存里有这个资源的有效副本(缓存命中,cache hit),节点直接把它吐回给用户,整个过程根本不碰你的源站,速度极快、源站零压力。这是CDN价值的核心。如果缓存里没有、或者副本已经过期(缓存未命中,cache miss),节点才会回源——也就是去你的源服务器把内容拉一份回来,一边返回给用户,一边按规则缓存下来,下一个访问同一资源的用户就能命中了。 所以整个CDN提速和减压的效果,几乎全部押在“命中率”这一个指标上。命中率高,意味着绝大多数请求在边缘就被解决了,源站清闲、用户飞快;命中率低,意味着CDN天天回源,既没给源站减压,还多绕了一道,甚至可能更慢。理解了这条路径,你就明白后面所有策略——TTL、缓存键、purge——本质上都是在为“提高命中率、且不缓存错东西”这一个目标服务。 ## CDN凭什么决定缓存什么、缓存多久?Cache-Control是怎么指挥它的? 边缘节点不会瞎缓存,它主要听源站响应头里的指令行事,其中最关键的就是Cache-Control。理解这个头怎么指挥CDN,是配置边缘缓存的基本功。 根据MDN的权威定义,Cache-Control里有两个最该分清的指令:max-age和s-maxage。max-age管的是“私有缓存”也就是用户浏览器该把资源存多久;s-maxage专门管“共享缓存”,CDN这种被所有用户共用的缓存就属于共享缓存。当响应里同时有这两个,CDN会优先听s-maxage的,浏览器则听max-age的。这给了你分别控制“边缘缓存多久”和“浏览器缓存多久”的能力,后面专门讲。 还有几个指令决定了一个资源到底能不能被CDN缓存。public 明确表示这个响应可以被共享缓存存;private 则告诉CDN“这是因人而异的私人内容,你不许缓存”,只允许浏览器自己存——用户的账户页、购物车、带个人信息的页面就该标private。no-store 是最严的,谁都不许存,敏感数据用它。no-cache 名字有迷惑性,它不是“不缓存”,而是“可以存,但每次用之前必须回源问一下有没有更新”。 这里就能解释开头那个案例:当源站给响应带上Set-Cookie,很多CDN会默认认为这是个性化内容而不缓存它。所以如果你的整站响应都莫名带着cookie,CDN的缓存就基本废了。配置边缘缓存的第一步,往往是把源站对静态资源、对公共页面的响应头理顺:该public的标public、给够s-maxage、别乱带Set-Cookie。这套响应头的逻辑和保哥讲浏览器HTTP缓存头 (https://zhangwenbao.com/http-browser-cache-control-etag-expires-cache-headers.html)那篇是一脉相承的,只不过那篇聚焦浏览器这一层,这篇聚焦CDN这层共享缓存,两层叠起来才是完整的缓存链路。 ## 边缘缓存TTL和浏览器缓存TTL是一回事吗? 这是新手最容易混的一组概念,保哥必须掰开讲。边缘缓存TTL和浏览器缓存TTL是两个独立的东西,分别控制资源在两个不同地方待多久,可以、而且常常应该设成不一样的值。 边缘缓存TTL(Edge Cache TTL)指的是一个资源在CDN的边缘节点上被当作“新鲜的、可直接拿来用”能待多久。根据Cloudflare官方文档,它就是资源在CDN全球网络里缓存的最大时长,由s-maxage控制。浏览器缓存TTL(Browser Cache TTL)则是这个资源被缓存在用户浏览器里能待多久,由max-age控制。 为什么要分开设?因为这两层的更新成本完全不同。边缘缓存你能主动purge(清掉),浏览器缓存你清不到——内容一旦进了用户浏览器,在TTL到期前你基本没法强制它更新。所以常见的稳妥策略是:边缘TTL可以设得长一点(反正能随时purge),浏览器TTL对那些可能要改的内容设得短一点或适中,免得用户长时间抱着旧版还没法刷新。 举个实战例子:一张带指纹文件名的图片(比如logo.a1b2c3.png,内容变了文件名就变),它既可以给很长的边缘TTL,也可以给很长的浏览器TTL,因为它根本不会原地更新。而一个HTML页面,边缘TTL可以给几分钟到几小时(更新时一purge就生效),但浏览器TTL最好给很短甚至不缓存,否则你purge了边缘,用户浏览器里那份还在。分清这两层、按“能不能purge、会不会改”来分别给值,是TTL策略的核心判断。 ## 缓存键是什么?为什么URL参数会悄悄拖垮你的命中率? 这是个隐蔽但杀伤力极大的点,很多站命中率上不去的元凶就在这儿。先说什么是缓存键。 缓存键(cache key)是CDN用来判断“两个请求要不要算同一个资源”的依据。默认情况下,缓存键主要由请求的完整URL(包括路径和查询参数)构成。CDN拿到一个请求,按缓存键去缓存里找:键一样就算命中,键不一样就当成不同资源、各缓存一份。 问题就出在查询参数上。如果缓存键把所有query参数都算进去,那么同一个页面带上不同的营销参数,就会被CDN当成无数个不同的资源,各回各的源、各缓存一份,命中率被稀释得惨不忍睹。想象一下,你的首页链接被投放到各个渠道,带着utm_source、utm_medium、fbclid、gclid等一堆追踪参数,结果同一个首页因为参数不同,在CDN眼里成了几千个不同URL,几乎每个都得回源一次——命中率怎么可能高? 解法是规范化缓存键:把那些不影响页面内容的参数从缓存键里剔除掉。各家CDN都提供这个能力,你可以配置“忽略所有查询参数”或者“只保留某几个真正影响内容的参数”(比如分页的page、语言的lang),把utm这类纯追踪参数统统排除在缓存键之外。这样无论链接带多少营销尾巴,CDN都认成同一个资源,命中率立刻上来。 反过来也要小心:有些参数是真的会改变页面内容的(比如 ?product_id=123),这种绝不能从缓存键里剔除,否则不同商品会串成同一个缓存、给用户返回错内容。规范化缓存键的精髓是“剔除噪声参数、保留内容参数”,剔多了串内容,剔少了命中率低,得逐站梳理你的参数清单。这一步保哥几乎在每个接CDN的项目里都要专门做一遍,收益往往立竿见影。 ## TTL该怎么分层设?静态资源、HTML、接口各给多久? 不同类型的内容更新频率天差地别,TTL当然不能一刀切。保哥按内容类型给一套分层的实战思路。 第一类,带指纹的静态资源(JS、CSS、图片、字体):给最长的TTL,越长越好,边缘和浏览器都可以缓存一年。前提是这些文件用了内容指纹命名——文件内容一变,构建工具就给它生成一个新文件名。这样旧文件名对应的内容永远不变,可以放心永久缓存;要更新就是引用一个新文件名,天然避开了“缓存了旧版”的问题。这类资源是CDN命中率的主力贡献者。 第二类,HTML页面:边缘TTL给适中(几分钟到几小时),浏览器TTL给很短或不缓存。HTML是内容会变的,但变的频率没那么高。给边缘几分钟到几小时的缓存,能挡掉绝大多数重复访问的回源;真要更新(改了文章、改了价格),主动purge一下立刻生效。浏览器这层别缓存太久,否则purge了边缘用户还看旧的。 第三类,API接口和真正动态的内容(购物车、用户中心、下单):原则上不缓存,标private或no-store。这些内容因人而异、实时性强,缓存了就会串号、就会给A用户看到B的数据,是事故的高发区。宁可让它们老老实实回源,也别为了命中率去缓存它们。如果某些接口数据是公共的、能容忍几秒延迟(比如商品列表),可以给一个很短的边缘TTL配合后台刷新,但要非常谨慎。 把这三层定下来,你的TTL策略就有骨架了:静态指纹资源往死里缓存,HTML短边缘缓存加随时purge,私人动态内容坚决不缓存。剩下的就是针对具体业务微调。这套分层思路和保哥讲TTFB与多层缓存 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)那篇里强调的“按内容特性分层缓存”是同一套方法论,CDN只是这个多层体系里离用户最近的那一层。 ## 源站挂了或内容过期,边缘缓存怎么兜底? 这是边缘缓存里非常实用、却常被忽略的一组能力:用“陈旧内容”兜底。它能同时改善速度和可用性。 第一个机制是stale-while-revalidate(过期后台刷新)。它的逻辑是:当缓存内容刚过期,CDN不让用户干等着回源,而是先把手头这份过期的(stale)内容立刻吐给用户,同时在后台悄悄回源拉一份新的更新缓存。这样用户永远是秒开的,代价只是可能看到几秒钟前的旧版,对绝大多数内容完全可以接受。根据MDN的说明,这个指令允许缓存在后台重新验证的同时复用过期响应。 第二个机制是stale-if-error(出错时用陈旧内容)。它管的是更要命的场景:当CDN回源时发现你的源站挂了、超时了、返回5xx错误,它不直接把错误页甩给用户,而是把缓存里那份过期的内容拿出来顶上。这等于给你的网站加了一层抗源站故障的保险——源站宕机的那几分钟,用户访问主要页面还能看到缓存的旧版,业务不至于整个白屏。 保哥的经验是,这两个机制对独立站特别值。源站偶尔抽风、重启、被突发流量打挂,是运维里躲不开的事;配好stale-if-error,这些瞬间用户基本无感,比裸奔着等源站恢复体面太多。配置上各家CDN略有不同,有的认Cache-Control里的stale-while-revalidate和stale-if-error指令,有的在面板里叫“总是在线”或类似的开关,但内核都是这套“用旧内容兜底”的思路。把它打开,等于花零成本给可用性买了份保险。 ## 内容更新后怎么精准失效缓存?purge有哪几种方式? 缓存的另一面是失效。内容更新了,你得让边缘缓存里的旧版及时下岗,否则用户一直看旧的。这就是purge(缓存清除/失效),它有几种粒度,用对了能既快又准。 第一种,按URL精准purge。你改了某一篇文章、某一个商品页,就只purge那几个具体的URL。这是最精准、对命中率伤害最小的方式——只清掉真正变了的那几个,其他缓存原封不动。日常内容更新首选这种。 第二种,按缓存标签(cache tag)purge。这是更高级也更实用的玩法:你在源站给响应打上标签(比如给所有属于“某个商品分类”的页面都打上tag:category-shoes),更新时一条purge命令把带这个标签的所有页面一次清掉。改了一个分类下的批量内容、或者一个被很多页面引用的模块变了,用标签purge比一个个列URL高效得多。 第三种,按路径前缀purge。清掉某个目录下的所有缓存,比如 /blog/ 下全部。适合一整块内容批量更新的场景。 第四种,清空全部(purge everything)。把整站缓存一键全清。这是核武器,非必要别用——一清全部,所有边缘缓存瞬间归零,紧接着海量请求一起回源,源站可能被这波“回源风暴”直接打垮。只有在大改版、缓存策略整体调整这种场景才动用它,且最好避开流量高峰。 保哥的原则是:能按URL就别按前缀,能按标签就别清全部,purge的粒度越精准,对命中率和源站越友好。这套精准失效的纪律,和保哥在Nginx反向代理缓存 (https://zhangwenbao.com/nginx-proxy-cache-reverse-proxy-upstream-cache-purge-stale.html)那篇里讲的自建缓存purge思路是相通的,无论缓存在CDN还是在自己的反代上,“精准失效”都是同一条铁律。 ## 缓存命中率怎么看、怎么提? 前面反复强调命中率是CDN的命根子,这一节说怎么量它、怎么提它。 命中率(cache hit ratio)的看法很直接:命中请求数 ÷ 总请求数。各家CDN的分析面板都有这个指标,还会按状态标出每个响应是HIT(命中)、MISS(未命中回源)、还是EXPIRED、DYNAMIC等。保哥的习惯是先看整体命中率,再下钻看哪些URL、哪类资源MISS最多,那里就是优化的突破口。一个健康的、以静态资源为主的站,命中率做到90% 以上是常态;如果你只有五六成,基本就是有东西配错了。 提命中率主要有几条路。一是规范化缓存键,把utm这类噪声参数从键里剔除,这往往是单项收益最大的一步,前面专门讲过。二是延长能延长的TTL,尤其是静态指纹资源,TTL太短会让缓存频繁过期、频繁回源。三是理顺响应头,别让该缓存的内容因为带了Set-Cookie、标了private或no-store而被CDN拒之门外。 四是对合适的站点开启更激进的缓存。如果你的站以静态内容为主(比如内容站、展示型独立站),可以配置规则让CDN把HTML也缓存起来(有的叫cache everything),别只缓存图片JS。当然这要配合好purge和对私人页面的排除,但对内容型站点,把HTML纳入边缘缓存往往能让命中率和速度再上一个台阶。 如果你用的恰好是Cloudflare,保哥在Cloudflare缓存与回源率优化决策树 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html)那篇里把具体到这一家的Cache Rules怎么写、回源率怎么压细讲过,可以拿这篇的通用原理对照那篇的具体操作落地。命中率不是玄学,它几乎总能被“缓存键 + TTL + 响应头 + 缓存范围”这四个旋钮调上去,逐个排查就能找到拖后腿的那个。 ## 回源风暴和缓存穿透怎么防?分层缓存有什么用? 最后讲一个偏进阶、但对中大型站很关键的话题:怎么保护源站不被回源打垮。 先理解风险。当某个热门资源的边缘缓存同时过期、或者你刚purge了全部缓存,海量本来在边缘被消化的请求会在同一瞬间一起涌向源站,这就是回源风暴。源站平时只接边缘漏过来的零星请求,突然要扛全量回源,很可能直接被打挂。缓存穿透则是另一种:大量请求访问一个根本不存在、或不可缓存的资源,每一个都穿过CDN直达源站,CDN没起到任何屏障作用。 防回源风暴最主流的手段是分层缓存(tiered cache)或源站盾(origin shield)。它的思路是在“众多边缘节点”和“你的源站”之间,再加一层中间缓存层:所有边缘节点的回源请求不直接打源站,而是先汇聚到这个中间层,由它统一向源站要一次、再分发给各边缘节点。这样源站面对的不再是成百上千个边缘节点的并发回源,而只是中间层这一个收口,回源压力被收敛了一个数量级。 分层缓存还能提整体命中率:一个冷门资源在某个边缘节点MISS了,但它可能在中间层是热的(因为别的节点请求过),就不用一路回到源站。对全球分布、节点众多的站,这个收益相当可观。各家CDN对这个功能叫法不同(Argo Tiered Cache、Origin Shield等),但解决的都是同一个问题。保哥的判断是:小站靠规范缓存键和合理TTL把命中率做高就够了;一旦你的源站经不起回源高峰、或者业务全球分布,分层缓存就该提上日程,它是给源站上的一道关键护栏。 ## 保哥给一个独立站套CDN把命中率从五成提到九成,做了哪几步? 回到开头那个命中率只有48% 的外贸独立站,保哥分享一下后来是怎么把它救上来的。 第一步是揪出Set-Cookie的元凶。保哥抓了一批响应头,发现源站给静态资源和公共页面也无差别地带了会话cookie,导致CDN把它们全当私人内容拒绝缓存。改源站配置,让静态资源和不需要会话的公共页面不再带Set-Cookie,这一步就让大批资源重新变得可缓存。 第二步是规范化缓存键。这个站的链接在各渠道投放,带着大量utm和广告点击参数。保哥配了规则,把所有纯追踪参数从缓存键里剔除,只保留分页和语言这种真正影响内容的参数。同一个落地页不再因为营销尾巴被拆成无数份,命中率立刻往上跳了一大截。 第三步是分层设TTL。给带指纹的JS、CSS、图片设了一年的长缓存;给HTML设了边缘缓存十分钟、浏览器不缓存;给购物车、账户这类页面明确标private,坚决不进边缘缓存。该缓存的往死里缓存,该实时的坚决回源,泾渭分明。 第四步是配好purge和兜底。接入了按URL和按缓存标签的精准purge,内容一更新就只清相关页面,不再动不动清全部;同时打开了stale-if-error,给源站抽风的时刻加了层保险。 四步做完,这个站的缓存命中率从48% 升到了91%,源站负载降了一多半,海外用户打开首页的速度也明显快了。老板原话是,原来CDN不是接上就完事,里头还有这么多门道。保哥跟他说,CDN给的是一套缓存的能力,但能力要靠策略激活——缓存键、TTL、响应头、purge这几样配对了,它才真的值你付的那笔钱。 ## CDN边缘缓存最容易翻车的几个地方有哪些? 保哥按踩坑频率,把CDN边缘缓存里最容易出事的几个点列出来,配置前对照检查能少掉很多坑。 第一,给所有响应乱带Set-Cookie,导致CDN不缓存。CDN默认把带cookie的响应当私人内容。静态资源和公共页面别带会话cookie,否则命中率从根上就废了。 第二,缓存键没剔除营销参数,命中率被稀释。utm、fbclid、gclid这类纯追踪参数会把同一个页面拆成无数份。规范化缓存键、剔除噪声参数,是提命中率收益最大的一步。 第三,把私人动态内容缓存了,给用户串号。购物车、账户页、带个人信息的页面标private或no-store,坚决别进共享缓存,否则A用户会看到B的数据,这是严重事故。 第四,内容更新了不purge,用户一直看旧版。边缘缓存不会因为源站变了就自动失效,你得主动purge。直写数据库、改了页面后,记得按URL或标签精准清掉对应缓存。 第五,动不动purge everything,引发回源风暴。清空全部会让所有请求瞬间一起回源,可能把源站打垮。能按URL就别按前缀,能按标签就别清全部,粒度越精准越安全。 第六,浏览器TTL设太长,purge了边缘也没用。内容进了用户浏览器,TTL到期前你purge不到。可能要改的内容,浏览器TTL给短一点,把更新的主动权留在自己手里。 这几个坑的共同点是:边缘缓存的威力来自“替源站挡住请求”,但挡得对不对、清得准不准,全靠你的策略。把缓存什么、缓存多久、缓存键怎么定、怎么精准失效这四件事想清楚,CDN才能从一个摆设变成真正的提速和减压利器。 ## 常见问题解答 ## 套了CDN网站还是不快、源站压力也没降,问题通常出在哪? 九成出在缓存命中率太低,也就是CDN接进来了,但大量请求并没有在边缘被缓存命中,而是老老实实回源了,CDN只起了个转发的作用,没起到缓存的作用。命中率低最常见的三个原因:一是源站给响应乱带Set-Cookie,CDN默认把带cookie的响应当个性化私人内容而拒绝缓存;二是缓存键没剔除营销参数,同一个页面带不同的utm、广告点击参数被CDN当成无数个不同资源,缓存被稀释得几乎没用;三是响应头没配好,该缓存的内容被标了private、no-store或根本没给缓存指令。排查时先去CDN面板看整体命中率和哪些URL的MISS最多,然后顺着Set-Cookie、缓存键、响应头这三个方向逐个查。一个以静态资源为主的健康站点,命中率应该在90% 以上,如果你只有五六成,一定有东西配错了,逐项排基本都能救回来。 ## 边缘缓存TTL和浏览器缓存TTL该怎么分别设? 核心判断是看“能不能purge”和“内容会不会改”。边缘缓存你能主动purge,浏览器缓存你purge不到,所以两层的策略不同。对带内容指纹的静态资源(文件名随内容变的JS、CSS、图片),边缘和浏览器都给最长的TTL,因为它们永远不会原地更新,要改就是换个新文件名。对HTML这类会变的内容,边缘TTL给适中(几分钟到几小时),因为真要更新时purge一下就立刻生效;但浏览器TTL要给很短甚至不缓存,否则你purge了边缘,用户浏览器里那份在TTL到期前还是旧的,你够不着。对购物车、账户这类私人动态内容,两层都别缓存,标private或no-store。一句话总结:能purge又不变的往长了设,会改的边缘短缓存加随时purge、浏览器尽量短,私人内容坚决不缓存。 ## 查询参数(比如utm)真的会影响CDN命中率吗?怎么处理? 影响极大,而且是很多站命中率上不去的隐形元凶。默认情况下,CDN的缓存键包含完整URL,连查询参数一起算。这意味着同一个页面,只要后面跟的参数不同,CDN就当成不同的资源各缓存一份、各回各的源。你的链接被投放到各渠道,带着utm_source、utm_medium、fbclid、gclid一堆追踪尾巴,结果同一个落地页在CDN眼里裂变成成千上万个不同URL,命中率被稀释到惨不忍睹。解法是规范化缓存键:在CDN配置里把那些不影响页面内容的参数从缓存键中剔除,只保留真正改变内容的参数(比如分页page、语言lang)。这样无论链接带多少营销参数,CDN都认成同一个资源。要注意别把会改变内容的参数也剔了(比如product_id),否则不同商品会串成同一个缓存返回错内容。剔除噪声、保留内容参数,逐站梳理一遍参数清单,命中率往往立竿见影地往上走。 ## 内容更新后,怎么让CDN缓存及时失效又不伤命中率? 用尽量精准的purge粒度。CDN的purge通常有几种粒度:按具体URL清、按缓存标签(cache tag)清、按路径前缀清、以及清空全部。日常内容更新优先用按URL精准清,只清你真正改了的那几个页面,对其他缓存零伤害。如果是一批相关页面一起变了(比如某个分类下的批量内容、或一个被很多页面引用的模块),用缓存标签清最高效——提前给这批页面打上同一个标签,更新时一条命令全清掉。一整块目录更新可以用前缀清。最该慎用的是清空全部,它会让所有边缘缓存瞬间归零,紧接着海量请求一起回源,可能引发回源风暴把源站打垮,只在大改版时才用且要避开高峰。原则是:能按URL就别按前缀,能按标签就别清全部,粒度越细,更新生效又快、对命中率和源站的冲击又小。另外别忘了,直写数据库改内容不会自动触发CDN失效,得手动purge。 ## 边缘缓存能在源站宕机时让网站还能访问吗? 能,靠的是stale-if-error这套用陈旧内容兜底的机制。正常情况下,当CDN回源发现源站挂了、超时或返回5xx错误,它默认会把错误甩给用户。但如果你配了stale-if-error,CDN在这种时刻不返回错误,而是把缓存里那份已经过期的旧内容拿出来顶上,让用户至少还能看到主要页面的旧版,业务不至于整个白屏。这等于给网站加了一层抗源站故障的保险,源站重启、被突发流量打挂的那几分钟,用户基本无感。和它配套的还有stale-while-revalidate,管的是内容刚过期时不让用户干等回源,先返回旧的、后台再悄悄更新,兼顾了速度。这两个机制对独立站特别值,因为源站偶尔抽风是运维躲不开的事,配好它们几乎零成本就换来了可用性的明显提升。配置上有的CDN认Cache-Control里的对应指令,有的在面板里是“总是在线”之类的开关,内核都是用旧内容兜底,建议都打开。 ## 权威参考资料 ## TTFB怎么优化才不白费:多层缓存如何同时左右Core Web Vitals与Google抓取 - URL:https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html - 分类:缓存与CDN - 发布:2026-04-08 | 更新:2026-04-08 - 摘要:做速度优化都盯着图片和JS,却忽略最底层的TTFB——浏览器下载任何资源前都得先等服务器吐出第一个字节。本文讲清TTFB慢在哪一段、一个请求要穿过几层缓存,以及全页缓存怎么和动态内容共存、又如何同时左右Core Web Vitals和Google抓取。 - 关键词:Core Web Vitals,抓取预算,服务器SEO > **TLDR**:摘要:聊页面速度,大家张口就是图片压缩、懒加载、JS拆包,这些都对,但很多人忽略了一件最底层的事:在浏览器开始下载任何一张图、执行任何一行脚本之前,它得先等服务器吐出第一个字节,这段等待就是TTFB。它是页面速度的隐形地基,地基塌了,上层优化做得再花哨也撑不起来。更要命的是,TTFB同时被两双眼睛盯着——真实用户的浏览器,和Google的爬虫。对用户,TTFB是LCP这个核心指标的起跑线,服务器慢半拍,LCP就别想好看;对爬虫,服务器响应越快,Google越愿意多抓你的页面,反之就缩手。而决定TTFB快慢的,是一整套从浏览器到数据库的多层缓存架构。保哥这篇就把这套架构拆开讲清楚:TTFB到底慢在哪一段、一个请求要穿过几层缓存、全页缓存怎么和动态内容共存、为什么缓存命中反而可能让Google抓到错内容、以及怎么让缓存预热和爬虫友好不互相打架,帮你把这块最容易被忽视、又牵一发动全身的地基夯实。 > 摘要:聊页面速度,大家张口就是图片压缩、懒加载、JS拆包,这些都对,但很多人忽略了一件最底层的事:在浏览器开始下载任何一张图、执行任何一行脚本之前,它得先等服务器吐出第一个字节,这段等待就是TTFB。它是页面速度的隐形地基,地基塌了,上层优化做得再花哨也撑不起来。更要命的是,TTFB同时被两双眼睛盯着——真实用户的浏览器,和Google的爬虫。对用户,TTFB是LCP这个核心指标的起跑线,服务器慢半拍,LCP就别想好看;对爬虫,服务器响应越快,Google越愿意多抓你的页面,反之就缩手。 而决定TTFB快慢的,是一整套从浏览器到数据库的多层缓存架构。保哥这篇就把这套架构拆开讲清楚:TTFB到底慢在哪一段、一个请求要穿过几层缓存、全页缓存怎么和动态内容共存、为什么缓存命中反而可能让Google抓到错内容、以及怎么让缓存预热和爬虫友好不互相打架,帮你把这块最容易被忽视、又牵一发动全身的地基夯实。 ## TTFB这个不起眼的指标,为什么是页面速度和SEO的隐形地基? 聊页面速度,十个人有九个张口就是图片压缩、懒加载、JavaScript拆包。这些没错,但都发生在一个前提之后——浏览器得先拿到服务器返回的第一个字节,才谈得上下载图片、执行脚本。这段从请求到收到首字节的等待,就是TTFB(Time to First Byte)。 它是页面速度真正的地基。地基没夯实,上面的优化做得再漂亮也是空中楼阁。你把图片压到极致、把脚本拆得再细,可服务器响应要一秒半才吐第一个字节,那用户在白屏前已经干等了一秒半,后面的优化省下的那点时间,全被这个糟糕的开局吃掉了。 TTFB特别值得重视,还因为它同时被两双眼睛盯着。一双是真实用户的浏览器:TTFB是后续所有渲染的起跑线,它慢,用户感知到的整体速度就慢。另一双是Google的爬虫:服务器响应越快,Google越愿意多抓你的页面;响应一慢,它就缩手。同一个TTFB,一头连着用户体验,一头连着抓取效率,这种一箭双雕的指标,值得你花心思。 保哥做过一个简单粗暴的对比,把一个TTFB到1.2秒的站和一个只有300毫秒的站摆一起,两者前端代码几乎一样,可后者的LCP直接好了一大截,体感上一个像卡顿、一个像顺滑。差距全来自那看不见的第一个字节到底等了多久。这就是地基的力量——它不在用户视线里,却决定了视线里的一切快不快。 web.dev官方在web.dev — Time to First Byte(TTFB定义、与LCP的关系、0.8秒阈值) (https://web.dev/articles/ttfb)里把话说得很明白:TTFB先于FCP、LCP这些以用户为中心的指标,多数站点应努力做到0.8秒以内,超过1.8秒算差。它本身虽然不是Core Web Vitals的三大指标,却是这些指标能不能达标的前置条件。这篇文章就把决定TTFB快慢的那套多层缓存架构拆开,讲清它怎么同时左右你的Core Web Vitals和Google抓取。 ## TTFB慢,到底慢在从点击到首字节的哪一段? 要优化TTFB,先得知道这段等待到底花在了哪里。从用户点击到收到第一个字节,中间其实串了好几个环节,每一段都可能是拖慢的元凶,得拆开来看才好对症下药。 第一段是重定向。如果用户访问的URL触发了跳转,尤其是连环跳转(http跳https、再跳www、再跳带斜杠),每一跳都是一次额外的往返,TTFB还没真正开始就先白白耗掉几百毫秒。重定向链是TTFB里最冤枉、又最容易被忽略的损耗。 第二段是DNS解析、建立连接和TLS握手。浏览器要先把域名解析成IP,再和服务器建立TCP连接,HTTPS还要多一轮TLS握手。这些是协议层的固定开销,可以靠DNS优化、连接复用、TLS会话恢复这些手段压缩,但压不到零。 第三段,也是最关键、最可控的一段,是服务器端的处理时间。请求到了服务器,后端要跑程序、查数据库、渲染模板,最后才生成HTML返回。这一段是TTFB大头里最常出问题的地方:一个没加索引的慢查询、一段笨重的业务逻辑、一次没必要的远程调用,都可能让服务器处理时间从几十毫秒膨胀到几秒。多层缓存架构要解决的,主要就是这一段。 第四段是网络传输,首字节从服务器走到用户浏览器的物理距离与带宽。用户离你的服务器越远,这段越长,这正是CDN用边缘节点要解决的问题。把这四段拆清楚,你才知道自己的TTFB到底卡在哪——是重定向太多、后端太慢,还是用户离源站太远,不同的病开不同的药。 ## 从浏览器到数据库,一个请求要穿过几层缓存? 决定服务器处理这段快慢的,是一整套层层叠叠的缓存。一个请求从用户浏览器出发,到最终可能访问数据库,理想情况下会在某一层被缓存拦截、直接返回,越早被拦截,TTFB越短。把这几层看清楚,缓存优化就有了地图。 最外层是浏览器缓存。用户自己的浏览器会缓存之前访问过的资源,命中的话压根不发请求,TTFB趋近于零。这层靠Cache-Control等响应头控制,MDN的MDN Web Docs — HTTP caching(Cache-Control、私有与共享缓存、Vary缓存键) (https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching)把这些指令讲得很全。 往里一层是CDN边缘缓存。请求出了浏览器,先到离用户最近的CDN节点,如果这个页面在边缘有缓存,直接从边缘返回,根本不碰你的源站,TTFB极快。这是CDN提速的核心。 再往里是源站上的全页缓存(FPC、Varnish这类)。请求回源到了你的服务器,如果整个页面的HTML已经被全页缓存存好,就直接吐出来,跳过后端所有的程序执行和数据库查询,TTFB也很快。 全页缓存没命中,才轮到后端真正干活,这里还有应用层的对象缓存(Redis、Memcached)和 OPcode缓存。对象缓存把数据库查询结果、渲染片段缓存起来,让后端少查库;OPcode缓存把编译后的脚本存着,省去每次重新编译。最里层才是数据库,所有缓存都没命中时的最终兜底,也是最慢的一层。缓存优化的本质,就是尽量让请求在靠外的层就被拦下来,别一路打到数据库。每往外拦一层,TTFB就省一大截。 保哥见过不少站,前面CDN、全页缓存都配了,TTFB还是忽快忽慢,一查发现后端对象缓存压根没启用,热点数据每次请求都老老实实去查库。把最常被读、又不常变的数据(比如分类树、配置项、热门商品信息)放进对象缓存,往往是性价比极高的一步:改动不大,却能把全页缓存没命中时那条慢路径显著缩短。这几层缓存是协同作战的关系,指望某一层包打天下、另几层偷懒,迟早会在某个没命中的场景里露馅。 ## 全页缓存能把TTFB砍到最低,但它和动态内容怎么共存? 在所有缓存层里,全页缓存对TTFB的提升最猛,因为它一步跳过了后端的全部计算。但它也最容易闯祸,因为它把整个HTML不分青红皂白地存下来,重复发给每一个人。问题就出在这个重复发给每个人上。 对内容对所有人都一样的页面——一篇文章、一个产品分类页、一个落地页——全页缓存是纯粹的福音,存一份发给所有人,又快又省。但对内容因人而异的页面,它就是个定时炸弹。一个登录后显示用户名的页面、一个带购物车数量的页面、一个按会员等级显示不同价格的页面,要是被囫囵全页缓存,A用户可能看到B用户的购物车,甚至别人的账户信息,数据串台还算轻的,隐私泄露就是大事故了。 所以全页缓存和动态内容的共存,是门精细活,业内有几套成熟办法。一种是直接排除:把购物车、结账、个人中心这类纯动态页面整个排除在全页缓存之外,老实回源实时生成。另一种更高级,叫打洞(hole punching,ESI是常见实现):一个页面绝大部分是公共内容、只有一小块是个性化的(比如右上角的登录状态),那就把那一小块掏出来单独用动态请求填充,页面其余部分照常享受全页缓存。 还有一种是用缓存键区分,让不同条件的用户命中各自的缓存版本,而不是共享一份。保哥服务过一个用Magento的跨境家居站,早期图省事把全站都开了激进的全页缓存,结果促销期价格规则按用户组变动,缓存却把某个用户组的价格发给了所有人,差点酿成批量错价的事故。后来按页面类型分级配置缓存、个性化部分打洞,才既保住了TTFB又守住了价格正确。判断很简单:这个页面的HTML对所有人是不是都一样,一样就放心缓存,不一样就得特殊处理。 ## 缓存命中却让Google抓到错内容,是怎么发生的? 缓存为了提速,本质是拿一份旧的副本去应付新的请求。这在大多数时候没问题,但当它把不该旧的东西也冻住时,就可能让Google抓到错误的内容,埋下SEO隐患。这类坑比性能问题更隐蔽,因为它不报错,只是悄悄让搜索引擎看到了不对的版本。 第一种是过期的价格和库存。商品早调价或卖空了,全页缓存却还冻着几小时前的旧HTML,Google这会儿来抓,抓到的就是过期价格。如果你还标了结构化数据,缓存把旧价格、旧库存一起冻在里头送出去,就会引发结构化数据与实际不一致的问题,轻则失去富媒体展示,重则被判误导。 第二种更要命,是被缓存错的canonical和hreflang。如果你的缓存键设置有问题,一个页面的canonical标签或hreflang被错误地缓存并发给了不该发的URL,搜索引擎收到的规范化信号就乱了,可能导致错误的页面合并、错误的语言版本指向,这种SEO事故排查起来极其头疼。 第三种是缺Vary头导致版本串台。如果你的页面会根据请求头(比如Accept-Language语言、设备类型)返回不同内容,却没有正确设置Vary头告诉缓存按这些维度区分,缓存就会把第一个访客拿到的版本发给所有后来者——英文用户可能拿到德文页,移动版可能拿到桌面版。MDN那篇HTTP缓存文档专门讲了Vary如何决定缓存键,这一步配错,多语言站尤其容易翻车。 保哥经手过一个做多语言的外贸B2B站就栽在Vary上。他们的页面按浏览器语言返回不同语种,却没给缓存设置按语言区分的缓存键,结果第一个抓到某页面的是英文爬虫,缓存就把英文版存成了这个URL的唯一版本,后来Google抓德语、法语版本时拿到的全是英文,多语言SEO几乎失效。排查了好久才定位到是缓存键漏了语言这个维度。缓存的SEO事故往往就是这样,不报错、不宕机,只是悄悄让搜索引擎看到了错的那一版。 还有个容易忽略的是 A/B测试和个性化的cookie被缓存裹挟,把某个测试分组的页面缓存成了默认版本发给爬虫。这些坑的共同点是:缓存提速的同时,悄悄改变了搜索引擎看到的内容。防范的关键是想清楚每个页面哪些维度会变内容,就让缓存键和失效机制把这些维度都照顾到。 ## TTFB和Core Web Vitals,特别是LCP是什么关系? 讲了这么多缓存,得回到它对SEO最直接的那条影响线上——Core Web Vitals,尤其是LCP。TTFB和LCP的关系,一句话概括就是:TTFB是LCP的起跑线,起跑慢了,后面再快也追不回。 LCP衡量的是页面最大的那块主要内容多久渲染出来,它是Core Web Vitals三大指标里和加载速度关系最直接的一个。而LCP这块时间,是从浏览器发起导航开始算的,里面第一段就是TTFB。也就是说,TTFB是被包含在LCP里的,它占了LCP的一大块底子。如果TTFB就花掉了一秒半,那LCP想做到良好的2.5秒以内,留给后面渲染的时间所剩无几,基本是不可能完成的任务。 这就是为什么保哥反复强调,页面速度优化不能只盯着前端。很多团队把图片、脚本、字体优化得无可挑剔,LCP还是上不去,回头一查,TTFB就占了一秒多,前端再怎么努力也是给一个糟糕的开局擦屁股。地基不行,装修再好的房子也住得不舒服。 所以一个完整的LCP优化,必须从TTFB这个最底层抓起,先把服务器响应和缓存架构理顺,让TTFB进到良好区间,再去优化前端的资源加载,这个顺序不能反。页面速度到底怎么系统性地优化、Core Web Vitals各项指标怎么落地,保哥在页面速度SEO与Core Web Vitals实战 (https://zhangwenbao.com/page-speed-seo.html)那篇里有完整拆解;而在AI搜索时代,这套速度投入到底值不值、ROI怎么算,可以看Core Web Vitals在AI搜索时代的ROI测算 (https://zhangwenbao.com/core-web-vitals-ai-search-industry-benchmark.html)那篇。 ## 服务器响应快一点,真能让Google多抓你的页面吗? TTFB影响SEO的另一条线,藏在抓取这一侧,比Core Web Vitals那条更隐蔽,却对大站尤其关键。简单说:服务器响应越快,Google越愿意多抓你的页面。这不是玄学,是Google官方讲明了的机制。 Google给每个站分配的抓取资源是有限的,它内部有个抓取容量上限,会根据你的站扛不扛得住动态调整。判断扛不扛得住,一个核心依据就是服务器的响应表现。Google在Google搜索中心 — Optimize your crawl budget(服务器响应越快、抓取上限越高) (https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget)里说得很直白:如果站点响应快、表现稳,它会上调这个上限,多派爬虫来抓;如果站点变慢或频繁返回服务器错误,它会主动下调抓取频率,免得把你的站压垮。 换句话说,你的服务器响应速度,直接决定了Google愿意分给你多少抓取配额。响应快,配额松,新页面、更新页面能被及时抓到;响应慢甚至动不动5xx,配额收紧,大量页面排队等着被爬,收录和更新都跟着延迟。 这件事对站的规模特别敏感。小站页面少,那点抓取配额怎么都够用,TTFB慢点对收录影响有限。但对那些动辄几万、几十万页面的大型电商或内容站,抓取预算就是稀缺资源,服务器慢一点,被压低的抓取上限会让深层页面长期得不到光顾。对大站而言,优化TTFB不只是为了用户体验,更是在为抓取预算松绑。抓取预算这块怎么系统性地省着花、把配额导向真正重要的页面,保哥在Google抓取预算优化指南 (https://zhangwenbao.com/google-crawl-frequency-optimization-guide-2026.html)那篇里讲得很细,服务器响应正是其中的一个关键变量。 ## 缓存预热和抓取友好,怎么配合才不互相打架? 缓存有个绕不开的尴尬:它再快,也得有人先把它喂热。第一个访问某个未缓存页面的请求,是穿透所有缓存层、一路打到后端实打实生成的,这次请求的TTFB反而是最慢的。要命的是,这个倒霉的第一访客,常常就是搜索引擎的爬虫。 设想一下,Google来抓一个冷门的深层页面,恰好这个页面的缓存早过期了,爬虫这一抓就撞上了冷缓存,得等后端慢慢生成,体验到一个很慢的TTFB。如果这种情况频繁发生,Google采样到的你的服务器响应就偏慢,前面说的抓取上限就可能被压低——缓存本是为了提速,结果在爬虫眼里你反而是慢的,冤不冤。 解开这个结,靠缓存预热和更聪明的失效策略。缓存预热是主动出击:在缓存过期或内容更新后,用脚本或定时任务提前把重要页面访问一遍,把缓存喂热,等真实用户和爬虫来的时候,命中的都是热缓存。可以用sitemap驱动,确保你最希望被抓的页面始终是热的。 另一个利器是 stale-while-revalidate 这类策略:缓存过期后,先把略旧的副本立刻返回给当前请求(保证TTFB快),同时在后台异步地把缓存刷新成最新版。这样既没人撞上冷缓存的慢,内容也能保持大体新鲜,是性能和新鲜度之间一个很巧妙的平衡。 把这两手用好,缓存预热和爬虫友好就不再打架,而是合力让爬虫每次来都享受热缓存的快,把你的服务器响应印象维持在优等生水平。Cloudflare这类CDN上具体怎么配缓存规则、调命中率和回源,独立站Cloudflare缓存与回源率优化 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html)那篇有实战级的决策树可参照。 ## 把TTFB与多层缓存治理收成一套排查顺序 道理铺完,落地得有个顺手的排查顺序。遇到TTFB偏高,保哥习惯从外到内、从易到难地走一遍,把这套顺序整理成一张表。 排查层 | 查什么 | 常见病灶 | 1重定向 | 有无多余跳转链 | http/www/斜杠连环跳 | 2边缘缓存 | CDN命中率与回源速度 | 命中率低、频繁回源 | 3全页缓存 | 该缓存的页面缓没缓上 | 动态页误排除、缓存键错 | 4后端处理 | 程序执行与远程调用耗时 | 笨重逻辑、无谓远程调用 | 5对象缓存 | 数据库查询有没有缓住 | 热点查询反复打库 | 6数据库 | 慢查询与索引 | 缺索引、全表扫描 | 这套顺序的逻辑是先排查靠外、改动小、收益快的层,再往里深挖。重定向和CDN命中率往往是低垂的果实,调一下立竿见影;后端和数据库的优化收益大,但动起来也更费劲。从外往里走,能用最小的代价先拿到大部分提速。 保哥还有个习惯,是排查前先把TTFB拆成各段耗时量出来,别凭感觉猜。用浏览器开发者工具或专业的性能监测,能看到这次请求里重定向、连接、等待服务器各花了多少毫秒。等待服务器那段(也就是真正的服务器处理时间)如果占了大头,就往后端和数据库挖;如果是连接和重定向占大头,就先解决协议层和跳转。先量后改,比一上来就盲目升级服务器配置省钱省力得多——很多TTFB问题根本不是硬件不够,而是某个环节有明显的浪费。 还要提醒一点:TTFB的优化不是一锤子买卖,它会随着流量增长、代码迭代、数据膨胀而悄悄退化。今天0.5秒的TTFB,半年后数据库表大了、新功能加了,可能就悄悄爬到了1.2秒。所以把TTFB纳入持续监控,设个告警阈值,让它退化时你能第一时间发现,比等用户和爬虫用脚投票之后才去救火要从容得多。地基这种东西,平时不显眼,塌的时候却是最伤筋动骨的。 ## 常见问题解答 ## TTFB是Core Web Vitals之一吗,达不到0.8秒会被Google直接扣分吗? TTFB本身不是Core Web Vitals的三大指标之一,Google也不会单独拿TTFB这个数去给你的排名打分,所以不用理解成达不到0.8秒就直接扣分。但这绝不意味着它不重要。TTFB是LCP这个核心指标的起跑线,LCP衡量主要内容多久渲染出来,而这一切要从服务器吐出第一个字节才开始,TTFB慢,LCP的天花板就被死死压住,你后面再怎么优化资源加载都救不回来。web.dev给的参考是多数站点应努力做到TTFB在0.8秒以内,超过1.8秒算差。所以正确理解是:TTFB不是被直接考核的指标,但它是被考核的LCP能不能达标的前提条件,地基性的影响,绕不过去。 ## 上了CDN,是不是TTFB就一定快了? 不一定,CDN只解决了TTFB的一部分,而且用不好甚至可能更慢。CDN的核心作用是把内容缓存到离用户近的边缘节点,省掉了用户到你源站之间漫长的网络传输,对静态资源和能被边缘缓存的页面,提速非常明显。但有两种情况CDN帮不上忙甚至添乱:一是边缘节点没有命中缓存,请求还得回源到你的源站,这时候TTFB取决于源站的响应速度加上回源的网络往返,如果源站本身后端慢,CDN反而多绕了一圈;二是大量动态、个性化页面没法在边缘缓存,每次都得回源,CDN的提速作用就大打折扣。所以上了CDN之后,还得关注边缘缓存命中率和回源速度,源站本身的后端性能和全页缓存照样要做,CDN不是装上就万事大吉的银弹。怎么把Cloudflare这类CDN的缓存命中和回源率调优,这里头门道不少。 ## 全页缓存这么能提速,为什么不干脆所有页面都开? 因为全页缓存把整个HTML响应原封不动地存下来重复发给所有人,这对内容对每个人都一样的页面(文章、分类页、落地页)是神器,但对内容因人而异的页面就是灾难。比如登录后显示用户名的页面、带购物车数量的页面、根据地区或会员等级显示不同价格的页面,一旦被全页缓存,A用户看到的可能是B用户的购物车、甚至别人的账户信息,轻则数据串台,重则隐私泄露。所以全页缓存不能无脑全开,要么排除掉这些动态页面,要么用更精细的技术(比如把页面里个性化的那一小块打洞出来单独动态加载,其余部分照常缓存),让大部分内容享受缓存提速,又不牺牲个性化的正确性。判断标准很简单:这个页面的HTML对所有人是不是都一样,一样就放心缓存,不一样就得特殊处理。 ## 缓存导致Google抓到旧价格、旧库存,到底怎么避免? 核心是控制好缓存的有效期和失效机制,别让该变的内容卡在旧版本里。几个抓手:一是给不同类型的页面设合理的缓存时长,价格库存这类高频变动的信息,要么缓存时间设短,要么在价格库存一变就主动清掉对应页面的缓存(缓存失效钩子),别一缓存就是一整天纹丝不动。二是用结构化数据时确保它跟着真实数据走,别让缓存把过期的价格、库存状态一起冻在结构化数据里送给Google,这会引发数据不一致问题。三是对极其敏感的实时信息,干脆不进全页缓存,用更细粒度的方式动态加载。说到底,缓存提速和数据新鲜本就是一对需要平衡的矛盾,你要按每类内容的变动频率,分别给它们配不同的缓存策略,而不是一刀切。能被Google抓到的内容,必须是你愿意它当下被看到的版本。 ## 服务器响应慢,到底会不会影响收录? 会,但路径是间接的,主要通过抓取预算这个中间变量起作用。Google给每个站分配的抓取资源是有限的,它会根据你的站值不值得多抓、扛不扛得住多抓来动态调整。其中扛不扛得住,很大程度看你的服务器响应:Google官方明确说过,如果站点响应快,它会上调抓取上限多抓一些;如果响应变慢或频繁返回服务器错误,它会下调抓取频率以免压垮你的站。对中小站,页面不多,抓取预算一般不是瓶颈,慢一点对收录影响有限;但对动辄上万、几十万页面的大站,服务器慢导致抓取上限被压低,就会让新页面、更新页面迟迟得不到抓取,收录和更新的及时性都受拖累。所以服务器响应慢不会直接判你不收录,但会通过压低抓取预算,间接拖慢大站的收录节奏,站越大影响越明显。 ## 动态个性化内容(登录态、购物车)能缓存吗? 能,但不能用全页缓存那种粗暴方式整页缓存,要用更精细的手段。常见做法有几种:一是页面打洞,把整个页面里个性化的那一小块(比如右上角的登录状态、购物车数量)单独划出来,用一个轻量的动态请求去填充,页面的其余大部分仍然走全页缓存,这样既享受了缓存提速,个性化的部分又是实时正确的。二是用缓存键区分,通过Vary之类的机制,让不同条件(比如不同语言、不同设备)的用户拿到各自对应的缓存版本,而不是所有人共享一份。三是对登录用户,可以走一套和游客不同的缓存策略,或者干脆对登录态页面降低缓存力度、提高源站性能来兜底。关键原则是:把页面拆成不变的公共部分和因人而异的私有部分,公共部分尽情缓存,私有部分实时获取,别把两者混在一起一锅端。 ## TTFB与缓存治理最容易踩的5个坑 最后照例收尾,把保哥见过的高频坑列出来,对照自查能少走不少弯路。 坑一:只优化前端,不管TTFB。图片脚本优化到极致,LCP还是上不去,因为TTFB就吃掉了一大半。页面速度必须从服务器响应这个地基抓起,顺序别反。 坑二:以为上了CDN就万事大吉。边缘没命中照样回源,源站后端慢、动态页多,CDN也救不了。命中率、回源速度、源站性能都得一起盯。 坑三:全页缓存无脑全开。把个性化页面也囫囵缓存,轻则数据串台,重则隐私泄露和批量错价。动态内容要么排除、要么打洞,别一锅端。 坑四:缓存把旧价格、错canonical喂给Google。缓存冻住了该变的内容,让搜索引擎抓到过期或错误的版本。按内容变动频率分级配缓存时长和失效,配好Vary头。 坑五:让爬虫总撞冷缓存。爬虫当了倒霉的第一访客,采样到的TTFB偏慢,抓取上限被压低。用缓存预热和stale-while-revalidate让爬虫每次都吃热缓存。 这五个坑串起来是一句话:TTFB是那块平时没人看、却撑着整栋楼的地基。它不像图片懒加载那样优化完能立刻截图邀功,但它一头压着LCP这个核心指标的天花板,一头攥着大站抓取预算的阀门,是真正牵一发动全身的底层变量。把这套从浏览器到数据库的多层缓存架构理顺,让TTFB稳稳待在良好区间,你上层那些前端优化才算找到了能站稳的地面,用户和Google也才会同时给你的站打上响应够快这个宝贵的印象分。 ## 权威参考资料 ## 哈希生成工具怎么用?从MD5、SHA-256到ETag缓存键与SRI完整性 - URL:https://zhangwenbao.com/hash-generator-md5-sha256-etag-sri-fingerprint-guide.html - 分类:缓存与CDN - 发布:2026-03-23 | 更新:2026-03-23 - 摘要:哈希生成工具是一款面向技术人员的内容指纹计算器:粘一段文本或拖一个文件进去,它用服务端PHP的hash函数算出固定长度的十六进制摘要,支持MD5、SHA-1、SHA-224到SHA-512、SHA-3、CRC32等十七种算法,并附带HMAC签名、文件校验、批量哈希、文本对比、Base64与香农熵等功能。 - 关键词:CDN,缓存,缓存与CDN > **TLDR**:摘要:这个哈希生成工具,干的核心活就一件:把你给的任意一段文本或一个文件,算成一串固定长度的十六进制"指纹"。它支持MD5、SHA-1、SHA-256到SHA-512、SHA-3、CRC32等十七种算法,全部走后端PHP的hash()函数算,不是浏览器本地算,所以你的内容会经HTTPS传到服务器再算回来。除了基本哈希,它还能做HMAC签名、文件哈希校验、批量哈希、两段文本对比、Base64与十六进制编码、香农熵估算。它最实用的几个落点是给HTTP响应生成ETag、做缓存键、校验文件下载完整性、给内容做去重指纹。但有三件事得先记死:一是MD5和SHA-1早已不抗碰撞,别再拿去做签名或存密码;二是它输出的是十六进制,而网页SRI子资源完整性要的是Base64编码,二者不能直接套用;三是批量超过500行会被悄悄截断、大文件会把浏览器卡住。把它当"内容指纹速查台",它好用;指望它当生产级密码学库,会出事。 > 摘要:这个哈希生成工具,干的核心活就一件:把你给的任意一段文本或一个文件,算成一串固定长度的十六进制"指纹"。它支持MD5、SHA-1、SHA-256到SHA-512、SHA-3、CRC32等十七种算法,全部走后端PHP的hash()函数算,不是浏览器本地算,所以你的内容会经HTTPS传到服务器再算回来。除了基本哈希,它还能做HMAC签名、文件哈希校验、批量哈希、两段文本对比、Base64与十六进制编码、香农熵估算。它最实用的几个落点是给HTTP响应生成ETag、做缓存键、校验文件下载完整性、给内容做去重指纹。但有三件事得先记死:一是MD5和SHA-1早已不抗碰撞,别再拿去做签名或存密码;二是它输出的是十六进制,而网页SRI子资源完整性要的是Base64编码,二者不能直接套用;三是批量超过500行会被悄悄截断、大文件会把浏览器卡住。把它当"内容指纹速查台",它好用;指望它当生产级密码学库,会出事。 做技术活,哈希这东西躲不开。你下载一个安装包,官网旁边挂着一串SHA-256值让你核对;你配Nginx缓存,响应头里那个ETag是用内容算出来的;你给CDN上的脚本加防篡改,要填一串integrity值;你排查两份看似一样的文件到底差在哪,最快的办法就是各算一次哈希一比。这些场景背后,都是同一个动作——把一段内容压成一枚独一无二的指纹。 哈希生成工具干的就是这件事。你把文本粘进去、或者把文件拖进去,它当场吐给你一串定长的十六进制字符。这篇我们团队就把它怎么用、哈希到底是什么、那十几种算法该怎么挑、哪些已经不能再碰、以及它在做缓存和完整性校验时藏着哪些坑,一次讲透,顺带把哈希在技术SEO和运维里的真实用法捋清楚。 ## 这个哈希生成工具,到底在帮你算什么? 先把它的家底盘清楚。打开工具,你能粘文本、能拖文件,选一种算法,它就给你算出对应的哈希值。它支持的算法不少,常见的有MD5、SHA-1、SHA-224、SHA-256、SHA-384、SHA-512,还有更新的SHA-3系列、RIPEMD、Whirlpool,以及校验用的CRC32和Adler32,加起来十七种。每个哈希值它还顺手给你大小写两种写法。 有一点要特别说清楚:它的全部计算走的是服务端PHP的hash()函数,不是浏览器里用JavaScript算的。这意味着你粘进去的文本、拖进去的文件,会通过HTTPS发到服务器,在那边算完再把结果传回来。好处是算法齐全、性能稳定;代价是如果你处理的是高度敏感的内容,它并非"绝不离开本机"的那种纯前端工具,这一点心里要有数。 除了基本的文本哈希,它还堆了好几样配套功能:HMAC带密钥签名、上传文件算哈希、一次算一批文本的批量模式、把两段文本各算哈希做对比、把输入顺带做Base64和十六进制编码、再加一个估算输入信息量的香农熵。这些功能在源码里都真实实现了,不是摆设。后面我们一个个看它们能干嘛、又各自藏着什么边界。 ## 哈希到底是什么?为什么它能当内容的"指纹"? 要用好这工具,得先把哈希这个概念想明白。哈希函数干的事,是把任意长度的输入,压缩成一段固定长度的输出。你给它一个字,它吐一串定长字符;你给它一整本书,它还是吐同样长度的一串字符。这串输出就叫哈希值,也叫摘要、指纹、校验和,叫法不同说的是同一个东西。 它能当"指纹",靠的是三个关键性质。第一是确定性:同样的输入,永远算出同样的输出,今天算和明天算一模一样。第二是雪崩效应:输入哪怕只改一个标点,输出也会面目全非,看不出和原来有半点关系。第三是单向性:从内容能轻松算出哈希,但从哈希几乎不可能反推回原内容。这三条凑在一起,让哈希成了内容身份的绝佳凭证。 正因如此,校验文件有没有被篡改、判断两份内容是不是完全一致、给一长串内容生成一个短小的唯一标识,这些活交给哈希再合适不过。你在工具里把同一句话算两遍,结果分毫不差;改动其中一个字再算,整串值全变——亲手验一次,你对"指纹"这个比喻的体感会一下子扎实起来。这种"内容一变指纹就变"的特性,正是后面讲ETag和缓存键时的地基。 ## MD5、SHA-1、SHA-256该怎么挑,哪些已经不能再碰了? 这工具给了十七种算法,但你日常用得上的就那么几种,关键是搞清楚哪些还能用、哪些已经退役。这里面踩坑的人不少,值得单独讲清楚。 先说两个已经"出局"的:MD5和SHA-1。这俩当年是主力,但都已经被证明能被人为构造出碰撞——也就是能找到两份不同的内容,算出一模一样的哈希值。碰撞一旦可行,拿它们做数字签名、做防篡改校验就失去了意义,因为攻击者能伪造出哈希相同的假内容。这工具的算法说明里其实也给它们标了"已不安全"。权威依据可以看RFC 6151关于MD5安全性的更新说明 (https://www.rfc-editor.org/rfc/rfc6151),它明确写道,在需要抗碰撞的场景(如数字签名)里,MD5已经不再可接受。 那MD5是不是彻底没用了?也不是。在不涉及对抗、只图快的场景里它还能用,比如给本地大量文件做去重、给缓存生成一个非安全的键、做个粗略的完整性自查。但凡场景里有"防别人作恶"这层含义,就必须换到SHA-256及以上。SHA-256是目前的安全主力,抗碰撞强度足够、性能也不差,做内容指纹、做完整性校验、做令牌,选它基本不会错;要更高强度就上SHA-384或SHA-512。一句话原则:图快且无对抗,MD5勉强能用;只要沾安全,SHA-256起步。 ## 这工具怎么用最顺手?从单条文本到文件再到批量 把这工具用顺,其实就是摸清它几个模式的入口和各自的脾气。实战里最常走的流程是下面这几步,照着来基本不会乱。 - 单条文本哈希,先选准算法再粘内容。这是最常用的模式。把要算的文本粘进输入框,在算法里选好SHA-256或你需要的那种,它立刻算出十六进制结果,还同时给你大写和小写两份。核对官网给的校验值时,注意对方用的大小写,照着选那一份对比就行。 - 要算文件指纹,直接把文件拖进文件区。它会真正读取文件的二进制内容来算哈希,不是拿文件名糊弄你。这一步常用来核对下载的安装包、镜像有没有损坏或被掉包:把官网公布的SHA-256和你本地算出的一比,一致就放心,不一致就别用。 - 有一批文本要批量算,用批量模式一行一条。把多行文本贴进去,它逐行算哈希,还会帮你检测重复行。注意它一次最多处理500行,超出的部分会被静默丢掉,这点后面会专门提醒。 - 要做带密钥的签名,切到HMAC模式。填上密钥和内容,选一种HMAC算法(如HMAC-SHA256),它算出带密钥的签名值。这个值常用在校验接口请求有没有被篡改、确认数据来源可信的场景。 - 想确认两段内容是否完全一致,用对比模式。把两段文本分别贴进来,它各算一次哈希再比对,相同就说明两段内容逐字节一致。这比人眼逐字对快多了,尤其文本很长时。 这几个模式背后都是同一个服务端的hash()在干活,区别只是喂给它的是一段文本、一个文件的字节,还是一批文本。摸清了入口,剩下的就是理解每种结果该怎么用——这正是下面几节要展开的。 ## ETag和缓存键,怎么用哈希算出来? 哈希在Web性能上最经典的用途,是生成ETag。ETag是HTTP响应头里的一个字段,作用是给某个资源的某个版本贴一个唯一标识。浏览器下次再请求这个资源时,会带上手里这个ETag问服务器:"我这版还新鲜吗?"如果内容没变,服务器回一个304,让浏览器直接用缓存,省下重新传一遍的带宽。 这个标识怎么生成最稳妥?拿内容算哈希。MDN的ETag响应头文档 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag)里就直说,ETag的值通常是内容的哈希、或最后修改时间的哈希、或一个版本号。用内容哈希的好处显而易见:内容一改,哈希就变,ETag跟着变,缓存自然失效;内容没动,哈希不变,缓存就稳稳命中。这工具算出的十六进制哈希,拿去填ETag是完全够用的——ETag对格式没有Base64那种硬要求,十六进制直接用即可。 同样的道理也适用于各种缓存键。你做页面缓存、做CDN缓存、做接口结果缓存,常常需要把一组参数或一段内容压成一个短小唯一的键。拿它们算个MD5或SHA-256当键,既短又不会撞,是非常常见的做法。想系统地理解ETag和缓存头怎么配合,可以读我们团队这篇HTTP浏览器缓存与ETag头详解 (https://zhangwenbao.com/http-browser-cache-control-etag-expires-cache-headers.html),把缓存的整套机制串起来看会更清楚。 ## 想给CDN脚本加SRI完整性,这工具的哈希能直接用吗? 这是整篇最该划重点的一处坑,因为它最容易让人想当然。当下很多站点把JS、CSS挂在公共CDN上,为了防CDN被入侵后偷偷替换文件,会给