# 保哥笔记 — 缓存与CDN > 本分片含 9 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:缓存与CDN **生成**:2026-06-04 23:09:29 CST --- ## 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,抓取预算,TTFB优化,缓存架构,服务器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也才会同时给你的站打上响应够快这个宝贵的印象分。 ## 权威参考资料 ## Nginx proxy_cache反向代理缓存怎么配?给上游应用提速实战 - URL:https://zhangwenbao.com/nginx-proxy-cache-reverse-proxy-upstream-cache-purge-stale.html - 分类:缓存与CDN - 发布:2026-03-18 | 更新:2026-03-18 - 摘要:从proxy_cache_path、keys_zone、cache_key一步步配起,讲清反向代理缓存命中状态判断、bypass/no_cache成对防串用户、ngx_cache_purge精确清除,以及use_stale、cache_lock抗后端抖动与缓存击穿的生产级用法。 - 关键词:反向代理,Nginx,缓存与CDN,proxy_cache > **TLDR**:摘要:proxy_cache是Nginx当反向代理时,把后端应用服务器返回的响应整页存下来的能力。它和fastcgi_cache缓存PHP-FPM那一套不是一回事——proxy_cache缓存的是proxy_pass转发给上游(Node、Java、Gunicorn、另一台Nginx,甚至另一台机器)的HTTP响应,谁在后端、用什么语言写的它都不关心。保哥这篇把它和fastcgi_cache的边界、最小可用配置怎么写、X-Cache-Status怎么看、登录态和动态接口怎么绕过、清缓存怎么不把上游压垮、proxy_cache_use_stale和lock怎么扛住后端抖动,一路讲到它在整套缓存体系里站哪一层。配置能抄走就用,坑也都标了出来。 > 摘要:proxy_cache是Nginx当反向代理时,把后端应用服务器返回的响应整页存下来的能力。它和fastcgi_cache缓存PHP-FPM那一套不是一回事——proxy_cache缓存的是proxy_pass转发给上游(Node、Java、Gunicorn、另一台Nginx,甚至另一台机器)的HTTP响应,谁在后端、用什么语言写的它都不关心。 保哥这篇把它和fastcgi_cache的边界、最小可用配置怎么写、X-Cache-Status怎么看、登录态和动态接口怎么绕过、清缓存怎么不把上游压垮、proxy_cache_use_stale和lock怎么扛住后端抖动,一路讲到它在整套缓存体系里站哪一层。配置能抄走就用,坑也都标了出来。 先把一个最常见的混淆理清楚。很多人配缓存时,看到Nginx里有fastcgi_cache,又有proxy_cache,名字像、参数也像,就随手抄一个,结果要么不生效,要么缓存了不该缓存的东西。这两套指令的差别,根子上是Nginx把请求转给谁的差别。 fastcgi_cache用在Nginx直接对接PHP-FPM的场景——Nginx通过FastCGI协议把请求交给本机的php-fpm,再把生成的整页HTML缓存起来。WordPress、Typecho这类PHP站点走的就是这条路,保哥在 Nginx fastcgi_cache全页缓存那篇 (https://zhangwenbao.com/nginx-fastcgi-cache-fullpage-php-wordpress-purge-microcache.html)里讲得很细。 而proxy_cache是另一码事:Nginx此时是个反向代理,它用proxy_pass把请求通过HTTP协议转发给一个上游服务——可能是跑在3000端口的Node应用、跑在8080的Java服务、Gunicorn起的Python后端,甚至是另一台机器上的Nginx。proxy_cache缓存的,就是这些上游回来的响应。 一句话记牢:后端是PHP-FPM走FastCGI,用fastcgi_cache;后端是任何能讲HTTP的应用服务,用proxy_cache。这一篇专讲后者。 ## proxy_cache和fastcgi_cache到底差在哪?什么时候该用反向代理缓存? 两者机制几乎对称——都是Nginx把上游返回的整页响应按一个key存进磁盘,下次同样的key进来直接吐缓存,不再打扰后端。指令名也成对:fastcgi_cache_path对proxy_cache_path、fastcgi_cache_key对proxy_cache_key、fastcgi_cache_valid对proxy_cache_valid,几乎一一对应。所以会了一个,另一个的配置你看一眼就懂。 真正的差别在“缓存的是什么后端”。FastCGI是一个专门的协议,只有PHP-FPM、以及少数支持FastCGI的运行时(比如某些Python的flup)在用。绝大多数现代应用——Node的Express、Java的Spring Boot、Go的net/http、Python的Django/Flask配Gunicorn——对外都是讲HTTP的,Nginx要在它们前面挡一层,只能用反向代理,那缓存就只能是proxy_cache。 什么时候该上proxy_cache?保哥的判断很直接:当你的站点是“Nginx反向代理 + 后端应用服务”这个架构,而且后端有相当一部分页面或接口是“算一次、所有人看到的结果都一样、而且短时间内不会变”的,就值得加。典型场景有这么几类。 - SSR渲染的前端站:Next.js、Nuxt这类服务端渲染应用,首页、文章页、列表页对匿名访客是一样的,每次都让Node进程重新渲染既慢又费CPU,缓存下来后Node基本可以歇着。 - 后端API的只读热点接口:商品分类树、首页推荐位、配置项、地区列表这种读多写少的接口,缓存几十秒到几分钟,能把数据库和应用层的压力削掉一大截。 - Nginx当边缘节点反代另一台源站:你在多地部署Nginx,回源到中心机房的源站,这时候每个边缘Nginx用proxy_cache缓存源站响应,自己就成了一个迷你CDN。 - 给慢后端兜底:后端偶尔抽风、响应慢甚至挂掉时,用缓存里的旧版本顶住,比直接给用户白屏或502强得多。 反过来,纯静态站(Nginx直接发文件,根本没有上游)、或者每个请求结果都因人而异的强动态接口(购物车、订单详情、个人中心),proxy_cache要么用不上,要么得非常小心地绕过,下文会专门讲。 ## proxy_cache的最小可用配置怎么写?keys_zone和cache_key一步步来 配置分两块:在http {} 块里用proxy_cache_path划一块缓存区,再在location里用proxy_cache把它启用。先看最小可用的一套,保哥逐行解释。 # —— 写在 http {} 块里 —— proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=app_cache:10m inactive=60m max_size=2g use_temp_path=off; upstream backend { server 127.0.0.1:3000; keepalive 32; } proxy_cache_path这一行的每个参数都不是摆设: - /var/cache/nginx/proxy 是缓存文件落盘的目录,得保证Nginx运行用户(一般是www-data或nginx)有写权限。 - levels=1:2 是目录分级。Nginx把缓存文件名(key的MD5)打散到两级子目录里,避免一个目录塞几十万文件把文件系统拖垮。1:2表示用哈希末位建一级、再用末两位建二级,是最常用的写法。 - keys_zone=app_cache:10m 最关键。app_cache是这块缓存区的名字,后面location里要靠它引用;10m是内存里存放key元数据的共享内存大小,1MB大约能放8000个key,10MB能放约8万个,按你的页面数量估。注意这只是key的元数据占的内存,真正的响应体是存在磁盘上的。 - inactive=60m 指一个缓存项60分钟内没人访问就被清理器删掉——注意它和缓存有效期(proxy_cache_valid)是两回事,inactive管的是“多久没人用就回收”,nginx.org上这个值默认是10m。 - max_size=2g 是这块缓存占磁盘的上限,超了Nginx的cache manager会按最近最少使用淘汰旧的。 - use_temp_path=off 强烈建议加。默认Nginx先把响应写到一个临时目录再挪到缓存目录,跨目录移动有额外开销;设成off让它直接写进缓存目录,省一次拷贝。 然后在server / location里启用它: server { listen 80; server_name example.com; location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_cache app_cache; proxy_cache_key $scheme$proxy_host$request_uri; proxy_cache_valid 200 301 302 10m; proxy_cache_valid 404 1m; add_header X-Cache-Status $upstream_cache_status; } } 这里几行的门道: - proxy_cache app_cache; 启用刚才那块缓存区,名字必须对上。 - proxy_cache_key 决定“什么算同一个请求”。nginx.org上的默认值是 $scheme$proxy_host$request_uri,多数情况够用。但要当心:默认key不含Host,如果你一台Nginx反代多个域名共用一块缓存,得把 $host 加进key,否则a.com和b.com的同路径页面会串成一份,这是高频事故,下文坑里还会强调。 - proxy_cache_valid 200 301 302 10m; 给不同状态码设不同的缓存时长。成功页缓存10分钟,404这种只缓存1分钟避免把临时的找不到也长期记住。不写这一行,Nginx默认什么都不缓存(除非上游响应头自带Cache-Control)。 - add_header X-Cache-Status $upstream_cache_status; 是你的眼睛。配好后用curl就能看到这次是命中还是没命中,没有它你根本不知道缓存有没有在干活。 配完 nginx -t 测试语法,nginx -s reload 平滑重载,最小可用版就跑起来了。 ## 缓存命中状态怎么看?X-Cache-Status各种值是什么意思? 判断缓存到底有没有生效,唯一靠谱的办法是看 $upstream_cache_status 这个变量的值。用curl带上 -I 看响应头: curl -I https://example.com/products # 第一次:X-Cache-Status: MISS # 第二次:X-Cache-Status: HIT 它一共有这么几种值,每一种都对应一种情况,看懂了排障事半功倍: - MISS:缓存里没有,这次回源去问了后端,并把响应存了下来。第一次访问必然是MISS。 - HIT:直接命中缓存,没碰后端。这是你想看到的状态。 - BYPASS:因为命中了proxy_cache_bypass的条件(比如带了登录Cookie),这次主动跳过缓存去问后端了。 - EXPIRED:缓存里有,但过期了,这次回源拿了新的。 - STALE:缓存过期了、后端又出问题了,Nginx给你吐了过期的旧版本兜底(需要配proxy_cache_use_stale)。 - UPDATING:缓存过期了,已经有另一个请求在回源更新,这个请求先拿旧的(需要配proxy_cache_use_stale updating)。 - REVALIDATED:开了proxy_cache_revalidate,Nginx带着If-Modified-Since去问后端“变了没”,后端回304没变,于是继续用旧的(省了传输响应体)。 保哥的经验是,上线后别用浏览器测——浏览器自己还有一层缓存,会把你骗得团团转,明明Nginx在MISS你却以为命中了。一律用curl -I看头,或者干脆把 $upstream_cache_status 写进access_log的日志格式里,统计一段时间的HIT率才是真实命中情况。命中率怎么看、日志怎么配,可以参考保哥在 Redis对象缓存运维那篇 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html)里讲的同一套观测思路,缓存这东西不观测就是玄学。 ## 哪些请求绝对不能缓存?登录态和动态接口怎么绕过? 这是proxy_cache最容易出大事故的地方,没有之一。缓存的本质是“一份响应给很多人看”,那凡是“因人而异”的响应就绝对不能进缓存,否则就是把A用户的个人页缓存下来,原封不动发给后面访问的B用户——轻则信息错乱,重则把别人的订单、地址、手机号泄露出去。保哥见过不止一次匿名访客刷新首页,结果看到了某个管理员的后台,根因都是这一条没做对。 正确做法是设一个标记变量,命中“不该缓存”的条件就跳过。proxy_cache_bypass控制“读的时候要不要跳过缓存直接问后端”,proxy_no_cache控制“拿到响应后要不要存进缓存”,这两个必须成对配,缺一个都会出漏洞。 map $http_cookie $skip_cache { default 0; "~*sessionid" 1; # 带登录会话 Cookie "~*logged_in" 1; } # 动态路径直接标记跳过 location /api/cart { set $skip_cache 1; proxy_pass http://backend; ... } location /account { set $skip_cache 1; proxy_pass http://backend; ... } location / { proxy_pass http://backend; proxy_cache app_cache; proxy_cache_bypass $skip_cache; # 这两行 proxy_no_cache $skip_cache; # 必须成对 ... } 需要跳过的典型清单,保哥给你列全: - 带登录态Cookie的请求:只要Cookie里有会话标识,一律跳过,这是底线。 - 购物车、结算、订单、个人中心、收货地址:这些路径本身就因人而异,整段跳过。 - POST / PUT / DELETE等写请求:Nginx默认只缓存GET和HEAD,但你最好显式确认proxy_cache_methods没被乱改。 - 带认证头(Authorization)的接口:API鉴权请求基本都是因人而异的。 - 后端主动声明不缓存的:如果上游返回了 Cache-Control: no-store 或 Set-Cookie,Nginx默认会尊重它不缓存(这个行为可以用proxy_ignore_headers改,但改之前先想清楚为什么后端要发这个头)。 一个真实的翻车案例。保哥接手过一个跨境独立站,前端是Nuxt服务端渲染,运维图省事给location / 一把全开了proxy_cache,没设任何bypass。平时没事,因为大部分是匿名流量;直到有个客户登录后把商品加进购物车,那个带购物车数量的页面响应被缓存了,结果后面十几分钟内所有匿名访客打开首页,购物车角标都显示着别人的“3件商品”,点进去还能看到别人选的货。 排查半天才定位到是缓存把登录态页面串了。修法就是上面那套map + bypass,把带Cookie的请求全部放行到后端。缓存这层,省事和安全是真会打架的,宁可少缓存一点,也不能把因人而异的东西缓存了。 ## 缓存怎么清才不会一清就把上游压垮? 内容更新了、缓存还是旧的,得清。清缓存有被动和主动两条路,用错了会出大问题。 被动清理靠proxy_cache_valid设的过期时间——到点了自然失效,下次访问回源拿新的。这是最省心的方式,对“不需要即时更新、晚几分钟没关系”的内容完全够用,能不主动清就别主动清。 主动清理是“现在马上让某个URL的缓存失效”。开源版Nginx本身不带按URL精确清除的能力,要装第三方模块ngx_cache_purge(需要重新编译Nginx或用带这个模块的发行版),配好后能发一个特殊请求点名清掉某个key。商业版Nginx Plus自带proxy_cache_purge指令,开箱即用。 # 用 ngx_cache_purge 模块的写法 location ~ /purge(/.*) { allow 127.0.0.1; # 只允许本机/内网发清除请求 deny all; proxy_cache_purge app_cache $scheme$proxy_host$1; } 这里要敲黑板的是:千万别动不动就把整块缓存全清了。保哥见过有人发版后图省事,直接 rm -rf /var/cache/nginx/proxy/*,结果一瞬间所有缓存全没了,几千个请求同时回源,后端Node进程CPU直接打满、数据库连接池被占满,站点反而比不开缓存还慢,这叫“缓存雪崩”。 正确做法是:能精确清就精确清(只清改动的那几个URL),实在要全清也得分批、或者趁低峰,最好配合下文的proxy_cache_lock防止回源风暴。如果你后端还接着数据库和对象缓存,记得这几层的更新要协调,别只清了Nginx这层、底下 Redis缓存的对象 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html)还是旧的。 ## 怎么用stale和lock扛住上游抖动和缓存击穿? proxy_cache真正拉开差距的,是它处理“后端不稳定”和“热点key同时过期”这两个老大难的几个指令。配好了,你的站点在后端抽风时照样稳。 第一个是 proxy_cache_use_stale,让Nginx在后端出问题时拿过期缓存兜底: location / { proxy_pass http://backend; proxy_cache app_cache; proxy_cache_valid 200 10m; # 后端报错/超时/5xx 时,先拿过期的旧版本顶住 proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; # 同一个 key 过期时只放一个请求去回源更新,其余先吃旧的 proxy_cache_background_update on; # 防击穿:同一 key 只允许一个请求回源,其余排队等它 proxy_cache_lock on; proxy_cache_lock_timeout 5s; } 逐个说清楚这几个的价值: - proxy_cache_use_stale:默认是off,意味着缓存一过期,后端又恰好挂了,用户就直接吃502。开了它,列出来的这些情况(错误、超时、5xx、正在更新)下,Nginx会把过期的旧缓存发出去,用户压根感觉不到后端出过问题。这是给后端兜底的最有效一招,外贸站尤其要配——后端跨机房、网络抖一下是常事。 - proxy_cache_background_update on:配合use_stale的updating,当缓存过期,Nginx一边把旧版本立刻发给当前用户、一边在后台默默回源更新缓存。用户永远拿到的是秒开的旧版本,新版本悄悄在后台备好给下一个人,体验最顺。 - proxy_cache_lock on:解决“缓存击穿”。设想一个热门页面缓存刚过期的那一瞬间,同时来了500个请求,没有lock的话这500个会全部回源去问后端,把后端瞬间打垮。开了lock,Nginx只放第一个请求去回源,其余499个在门口排队等它把缓存填好,然后一起吃新缓存。lock_timeout是排队的最长等待时间,超了就放它们也去回源,避免无限等。 这三个搭配起来,就是生产环境抗压的标准组合:use_stale管后端挂了的兜底,background_update管更新时的体验,lock管热点过期时的击穿。保哥的实战默认会把这三行都配上,代价几乎为零,收益是后端抖动时站点稳如老狗。这套“用旧版本扛抖动”的思路,和Magento用Varnish做全页缓存时的grace机制异曲同工,保哥在 Magento性能调优那篇 (https://zhangwenbao.com/magento-2-performance-tuning-indexer-cache-redis-varnish-production-mode.html)里讲过Varnish那一套,原理是相通的。 ## 反向代理缓存这一层,和CDN、整页缓存、对象缓存怎么配合? proxy_cache不是孤立的,它在整套缓存体系里站的是“源站服务器的页面缓存”这一层,前面有CDN,后面有应用缓存和对象缓存,各管一段,配合好了才不会互相打架。 层级 | 典型实现 | 缓存什么 | 谁来配 | 边缘 / CDN | Cloudflare、各家CDN | 静态资源、可缓存页面,在离用户最近的节点 | CDN控制台 / 缓存规则 | 源站页面缓存 | Nginx proxy_cache / fastcgi_cache | 上游应用生成的整页响应 | Nginx配置 | 应用层缓存 | 应用框架自带缓存、CDN边缘逻辑 | 渲染结果、片段 | 应用代码 | 对象 / 数据缓存 | Redis、Memcached | 数据库查询结果、会话、计算结果 | 应用 + Redis | 几个配合上的关键点。CDN和proxy_cache用的缓存控制头是同一套(Cache-Control、Expires),所以源站这层别和CDN那层对着干——比如源站发了no-store,CDN那层就缓存不住,你在CDN配的规则全白费。理清楚每层各缓存多久,是个系统活,保哥在 Cloudflare缓存与回源率那篇 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html)里画过一棵决策树专门讲多层怎么协调。 还有个反直觉的点:上了CDN是不是源站proxy_cache就不用配了?不是。CDN只能缓存“可公开缓存”的内容,而且CDN节点很多、各自回源,源站这层proxy_cache能把这些回源请求再削一道;更别说很多动态页面CDN默认不缓存,但在源站这层缓存几十秒(微缓存)就能挡掉绝大部分重复回源。两层是叠加关系,不是替代关系。 ## 实战里最容易踩的坑有哪些? 把保哥这些年配proxy_cache踩过和见过的坑集中列一遍,配之前对一遍,能少走很多弯路: - 多域名共用缓存,cache_key没加 $host:一台Nginx反代好几个站,默认key不含Host,结果a.com和b.com的 /about串成一份,访客看到别站内容。多域名场景,cache_key必须带 $host。 - bypass和no_cache只配了一个:只配bypass,登录用户的请求虽然跳过了读缓存,但响应还是被存了进去,下一个匿名访客就命中了这份个人化页面。两个必须成对。 - 把因人而异的页面缓存了:购物车、个人中心、带Authorization的接口忘了排除,直接造成数据串用户,这是最严重的事故,配完务必拿登录账号实测一遍。 - rm -rf全清缓存导致雪崩:发版后一把全清,几千请求同时回源压垮后端。要么精确清,要么配lock + use_stale兜底。 - keys_zone太小:页面多但keys_zone只给了1m,老key不停被挤出去,命中率怎么都上不去。按页面数量估够内存。 - 磁盘写满:max_size没设或设太大,缓存目录把磁盘撑满,整个Nginx写不进日志直接出问题。给max_size留余量,或单独挂一块盘放缓存。 - 没设add_header X-Cache-Status:缓存到底有没有生效全靠猜,排障时两眼一抹黑。这一行几乎零成本,必加。 - 用浏览器测命中率:浏览器本地缓存会骗你,看到的“秒开”可能是浏览器自己缓存的,不是Nginx命中。一律用curl -I或看access_log。 - 缓存目录权限不对:proxy_cache_path指的目录Nginx进程没写权限,缓存一直建不出来,全是MISS还不报错。建目录后chown给Nginx运行用户。 - 动态接口缓存时间设太长:API数据缓存了一小时,结果后端数据早变了前端还在拿旧的。读多写少的接口用微缓存(几十秒)就够,别贪长。 proxy_cache这东西,配置不难,难在“想清楚每个请求该不该缓存、缓存多久、串了会不会出事”。把上面这套机制和坑过一遍,先用curl反复验证命中状态,再逐步收紧bypass规则,基本就能稳稳地把后端压力削下来,还不出安全事故。它和服务器上其他缓存层不是二选一,而是各守一段、叠加发力。 ## 常见问题解答 ## proxy_cache和fastcgi_cache能在同一个Nginx里同时用吗? 能,而且很常见。它们是两套独立的指令,缓存区也各自划分,互不干扰。比如一个location用fastcgi_pass对接PHP-FPM配fastcgi_cache,另一个location用proxy_pass反代Node服务配proxy_cache,完全可以共存。只要记住:对接谁决定用哪套——PHP-FPM用fastcgi_cache,HTTP上游用proxy_cache,别把两套的指令混着写在同一个location里就行。 ## 为什么我配了proxy_cache,X-Cache-Status一直是MISS,从不命中? 最常见的几个原因:一是上游响应带了Set-Cookie或Cache-Control: no-cache/no-store,Nginx默认尊重它不缓存,可以用proxy_ignore_headers显式忽略(但要想清楚后端为什么发这个头);二是请求带了登录Cookie命中了你自己配的bypass;三是没写proxy_cache_valid,Nginx不知道该缓存多久干脆不缓存;四是缓存目录权限不对,文件根本写不进去。按这个顺序排查,配合error_log看具体原因,基本都能定位到。 ## 缓存的内容更新了,但用户还看到旧的,怎么让它立刻更新? 三个办法按场景选。能等就等——靠proxy_cache_valid的过期时间自然失效,最省心。要即时更新且改动的URL明确,装ngx_cache_purge模块(开源版)或用Nginx Plus的proxy_cache_purge,发个清除请求精确清掉那几个URL。要全站更新(比如换了模板),别rm -rf一把清,那会引发回源雪崩,应该配合proxy_cache_lock和use_stale,分批或趁低峰清。日常内容站,把有效期设短一点(几分钟)往往比折腾主动清更划算。 ## proxy_cache缓存的文件存在内存还是磁盘?会不会很占内存? 响应体(也就是真正的页面内容)存在磁盘上,由max_size控制总量;内存里只放key的元数据,由keys_zone的大小控制,1MB大约8000个key。所以它不会像Redis那样吃大量内存,主要吃的是磁盘空间和磁盘IO。如果你想榨干性能,可以把缓存目录挂在tmpfs(内存盘)上,读写快但重启就没了、且要严格控制大小别把内存撑爆,这是进阶玩法,一般机械盘换SSD放缓存就够用了。 ## 上了Cloudflare这类CDN,源站还需要配proxy_cache吗? 需要,两层是叠加不是替代。CDN只缓存可公开缓存的内容,而且CDN有很多节点各自回源,源站这层proxy_cache能把这些回源请求再削一道;很多动态页面CDN默认不缓存,但源站用几十秒的微缓存就能挡掉大量重复回源,保护后端。注意两层用的是同一套缓存控制头,配的时候别让源站的no-store把CDN规则架空了,每层缓存多久要统一规划,这部分保哥在Cloudflare那篇里有专门的决策树。 ## 权威参考资料 ## Nginx fastcgi_cache全页缓存怎么配?给PHP/WordPress动态站提速实战 - URL:https://zhangwenbao.com/nginx-fastcgi-cache-fullpage-php-wordpress-purge-microcache.html - 分类:缓存与CDN - 发布:2026-03-02 | 更新:2026-03-02 - 摘要:Nginx fastcgi_cache全页缓存实战:它缓存的是整页HTML、与Redis对象缓存和CDN的四层分工、最小可用配置、登录态购物车bypass排除、ngx_cache_purge主动清、microcache微缓存与X-Cache-Status验证命中。 - 关键词:Nginx,WordPress,性能优化,缓存与CDN > **TLDR**:摘要:站点一上点量TTFB就飙、PHP-FPM进程瞬间被打满,多半是因为每个请求都在让PHP把同一个页面重新生成一遍——纯属白干。fastcgi_cache就是让Nginx把PHP吐出来的整页HTML存在自己这一层,下次命中连PHP都不碰,直接把缓存丢回去。这篇讲清楚它到底缓存哪一层、和Redis对象缓存与Cloudflare各管哪一段、一套最小可用配置怎么写、哪些请求(登录态、购物车、后台)绝对不能进缓存、内容改了怎么主动清,以及怎么用响应头确认到底命中没命中。看完你能给WordPress或任意PHP动态站上一层真正扛量的全页缓存。 > 摘要:站点一上点量TTFB就飙、PHP-FPM进程瞬间被打满,多半是因为每个请求都在让PHP把同一个页面重新生成一遍——纯属白干。fastcgi_cache就是让Nginx把PHP吐出来的整页HTML存在自己这一层,下次命中连PHP都不碰,直接把缓存丢回去。 这篇讲清楚它到底缓存哪一层、和Redis对象缓存与Cloudflare各管哪一段、一套最小可用配置怎么写、哪些请求(登录态、购物车、后台)绝对不能进缓存、内容改了怎么主动清,以及怎么用响应头确认到底命中没命中。看完你能给WordPress或任意PHP动态站上一层真正扛量的全页缓存。 做独立站、做外贸站,十有八九绕不开PHP:WordPress、WooCommerce、Magento、各种自研后台。这类站有个共同的痛点——慢。慢的根子很多时候不在带宽、不在数据库本身,而在于每来一个访客,PHP都要把同一个页面从头跑一遍:连数据库、查文章、套模板、拼HTML,最后才吐给浏览器。一个访客这么干没事,一千个并发同时这么干,PHP-FPM进程就被占满,新请求排队,TTFB直接拉到几秒,严重的直接502。 问题在于,对绝大多数访客来说,他们看到的首页、文章页、分类页其实长得一模一样。同一个页面,凭什么要为每个人重新生成一遍?fastcgi_cache解决的就是这件事:把PHP第一次生成好的整页HTML缓存起来,后面的访客直接拿现成的,PHP一行代码都不用再跑。这篇保哥就把这层缓存从原理到配置、从踩坑到验证,一次讲透。 ## fastcgi_cache到底把哪一层缓存下来了? 要用对它,先得搞清楚它在整个请求链路里站在哪个位置、缓存的是什么东西。 一个PHP站点的典型链路是这样的:浏览器把请求发给Nginx,Nginx通过FastCGI协议把请求转交给PHP-FPM,PHP-FPM跑PHP代码、查数据库、渲染模板,把生成好的HTML通过FastCGI再交回给Nginx,Nginx最后把这串HTML发给浏览器。fastcgi_cache干的事,就是在Nginx从PHP-FPM拿到这串HTML的瞬间,把它存一份到磁盘(或内存)里,并打上一个键。 下一个请求进来,Nginx先按同样的规则算出键,去缓存里找。找到了且没过期,就直接把存好的HTML吐回去,整个PHP-FPM、数据库、模板渲染全部跳过——这就是命中。没找到或者过期了,才老老实实再走一遍PHP,顺手把新结果再缓存上——这就是未命中。 所以它缓存的是“整页HTML的最终成品”,是页面缓存(page cache)这一层。命中时省下的是PHP解释执行、数据库往返、模板拼装这一整套最贵的活儿,效果立竿见影:TTFB从几百毫秒甚至几秒,直接掉到几毫秒级,因为Nginx读个磁盘文件丢出去太轻松了。这也是为什么页面缓存被称为对动态站性能影响最大的一层。这层缓存怎么影响TTFB、又怎么和其他层联动,保哥在TTFB多层缓存与Core Web Vitals那篇 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)里拆得更细,这里只聚焦fastcgi_cache本身。 ## 它和Redis对象缓存、Cloudflare是抢饭碗还是各管一段? 这是新手最容易绕晕的地方:又是页面缓存、又是对象缓存、又是CDN,到底装哪个、是不是装一个就够了?答案是它们各管一段,配合着用才是完整方案,谁也替代不了谁。 把一个动态站的缓存拆成四层来看就清楚了。第一层是浏览器HTTP缓存,靠Cache-Control、ETag这些响应头让回头客本地直接读,连请求都不发——这块保哥在浏览器HTTP缓存头那篇 (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)专门讲这一层。 第三层才是fastcgi_cache所在的页面缓存,治的是“同一个动态页面被反复生成”,它跑在你自己的源站Nginx上。第四层是对象缓存,比如用 Redis给WordPress做对象缓存 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html),缓存的是数据库查询结果这种细粒度数据,治的是“没法整页缓存的动态页面里那些重复的数据库查询”。 关键区别在粒度和适用场景。fastcgi_cache缓存的是整页成品,适合内容相对稳定、对所有访客长得一样的页面(首页、文章页、落地页),命中后连PHP都不进,最猛但最粗。Redis对象缓存缓存的是页面内部的数据查询碎片,适合那些必须每次都进PHP、没法整页缓存的动态页面(比如带购物车、带个性化推荐的页面),它没法让你跳过PHP,只是让PHP跑得快点。 所以正确姿势是:能整页缓存的页面交给fastcgi_cache,让它们根本不碰PHP;不能整页缓存的动态页面进PHP后靠Redis对象缓存减少数据库往返;外层再套CDN管分发、套浏览器缓存头管回头客。四层叠起来,各打各的,才是扛量的完整方案。本篇专攻fastcgi_cache这一层。 ## 一套最小可用的fastcgi_cache配置长什么样? 概念讲完,上配置。fastcgi_cache的配置分两部分:在http块里定义一个缓存区,在server/location块里启用它。先看最小骨架。 第一步,在http块里用fastcgi_cache_path划出一块缓存空间: fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=2g use_temp_path=off; 逐个拆开看。path是缓存文件落盘的目录。levels=1:2是把缓存文件按两级子目录散开,避免一个目录里堆几十万个文件拖慢文件系统。keys_zone=WORDPRESS:100m给这块缓存起个名字叫WORDPRESS,并划100MB共享内存专门存键和元数据(注意这100MB存的是键不是内容本身,100MB大概能放几十万个键,足够大多数站用)。 inactive=60m表示一个缓存项60分钟内没人访问就被清掉(不管它有没有过期,纯粹是冷数据回收)。max_size=2g是缓存内容在磁盘上最多占2GB,超了按LRU淘汰最久没用的。use_temp_path=off让Nginx直接把缓存写到目标目录,省掉先写临时目录再搬一次的开销,官方也推荐这么设。 第二步,定义缓存键,并在处理PHP的location里启用缓存: fastcgi_cache_key "$scheme$request_method$host$request_uri"; location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_index index.php; fastcgi_cache WORDPRESS; fastcgi_cache_valid 200 301 302 30m; fastcgi_cache_valid 404 1m; add_header X-Cache-Status $upstream_cache_status; } fastcgi_cache_key决定“怎么算两个请求是同一个页面”。这里用协议+方法+主机名+完整URI拼成键,意味着HTTP和HTTPS、不同域名会各存一份。这个键怎么设是有讲究的,下文专门会说一个高频坑。 fastcgi_cache WORDPRESS启用刚才定义的缓存区。fastcgi_cache_valid 200 301 302 30m表示200和跳转响应缓存30分钟,404只缓存1分钟(防止某个偶发404被长期缓存住)。最后那行add_header X-Cache-Status极其重要,它把命中状态写进响应头,是后面验证缓存到底有没有生效的眼睛,强烈建议常驻。 还有个进阶但很实用的参数叫fastcgi_cache_min_uses,默认值是1,意思是一个页面被请求1次就缓存。把它调大(比如设成2或3),可以让一个页面被访问到第2、第3次才进缓存。好处是过滤掉那些只被访问一次的长尾冷门页面——这些页面缓存了也很少再命中,白占缓存空间和磁盘。对页面数量巨大、但流量集中在少数热门页的站,调高这个值能让缓存空间集中给真正高频的页面,命中率反而更健康。小站默认1就行,不用动。 这套配下去重载Nginx,第一次访问页面是MISS(走了PHP),第二次起就该是HIT(没碰PHP)了。但先别急着上生产——上面这套裸配置有个致命问题:它会把登录后台、购物车这些也一股脑缓存了,下面就讲怎么排除。 ## 哪些请求绝对不能让它缓存? 这是fastcgi_cache的头号大坑,也是新手翻车最惨的地方。如果不做排除,会出现什么后果?管理员登录后台,Nginx把带着登录态的后台页面缓存了,结果下一个匿名访客一访问,直接看到管理员的后台——这叫缓存串用户,是真实发生过的安全事故。 核心原则:凡是“因人而异”的页面、凡是会改数据的请求,统统不能进缓存。具体至少要排除这几类:登录用户的页面(带登录Cookie的)、所有POST请求、后台地址(wp-admin、wp-login)、购物车和结账页、站内搜索结果(?s= 这种)、带评论Cookie的页面。 做法是先用一个变量判断要不要跳过,再把这个变量喂给两个指令。在server块里写判断: set $skip_cache 0; # POST 请求不缓存 if ($request_method = POST) { set $skip_cache 1; } # 带查询串的不缓存(搜索/分页等按需放宽) if ($query_string != "") { set $skip_cache 1; } # 后台/登录/预览/Feed 等地址不缓存 if ($request_uri ~* "/wp-admin/|/wp-login.php|/cart|/checkout|/my-account|sitemap.*\.xml") { set $skip_cache 1; } # 已登录/刚评论/购物车 Cookie 不缓存 if ($http_cookie ~* "wordpress_logged_in|woocommerce_items_in_cart|comment_author") { set $skip_cache 1; } 然后在PHP的location里把这个变量接到两个关键指令上: fastcgi_cache_bypass $skip_cache; fastcgi_no_cache $skip_cache; 这两个指令必须成对出现,含义不同别搞混。fastcgi_cache_bypass控制“这次请求要不要读缓存”——值非空非零时,绕过缓存直接去问PHP(拿最新的)。fastcgi_no_cache控制“这次响应要不要写进缓存”——值非空非零时,PHP返回的结果不存。两个都设,登录态请求才能既不读旧缓存、又不把私有内容写进公共缓存。只设一个,照样出事。 这里多说一句那个query_string判断。把所有带查询串的请求都跳过缓存是最稳的保守做法,但它会让带UTM参数、带分页参数的页面全部不缓存,命中率受影响。更精细的做法是只跳过真正动态的查询串(比如 ?s= 搜索、?add-to-cart=),对UTM这类营销参数则在fastcgi_cache_key里把它们忽略掉。怎么取舍看站点流量结构,先求稳、再优化命中率,是个合理顺序。 ## 缓存里的内容更新了,怎么让它立刻失效? 页面缓存最反直觉的地方在这儿:你后台改了一篇文章、改了价格,可前台访客看到的还是缓存里的旧版本,因为缓存还没到期。这就是页面缓存这一层最经典的矛盾——既要长缓存扛量,又要内容改了立刻生效。有两条路。 第一条是被动失效:靠fastcgi_cache_valid设的TTL自然过期。比如设30分钟,那最长30分钟后访客就能看到新内容。这条路零依赖、最简单,适合内容更新不那么实时的站(博客、企业站)。缺点是有延迟,且改一个小东西也得等全站缓存慢慢过期。 第二条是主动清除:内容一更新,立刻把对应页面的缓存删掉,下次访问重新生成。这需要ngx_cache_purge这个第三方模块(Nginx默认不带,需要编译进去,或用自带该模块的发行版/容器镜像)。装好后配一个purge的location: location ~ /purge(/.*) { fastcgi_cache_purge WORDPRESS "$scheme$request_method$host$1"; } 之后访问 /purge/文章地址 就能精确删掉那一页的缓存。配合WordPress端的缓存插件(很多支持Nginx Helper、或自己写钩子),就能做到“发布/更新文章时自动ping这个purge地址”,实现内容一改、对应页面缓存立刻失效。 没有这个模块也有土办法:直接rm掉缓存目录里的文件、或者整目录清空。但整目录清空是把全站缓存一锅端,清完瞬间所有请求都打到PHP上,流量大的站可能直接被自己清缓存的动作压垮,这个连锁反应下文会专门提。所以主动清除优先做“精确清单页”,别动不动全清。 实战里通常是两条路一起用:给大多数页面设一个不太短的TTL兜底(防止主动清漏了),再对“发布、更新、评论”这类明确事件做精确主动清。被动管全局保险,主动管即时性,配合起来既扛量又不耽误内容更新。 ## 想扛住突发流量和爬虫,微缓存怎么用? 有一类站,页面更新很频繁(比如带实时数据、带个性化区块),长TTL缓存会让内容太旧,可又确实扛不住突发流量和爬虫的反复抓取。这时候有个很好用的招:微缓存(microcache)。 微缓存的思路是把TTL设得极短——1到10秒。听起来缓存1秒有什么用?用处大了。设想一个页面每秒被请求100次,没缓存时这100次全打到PHP;开了1秒微缓存,这1秒内只有第一个请求真正走PHP,剩下99个全吃缓存。也就是说PHP的压力从每秒100次降到每秒1次,降了两个数量级,而访客最多看到1秒前的内容——对绝大多数页面完全可以接受。 微缓存的配置就是把valid时间改短: fastcgi_cache_valid 200 1s; fastcgi_cache_use_stale updating error timeout; fastcgi_cache_lock on; 后两行是微缓存的灵魂。fastcgi_cache_use_stale updating的意思是:当某个缓存项正在被一个请求重新生成时,其他同时进来的请求先吃旧的(stale)缓存,而不是全都排队等PHP或全都涌向PHP。再加上error timeout,万一PHP后端挂了或超时,Nginx还能把过期缓存先顶上去救急,避免后端一抖动就全站502。 fastcgi_cache_lock on解决的是缓存击穿(也叫惊群):当一个热门页面的缓存刚好过期,同一瞬间几百个请求同时发现没缓存、同时涌向PHP去生成,把后端瞬间打爆。开了lock,同一个键同一时刻只放一个请求去生成,其余的等它生成完直接吃新缓存。突发流量、秒杀、被爬虫密集抓取的场景,这三行能救命。 ## 怎么确认缓存到底命中了没有? 配完别凭感觉说“应该生效了”,一定要验证。最直接的就是前面那行add_header X-Cache-Status写进去的响应头。用curl看头就行: curl -I https://你的域名/某篇文章/ # 看返回头里的: # X-Cache-Status: MISS 第一次,走了PHP # X-Cache-Status: HIT 命中,没碰PHP # X-Cache-Status: BYPASS 被bypass规则跳过了(比如带Cookie) # X-Cache-Status: EXPIRED 过期了,重新生成 # X-Cache-Status: STALE 吃的是过期缓存(use_stale救急) 验证套路是:第一次访问应该是MISS,紧接着再访问同一个地址应该变成HIT。如果第二次还是MISS,说明缓存根本没写进去——多半是命中了某条no_cache规则(比如你带着登录Cookie在测、或者那个query_string判断把它跳过了)。如果该缓存的页面一直显示BYPASS,回头查你的set $skip_cache判断是不是误伤了。 另一个验证手段是看日志:在log_format里加上 $upstream_cache_status,就能在访问日志里统计全站的命中率,看哪些URL老是MISS。再配合压测工具(ab、wrk)对同一个页面打一轮,对比开缓存前后的TTFB和PHP-FPM进程占用,差距会非常直观——命中后TTFB通常是个位数毫秒,PHP-FPM进程几乎不动。 提醒一点:浏览器自己也有缓存,用浏览器测容易被本地缓存骗。验证fastcgi_cache这一层,优先用curl -I看服务器返回的头,或者无痕窗口、命令行工具,别被浏览器的强缓存(这是另一层的事)混淆了视线。 还有个容易被忽略的验证维度是状态码本身。命中缓存返回的应该是当初被缓存时的状态码——如果你发现某个本该200的页面缓存后老返回别的码,或者一个错误页被当成正常页长期缓存住了,那要回头查fastcgi_cache_valid的配置,看是不是把不该缓存的状态码也缓存了、或者缓存时机不对。把命中状态、状态码、TTFB三个指标一起看,缓存到底健不健康一目了然,比凭感觉强太多。 ## 上了fastcgi_cache还会踩哪些坑? 把保哥和同行们踩过的坑集中列一下,照着躲能少走很多弯路。 缓存串用户(最严重):没排除登录态就缓存后台页面,匿名访客看到别人的私密内容甚至后台。前面bypass/no_cache那套必须配齐,上线前务必带着登录Cookie和不带Cookie各测一遍。 移动端和PC端共用一个键:如果你的主题是同一套HTML靠CSS自适应,没问题;但如果是服务端按User-Agent分别输出移动版和PC版HTML,那fastcgi_cache_key里必须把设备类型也算进去,否则手机用户可能吃到PC版缓存,反之亦然。 分页、评论、点赞不更新:访客发了评论却看不到自己的评论,因为页面被缓存了。要么把带评论Cookie的请求bypass掉,要么在评论提交后主动purge那一页。这类“刚操作完看不到结果”的投诉,九成是缓存没及时清。 清缓存把自己压垮:流量大的站千万别动不动flush全站缓存。全清的瞬间所有页面同时变MISS,请求洪峰全砸到PHP上,可能直接502。要清就精确清单页,全清放在低谷期、并确保cache_lock开着兜底。 缓存放磁盘还是内存:fastcgi_cache_path默认落普通磁盘。如果磁盘是机械盘、或缓存命中率极高追求极致延迟,可以把缓存目录挂在tmpfs(内存盘)上,读取更快;但内存盘重启即空、且占内存,得权衡。多数SSD站点用默认磁盘就够。 HTTPS与HTTP各存一份是预期行为:因为键里带了 $scheme。如果你已经全站强制HTTPS,HTTP端只是301跳转,这没问题;但别被“怎么存了两份”吓到,那是键设计决定的,正常。 装了缓存就不管命中率了:上线后要持续看 $upstream_cache_status的命中率。命中率长期偏低,多半是bypass规则太激进(把不该跳的也跳了)、或者键设计太细(带了不该带的参数导致每个URL都算不同页)。缓存不是配完就完事,是要养的。 ## 常见问题解答 ## fastcgi_cache和WordPress缓存插件(比如WP Super Cache)冲突吗? 不冲突,但要想清楚分工,别重复劳动。WordPress那些纯PHP的页面缓存插件,本质是在PHP层把生成好的HTML存成静态文件,下次请求还是得先进PHP才能判断要不要返回缓存。fastcgi_cache跑在Nginx这一层,命中时连PHP都不进,比PHP层缓存更靠前、更省资源。两者只用一个就行,优先用fastcgi_cache。如果你已经在用Nginx,那WordPress端装的缓存插件最好只保留它的purge联动能力(比如Nginx Helper,负责文章更新时帮你清fastcgi_cache),把页面缓存这件事交给Nginx,别让PHP层再缓存一遍。两层都做页面缓存不仅多余,还会让缓存失效逻辑互相打架,调试时让人抓狂。 ## 开了fastcgi_cache,Google抓取看到的会是缓存的旧页面吗?影响SEO吗? 正常配置下不影响,反而有利。搜索引擎爬虫和普通访客一样,命中缓存就拿缓存、过期就拿新生成的,看到的内容和真实页面一致,不存在“给爬虫看旧版”的问题。而且页面缓存让TTFB大幅下降、服务器扛得住爬虫的密集抓取,对抓取预算和速度类排名信号都是正向的。要注意的是别把带个性化、带时效性的页面用过长TTL缓存住,导致内容明显滞后;也别误把sitemap.xml这类需要实时的文件缓存太久。把内容页设合理TTL、动态文件排除掉,SEO上只有好处没有坏处。真正会出问题的是缓存配错导致串内容或返回错误状态码,那属于配置bug,不是缓存本身的锅。 ## 我用的是Apache不是Nginx,也能用fastcgi_cache吗? fastcgi_cache是Nginx特有的指令,Apache没有同名功能,但思路可以平移。Apache想做类似的源站全页缓存,常见路子是mod_cache配合mod_cache_disk,或者在Apache前面架一台Nginx/Varnish专门做缓存层。很多生产环境就是Nginx在前做缓存和静态分发、Apache在后跑PHP这种组合。如果你是纯Apache环境又想要强力的全页缓存,最干净的方案往往是前置一层缓存代理,而不是硬在Apache里凑。当然,如果你的站点本来就在Nginx + PHP-FPM上,那直接用fastcgi_cache是最顺的,不用为了缓存去换架构。先看清自己的栈,再选对应方案。 ## 缓存项设多大的TTL比较合适? 没有标准答案,取决于内容更新频率和你有没有主动清除机制。如果你配了ngx_cache_purge、能在内容更新时精确清掉对应页面,那TTL可以设得长一些(比如几小时甚至一天),靠主动清保证即时性,靠长TTL把命中率拉满。如果你没有主动清除、纯靠被动过期,那TTL就得短一些(比如10到30分钟),在“扛量”和“内容别太旧”之间折中。对更新极频繁又扛不住量的页面,可以走前面说的微缓存,1到10秒。一个实用做法是分类型设TTL:稳定的文章页长一点,动态性强的列表页短一点,首页居中。别全站一个数值拍脑袋,按页面性质分层设,命中率和新鲜度才能兼顾。 ## 缓存目录占用磁盘会不会越涨越大把盘撑爆? 不会,前提是你在fastcgi_cache_path里设了max_size。这个参数就是给缓存内容设的磁盘上限,达到上限后Nginx会按最近最少使用(LRU)的策略自动淘汰最久没被访问的缓存项,腾出空间。另外inactive参数会把一段时间内没人访问的冷缓存清掉。所以只要这两个参数设了合理值(比如max_size设成你能接受的磁盘占用、inactive设个把小时),缓存目录就会稳定在一个区间,不会无限膨胀。真正会出问题的是忘了设max_size——那它确实会一直涨。还有个隐蔽情况是缓存目录所在分区本身就小、又和系统盘共用,建议给缓存单独规划好空间,并监控磁盘使用率,别等撑爆了才发现。 ## 权威参考资料 ## 浏览器HTTP缓存头怎么配?让回头客秒开又不犯改了不更新的事故 - URL:https://zhangwenbao.com/http-browser-cache-control-etag-expires-cache-headers.html - 分类:缓存与CDN - 发布:2026-01-26 | 更新:2026-01-26 - 摘要:HTTP浏览器缓存头详解:强缓存与协商缓存机制、Cache-Control的max-age与no-cache与no-store、Expires之别、ETag与Last-Modified协商304、静态资源指纹长缓存策略与SEO影响。 - 关键词:性能优化,HTTP缓存,Cache-Control,浏览器缓存 > **TLDR**:摘要:同一个访客第二次打开你的站,该不该让浏览器重新下载那堆图片、CSS、JS?这事不归运气管,归HTTP缓存头管。保哥这篇把浏览器缓存讲透:强缓存和协商缓存到底差在哪、Cache-Control那一串指令分别管什么、最容易搞混的no-cache和no-store、Expires过时没、ETag和Last-Modified怎么用一个304省下整次下载,最后给一套“静态资源缓一年、HTML实时更新”既快又不翻车的配置策略。看完你就能让回头客的页面秒开,还不犯“改了代码用户看到的还是旧的”这种事故。 > 摘要:同一个访客第二次打开你的站,该不该让浏览器重新下载那堆图片、CSS、JS?这事不归运气管,归HTTP缓存头管。保哥这篇把浏览器缓存讲透:强缓存和协商缓存到底差在哪、Cache-Control那一串指令分别管什么、最容易搞混的no-cache和no-store、Expires过时没、ETag和Last-Modified怎么用一个304省下整次下载,最后给一套“静态资源缓一年、HTML实时更新”既快又不翻车的配置策略。看完你就能让回头客的页面秒开,还不犯“改了代码用户看到的还是旧的”这种事故。 做独立站、跑外贸站的朋友,都想让页面打开更快。优化的招数很多,但有一档常被忽略、收益又极大的,就是浏览器缓存。道理很朴素:一个老访客今天来过、明天又来,你站上那些logo、CSS、字体、JS根本没变,凭什么让他的浏览器再下载一遍?让浏览器把这些东西存在本地,下次直接从硬盘读,页面几乎瞬间出来,服务器也少扛一份流量。这笔买卖,本该稳赚。 可问题是,浏览器怎么知道哪些能缓、缓多久、什么时候该去服务器问一句“变了没”?答案全藏在服务器响应时带的那几个缓存头里。保哥之前写TTFB多层缓存优化 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)时把浏览器缓存列为离用户最近的一层,这篇就把这一层单独拎出来讲透——Cache-Control、Expires、ETag、Last-Modified这几个头到底怎么读、怎么配,才能既榨出速度,又不踩“改了不更新”的雷。 ## 浏览器到底把什么东西缓存了,又凭什么缓? 先建立个整体认识。浏览器缓存的对象,主要是页面上那些静态资源:CSS样式表、JS脚本、图片、字体、图标等。这些东西体积不小、又长期不变,是缓存的最佳人选。至于HTML页面本身缓不缓、怎么缓,是另一档讲究,后面专门说。 那浏览器凭什么决定缓不缓、缓多久?凭服务器在响应头里下的指令。你访问一个资源,服务器返回内容的同时,会在HTTP响应头里带上Cache-Control、ETag这类字段,等于告诉浏览器:“这个文件你可以存一年”“这个每次用前来问我一下”。浏览器是个听话的执行者,照着这些指令办事。所以缓存策略的主动权在服务器、在你手里,配好这些头,就是在指挥每一个访客的浏览器怎么缓存你的站。 浏览器缓存分两大类机制,这是理解后面所有内容的总纲,必须先拎清楚: - 强缓存(强制缓存)。服务器说“这文件X秒内有效”,那么在这段时间里,浏览器再要用这个资源,压根不联系服务器,直接从本地缓存拿,连一个请求都不发。这是最快的——零网络往返。Cache-Control的max-age、Expires管的就是它。 - 协商缓存(对比缓存)。强缓存过期后,浏览器也不是傻乎乎重新下载,而是带着一个“标识”去问服务器:我手上这份还能用吗?如果服务器说“没变”,回一个轻飘飘的304状态码(不带文件内容),浏览器继续用本地那份;只有真变了,才返回新文件。ETag、Last-Modified管的就是这一步。 把这两层串起来看,一个资源的完整命运是:第一次访问,老老实实下载,同时服务器告诉浏览器缓存规则;在强缓存有效期内,再要用就直接读本地、零请求;有效期过了,进入协商缓存,问一句服务器变没变,没变就回304接着用、变了才重新下。强缓存省的是整个请求,协商缓存省的是文件主体的传输,一层比一层退而求其次,但都比无脑重下强得多。下面就按Cache-Control、Expires、ETag的顺序,一个个拆开。 ## Cache-Control这个头怎么读,常见指令都管什么? Cache-Control是现代HTTP缓存的核心,强缓存、协商缓存的行为很大程度上都由它的各种指令拼出来。它长这样,一个头里可以塞多条指令,逗号隔开: Cache-Control: public, max-age=31536000, immutable Cache-Control: no-cache Cache-Control: private, max-age=0, must-revalidate 把常用的指令逐个讲清楚,你就能读懂、也能写出大部分缓存策略了: - max-age=秒数。最重要的一条,规定资源的“新鲜期”有多长。比如max-age=3600就是“一小时内算新鲜,浏览器直接用本地的,别来烦我”。31536000是一年的秒数,常用来缓那些永不变的静态资源。 - no-cache。名字极具误导性——它不是“不缓存”,而是“可以缓存,但每次用之前必须先去服务器验一下还新不新”。也就是强制走协商缓存。下一节专门掰它和no-store。 - no-store。这才是真正的“一点都别存”,浏览器和中间缓存都不许保存这个响应,每次都得完整重新请求。用于绝对敏感、绝对不能留痕的内容,比如含隐私的接口响应。 - public。表示这个响应可以被任何缓存存储,包括CDN、代理这种“共享缓存”。 - private。只允许存在用户自己的浏览器(私有缓存)里,CDN和代理不许缓。带个性化信息、登录态的页面要用它,免得A用户的私人页面被CDN缓了、发给了B用户。 - immutable。告诉浏览器“这文件在有效期内绝对不会变”,于是连用户按刷新时本会触发的那次校验都省了,进一步减少请求。配长max-age用在带指纹的静态资源上效果最好。 - must-revalidate。资源一旦过期,必须去服务器校验,不许在服务器联系不上时拿过期的凑合用。对正确性要求高的内容加这条更稳。 - s-maxage=秒数。专门给共享缓存(CDN/代理)设的新鲜期,会覆盖max-age对它们的效果。这让你能给浏览器和CDN设不同的缓存时长。 这些指令是可以组合的,组合出来才是完整策略。比如 public, max-age=31536000, immutable 的意思是“谁都能缓、缓一年、期间绝不变”,这是给带哈希指纹的静态资源用的黄金配置;而 private, no-cache 是“只存浏览器、每次用前必验”,适合那种要保证拿到最新、又不想每次都重传全文的动态内容。会拼这几条,缓存策略就掌握大半了。 ## no-cache和no-store到底差在哪? 这俩是Cache-Control里最容易搞混、也最容易配错的一对,保哥单开一节讲,因为配错代价不小。 先记结论:no-cache是“存,但每次用前都要问”;no-store是“根本不存”。它俩差着十万八千里。 no-cache 这个名字坑了无数人。直觉上一看“no cache”,以为是“不缓存”,其实完全不是。它的真实含义是:浏览器可以把这个响应缓存下来,但在每次要用它之前,必须先向服务器发个校验请求,确认这份缓存还是最新的才能用。如果服务器说没变(回304),浏览器就用本地缓存,省下了重新传输文件的开销;如果变了,才下载新的。所以no-cache走的是协商缓存,它省流量(命中时只传一个304),但不省请求(每次都得问一下)。 no-store 才是字面意义上的“别缓存”。它命令浏览器和所有中间缓存:这个响应一个字节都别存,每次需要都得老老实实从服务器完整下载。它最彻底、最安全,但也最费——没有任何缓存收益。它的正确用武之地是绝对不能被缓存的敏感数据,比如银行交易页、含个人隐私的接口数据,存下来就有泄露风险的那种。 用错的后果很现实。本该用no-cache(每次验一下、变了才更新)的页面,错配成no-store,等于把缓存收益全扔了,每次全量重下,白白增加服务器压力和加载时间;反过来,本该no-store的敏感数据错配成no-cache甚至max-age,那就可能把不该留的东西留在了缓存里,是安全隐患。记牢这一句:要“实时但省流量”用no-cache,要“绝对不留”用no-store,别再被名字误导。 ## Expires和Cache-Control是什么关系,过时了吗? 你抓包看响应头时,可能还会见到一个 Expires 头,它和Cache-Control看着功能重叠,关系得理清楚。 Expires是HTTP/1.0时代的老头,它的值是一个绝对时间点,比如: Expires: Wed, 21 Oct 2026 07:28:00 GMT 意思是“这个资源到这个时刻之前都算新鲜”。它干的活和Cache-Control的max-age类似,都是定强缓存的有效期,区别在于:Expires用绝对时间,max-age用相对秒数。 绝对时间有个天生的毛病——它依赖客户端的本地时钟。要是用户电脑的时间不准(这事比你想的常见),Expires的判断就会出错,该过期的没过期、或者反过来。而max-age是“从拿到响应起算多少秒”,是相对的,不受客户端时钟漂移影响,更可靠。 所以现在的标准答案是:优先用Cache-Control的max-age,Expires留着只是为了兼容极老的客户端。而且按规范,当Cache-Control的max-age和Expires同时存在时,max-age说了算,Expires被忽略。你平时配缓存,盯着Cache-Control配就行,Expires知道它是怎么回事、看到别懵就够了,不用专门去设。这也是为什么保哥说Cache-Control是现代缓存的核心——它把老的Expires该干的活更可靠地接管了。 ## 缓存过期后怎么不重复下载?ETag和Last-Modified怎么省流量? 强缓存有效期内爽是爽,可总有过期的时候。过期了难道就只能重新下载整个文件?不是。这正是协商缓存登场的地方,主角是ETag和Last-Modified这对“验证器”。 先说Last-Modified,最直观。服务器返回资源时,带上这个文件的最后修改时间: Last-Modified: Tue, 22 Feb 2026 20:20:20 GMT 浏览器把这个时间记下来。等强缓存过期、要再用这个资源时,浏览器发请求,带上一个 If-Modified-Since 头,值就是上次记下的那个时间,等于问服务器:“我这份是这个时间的,之后改过没?”服务器一比对:没改过,回一个 304 Not Modified,不带任何文件内容;改过了,才返回200加新文件。命中304时,省下的是整个文件主体的传输,只走了一个几乎没有体积的状态响应,流量收益巨大。 但Last-Modified有几个软肋:它的精度只到秒,一秒内多次修改它分辨不出来;有些文件内容没变、只是被重新生成或触碰了一下,修改时间变了它就误判成“变了”;分布式部署时多台服务器的文件时间还可能对不齐。为了解决这些,HTTP又设计了更精确的 ETag。 ETag(实体标签)是服务器给资源算出的一个版本标识,可以理解成内容的指纹,内容一变指纹就变: ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" 它的协商流程和Last-Modified一个套路,只是换了字段:浏览器再次请求时带上 If-None-Match 头,值是上次拿到的ETag,问服务器“我这份指纹是这个,跟你现在的一样吗?”一样就回304接着用,不一样才下发新内容。因为比的是内容指纹而不是时间,ETag比Last-Modified准得多,不会因为“时间变了内容没变”而误判。 这里还有个细节值得知道:ETag分强验证器和弱验证器。强ETag要求字节级完全一致,写法就是普通的引号包起来;弱ETag前面带个 W/ 前缀,表示“语义上等价就行,不要求字节完全相同”,更容易生成但精度略松。日常理解到“ETag是内容指纹、用于协商缓存”这一层,绝大多数场景就够用了。 当ETag和Last-Modified同时存在时,ETag优先级更高,服务器会以ETag的比对结果为准。实际中两个都带上也无妨,给不同能力的环境留个兜底。记住协商缓存的价值:它不能让你零请求(还得问一句),但能把“问完发现没变”的情况从一次完整下载压缩成一个304空响应,对大文件尤其划算。 ## 这套缓存头怎么配才能既快又不出“改了不更新”的事故? 原理都懂了,落到实操:到底该怎么配,才能又快又不翻车?这里有个经典矛盾——缓存时间设长了快,但你更新了文件用户看到的还是旧的;设短了更新及时,但缓存收益又小了。怎么破? 业界的标准答案是按资源类型分而治之,核心是区分“带指纹的静态资源”和“HTML入口文件”,两套截然不同的策略: - 带哈希指纹的静态资源(CSS、JS、图片),用超长强缓存。现在的前端构建工具打包时,会给文件名加上一段内容哈希,比如 app.3f8a9c.js。内容一变,哈希就变,文件名也跟着变,等于变成了一个全新的URL。所以这类文件可以放心配 Cache-Control: public, max-age=31536000, immutable——缓一年、绝不校验。因为它永远不会“原地更新”,要更新就是换了个新文件名,浏览器自然会去请求新的那个。 - HTML入口文件,用no-cache或极短缓存。HTML是整个页面的入口,它里面引用着那些带哈希的资源。HTML绝不能长缓,否则你发布了新版本、引用了新哈希的资源,用户的浏览器还拿着旧HTML、引用着旧资源,更新就不生效了。所以HTML通常配 Cache-Control: no-cache(每次验一下,靠ETag/304省流量)或者很短的max-age,保证用户总能尽快拿到指向最新资源的入口。 这套组合的精妙之处在于:用文件名指纹(cache busting,缓存击穿/破缓存)这个机制,把“长期缓存”和“及时更新”这对矛盾化解了。静态资源享受一年长缓存带来的极致速度,更新时换文件名让浏览器自然获取新版;HTML这个小小的入口不长缓,保证它永远指向最新的资源清单。两头都顾上了,这就是为什么现代网站既能秒开回头客、又不会出“改了不更新”的事故。 那不带指纹、又会更新的文件怎么办(比如你手动传的一张产品图,文件名固定)?折中配个中等时长的max-age,再配合协商缓存兜底——过期后靠ETag校验,没变就304,变了才重下。或者更新图片时主动改文件名/加版本号查询参数,手动制造cache busting。原则始终是:能靠换名实现长缓存的尽量长缓,必须固定名又会变的就上协商缓存做兜底。 实在拿不准某个文件该缓多久,宁可设短一点配上协商缓存,也别贸然设超长强缓存——短了无非多发几个304校验请求,损失有限;长了一旦要更新就只能干等过期或求用户清缓存,那才是真难受。缓存策略的安全垫,永远是协商缓存这道兜底。 那怎么确认缓存到底配没配对、有没有生效?打开浏览器开发者工具的“网络(Network)”面板,刷新页面,看每个资源那一行的“大小(Size)”列,状态就一清二楚了。 如果显示的是 from disk cache(从磁盘缓存)或 from memory cache(从内存缓存),说明命中了强缓存,连请求都没发,最理想;如果显示 304 状态码,说明走了协商缓存,发了校验请求但没重传文件主体,也不错;如果显示实打实的文件大小、状态码200,那就是又重新下载了一遍,说明没命中缓存。点开某个资源还能看到它的完整响应头,Cache-Control、ETag、Last-Modified各配成了什么一目了然,对照预期就知道配对没配对。 配完缓存务必这样抓一眼实际行为,别只在服务器配置里想当然——配了不等于生效,nginx、Apache、CDN任何一层覆盖了你的设置都可能让结果和预期不符。尤其要留意“硬刷新”和“普通访问”的差别:你按Ctrl+F5强制刷新时浏览器会绕开强缓存重新请求,看到的不是真实的回头客体验,想验证缓存效果得用普通的二次访问,或者干脆在隐身窗口里走一遍正常流程。 保哥举个真实排查。有个外贸站老板抱怨“回头客打开还是慢”,保哥让他开Network面板一看,全站几十个图片、JS每次访问都是200、实打实重下——压根没配缓存头。给静态资源加上一年的强缓存加文件名指纹后,再看面板,第二次访问那些资源全变成了from disk cache,页面肉眼可见地秒开。这种站太多了,不是优化不动,是连白送的缓存都没领。缓存头是那种“配置存在与否”就能拉开巨大差距的优化,先确认配了没,再谈配得好不好。 还要提醒一句,缓存头不只浏览器在看,CDN和反向代理也照着它缓。如果你站前面挂了 Cloudflare这类CDN (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html),缓存头会同时指挥CDN的边缘缓存,这时public/private、s-maxage这些区分共享缓存的指令就格外重要——带登录态的页面务必private,否则可能被CDN缓存后串给别的用户,那就是事故了。浏览器缓存和CDN缓存用的是同一套头,理解了这套头,两层一起就都拿捏了。 ## 缓存头配错的翻车现场,以及它和SEO的关系 最后,保哥把实操里最常见的翻车场景和容易忽略的SEO关联讲一讲。 翻车一:HTML也配了长max-age。头号事故。你发布了新版网站,可一部分用户死活看到的还是旧页面,刷新都没用——因为他们的浏览器把旧HTML强缓存住了,在过期前根本不来服务器。HTML必须no-cache或短缓存,切记。翻车二:no-cache当成了no-store。本想让页面实时更新又省流量,结果配了no-store,每次全量重下,性能白白损失。翻车三:带登录态的页面配了public。被CDN缓存后,一个用户的私人页面发给了另一个用户,隐私事故,个性化内容务必private。 翻车四:静态资源不加指纹却配超长缓存。文件名固定又缓一年,你更新了用户一年内都看不到,只能让用户清缓存——极差的体验。要长缓存就一定配合文件名指纹。翻车五:完全不配缓存头。不配的话浏览器只能用一套保守的默认启发式,缓存收益大打折扣,等于把白送的速度扔了。再小的站,给静态资源配上缓存头都是稳赚。 缓存头和SEO也有实打实的关系,做站的更该重视。其一,页面速度是Google的排名信号,缓存配好了,回头访问和资源加载更快,对Core Web Vitals(尤其LCP)有正面帮助,体验分上去了,排名也受益。 其二,影响抓取预算。Googlebot抓取时也尊重缓存语义,合理的缓存能减少对没变资源的重复抓取,把宝贵的抓取预算留给真正需要被发现的新内容——这一点保哥在讲服务端缓存与抓取效率 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html)时也反复强调过。一前一后两层缓存(浏览器这层管访客体验、服务端那层管动态生成),加上 OPcache砍编译 (https://zhangwenbao.com/php-opcache-bytecode-cache-tuning-preload-jit-hit-rate.html),整条性能链路才算配齐。 说到底,浏览器缓存头是那种“配一次、长期收益”的优化:花点时间把静态资源配成长缓存加指纹、把HTML配成no-cache、把敏感内容配成no-store,回头客的页面就能秒开,服务器也轻松,还不犯“改了不更新”的低级事故。它不烧钱、不伤筋动骨,就是几行响应头配置的事,却是页面速度优化里性价比最高的一档,做独立站、外贸站的没有理由不把它配明白。配明白这一层,再往上叠CDN、往下叠服务端缓存,整个站的性能才算真正立住了。 ## 常见问题解答 ## no-cache是不是就是“不缓存”,和no-store一样吗? 完全不一样,这是Cache-Control里最坑人的误解。no-cache的真实含义是“可以缓存,但每次用之前必须先去服务器验一下还新不新”,也就是强制走协商缓存——命中时服务器回304,浏览器用本地那份,省下了重新传输文件的流量,但每次都要发一个校验请求。而no-store才是字面意义的“一点都别存”,浏览器和所有中间缓存都不许保存这个响应,每次需要都得从服务器完整重新下载,没有任何缓存收益。两者用途也不同:要“实时更新又省流量”用no-cache(比如HTML入口);要“绝对不留痕”用no-store(比如含隐私的敏感数据)。配错代价不小——把该no-cache的配成no-store会白白损失性能,反之则可能造成敏感信息泄露。务必记牢这个区别。 ## Cache-Control和Expires都能设缓存时间,该用哪个? 优先用Cache-Control的max-age,Expires只为兼容极老的客户端保留。两者都是定强缓存有效期的,核心区别在于:Expires用的是绝对时间点(比如“到2026年某月某日某时之前有效”),而max-age用的是相对秒数(“从拿到响应起算多少秒内有效”)。绝对时间有个天生缺陷——它依赖客户端本地时钟,用户电脑时间不准时判断就会出错;而相对秒数不受客户端时钟漂移影响,更可靠。而且按HTTP规范,当max-age和Expires同时出现时,max-age优先、Expires被忽略。所以日常配缓存盯着Cache-Control配就够了,Expires知道是什么、看到别懵即可,不用专门设。Cache-Control被称为现代缓存核心,正因它更可靠地接管了老Expires的活。 ## ETag和Last-Modified有什么区别,要不要都配? 两者都是协商缓存的“验证器”,作用是让缓存过期后不必重下整个文件、只需校验一下。Last-Modified带的是文件最后修改时间,浏览器下次用If-Modified-Since问服务器“这之后改过没”,没改回304。它直观但有软肋:精度只到秒、内容没变但被重新生成时会误判、多服务器时间还可能对不齐。ETag是服务器给资源算的版本标识,相当于内容指纹,浏览器用If-None-Match比对指纹,内容没变就回304。因为比的是内容而非时间,ETag比Last-Modified准得多,不会“时间变了内容没变”就误判。两者同时存在时ETag优先。实际中可以都配,给不同环境留兜底,但ETag是更可靠的那个。理解到“ETag是内容指纹、用于协商缓存省流量”这一层,日常就够用了。 ## 为什么我更新了网站,有些用户看到的还是旧页面? 九成是HTML被配了过长的强缓存。HTML是页面入口,里面引用着各种CSS、JS、图片资源。如果你给HTML配了长max-age,那么在缓存过期前,用户的浏览器根本不来服务器、直接用本地那份旧HTML,自然引用的还是旧资源,你发布的新版就不生效,刷新都没用(除非强制刷新)。正确做法是:HTML入口配no-cache或很短的max-age,保证用户总能尽快拿到指向最新资源清单的入口;而那些CSS、JS静态资源配长缓存加文件名哈希指纹,更新时哈希变、文件名变,等于新URL,浏览器自然会去请求新的。这套“HTML不长缓、静态资源靠换名长缓”的组合,正是既快又不出更新事故的关键。如果你的静态资源文件名固定又配了长缓存,更新后用户也会看到旧的,得改文件名或加版本号。 ## 浏览器缓存配好了对SEO有帮助吗? 有实打实的帮助,主要体现在两方面。其一,页面速度是Google公开的排名信号之一,缓存配好后回头访问和资源加载更快,对Core Web Vitals(特别是衡量主内容加载的LCP)有正面作用,体验分上去排名也受益。其二,影响抓取预算——Googlebot抓取时也尊重缓存语义,合理的缓存能减少对没变资源的重复抓取,把有限的抓取预算省下来留给真正需要被发现和收录的新内容,这对内容量大的站尤其重要。需要说明的是,缓存优化不是直接的排名魔法,它是通过“更快的体验”和“更高效的抓取”间接利好SEO。把浏览器缓存这层和服务端缓存配合起来,性能和抓取效率双双改善,是做站值得投入的基础功,性价比很高。 ## 权威参考资料 ## PHP OPcache字节码缓存怎么调才能让站点真正快起来? - URL:https://zhangwenbao.com/php-opcache-bytecode-cache-tuning-preload-jit-hit-rate.html - 分类:缓存与CDN - 发布:2026-01-12 | 更新:2026-01-12 - 摘要:PHP OPcache字节码缓存调优实战:opcode编译缓存原理、memory_consumption与max_accelerated_files配置、validate_timestamps部署坑、opcache_get_status命中率排查、preload与JIT取舍。 - 关键词:性能优化,OPcache,PHP,缓存 > **TLDR**:摘要:OPcache是PHP站点性价比最高的一档优化,开一个开关就能让动态页快一大截,可偏偏最容易被人配错、甚至配出"部署了代码不生效"的灵异事件。保哥这篇把它讲透:PHP为什么每次请求都在白白重复编译、OPcache缓的到底是什么、内存和文件数两个核心参数怎么定、validate_timestamps这个生产大坑怎么躲、命中率怎么看、preload和JIT到底值不值得碰。看完你就能把这层缓存调得明明白白,而不是抄一段配置提心吊胆。 > 摘要:OPcache是PHP站点性价比最高的一档优化,开一个开关就能让动态页快一大截,可偏偏最容易被人配错、甚至配出"部署了代码不生效"的灵异事件。保哥这篇把它讲透:PHP为什么每次请求都在白白重复编译、OPcache缓的到底是什么、内存和文件数两个核心参数怎么定、validate_timestamps这个生产大坑怎么躲、命中率怎么看、preload和JIT到底值不值得碰。看完你就能把这层缓存调得明明白白,而不是抄一段配置提心吊胆。 做独立站、跑WordPress或Magento的朋友,多多少少听过OPcache这个词,知道它"能让PHP变快"。但真要问它快在哪、参数怎么配、为什么有时候改了代码前台死活不更新,能说清楚的就不多了。保哥见过太多服务器,OPcache要么压根没开白白浪费性能,要么参数照抄网上一段配置,内存设得要么撑爆要么不够,命中率惨不忍睹自己还蒙在鼓里。 这篇就把这层缓存彻底讲明白。它跟保哥之前写的Redis对象缓存 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html)是两个完全不同的东西——一个缓代码、一个缓数据,后面会专门掰开。咱们先从一个根本问题问起:PHP到底在重复做什么无用功。 ## PHP每次请求都在重复编译,OPcache到底省了什么? 要懂OPcache,得先知道PHP是怎么跑一段代码的。你写的 .php 文件,PHP不能直接执行,它得先翻译。整个过程大致分几步:先做词法分析和语法分析,把源码拆成结构化的语法树;再把语法树编译成一种叫opcode(操作码)的中间字节码;最后由Zend引擎一条条执行这些opcode,产出最终的HTML。 问题就出在中间那步。每来一个请求,PHP默认都要把涉及的PHP文件从头解析、编译一遍。可你的代码在两次请求之间根本没变啊——同一份 index.php,第一个访客来编译一次,第二个访客来又编译一次,第一万个访客来还编译,编译出来的opcode每次都一模一样。这是赤裸裸的重复劳动,纯属浪费CPU。 OPcache干的事就是终结这种浪费。它把编译好的opcode缓存到一块共享内存里,下次再有请求用到同一个文件,PHP一看缓存里有现成的opcode,直接拿来执行,跳过了词法分析、语法分析、编译这三大步。省下来的是实打实的CPU时间,反映到用户那头就是动态页响应更快、服务器在同样硬件下能扛更多并发。 这里有个关键词叫"共享内存",它解释了OPcache为什么这么高效。PHP-FPM通常跑着一堆工作进程同时处理请求,OPcache的缓存是放在一块所有进程都能访问的共享内存区里,编译一次,全体进程共用,不是每个进程各缓一份。这就是为什么清缓存往往要重载整个FPM——你动的是这块大家共享的内存。也正因为缓存在内存里,服务器一重启,OPcache就空了,得重新预热。 那怎么确认你的服务器到底开没开OPcache?最简单是写个只有 代价是什么?你改了代码、传了新文件,OPcache根本不去看文件时间戳,它压根不知道代码变了,前台跑的还是内存里那份旧opcode。你盯着页面纳闷"我明明改了啊",其实改的是磁盘上的文件,跑的是内存里的旧缓存。 这就是那个经典坑的全貌。validate_timestamps=0不是bug,它是一个性能与便利的权衡:你拿"不自动感知代码变化"换"不做时间戳检查的性能"。一旦选了它,你的部署流程里就必须加一个主动清缓存的步骤,否则部署等于没部署。下一节专门讲这个。 ## 部署后代码不更新,是不是OPcache没刷? 如果你的生产环境关了时间戳检查,那答案几乎一定是:对,就是OPcache没刷。修复思路很简单——部署完,主动让OPcache把旧缓存丢掉。具体有几种清法: - 调用opcache_reset()。这个PHP函数会清空整个OPcache,下次请求重新编译。可以写个小脚本部署后访问一下触发,但要注意它得在和PHP-FPM同一个进程语境里执行才有效,命令行CLI跑的是另一个OPcache实例,清的不是FPM那个。 - 重载或重启PHP-FPM。systemctl reload php-fpm 之类,让FPM进程重来,OPcache自然清空。这是最干脆可靠的方式,配合PHP-FPM进程管理 (https://zhangwenbao.com/apache-performance-tuning-mpm-event-php-fpm-maxrequestworkers-high-concurrency.html)一起做,部署脚本里加一行就行。 - 用专门的工具。像cachetool这类命令行工具能通过FPM的socket远程触发清理,绕开了CLI与FPM实例不互通的问题,适合自动化部署。 还有个相关参数 opcache.file_update_protection,默认2秒,意思是文件修改时间在这个秒数以内的文件先不缓存。它防的是这种情况:你的文件正写到一半,OPcache就把这个残缺状态缓存了。留个几秒保护期,等文件确定写完了再缓,避免缓存到半成品。 这里还有个高级部署的坑值得一提。有些团队用"原子部署"——把新版本放到一个新目录,再用软链接一次性切过去。如果你的OPcache缓存key是按真实路径算的,软链接切换后真实路径变了,缓存key自然变了,相当于天然清了缓存;但如果配置让它认软链接路径,路径没变,旧缓存还在,照样得手动清。部署方式和清缓存策略要对上,别想当然。 ## 命中率怎么看,低了从哪查? OPcache配得好不好,不能靠感觉,得看数据。PHP提供了一个函数 opcache_get_status(),调一下就能拿到OPcache的完整运行状态,市面上各种OPcache监控面板底层都是读它。重点看这几个指标: 指标 | 含义 | 怎么判断 | opcache_hit_rate | 命中率 | 正常该在95% 以上,偏低就有问题 | num_cached_scripts | 已缓存文件数 | 逼近max_accelerated_files就是文件数不够用 | used_memory / free_memory | 已用 / 空闲内存 | 空闲所剩无几就是内存该加了 | wasted_memory | 浪费(碎片)内存 | 占比高说明频繁刷新导致碎片化 | oom_restarts | 因内存不足重启次数 | 大于0就是内存严重不足 | 命中率低,通常逃不出三个根因,对照着查。一是文件数超限:num_cached_scripts顶到了max_accelerated_files上限,多出来的文件缓不进去,每次现编译,把命中率拉低。解法是调高文件数上限。 二是内存不足频繁淘汰:内存装不下所有opcode,OPcache不得不淘汰一部分,被淘汰的下次又得重编译。表现是free_memory很低、wasted_memory高、甚至oom_restarts在涨。解法是加memory_consumption。 三是时间戳检查太勤:revalidate_freq设得太小(比如0),每个请求都去查文件时间戳,虽然不直接砍命中率,但增加了额外开销。生产环境在确认会主动清缓存的前提下,可以把这个值调大或干脆关掉检查。三个根因里前两个最常见,先从内存和文件数这两个参数查起准没错。 顺便说说那几个restart(重启)计数,它们是判断健康度的好信号。OPcache内部在某些情况下会自己重启、清空缓存重来,状态里分了三类计数,分别对应不同的诱因。 oom_restarts 是内存耗尽(out of memory)被迫重启,这个涨了说明内存严重不足;hash_restarts 是哈希表满了(文件数撞上限)触发的重启,涨了说明max_accelerated_files该调大;manual_restarts 是你主动调opcache_reset() 清的,部署时涨是正常的。盯着前两个,只要它们在持续增长,就是配置不够用的铁证,比命中率更直接。 保哥举个真实排查。有台跑着电商的服务器,老板抱怨后台越来越卡。一查opcache_get_status(),命中率才八成出头,num_cached_scripts死死贴着默认的10000上限,hash_restarts还在不停涨——典型的文件数超限。那套电商系统加上插件早过万个PHP文件,默认上限根本装不下,一部分文件每次请求现编译,能不卡吗。 把max_accelerated_files提到两万多、内存也加到256MB,命中率立马回到99%,后台顺滑如初。整个排查没碰一行业务代码,纯靠看状态数据定位,这就是会读opcache_get_status() 的价值。与其等用户投诉了再救火,不如把这几个指标接进监控,命中率掉到阈值以下、或者oom_restarts开始涨就自动告警,问题萌芽阶段就摁住。这一步跟保哥讲服务器运维时强调的"指标可观测"是一个思路,缓存层尤其不能当黑盒。 ## preload和JIT这些进阶功能值得开吗? OPcache这些年加了两个进阶特性,preload和JIT,听着很唬人,但保哥要给你泼点冷水:它们不是人人都该开,得看场景。 preload(预加载,PHP 7.4起)。普通OPcache是"用到才缓"——第一个访问某文件的请求还是得编译一次,之后才走缓存。preload更进一步:服务器启动时就把你指定的一批核心文件(通常是框架本体)预先编译好、常驻内存,连第一次请求都不用编译,而且这些类和函数全程可用。 对代码量大的框架,preload能再省一截首次编译的开销。但它有两个代价:一是改了预加载的文件必须重启PHP-FPM才能生效,没有时间戳检查那一说;二是多个站点共享同一个PHP进程时,预加载的文件是全局的,可能撞命名空间。所以preload适合单一的、重型框架站点,多站混跑的环境要谨慎。顺便一提,Windows上不支持preload。 配置上,preload靠 opcache.preload 指向一个预加载脚本(这个脚本负责把要常驻的文件opcache_compile_file进来,很多框架直接提供了现成的预加载脚本),再用 opcache.preload_user 指定以哪个系统用户身份执行(出于安全,不允许用root跑)。Laravel、Symfony这些框架的官方文档通常会给出推荐的预加载配置,照着接就行,别自己硬写。 记住核心代价没变:预加载文件一改就得重启FPM,所以它适合给那些上线后基本不动的框架核心文件用,业务代码那种常改的别往里塞。否则你每改一次业务逻辑都要重启整个FPM,反而把开发效率拖垮,得不偿失。 JIT(即时编译,PHP 8.0起)。它在OPcache基础上再进一步,把热点opcode直接编译成CPU能跑的机器码,理论上更快。但关键在于:JIT的收益高度依赖工作负载类型。对计算密集型任务(图像处理、数学运算、AI推理)效果明显;可典型的Web应用是IO密集型——时间大多花在等数据库、等网络上,CPU计算占比不高,JIT能优化的那部分本来就不是瓶颈,提升有限,个别场景甚至因为额外开销出现微小负优化。 真要开JIT,配置上也有讲究。它通过 opcache.jit 设模式,常见的有tracing(跟踪热点路径,通常效果最好)和function(函数级),还有 opcache.jit_buffer_size 单独划一块内存存编译出来的机器码(默认64MB,设0等于关闭JIT)。这块缓冲是在OPcache内存之外另算的,开JIT记得给它留够。配错了buffer_size,JIT等于没生效你还以为开了,又是一个看着配了实则空转的坑。 保哥的结论很明确:OPcache本体,所有PHP站点都该开,这是基本功;preload,重型框架站点可以上,能再榨一点;JIT,别盲目跟风,先搞清楚你的站是不是计算密集型,是再开,不是就别折腾。把OPcache这个根本的开关用对、参数调好,收益已经远超那两个花哨特性。像Magento这种重型应用,OPcache更是性能调优 (https://zhangwenbao.com/magento-2-performance-tuning-indexer-cache-redis-varnish-production-mode.html)里不可省略的一环,先把它配扎实,再谈别的。 说到底,OPcache是那种"花十分钟配好、长期默默省钱"的优化。它不像换硬件那么烧钱,也不像重构代码那么伤筋动骨,一个开关加两三个参数,就能让你的PHP站点在同样的服务器上跑得更快、扛得更多。唯一要记牢的,就是生产环境关时间戳检查后,部署务必带上清缓存这一步——把这个坑躲过去,OPcache就是稳赚不赔的买卖。 ## 常见问题解答 ## OPcache和Redis对象缓存只配一个行不行,非得都上吗? 建议都上,因为它们优化的是完全不同的环节,不是二选一。OPcache缓的是PHP代码编译成字节码的结果,省的是每次请求重复编译的CPU开销,只要是PHP站点几乎都该开,跟业务类型无关,而且配置简单、风险极小。Redis对象缓存缓的是数据库查询结果,省的是反复查库的开销,治的是动态页和后台那种数据库往返频繁的场景。一个砍编译、一个砍查询,作用在请求处理的不同阶段,叠加起来动态页才能真正快。如果只能先上一个,OPcache优先——它收益面最广、几乎零副作用;对象缓存则看你的站是不是数据库密集型再决定。理想状态是两层都配齐,再加上CDN分发,各管一段。 ## 改了PHP代码,刷新页面前台却还是旧的,是什么原因? 如果你的生产环境把opcache.validate_timestamps设成了0(关闭时间戳检查),那几乎可以肯定就是OPcache没刷。关闭时间戳检查后,OPcache不再去看源文件改没改,认准内存里那份旧字节码一直用,你改的是磁盘文件,跑的是内存旧缓存,自然看不到变化。解决办法是部署后主动清OPcache:调用opcache_reset()、重载或重启PHP-FPM、或者用cachetool这类工具触发清理。最可靠的是部署脚本里加一行reload php-fpm。如果你的环境validate_timestamps还是默认开启的,那等revalidate_freq那几秒过去就会自动更新,不是这个问题,得往别的缓存层(对象缓存、页面缓存、CDN)去查。 ## opcache.memory_consumption到底该设多大? 看你站点的代码体量,并以运行时数据为准,别照抄。默认128MB对轻量博客、小型站点通常够用;像Magento、大型Laravel这类代码量巨大的应用,128MB往往不够,要调到256MB甚至更高。判断够不够的办法是看opcache_get_status() 返回的内存使用情况:如果free_memory(空闲内存)所剩无几、wasted_memory(碎片)占比偏高、或者出现了oom_restarts(因内存不足被迫重启),就说明内存不够,该往上加。反过来如果空闲内存还很充裕,说明设大了也是浪费,可以适当回收。记住interned_strings_buffer是从这块总内存里扣的,调它会挤占存opcode的空间,要一起算账。 ## max_accelerated_files设大一点会不会浪费资源? 会占一点管理用的内存,但比起设小了导致文件缓不进去、命中率被拖垮,这点开销完全值得。这个参数是OPcache能缓存的文件数上限,默认10000,现代框架加上依赖包文件数轻松上万,一旦实际文件数超过上限,超出的部分每次请求都得现编译,命中率直接受损。所以宁可设得宽裕些。具体设多少?看opcache_get_status() 里的num_cached_scripts知道实际缓了多少文件,设一个略高于它的值即可。还有个小技巧:OPcache内部会把你设的数向上取到最近的质数,所以直接设个质数(比如20011)更省事,免得它自己调整。设大带来的额外内存占用很有限,不必为此纠结。 ## JIT开了能让我的网站快很多吗? 对绝大多数普通网站,提升有限,别抱太大期望。JIT是PHP 8.0引入的,它把热点字节码进一步编译成机器码,理论上执行更快。但它的收益高度依赖你的工作负载:对计算密集型任务——图像处理、复杂数学运算、AI推理这类CPU吃满的场景,JIT效果明显;而典型的Web应用是IO密集型,时间大多耗在等数据库查询、等网络响应上,CPU计算本来就不是瓶颈,JIT能优化的那部分占比很小,整体提升不明显,个别情况下因为额外开销甚至有轻微负优化。所以别因为它新就盲目开。先把OPcache本体配扎实——开启、调好内存和文件数,这部分收益远大于JIT。确实是计算密集型业务再去评估JIT,普通内容站、电商站没必要折腾。 ## 权威参考资料 ## 独立站Cloudflare缓存与回源率优化:8维决策树+Cache Rules迁移实战 - URL:https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html - 分类:缓存与CDN - 发布:2025-11-15 | 更新:2025-11-15 - 摘要:Cloudflare独立站想压低回源率,得把缓存配对。本文给实战手册:Cache Rules覆盖Page Rules的迁移路径、Bypass表达式按path与cookie与query的写法、Argo与Tiered Cache的ROI判定、用cf-cache-status做三层回源监控,以及WAF与缓存联调的避坑。 - 关键词:Cloudflare,CDN缓存,回源率优化,Cache Rules,独立站性能 > **TLDR**:摘要:独立站把Cloudflare接上不调Cache Rules,等于花两份钱买一份缓存。多数独立站站长以为开了Cloudflare流量就被边缘吃掉了,回源率随便看一眼往往还在50%=60%。真正能把数字压下去的不是再加一个Argo或Origin Shield,而是先把Cache Rules重写一遍、把Bypass名单收紧、把cf-cache-status接进监控。这篇把我们团队半年里调17家独立站缓存的8维决策树拆开讲:Cache Rules与Page Rules怎么迁、Bypass写错为什么会拖低SEO、Argo与Origin Shield各自适合什么型站、回源率监控怎么做才不只是看面板数字、以及最后的6个月把回源率从60%压到15%的真实清单。读完你能拿一张可执行的判定表,对照自己站点的Cloudflare配置打分。 > 摘要:独立站把Cloudflare接上不调Cache Rules,等于花两份钱买一份缓存。多数独立站站长以为开了Cloudflare流量就被边缘吃掉了,回源率随便看一眼往往还在50%=60%。真正能把数字压下去的不是再加一个Argo或Origin Shield,而是先把Cache Rules重写一遍、把Bypass名单收紧、把cf-cache-status接进监控。这篇把我们团队半年里调17家独立站缓存的8维决策树拆开讲:Cache Rules与Page Rules怎么迁、Bypass写错为什么会拖低SEO、Argo与Origin Shield各自适合什么型站、回源率监控怎么做才不只是看面板数字、以及最后的6个月把回源率从60%压到15%的真实清单。读完你能拿一张可执行的判定表,对照自己站点的Cloudflare配置打分。 ## 为什么独立站60%的回源率属于慢性病? 保哥团队这两年接的独立站性能咨询里,60%以上的站打开Cloudflare控制台看Analytics,回源带宽(Origin Bandwidth)占总带宽的比例普遍在40%=55%之间。换算成回源率(origin hit ratio)大约就是60%上下——也就是说每10个请求有6个真的回到了源站。多数站长看到这个数字第一反应是“我都开了Cloudflare还这样?”,第二反应是“是不是要加Argo或者升级到Business套餐?”,第三反应才是“要不要重新配一下Cache Rules”——顺序刚好反了。 慢性病的标志是它不会让站点崩,但持续抽走预算和性能。保哥跟踪过一家美妆DTC站,月GMV大约80万美金,源站放在AWS Lightsail 4 GB机型上。开Cloudflare Pro一年,源站带宽月支出从230美金降到140美金,看似省了40%。但同期我们让运维同学把回源请求按URL维度分组导出后发现,光是被忽视的sitemap.xml、static-assets子目录的图片、PDP页面的noscript加密资源,每月就要打回源站280万次——其中70%的请求源站给出的响应是200 OK内容上一次相同请求10分钟前刚返回过。这200万次重复回源占掉了源站35%的CPU时间,PDP平均TTFB从280 ms推高到540 ms,Lighthouse移动端LCP卡在3.8秒。 把这5类“被忽视的回源”一一关掉之后,源站CPU利用率从62%降到28%,移动端LCP拉到1.9秒。整个调优周期6周,没换一行业务代码,也没升级套餐。这就是我们常跟客户说的一句话:Cloudflare不是开了就有效,它默认行为偏保守是因为它要兼容全网各种千奇百怪的源站,独立站要把缓存吃满必须主动写规则、主动监控、主动收口。 第二个常被低估的代价是SEO间接影响。回源率高意味着源站经常被Googlebot和真实用户共同打到,源站慢就会让Googlebot抓取预算(crawl budget)下降。我们手上有一家B2B工业设备独立站,2025年3月一次Black Friday流量峰,源站撑不住返了12%的5xx,Search Console当周抓取请求数从28000跌到9400,PDP的索引数6周里跌了17%。事后复盘根因只有一句:Cloudflare缓存命中率不够,源站做了一次本来应该是边缘做的工。 ## Cache Rules和Page Rules怎么选? 2024年底Cloudflare正式把Page Rules标记为legacy,所有新功能(如基于URL/Header/Cookie的细粒度匹配、Edge TTL与Browser TTL分离、Cache Reserve接入)都只放在Cache Rules里。Cache Rules走的是Ruleset Engine,跟WAF / Transform Rules同一栈,规则可以最多50条、表达式语法支持in / contains / matches / regex、可以读ip.src / http.cookie / http.request.headers / http.request.uri.path这类完整请求维度。Page Rules老站点最多3=125条(按套餐),表达式只支持简单的url通配符。 但Cache Rules不会自动迁移老Page Rules。我们实操过的17家站里有11家是5=7年的老站,Page Rules写了80=120条堆得乱七八糟(很多还是前任运维的“防御性配置”,比如cache everything + edge cache TTL 30天却没排除cart/account/checkout)。直接迁移的话工作量大概是1条Page Rule对应1=3条Cache Rule(因为Cache Rules把cache行为拆得更细:是否缓存、TTL、缓存键、Origin Cache Control等)。 我们建议的迁移路径是分三步走。第一步是冻结:把现有Page Rules全部截图存档、关掉一半看哪些是真的在生效(Cloudflare控制台Rules标签可以看“最近匹配次数”这个字段)。我们实操经验是平均会有40%的Page Rules一个月匹配次数为0,可以直接清掉。第二步是分桶:把剩下的规则按用途分成5桶——纯缓存策略、Bypass排除、Header改写、Redirect、安全相关——只有前两桶迁到Cache Rules,后三桶分别迁到Transform Rules / Bulk Redirects / WAF。第三步是重写:用Cache Rules的表达式语法把前两桶重新写一遍,并把Edge TTL和Browser TTL分开设置(Page Rules老规则常常把两者绑成同一个值)。 分开TTL这一点是Page Rules时代干不到的红利。我们的标准配置是PDP / PLP页面Edge TTL设4小时(足够吸收正常流量)、Browser TTL设0(让浏览器每次都来问Cloudflare而不是凭自己缓存判断,避免促销改价后用户看到旧价)。这样做的代价是边缘命中率会比统一1天的方案略低,但避免了SKU改价后8小时内浏览器还在显示老价的客诉灾难——我们客户里因此把客诉率压低过30%。 关于Edge TTL与浏览器Cache-Control的优先级机制,更系统的拆解可以查HTTP响应头SEO机制 (https://zhangwenbao.com/http-response-headers-seo-x-robots-cache-vary-canonical-mechanism.html)。Cache Rules唯一的硬约束是Free套餐只能用10条、Pro套餐25条、Business套餐50条。独立站如果在Free套餐想吃Cache Rules,需要把规则尽量合并(用in / regex表达式合并)。我们见过一家做户外装备的独立站把38条Page Rules重写成8条Cache Rules,匹配覆盖率反而比原来更全。Cloudflare官方的 Cache Rules文档 (https://developers.cloudflare.com/cache/about/cache-rules/)里有完整的表达式语法对照。 ## Bypass规则怎么写才不破SEO? Bypass是Cache Rules里最容易写错的一类——写得太松,登录态、购物车、checkout这些动态页被错误缓存导致用户串号;写得太紧,PDP / PLP上本该缓存的内容被排除导致回源率涨。我们见过的最离谱案例是一家Shopify+Headless架构的独立站,前任工程师写Bypass用了url contains “user”,结果把 /products/user-friendly-tent这条SKU整页Bypass了4个月,PDP回源率100%。 正确的Bypass表达式要走严格path匹配。我们的标准模板是: (http.request.uri.path eq "/cart") or (http.request.uri.path eq "/checkout") or (starts_with(http.request.uri.path, "/account/")) or (starts_with(http.request.uri.path, "/api/")) or (http.request.uri.path contains "/wp-admin/") or (http.cookie contains "wordpress_logged_in") or (http.cookie contains "woocommerce_cart_hash") or (http.request.method in {"POST" "PUT" "DELETE" "PATCH"}) 这里有4个细节坑值得拆。第一个是starts_with与contains的区别。对路径前缀用starts_with比contains更安全,因为contains会把 /products/account-manager这种正常SKU匹配进来。第二个是非幂等HTTP method的处理。POST/PUT/DELETE/PATCH默认就是bypass,但写在Cache Rules里能避免缓存键被错误生成。第三个是Cookie维度的Bypass。登录态、购物车hash这类带状态的Cookie必须明确列出,否则Cloudflare默认不会读Cookie决定是否缓存(除非启用了Cache Reserve的Cookie-aware模式)。第四个是query string的处理。带utm_source / fbclid / gclid的URL默认会被当成不同缓存键,我们的标配是在Cache Rules里加Cache Key → Query String → Ignore(保留sort / page / size这类影响内容的参数,忽略其余)。 对SEO影响最大的是第四个Cache Key处理。如果不忽略追踪参数,同一个PDP页面会因为不同utm来源产生几十上百个独立缓存键,回源率被人为推高、Googlebot抓取同一个页面也会因为来源不同而被当成多个URL来评估。我们实操中见过一家美妆站把Cache Key配好后,回源率单项下降12个百分点,Googlebot的robots.txt抓取错误率也从4%降到0.6%。 Bypass规则改完一定要做一次完整爬测。我们团队用的工具是Screaming Frog设50并发跑全站、配合cf-cache-status头观察每个URL的缓存状态。理想状态是非Bypass路径里90%+的URL第二次抓取都返回HIT或REVALIDATED,Bypass路径里100%返回DYNAMIC或BYPASS。如果出现MISS偏多,说明Cache Rules没把cache行为强制成Eligible for cache,或者Origin给出的Cache-Control头是no-store / private把Cloudflare的缓存指令覆盖掉了。 ## Argo Smart Routing什么场景值得开钱? Argo Smart Routing是Cloudflare的付费加速产品,按1GB流量大约0.1=0.15美金计费(套餐内有免费额度)。它做的是“边缘到源站”那段链路的智能路由——也就是说Cloudflare数据中心收到一个回源请求后,不走BGP默认路径,而是走它自己学习出来的最低延迟路径。官方宣传是平均能把回源TTFB降低30%。我们的实测结论是:这个数字只在源站离Cloudflare主要数据中心远、且回源比例高的站才成立。 判断要不要开Argo的决策树是3个问题。第一问:你的源站在哪?如果源站在主要城市(北美的弗吉尼亚、欧洲的伦敦法兰克福、亚洲的东京新加坡),离Cloudflare的anycast中心本来就近,Argo收益普遍只有10%=15%。如果源站在二线区域(比如AWS的us-east-2 / ap-east-1 / sa-east-1)或者用了非主流云(如阿里云华东、腾讯云广州),Argo收益能到25%=40%。第二问:你的回源比例多高?如果通过前面Cache Rules调优已经把回源率压到30%以下,Argo加速的绝对量很小,付出去的流量费可能比省下的TTFB更亏。第三问:你的流量峰是不是来自全球?如果80%的流量来自单一国家(比如纯US市场或纯德国市场),Argo的多路径优势没什么发挥空间。 我们手上有一个反例:一家做户外露营装备的DTC站,源站在新加坡,市场覆盖东南亚 + 澳洲 + 北美西海岸。开Argo前回源TTFB平均380 ms,开Argo后240 ms,下降36%。月度Argo费用47美金,省下来的TTFB让PDP LCP从2.6秒降到2.0秒,SEO排名上有14个核心词从第二页进了第一页。这是开Argo真有ROI的典型场景。 同期我们也劝退过一家美国本土的女装DTC:源站在AWS us-east-1,95%流量来自美国本土,Argo实测只把TTFB从95 ms降到82 ms,下降13 ms。月费38美金换13 ms的提升,纯属浪费。这家站后来把这笔预算转给了Origin Shield,效果好得多。Argo的产品定位见Cloudflare的 Argo Smart Routing文档 (https://developers.cloudflare.com/argo-smart-routing/)。 ## Origin Shield适合什么类型的独立站? Origin Shield(在Cloudflare体系内对应的产品是Tiered Cache)的逻辑是:在Cloudflare全球200+数据中心和你的源站之间,加一层“中央汇聚节点”。任何边缘缓存miss的请求都先走中央节点,由中央节点统一回源。这样做的好处是同一个URL的缓存只回源1次而不是200次,对源站的回源压力可以下降60%=80%。 Origin Shield不是万能。它的甜区是3类站。第一类是流量分布全球但SKU集中的站。比如卖单品的DTC站、卖单一型号产品的众筹后续站,全球各地用户访问同一个PDP,Tiered Cache把回源压成1次最划算。第二类是源站性能瓶颈在并发数而非带宽的站。比如WordPress + WooCommerce的中小独立站,源站PHP-FPM并发上限是50=80,开Tiered Cache后并发回源数能从峰值200降到30,源站不再502。第三类是源站带宽费贵的站。比如源站放在AWS但用了流量出口(egress)昂贵的区域,Tiered Cache把回源次数压一压能直接省钱。 不适合的反面2类。第一类是SKU量极大(10万+)且长尾流量分散的站。这种站每个PDP在中央节点也很难命中,Tiered Cache的“汇聚效应”发挥不出来,徒增一跳延迟。第二类是源站本身就是multi-region多活架构的站。已经有自己的边缘汇聚层,再叠Cloudflare Tiered Cache会把延迟路径拉长。 开Tiered Cache之前一定要先看Cloudflare Analytics里的“Cache Hit Ratio by Origin”这个图表。如果同一个URL的回源次数在过去24小时里超过50次,说明边缘没起到合并作用,开Tiered Cache才有意义。我们见过一家做小众香水的独立站,全球SKU一共8个但流量分散到47个国家,开Tiered Cache一周后源站回源QPS从平均12降到3,PHP-FPM 502错误率从1.4%降到0.1%。这就是Tiered Cache解决并发瓶颈的典型场景。Tiered Cache的工作机制详见 Cloudflare分层缓存文档 (https://developers.cloudflare.com/cache/about/tiered-cache/)。 ## 真实回源率怎么监控才有效? Cloudflare Analytics面板里那个“Cache Hit Ratio”大数字是聚合值,看不出哪些URL在拖后腿。要做有效监控,必须把维度拆细,并且把数据接到自己的可观测系统里。我们团队的标配是3层监控。 第一层是cf-cache-status头实时采集。所有边缘响应都会带这个header,值有HIT / MISS / DYNAMIC / BYPASS / REVALIDATED / EXPIRED / STALE / UPDATING几种。在前端注入一段轻量JS,把每次fetch的响应头里的cf-cache-status上报到自家的GA4或Plausible自定义事件。这样能按页面、按设备、按地区拆分缓存命中情况。我们客户里见过的最常见问题是PDP在桌面端HIT率92%,移动端只有64%——原因是移动端的viewport探测脚本注入了一个动态请求被错误打回源站。 第二层是Logpush + 数据仓库。把Cloudflare的访问日志push到S3 / BigQuery / Snowflake(Business套餐起支持Logpush)。Logpush的字段里有CacheCacheStatus这一列,加上ClientRequestPath、ClientRequestUserAgent等维度,可以跑出更精细的报表。我们团队的常规分析是按path prefix分组算回源率,把回源率>80%的path拉出来当成下个迭代周期的优化清单。这一层对工具链要求高,独立站站长可以先跳过、用第一层的GA4数据顶一段时间。 第三层是Cloudflare Workers Analytics Engine。这是Cloudflare自家的轻量时序数据库,可以在Worker里直接写入指标、用GraphQL查询。我们团队用它做的是“热门URL回源监控”——每天列出回源次数Top 100的URL,自动跑诊断脚本看是Cache Rules配置问题、源站Cache-Control头问题、还是Cookie维度问题。这种工程化监控的成本是要写100=300行Worker代码——保哥团队用Worker做SEO场景的边缘改稿与实验分流的实战拆解,可以查边缘SEO是什么 (https://zhangwenbao.com/edge-seo-cdn-worker-no-deploy-implementation.html)那篇,但能把缓存调优从“一年大调一次”变成“每周自动优化”。 对独立站站长来说,第一层是必须做的最低标准,第二层是月GMV 50万美金以上推荐做,第三层是有专职运维的团队再考虑。我们手上有一家月GMV 200万美金的家居DTC站,跑齐3层监控后,6个月里回源率从58%降到19%。源站从8核16G降配到4核8G,月度账单省了340美金,比Cloudflare三层付费产品加起来还多。这种把监控做到极致的ROI,是Cloudflare官方面板看不出来的隐藏红利。把缓存命中率从CDN视角延伸到SEO维度,可以对照CDN对SEO的影响与边缘路由实战 (https://zhangwenbao.com/cdn-cache-configuration-seo-impact-edge-routing-complete-guide.html)那篇里的缓存层级图。HTTP缓存的标准语义可以参考 web.dev的HTTP Caching介绍 (https://web.dev/articles/http-cache)。 ## WAF拦截规则和缓存命中如何不打架? WAF和缓存看似两个独立产品,实际在Cloudflare的请求处理管线上是连环的——WAF规则先跑、命中规则后的请求才进入缓存决策。这意味着WAF写得不好会直接把本应缓存的请求拉成Bypass。我们见过的最常见问题是3类。 第一类是Country block写得太粗。很多独立站只面向北美 + 欧洲,会写(ip.geoip.country ne “US” and ip.geoip.country ne “CA” and ...) block。但Googlebot的IP段在google-ip-ranges列表里跨多个国家,被block后Googlebot抓取直接0。我们的标配是先allow已知爬虫(cf.client.bot eq true或cf.verified_bot_category in {“Search Engine Crawler”}),再走地理限制。 第二类是Rate limiting触发缓存Bypass。启用Rate Limiting规则后,Cloudflare会对触发的请求加一个special-treatment标记,这个标记会让后续的Cache Rules默认Bypass。所以Rate Limiting的阈值不能定得太严——比如对PDP设10 req/min/IP看似合理,但Googlebot抓站时20秒内可能就100+ requests来自同一IP,触发后整段抓取的缓存命中归零。我们建议PDP和PLP这种公开页Rate Limit不要 < 100 req/min/IP,或者干脆只对 /checkout / /account这种带状态的path加Rate Limit。 第三类是Bot Fight Mode误伤。Pro套餐自带的Bot Fight Mode默认会对“疑似自动化”的请求做挑战,包括一些低score的真实用户和某些版本的爬虫。挑战页本身不能被缓存,所以这部分流量直接拉低命中率。我们的处理是关掉默认Bot Fight Mode、改用Super Bot Fight Mode(Business套餐起,可以精细到likely automated / definitely automated两档分别处理),或者在WAF里手工加一条cf.bot_management.score lt 30的challenge规则。 WAF和缓存联调的验证方法是看cf-cache-status头的BYPASS比例。健康的站BYPASS应该只出现在明确写Bypass的path(cart/checkout/account/api),如果发现首页或PDP也出现BYPASS,大概率是WAF的某条规则在打断缓存。我们团队的诊断流程是:先在Cloudflare控制台的Security Events里拉过去24小时按path分组的拦截记录,再跟cf-cache-status的BYPASS路径做交叉对比,能快速锁定哪条WAF规则在干扰缓存。 ## 6个月把回源率从60%压到15%的真实操作清单 下面这张清单是我们团队2025年1月=2025年7月给一家月GMV 120万美金的母婴DTC独立站做缓存调优的真实复盘。源站是WordPress + WooCommerce,部署在AWS Lightsail 8 GB实例上,Cloudflare用Business套餐(开了Cache Rules + Tiered Cache,没开Argo)。起步回源率61%,6个月后15.4%。每步动作背后都对应了一个可量化的回源率改进。 第1周=Cache Rules重写。把老Page Rules 80条全清,按5桶逻辑(缓存策略 / Bypass / Header / Redirect / Security)拆出12条Cache Rule。其中4条核心:PDP缓存4小时、PLP缓存2小时、首页缓存30分钟、静态资源缓存30天。回源率61%降到48%。 第3周=Cache Key优化。追踪参数(utm_xxx / fbclid / gclid / msclkid等共14个)在Cache Key里Ignore,business参数(sort / page / size / variant)保留。回源率48%降到39%。 第5周=Bypass规则收紧。把老Page Rules里contains “user” 改成starts_with(“/account/”) + cookie检测,PDP命中率立刻上来。回源率39%降到32%。 第8周=Tiered Cache开启。选Smart Topology(Cloudflare自动选最优中央节点)。源站回源QPS平均值从8降到3。回源率32%降到26%。 第11周=源站Cache-Control头修正。WordPress默认wp-content/uploads下的图片返回Cache-Control: public, max-age=0, must-revalidate,我们让运维改成max-age=31536000, immutable。配合Cloudflare的Respect Origin Cache-Control,静态资源回源量降到0。回源率26%降到21%。 第14周=WAF规则梳理。关掉默认Bot Fight Mode、加Super Bot Fight Mode精细控制、调高PDP的Rate Limit阈值。BYPASS比例从8.2%降到1.4%。回源率21%降到17%。 第18周=Workers优化注水接口。WooCommerce默认有一个 /wp-json/wc/store/cart接口在PDP上会被前端JS周期性调用,把它从PDP模板里干掉、改成只在cart页加载。回源QPS再降30%。回源率17%降到15.4%。整段性能优化的工程化思路与WooCommerce性能优化6层架构 (https://zhangwenbao.com/woocommerce-performance-6-layer-lcp-core-web-vitals-real-path.html)那篇拆解的6层路径是同一套底层方法论,缓存只是其中最外的一层。 第22周=复盘与监控固化。把以上动作沉淀成SOP,cf-cache-status上报 + 周报自动化、回源率>20%触发Slack告警、每月跑一次Top 100回源URL诊断。SOP跑稳之后回源率保持在15%=17%区间稳定。 这个项目的隐性收益其实比回源率数字更值钱。源站PHP-FPM 502错误率从2.1%降到0.08%,PDP LCP从3.4秒降到1.7秒,Google Search Console抓取错误数从月均2300降到180,Black Friday当天源站CPU没破60%(去年峰值96%)。所有这些指标都没靠加机器、没换框架——只是把Cloudflare当成一个产品而非一个工具去运营。 ## 独立站站长常犯的3个Cloudflare缓存认知误区 我们做了17家站的缓存调优咨询,发现独立站站长普遍有3个根深蒂固的误区,纠正这些误区比加套餐管用得多。 误区1:Cloudflare缓存命中率90%就够了。错。90%是聚合值,分维度看可能差很多。一个站静态资源命中率99%、PDP命中率75%、PLP命中率85%,聚合下来确实是90%+。但PDP是流量大户,PDP每降低10%命中率源站回源QPS就翻倍。健康的目标是PDP/PLP/首页这3类核心页命中率都 > 90%、整站聚合 > 95%。 误区2:Cloudflare套餐升级就能解决缓存问题。错。Cloudflare套餐升级带来的是Cache Rules条数上限、Logpush能力、Argo / Tiered Cache这些功能解锁,但不会自动帮你优化已有配置。我们见过的90%的“缓存效果不好”案例,根因都是Cache Rules没写好或Bypass写错,跟套餐无关。Free套餐认真配10条Cache Rules的命中率,往往比Business套餐随便配50条的命中率还高。 误区3:开了Cloudflare就不用管源站缓存。错。Cloudflare是Respect Origin Cache-Control的(除非你明确override),源站给的no-cache / no-store / private这些指令Cloudflare会尊重。WordPress / Magento / Shopify的默认Cache-Control头很多场景是偏保守的,必须在源站层(nginx / Apache / 应用层中间件)也做一次梳理。我们的标准做法是PDP / PLP在源站层就返回public, s-maxage=14400, max-age=0这种语义(边缘缓存4小时、浏览器不缓存),让Cloudflare的Cache Rules和源站Cache-Control互相印证而不是互相覆盖。 独立站缓存这件事,工程上不复杂——10条Cache Rules + 严谨的Bypass + cf-cache-status监控基本就能做到90%的效果。难点是观念:必须把Cloudflare当成一个需要持续运营的产品、而不是“开了就忘”的工具。CDN的边缘缓存原理可以参考 维基百科Content delivery network词条 (https://en.wikipedia.org/wiki/Content_delivery_network),HTTP缓存的语义标准可以查 MDN的Cache-Control文档 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)。 ## 常见问题解答 ## Cloudflare Cache Rules和Page Rules能同时存在吗? 能。Cloudflare的请求处理管线里Cache Rules优先级高于Page Rules,同一个URL如果两边都匹配,Cache Rules的指令生效。但我们建议迁完Cache Rules后把对应的Page Rules关闭——保留双份规则会让后续排查问题变得很难,6个月后没人记得规则之间的优先级和组合效果。 ## 独立站要不要把整站设成Cache Everything? 不要无脑设。Cache Everything的语义是“忽略源站Cache-Control,强制按Edge TTL缓存”,对PDP / PLP这种内容相对稳定的页面有效,但对带登录态的页面是灾难。我们的标准做法是只对明确的页面类型用Cache Everything(通过path表达式精确锁定),其他保持Standard模式让源站的Cache-Control主导。 ## 开Tiered Cache会不会增加TTFB? 边缘命中的请求不受影响(直接边缘返回,根本不到Tiered这一层);边缘miss但中央节点命中的请求TTFB比直接回源略低(中央节点更近源站、且常常已缓存);边缘和中央都miss的请求TTFB会多30=80 ms(多了中央节点这一跳)。所以整体TTFB是降是升取决于命中率分布。回源率 > 30%的站开Tiered Cache通常TTFB净降,回源率 < 15%的站开了反而略升。 ## Argo Smart Routing和Tiered Cache能一起开吗? 能。Argo是优化回源链路的延迟,Tiered Cache是减少回源次数,两者解决的问题正交。但同时开的预算开销不小(Argo按流量、Tiered Cache含在Business套餐里),我们建议先开Tiered Cache把回源次数压下来再评估Argo的边际收益。多数独立站只开Tiered Cache就够了。 ## cf-cache-status显示DYNAMIC是什么意思? DYNAMIC表示这个URL默认被Cloudflare判定为不可缓存(通常是因为HTTP method是POST/PUT/DELETE,或者URL路径在Cloudflare内置的default-non-cacheable列表里如 /wp-admin /admin /login)。如果你期望它被缓存,需要在Cache Rules里显式把cache行为设成Eligible for cache + 指定Edge TTL。仅写一条path匹配但不改cache行为,URL仍然会显示DYNAMIC。 ## Cloudflare缓存调优会不会影响Google抓取频率? 会,而且通常是正向影响。Googlebot看到源站响应快(边缘命中的TTFB在50 ms以内)会逐步增加抓取预算。我们跟踪过的项目里,缓存调优6个月后Search Console的“每日抓取请求数”平均上涨35%=60%,相应索引数也跟着涨。但有一个反向情况要注意:如果Bypass规则错误地把sitemap.xml或robots.txt排除掉、源站又慢,Googlebot抓取这两个关键文件失败会直接掉抓取频率。 ## 权威参考资料 独立站Cloudflare缓存治理是个高ROI但低关注的工程领域,配置写得好坏直接影响源站成本、SEO表现、用户体验三件事。保哥的建议是把它当成一个独立项目去运营、每3个月跑一次回源率审计、把cf-cache-status接入日常监控。把缓存命中率从70%推到95%这件事的边际成本是写规则的人力,边际收益是源站成本下降 + 抓取预算上升 + LCP改善,复利会持续很久。下次预算紧张要降运维成本前,先看看Cloudflare控制台的回源率数字,这是最不容易被注意但最值钱的杠杆之一。