你的CDN没生效,可能是PHP在响应头里替你写了一行1981年的日期

你的CDN没生效,可能是PHP在响应头里替你写了一行1981年的日期
张文保 35 分钟阅读 1,258 阅读
本文目录
  1. 那行1981年的日期到底是谁写进去的?
  2. 一句session_start换来三个头
  3. 这句话是在你不知情的时候说出去的
  4. 先拿已知答案的样本,校准这台探测器
  5. 它跟着页面走,还是跟着站走?
  6. 按“这一页本该不该进缓存”分层取样
  7. 四层的数字几乎是平的
  8. 120个PHP站里中招的是哪些,成熟平台为什么全身而退?
  9. 先把分母说清楚
  10. 结果是12个站,一成
  11. 全有全无,不是慢慢腐烂
  12. 成熟平台为什么一个都没中招
  13. 页面自己会不会证明它本可以被缓存?
  14. 连打12次,比对字节指纹
  15. 顺手排除一种解释:不是给爬虫特殊准备的
  16. 发了no-store,边缘层就一定照做吗?
  17. 抓到过一次不该发生的HIT
  18. 但接下来96次一次都没复现
  19. 整体上,这个头是真的在生效
  20. 为什么第一次来的人反而拿不到缓存?
  21. 把Cookie带回去,头就消失了
  22. 顺便纠正一个常见的错判据
  23. 对照组:非PHP站一个都没有
  24. 这个头在SEO上究竟让你损失了什么?
  25. 第一层:TTFB变成了永远的回源
  26. 第二层:你花的钱买了个寂寞
  27. 第三层:反过来也不安全
  28. 顺便说说这个头的另一半:Pragma
  29. 怎么十秒钟判断自己的站中没中招?
  30. 一条命令
  31. 但只查首页会漏
  32. 看懂三种结果
  33. 关掉它的正确姿势,以及一个容易搞反的顺序
  34. 先问一句:这一页真的需要会话吗
  35. 如果会话确实要开,就显式指定缓存策略
  36. 别在Web服务器层用覆盖来打补丁
  37. 保哥拿自己的站做了一遍,查出了什么
  38. 好消息:前台是干净的
  39. 坏消息:矛盾头的地雷已经埋好了
  40. 处置
  41. 一个给顾问客户的场景
  42. 如果只带走一句话
  43. 常见问题解答
  44. 那个1981年11月19日到底是什么日子?
  45. 我的购物车页面带这个头,需要修吗?
  46. 把session.cache_limiter设成空会不会有安全风险?
  47. 用Cloudflare的话,直接开忽略源站缓存头是不是更省事?
  48. 为什么用浏览器开发者工具看不到这个头,用curl却能看到?
  49. WordPress站中招率5%算高还是低?
  50. 权威参考资料

摘要: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 GMT

1981年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_expireCache-Control: private, max-age=… +Last-Modified,不发Expires同上,但少一个自相矛盾的头
publicExpires指向未来 +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读出来是nocachesession.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站数中招站数比例
第一批知名大站为主7779.1%
第二批中小站为主45511.1%
合并1201210.0%

两个独立池子给出9.1%和11.1%,几乎叠在一起。这说明中招率和站点体量、和有没有专业运维,关系都不大。这不是个“小站才犯”的错误,也不是个“大站已经解决”的错误,它就是均匀地散在那儿。

全有全无,不是慢慢腐烂

12个中招站里,8个是整站全中——首页、内容页、搜索页,抓到哪页是哪页,一个不漏。只有4个是部分页面命中。

这个形状本身就是诊断信息。看到一个百分比,先看分布形状再看大小:摊得平平的,说明是数据随时间自然衰减,该建定期巡检机制;堆在少数几个对象上、而且每个对象内部要么全中要么全不中,说明是某个开关拨错了,改一次就能归零。

这两种毛病的治法完全相反,搞混了会在错误方向上砸进去大量精力,而且指标确实在缓慢改善,你还以为自己走对了路。这一次显然是开关问题——8个站全中,说明那句session_start()蹲在全局引导里,每个请求都路过。

成熟平台为什么一个都没中招

按平台拆开,画面就更清楚了:

平台带1981头比例
Magento0/460.0%
Laravel0/150.0%
MediaWiki0/130.0%
Typecho0/80.0%
Joomla0/50.0%
Drupal0/40.0%
Shopware0/40.0%
Moodle0/40.0%
WordPress19/3835.0%
其他PHP(含Grav)11/1764.7%
XenForo8/8100%

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.com1种字节完全一致
cssigniter.com1种字节完全一致
siteground.com1种字节完全一致
getgrav.org11种基本每次都变
wpbeginner.com12种每次都变
problogger.com12种每次都变
xenforo.com12种每次都变
talk.plesk.com12种每次都变

看前三行。连打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.comPHPSESSID等三个带1981头不带了
xenforo.comxfs2_csrf
getgrav.orggrav-site-…
problogger.comPHPSESSID

第一行是重点。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_headerfastcgi_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

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交