你的CDN没生效,可能是PHP在响应头里替你写了一行1981年的日期
本文目录
- 那行1981年的日期到底是谁写进去的?
- 一句session_start换来三个头
- 这句话是在你不知情的时候说出去的
- 先拿已知答案的样本,校准这台探测器
- 它跟着页面走,还是跟着站走?
- 按“这一页本该不该进缓存”分层取样
- 四层的数字几乎是平的
- 120个PHP站里中招的是哪些,成熟平台为什么全身而退?
- 先把分母说清楚
- 结果是12个站,一成
- 全有全无,不是慢慢腐烂
- 成熟平台为什么一个都没中招
- 页面自己会不会证明它本可以被缓存?
- 连打12次,比对字节指纹
- 顺手排除一种解释:不是给爬虫特殊准备的
- 发了no-store,边缘层就一定照做吗?
- 抓到过一次不该发生的HIT
- 但接下来96次一次都没复现
- 整体上,这个头是真的在生效
- 为什么第一次来的人反而拿不到缓存?
- 把Cookie带回去,头就消失了
- 顺便纠正一个常见的错判据
- 对照组:非PHP站一个都没有
- 这个头在SEO上究竟让你损失了什么?
- 第一层:TTFB变成了永远的回源
- 第二层:你花的钱买了个寂寞
- 第三层:反过来也不安全
- 顺便说说这个头的另一半:Pragma
- 怎么十秒钟判断自己的站中没中招?
- 一条命令
- 但只查首页会漏
- 看懂三种结果
- 关掉它的正确姿势,以及一个容易搞反的顺序
- 先问一句:这一页真的需要会话吗
- 如果会话确实要开,就显式指定缓存策略
- 别在Web服务器层用覆盖来打补丁
- 保哥拿自己的站做了一遍,查出了什么
- 好消息:前台是干净的
- 坏消息:矛盾头的地雷已经埋好了
- 处置
- 一个给顾问客户的场景
- 如果只带走一句话
- 常见问题解答
- 那个1981年11月19日到底是什么日子?
- 我的购物车页面带这个头,需要修吗?
- 把session.cache_limiter设成空会不会有安全风险?
- 用Cloudflare的话,直接开忽略源站缓存头是不是更省事?
- 为什么用浏览器开发者工具看不到这个头,用curl却能看到?
- WordPress站中招率5%算高还是低?
- 权威参考资料
摘要:PHP只要执行到一句
session_start(),就会替这一页自动发出三个缓存头,其中一个是写死在源码里的1981年11月19日。这次扫了222个域名、筛出120个PHP站、打了508次分层请求,中招的有12个站,正好一成。但真正让人坐直的是分层数字:购物车4.8%、登录页2.9%,首页反而7.4%——这个头压根不看这一页该不该缓存。最刺眼的一组是三个中招站连打12次返回的字节完全一致,页面自己证明了它可以被缓存,头却坚持说谁都不许存。
先说这件事是怎么撞进来的。
那天在给一个站做缓存排查,抓响应头的时候,眼角瞟到一行东西:
Expires: Thu, 19 Nov 1981 08:52:00 GMT1981年11月19日,早上8点52分。精确到分钟。
第一反应当然是哪儿的时间戳算错了,溢出成了负数。但溢出通常给你1970年1月1日,不会给你1981年11月的一个周四早晨,还带着52分这种没法解释的零头。这个日期太具体了,具体到不像是错的,像是有人特意选的。
它确实是有人特意选的,而且写在PHP源码里,一躺就是二十多年。更要命的是,它出现在响应头里的时候,等于替这一页向全世界的缓存宣布了一句话:这份内容任何人不许存,包括你花钱买的那个CDN。
那行1981年的日期到底是谁写进去的?
答案很朴素:是PHP的会话机制自己加的,不需要你写任何一行相关代码。
一句session_start换来三个头
PHP有个配置项叫session.cache_limiter,管的是“开了会话的页面,该用什么缓存策略”。它的默认值是nocache。按官方手册的说法,它指定用于会话页面的缓存控制方法,缺省就是这一档。
而nocache这一档要发的头,手册里列得清清楚楚:
| cache_limiter取值 | 实际发出的响应头 | 后果 |
|---|---|---|
| nocache(默认) | Expires那个1981日期 +Cache-Control: no-store, no-cache, must-revalidate+Pragma: no-cache | 任何缓存层都不许存 |
| private | 同样带1981日期 +Cache-Control: private, max-age=(按cache_expire) | 只许浏览器自己存,共享缓存出局 |
| private_no_expire | Cache-Control: private, max-age=… +Last-Modified,不发Expires | 同上,但少一个自相矛盾的头 |
| public | Expires指向未来 +Cache-Control: public, max-age=… | 共享缓存可以存 |
| 空字符串 | 什么都不发 | 缓存策略完全交给你自己 |
注意第一行和第二行的关系。那个1981日期在两档里都出现,所以光看见Expires是判不出你落在哪一档的,必须一起看Cache-Control。这个细节后面会用到,因为实测里真的抓到了两种。
这句话是在你不知情的时候说出去的
关键在于触发条件有多低。你不需要写缓存相关的任何代码,只要这次请求的执行路径上有一句session_start(),头就发了。而session_start()可能藏在:
- 主题的
functions.php里,某个插件为了记住一个筛选状态加的 - 某个统计代码为了做去重访客加的
- 某个表单插件为了防重复提交加的
- 某个框架的引导文件里,全局无条件调的
- 你三年前为了调试临时加的,然后忘了
这些人加它的时候,想的都是“我要存个小状态”。没有一个人想的是“我要把这一页从所有缓存里踢出去”,但这确实是他们同时完成的动作。这也是这类问题最难查的地方——干这件事的代码,长得一点都不像在干这件事。
先拿已知答案的样本,校准这台探测器
要用一个新工具去测未知,得先拿它测一批你已经知道答案的东西。这条规矩是吃过亏才立下的,现在每次都先走一遍。
所以正式开测之前,在自己服务器上放了一对探针:一个第一行就是session_start(),另一个除了不调会话之外一模一样。再加两个必然为阴性的样本:一个静态文件,一个走整页缓存的前台页面。四个样本的正确答案我事先都知道。
[PASS] 阳性·自建 session 探针 期望 1981=True 实测=True
[PASS] 阴性·自建无 session 探针 期望 1981=False 实测=False
[PASS] 阴性·本站静态资源 期望 1981=False 实测=False
[PASS] 阴性·本站文章页 期望 1981=False 实测=False
校准结果:4/4 与已知答案一致顺带确认了服务器上的现状:PHP 8.3.31,session.cache_limiter读出来是nocache,session.cache_expire是180。2026年的PHP 8.3,这个默认值和二十年前一字未改。这不是遗留系统的问题,是当下每一台新装PHP的机器的问题。
校准通过,可以出门测别人了。顺便说,这次校准还意外捞出一条本站自己的毛病,放在最后一节讲,那条更丢人一点。
它跟着页面走,还是跟着站走?
开测之前保哥先写下了自己的预期,免得事后自己骗自己:购物车和登录页应该大面积中招,首页和文章页应该基本干净。理由听起来无懈可击——购物车本来就带会话,本来就不该缓存嘛。
这个预期错得相当彻底。
按“这一页本该不该进缓存”分层取样
取样的设计是这样的:对每个PHP站,按页面本身的缓存资格分四层去打,而不是随便抓几个URL。
- L1首页:必须可缓存,没有任何争议
- L2内容深层页:必须可缓存。链接是从首页HTML里原样抠出来的
href,不是我自己拼的路径 - L3搜索结果页:缓存价值有争议,但不至于no-store
- L4购物车与登录页:本来就不该缓存,放在这里当对照组
L2那条“原样抠href”的讲究不是洁癖。上一次做站点地图体检时,保哥用主机名加路径重新拼URL去请求,把自己拼错主机名记成了“站点在跳转”,凭空造出十个百分点的假阳性。要检查一份清单里的值对不对,就必须原样使用清单里的值,这种错只会让指标变难看,所以永远伪装成“发现了问题”。
四层的数字几乎是平的
| 层级 | 这一页本该不该缓存 | 带1981头的比例 |
|---|---|---|
| L1首页 | 必须可缓存 | 9/122=7.4% |
| L2内容深层页 | 必须可缓存 | 16/217=7.4% |
| L3搜索结果页 | 有争议 | 11/113=9.7% |
| L4购物车 | 本来就不该缓存 | 1/21=4.8% |
| L4登录页 | 本来就不该缓存 | 1/35=2.9% |
| 合计 | — | 38/508=7.5% |
把这张表念一遍:最该发no-store的登录页,是五层里发得最少的那一层;最不该发的首页,比它高出一倍半。
这说明这个头和“页面的缓存资格”之间没有关系。它不是一个关于页面的决定,是一个关于站点的决定。你的购物车缓不缓存,跟它发不发这个头是两件独立的事。
回过头看也合理:购物车之所以没中招,不是因为它守规矩,而是因为像Magento这样的成熟电商平台早就把这个默认值关掉了,改用自己那套缓存体系。而某个博客的首页之所以中招,是因为它的主题里有一句谁也说不清来历的session_start()。
120个PHP站里中招的是哪些,成熟平台为什么全身而退?
先把分母说清楚
整条管线是这样跑的:222个候选域名 → 202个可达 → 判出120个PHP站 → 对它们打508次有效的分层请求。判是不是PHP站用了四条并列判据:响应头里的X-Powered-By、会话类Cookie的名字、页面HTML里的平台指纹、以及这个1981头本身。
最后那条判据是测着测着才加上的。有个站四项指纹全不命中,却结结实实带着1981头——那就是Grav,一个扁平文件CMS,页面里不留任何常规痕迹。这个头本身就是一条相当可靠的PHP指纹,因为几乎没有别的技术栈会发这个日期。
结果是12个站,一成
120个PHP站里,至少有一页发这个头的是12个,10.0%。
这里要多说一句抽样的事。第一批150个候选,挑的全是叫得出名字的站——知名媒体、大插件厂商、老牌电商。这种池子天然偏向“配置被专业运维摸过”,用它推全网是要出洋相的。所以又单独跑了第二批72个中小站:个人博客、小工具站、地方论坛、中文PHP社区。
| 抽样批次 | 池子性质 | PHP站数 | 中招站数 | 比例 |
|---|---|---|---|---|
| 第一批 | 知名大站为主 | 77 | 7 | 9.1% |
| 第二批 | 中小站为主 | 45 | 5 | 11.1% |
| 合并 | — | 120 | 12 | 10.0% |
两个独立池子给出9.1%和11.1%,几乎叠在一起。这说明中招率和站点体量、和有没有专业运维,关系都不大。这不是个“小站才犯”的错误,也不是个“大站已经解决”的错误,它就是均匀地散在那儿。
全有全无,不是慢慢腐烂
12个中招站里,8个是整站全中——首页、内容页、搜索页,抓到哪页是哪页,一个不漏。只有4个是部分页面命中。
这个形状本身就是诊断信息。看到一个百分比,先看分布形状再看大小:摊得平平的,说明是数据随时间自然衰减,该建定期巡检机制;堆在少数几个对象上、而且每个对象内部要么全中要么全不中,说明是某个开关拨错了,改一次就能归零。
这两种毛病的治法完全相反,搞混了会在错误方向上砸进去大量精力,而且指标确实在缓慢改善,你还以为自己走对了路。这一次显然是开关问题——8个站全中,说明那句session_start()蹲在全局引导里,每个请求都路过。
成熟平台为什么一个都没中招
按平台拆开,画面就更清楚了:
| 平台 | 带1981头 | 比例 |
|---|---|---|
| Magento | 0/46 | 0.0% |
| Laravel | 0/15 | 0.0% |
| MediaWiki | 0/13 | 0.0% |
| Typecho | 0/8 | 0.0% |
| Joomla | 0/5 | 0.0% |
| Drupal | 0/4 | 0.0% |
| Shopware | 0/4 | 0.0% |
| Moodle | 0/4 | 0.0% |
| WordPress | 19/383 | 5.0% |
| 其他PHP(含Grav) | 11/17 | 64.7% |
| XenForo | 8/8 | 100% |
Magento这个0/46特别值得琢磨。Magento是PHP世界里最典型的重会话应用,购物车、报价单、客户段全靠会话撑着,按直觉它该是重灾区。结果一条都没有。因为它有一整套自己的缓存体系(这套体系怎么调,Magento 2的索引器与Varnish调优那篇讲过),而搭这套体系的人第一件事就是把PHP的默认缓存头摁掉。
所以结论不是“PHP有个坑”,而是:凡是认真做过缓存设计的平台,都显式关掉了这个默认;剩下中招的,是那些没人专门想过这件事的站。这个默认值本身不区分好坏,它只是忠实地暴露了“有没有人管过缓存”。
XenForo那个100%是另一种情况,它落在private档而不是nocache档——8条记录里的Cache-Control是private, no-cache, max-age=0,配着同一个1981日期。前面说过光看Expires分不出档位,这就是活例子。论坛软件默认按“每个人看到的内容都不同”设计,这个选择有它的道理,代价是共享缓存全程出局。
页面自己会不会证明它本可以被缓存?
到这儿会遇到一个很难反驳的质疑:你怎么知道这些页面本来就该缓存?万一人家页面真的每次都不一样呢?
这个质疑很对,而且它是可以用实验回答的,不用靠猜。
连打12次,比对字节指纹
方法很笨但很硬:对每个中招站的同一个URL连打12次,每次算一遍响应体的哈希,看出现几种不同的指纹。如果12次返回的字节完全一致,那这一页就是完全静态的,没有任何个性化内容,缓存它一点风险都没有。
| 站点 | 12次请求的内容指纹 | 说明 |
|---|---|---|
| gravityforms.com | 1种 | 字节完全一致 |
| cssigniter.com | 1种 | 字节完全一致 |
| siteground.com | 1种 | 字节完全一致 |
| getgrav.org | 11种 | 基本每次都变 |
| wpbeginner.com | 12种 | 每次都变 |
| problogger.com | 12种 | 每次都变 |
| xenforo.com | 12种 | 每次都变 |
| talk.plesk.com | 12种 | 每次都变 |
看前三行。连打12次,返回的字节一个比特都没变,而这三个页面全程坚持声明自己no-store。这不是“缓存价值有争议”,这是页面用自己的输出,把头上那句话给否了。
这三家的身份还让事情更有戏剧性:一个是老牌表单插件厂商,一个是主题厂商,还有一个是专门卖WordPress主机、把缓存加速印在首页上的托管商。第三家的首页十二次请求字节完全相同,同时告诉全世界这一页谁都不许存。这个组合值得盯着多看两眼。
后面五个站12次12种指纹,说明页面里确实有每次都变的东西——一次性令牌、时间戳、随机推荐位之类。它们发no-store至少不算离谱。但要注意因果:页面之所以每次都变,多半也是因为开了会话;这不是它需要no-store的理由,这是同一个原因造成的两个结果。
顺手排除一种解释:不是给爬虫特殊准备的
还有个可能:会不会这些站是看人下菜碟,给浏览器一套头,给爬虫另一套?
换成Googlebot的User-Agent又打了一轮,六个站六比六完全一致,头一模一样,状态码全是200。没有区别对待,爬虫拿到的就是这一份。
这是个阴性结果,但它有用——它砍掉了一整条“可能人家是故意的”的解释路线。做实测最怕的就是留着一个没排除的替代解释,然后在文章里含糊过去。
发了no-store,边缘层就一定照做吗?
规范上这事没得商量。RFC 9111对no-store的定义是:缓存不得存储该请求或响应的任何部分,也不得用该响应满足任何其他请求。用词是MUST NOT,最硬的那一档。
但规范说的是应该,实际跑的是配置。
抓到过一次不该发生的HIT
在对比实验里,对wpbeginner连打三次,拿到的是这个:
cf-cache-status: HIT Age: 24603
cf-cache-status: HIT Age: 24605
cf-cache-status: HIT Age: 24607三次全部命中,Age是24603秒,将近6小时50分钟。而且三次之间Age只涨了2和2,正好等于我两次请求之间的间隔——这说明计时的是同一份缓存副本,是真命中,不是碰巧。
一个发着no-store, no-cache, must-revalidate外加Pragma: no-cache的页面,在边缘节点上被冻了将近七个小时。
这在CDN那边不算bug,是个明摆着的开关。Cloudflare的缓存规则里有一项叫Ignore cache-control header and use this TTL,说明白了就是“完全忽略响应上的任何cache-control头,改用这里指定的时长缓存”。开了它,源站说什么都不算数。这类规则怎么设计才不翻车,Cloudflare缓存与回源率那篇拆过一遍。
但接下来96次一次都没复现
这里必须把话说完整,因为后面的数据不支持一个更强的结论。
为了确认这个现象,对8个中招站各打了12轮,合计96次请求。结果是:cf-cache-status全部是DYNAMIC,Age全部为空,一次HIT都没有。
差别在哪?96次请求的cf-ray显示,它们全部落在同一个机房。而抓到HIT的那次没记录节点。最合理的解释是:那次打到了另一个边缘节点,那个节点上恰好躺着一份六小时前的副本。
所以诚实的说法是这样的:这条no-store至少被某一个边缘节点无视过一次,有Age自洽递增的三连击为证;但在后续96次请求里无法复现。我没法说“CDN总是无视no-store”,也不能说“那次是噪声”。
这件事本身值一条经验:一个URL在CDN上的缓存状态不是一个值,是一个按节点分布的集合。你测一次拿到HIT或者MISS,测到的都只是“你这次打到的那个节点此刻的状态”。想判断边缘层到底听不听源站的话,就得多次、多时段、并且记下自己打到了哪个节点。只测一次就下结论,跟没测差不多。
整体上,这个头是真的在生效
抛开单点争议,把508条记录整体拉出来对照,趋势非常干净:
- 带1981头的38条:有缓存命中迹象的占
10.5% - 不带的470条:有缓存命中迹象的占
31.3%
差了整整三倍。所以这个头不是个装饰品,绝大多数缓存层老老实实照做了。你损失的缓存是真损失,只是偶尔会有一个节点阳奉阴违,让你连“至少它安全”这个安慰都拿不稳。
这就是最别扭的地方——两头都不落好。想要缓存的人,缓存被踢掉了;指望no-store保护隐私的人,又不能百分百指望它。
为什么第一次来的人反而拿不到缓存?
这一节的发现是整轮实验里最有意思的一个,因为它把损失精准地落在了最不该损失的那批流量上。
把Cookie带回去,头就消失了
实验很简单:先匿名请求一次,把响应里的Cookie收好,第二次请求原样带回去,看两次的头有没有区别。
| 站点 | 带回的Cookie | 首次请求 | 带Cookie再请求 |
|---|---|---|---|
| siteground.com | PHPSESSID等三个 | 带1981头 | 不带了 |
| xenforo.com | xfs2_csrf | 带 | 带 |
| getgrav.org | grav-site-… | 带 | 带 |
| problogger.com | PHPSESSID | 带 | 带 |
第一行是重点。siteground只对没有会话的访客发这组头——因为正是这次请求在给对方新建会话;老访客带着会话回来,这一页就恢复成可缓存了。
那么“没有会话的访客”都是谁?
- 每一个爬虫,它们不带Cookie,永远是新访客
- 每一个从搜索结果点进来的陌生人
- 每一个从社交媒体、从别人文章的链接过来的人
- 每一个换了设备、清了缓存、开了无痕的人
换句话说,唯一拿不到缓存加速的,正好是你最想留住的那批第一次见面的流量。而回头客——本来就熟门熟路、本来就最不在乎多等半秒的那批——倒是稳稳吃到了缓存。
这个分配方式的荒谬程度,大概相当于餐厅门口排队的新客人得等后厨现做,而熟客进门就有预备好的。
顺便纠正一个常见的错判据
很多人排查这类问题时,习惯看响应里有没有Set-Cookie: PHPSESSID来判断“这一页开没开会话”。这个判据是错的。
实测里,38条带1981头的响应中,同时下发PHP会话Cookie的只有12条,31.6%。剩下将近七成,头照发,Cookie不发。
道理不难懂:缓存头是每次请求都发的,Cookie只在新建会话那一次发。所以看见Cookie能确定开了会话,看不见Cookie什么都不能确定。要判断这一页有没有被会话机制波及,看的应该是缓存头本身。
对照组:非PHP站一个都没有
最后补一组反向验证。从候选池里挑出44个明确不是PHP的站,用同样的路径打了57次请求,统计同一个日期。
结果:0条,0.0%。
这一组的价值在于它排除了“所有站都这样”这种解释。上一次做机读端点审计时,保哥就吃过这个亏——原本以为发现了某个框架特有的重复内容灾难,加了个对照组一测,发现所有站都这德行,一整篇文章的立论当场作废。做实测之前先想清楚对照组该是什么,比事后补救便宜得多。这次对照组反过来加固了结论:这个日期确实是PHP独有的签名。
这个头在SEO上究竟让你损失了什么?
把机制讲完了,得算算账。这笔账分三层,越往下越隐蔽。
第一层:TTFB变成了永远的回源
最直接的损失是每一次访问都要穿透到源站。整页缓存本来是把PHP彻底跳过去的——请求到了Nginx就返回,不碰PHP-FPM,不碰数据库。带上no-store之后,这条捷径整个作废,每次都要走完整链路。
这件事对搜索的影响不是玄学。Google关于抓取预算的文档写得很直白:如果站点变慢、延迟升高或者响应时间变长,抓取上限就会往下调,Google抓得更少;反过来,如果站点响应稳定,响应时间(包括延迟和TTFB)保持稳定或改善,上限就会提高。
TTFB是怎么被多层缓存共同决定的,TTFB与Core Web Vitals那篇拆过每一层的贡献;而Nginx fastcgi_cache全页缓存那篇讲的正是被这个头废掉的那一层。
第二层:你花的钱买了个寂寞
第二层损失更让人肉疼一点:你为CDN、为整页缓存、为对象缓存付的钱,在这些页面上一分都没生效。
而且账单不会告诉你。CDN后台显示的缓存命中率会诚实地低下去,但没人会把“命中率低”和“某个插件三年前加了句session_start”联系起来。运维看到的是命中率不理想,于是去调TTL、调缓存键、加预热——全都是在源站已经喊了no-store的前提下调,怎么调都不会好。CDN边缘缓存的TTL分层与缓存键那套方法很有效,但前提是源站得允许缓存。
这里有个通用的判断:当你调了一堆参数却一点效果都没有的时候,先怀疑有没有一个更上游的开关把整件事否决了,而不是继续在下游加大力度。
第三层:反过来也不安全
第三层最微妙。假如你的判断是“这页确实有个性化内容,就该no-store”,那也别把安全感全押在这个头上。前面那次Age 24603的HIT已经说明,只要下游某一层开了忽略源站缓存头的开关,你这句话就落空了。
真要防个性化内容被缓存,靠的是缓存键设计和分层架构,不是靠在响应上写一句请求全世界配合的话。这跟之前审计机读端点时的结论是一回事:那些没有head标签的接口地址只能靠响应头表态,而响应头能不能被听见,取决于一整条你不完全控制的链路。
顺便说说这个头的另一半:Pragma
nocache档发的三个头里,Pragma: no-cache是最没用的一个。RFC 9111里对它的处置就一句话:由于Cache-Control的支持已经普及,本规范弃用Pragma。
它留在那儿纯粹是为了照顾HTTP/1.0时代的代理。所以你在响应里看到Pragma,基本可以当成一个年代标记——它和那个1981年的日期一样,都是这套默认值有多老的物证。
怎么十秒钟判断自己的站中没中招?
说了这么多,落到自己站上其实非常好查。查这个不需要装任何工具,也不需要进服务器。
一条命令
curl -sI https://你的域名/ | grep -iE 'expires|cache-control|pragma|set-cookie'看到Expires: Thu, 19 Nov 1981 08:52:00 GMT,就是中了。没有这一行,这一页就是干净的。
但只查首页会漏
实测里12个中招站有4个是部分页面命中,只查首页会漏掉三分之一。至少按这几类各查一个:
- 首页——中了说明会话蹲在全局引导里,整站都跑不掉
- 一篇正常文章页或商品页——你最靠它吃自然流量的那一类
- 搜索结果页——实测里比例最高的一层,9.7%
- 分类页或标签页——常有插件为了记住筛选状态开会话
- 任意一个带表单的页——表单插件是最常见的会话来源
看懂三种结果
- Expires是1981+Cache-Control含no-store:nocache档,整页缓存与CDN全部出局,最严重
- Expires是1981+Cache-Control是private:private档,浏览器还能存,共享缓存出局,中等
- 只有Cache-Control没有Expires:多半不是会话干的,去查你自己的Nginx或应用配置
还有一个容易踩的坑:如果你在浏览器里已经登录过、或者已经有会话Cookie,可能根本看不到这个头。siteground那个例子就是——带着Cookie去看是干净的。所以自查一定要用不带Cookie的干净请求,curl天然满足,用浏览器就得开无痕,并且别忘了刷新时它可能还带着旧Cookie。
顺带一提,如果查出来一切正常但缓存命中率还是低,那就是别的地方的事了,浏览器缓存头的配法和反向代理缓存那两篇各覆盖了一段链路。
关掉它的正确姿势,以及一个容易搞反的顺序
确认中招之后,修法分两种,取决于这一页到底需不需要会话。
先问一句:这一页真的需要会话吗
大多数情况的正解不是“把缓存头关掉”,而是让这一页压根别开会话。前台的文章页、分类页、商品列表页,绝大多数根本不需要服务端会话。
找出是谁开的会话,最省事的办法是在会话真正启动的地方打一个调用栈出来:
<?php
// 临时排查用:找出是谁调的 session_start
// 放在最早执行的位置,比如 WordPress 的 mu-plugins 里
register_shutdown_function(function () {
if (PHP_SESSION_ACTIVE === session_status()) {
error_log('[会话排查] 本次请求开了会话:' . $_SERVER['REQUEST_URI']);
}
});先确认哪些URL中招,再用debug_backtrace()在session_start的位置抓一次栈,通常几分钟就能揪出那个插件。查出来之后,能改条件就改条件——只在真正需要的路径上开会话,比如结账、后台、登录态相关的接口。
如果会话确实要开,就显式指定缓存策略
会话必须开、但这一页的内容对所有人都一样,那就明确告诉PHP别管缓存的事:
<?php
// 方式一:完全交给自己 / Nginx / CDN 决定
ini_set('session.cache_limiter', '');
session_start();
// 方式二:显式指定为可公开缓存,并给出时长
session_cache_limiter('public');
session_cache_expire(30); // 单位是分钟
session_start();这里有个顺序问题,搞反了就完全无效:session_cache_limiter()必须在session_start()之前调。官方手册专门强调过——缓存限制器在每次请求开始时都会被重置回配置文件里的默认值,所以每个请求都得重新调一次,而且要赶在会话启动之前。写在session_start()后面的那一行,是纯粹的心理安慰。
要全局改,就直接改配置:
; php.ini
session.cache_limiter =等号后面留空,就是不发任何缓存头。改完记得确认OPcache那边也刷新到位,OPcache的调优与失效那篇讲过配置改了不生效的几种情形;不同PHP版本之间这类默认值的差异,PHP 8.x版本选型那篇有对照。
别在Web服务器层用覆盖来打补丁
还有一种看起来很省事的修法:在Nginx里直接把这个头改掉。
这条路能走通,但有个陷阱,而且是在自己站上撞见的。add_header是追加不是替换——PHP发的no-store还在,你加的public, s-maxage=300也在,两句话拼进同一个头里,最后信谁由下游各自决定。这种自相矛盾的头在实测里也抓到过一例,某个知名前端站的登录页发的是:
Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private, s-maxage=2592000同一行里既说“绝对不许存”,又给共享缓存派了30天的寿命。这种头的行为完全取决于下游实现,属于薛定谔的缓存。真要在Nginx层处理,得用proxy_hide_header或fastcgi_hide_header把上游那个头先摘掉,再加自己的。相关配置里还藏着哪些静默副作用,Nginx配置的静默SEO副作用那篇专门盘过。
顺序上也有讲究,和之前处理feed索引那次一样:先确保新策略生效,再撤掉旧的保护,别让中间出现裸奔窗口。
保哥拿自己的站做了一遍,查出了什么
写别人容易,写自己费劲。这一节是这轮实验的自查部分,结果一半好一半不好。
好消息:前台是干净的
本站前台不开PHP会话,首页、文章页、静态资源三类样本全部阴性,整页缓存正常吃到。这部分符合预期。
坏消息:矛盾头的地雷已经埋好了
问题出在校准阶段那个阳性探针上。那个只有三行的测试脚本,返回的Cache-Control是这样的:
Cache-Control: no-store, no-cache, must-revalidate, public, max-age=0, s-maxage=300, stale-while-revalidate=86400
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache前半截是PHP发的,后半截是本站Nginx用add_header追加的。两层各写各的,谁也没覆盖谁,最后拼成一句自相矛盾的话。和上面那个前端站的登录页,是同一种病。
眼下没出事,只是因为前台没有任何一页开会话。但这意味着:今后任何一个插件、任何一次改动,只要在前台引入一句session_start,本站立刻就会开始输出这种矛盾头,而且线上不会有任何报错。
这就是那种典型的“风险没爆发不等于防护健全”的局面——没出事只是因为最后一道防线恰好还在。审计该数的是还剩几层,不是出没出过事。
处置
已经记进待修清单:把Nginx那条add_header Cache-Control改成先fastcgi_hide_header摘掉上游的头再追加,顺序按“先加新的、确认生效、再撤旧的”走。这类配置改动的自动化巡检,本站是拿cron把运维自动化那套脚本在跑的,加一条响应头断言进去成本很低。
另外,实验用的两个校准探针在测完之后已经从服务器上删掉了。临时文件放在web根目录还带着session_start(),这种东西留过夜是要出事的。
一个给顾问客户的场景
前段时间有个做欧洲户外装备的独立站客户,问题描述是“CDN买了大半年,命中率一直上不去,博客文章打开还是慢”。他们的运维已经把TTL调了三轮,缓存键也重新设计过。
实际查下来,是文章页模板里为了做一个“最近浏览过的商品”的小模块开了会话。那个模块在页面右下角,一个巴掌大,没几个人点。为了这个巴掌大的模块,整个博客目录失去了缓存资格。
解法也不复杂:那个模块改成前端用本地存储实现,服务端会话撤掉。这个案例值得记的不是修法,是它的形状——一个视觉上微不足道的功能,可以完整地否决掉一整层基础设施,而且中间没有任何一步会报错。这类问题在后端与SEO的协作里最常见,因为它跨在两个人的职责边界上,两边都觉得对方在管。
如果只带走一句话
那就是这句:缓存不是你配了就有的,是你配了、而且没有任何一层在你背后把它否决掉,才有的。
你在CDN后台看到的命中率,是整条链路协商之后的结果,不是你配置的结果。而这条链路里最不起眼的那个参与者,可能是二十多年前某个人留下的一个默认值,和一个1981年11月的周四早晨。
常见问题解答
那个1981年11月19日到底是什么日子?
它是写死在PHP会话模块源码里的一个常量,用途是给出一个必然属于过去的时间点,让所有缓存立刻判定这份响应已经过期。至于为什么偏偏选这一天、这个时刻,PHP官方文档里没有解释,坊间有几种说法但都没有权威出处,这里就不跟着传了。对排查来说,重要的不是它的来历,而是它作为一个精确到分钟的固定字符串,是全网最好认的PHP会话指纹之一。
我的购物车页面带这个头,需要修吗?
大概率不需要。购物车本来就该按用户区分,不进共享缓存是对的。这次实测里购物车层的命中率只有4.8%,是五层里最低的之一,说明大部分平台在这块做得没问题。真正要修的是那些本该可缓存却带了这个头的页面——首页、文章页、分类页、商品列表页。判据很简单:这一页给所有匿名访客看到的内容一样吗?一样就该修。
把session.cache_limiter设成空会不会有安全风险?
把它设成空,意思是PHP不再自动发缓存头,缓存策略改由你自己或Nginx、CDN决定。风险不在这个设置本身,在于改完之后有没有人接手做这个决定。如果改成空之后没人给敏感页面配缓存规则,那些页面就从“过度保护”变成了“完全没保护”。稳妥的顺序是:先在Web服务器或CDN层为登录态、账户、结账这些路径配好不缓存规则,确认生效,再去动PHP的默认值。
用Cloudflare的话,直接开忽略源站缓存头是不是更省事?
能解决表面症状,但它是把问题盖住而不是修好。开了这个开关之后,源站发什么头都不算数,包括那些你真心希望被尊重的no-store。等于为了让该缓存的能缓存,把不该缓存的保护也一起拆了。正确顺序是先在源站把头修对,再让CDN尊重源站。实在要用忽略开关,也务必配合路径条件,只对确认可公开缓存的目录生效。
为什么用浏览器开发者工具看不到这个头,用curl却能看到?
最常见的原因是浏览器已经带着会话Cookie了。实测里siteground就是这个情况:匿名请求带这个头,带着Cookie再请求就不带了。浏览器几乎不可能是一次干净的匿名请求,即使开了无痕也可能在你之前的操作里拿到过Cookie。所以自查一律用curl或者别的不带Cookie的方式,这也是为什么这次实验全程用脚本发匿名请求,而不是靠开发者工具看。
WordPress站中招率5%算高还是低?
横向看不算高——WordPress核心自己是不开PHP会话的,19/383这个比例基本可以归给主题和插件。但这个数字有个特点值得注意:它高度集中,不是均匀分布。中招的站往往是整站全中,因为那句session_start蹲在全局加载的位置。所以对单个站长来说,这不是一个百分之五的概率问题,而是一个查一下就知道的是非题——你的站要么基本干净,要么整站中招,中间状态很少见。
权威参考资料
本文标题:《你的CDN没生效,可能是PHP在响应头里替你写了一行1981年的日期》
本文链接:https://zhangwenbao.com/php-session-cache-limiter-nocache-cdn-audit.html
版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0
← 上一篇
同一根手指要负责翻页、握住手机和下单,而你的界面只认得最后那一种下一篇 →
没有了