安全公告 PHP 严重安全漏洞:请及时升级至修复版本 查看详情

PHP 处理请求时发生了什么,为什么状态总会重置 ​

计数器每次请求都重置为 1,缓存始终无法命中。这并非程序缺陷,而是 PHP 的无共享模型在起作用,真实请求可以证明这一点。

有一类问题,几乎每位 PHP 开发者初次遇到时都会感到困惑。有人用全局变量统计请求次数,或用静态属性缓存开销较大的计算结果,或设计一个单例来保存状态。这些代码在测试时表现正常,上线后却毫无效果。计数器始终无法超过 1,缓存从未命中,本应保存状态的单例在每次请求中都处于全新、空白的状态。程序没有出错,也没有抛出异常,代码看起来显然没有问题,却始终无法记住任何内容。

这其实并不是程序缺陷,而是理解 PHP 运行方式时最重要的一点。许多开发者使用 PHP 多年,却从未有人明确向其解释过这一点:PHP 采用无共享模型(shared-nothing)。每个请求都从完全空白的状态开始,从头执行脚本,然后在发送响应时丢弃一切,包括所有变量、对象、静态成员和全局变量。下一个请求重新开始,仿佛前一个请求从未发生过。一旦理解这一点,一整类令人困惑的行为都会变得清晰,编写 PHP 代码时也就能够顺应这一模型,而不是与之对抗。

下面沿着请求的执行过程,看看从请求到达直到内存被清理,究竟发生了什么。以下所有示例都通过向实际运行的 PHP 服务器发送真实、独立的请求进行了验证,展示的输出均为实际返回结果,其中包括连续三次重置为 1 的全局计数器,以及每次请求之间都会丢失所存值的单例。

理解所有行为的核心模型 ​

在逐步分析之前,需要先理解核心模型,因为它解释了后续的一切。在许多语言的常见运行模式中,程序启动一次后便持续运行,接连处理请求,同时将状态保留在内存中:计数器递增后的值会保留,创建的对象会继续存在。PHP 的传统运行模式,也是迄今最常见的模式,恰好相反。程序不会在请求之间持续运行,而是针对每个请求构建一个全新的运行环境,在其中执行脚本,发送输出,然后销毁整个环境。没有任何状态会延续到下一个请求的环境中。

这就是“无共享”:请求之间不共享内存,每个请求都是一座孤岛。这带来了巨大的优势,也是 PHP 长期以来能够良好扩展的重要原因。由于请求之间不共享任何状态,一个请求无法破坏另一个请求的状态;某个请求崩溃,也不会污染下一个请求。多个进程可以并行处理大量请求,而不会相互干扰。这一模型恰恰因为“健忘”而具备稳健性。其代价正是初学者容易困惑之处:任何需要在请求之间持久保存的内容,都必须存放在请求之外,例如数据库、缓存、会话或文件,因为 PHP 自身的内存每次都会被清空。

第一步,构建空白环境并准备好超全局变量 ​

请求到达时,PHP 会在一个全新的内存空间中启动脚本,其中没有遗留变量,也没有之前创建的对象。不过,这个空间并非完全空无一物:在第一行代码执行之前,PHP 就已收集传入请求的相关信息,并将其整理到超全局变量中。当代码开始执行时,$_GET、$_POST、$_SERVER、$_COOKIE 等变量已经填充完毕,可以直接使用。在处理带有查询字符串的请求时,于脚本开头验证的结果如下:

php
$_SERVER['REQUEST_METHOD'];   // "GET"
array_keys($_GET);            // ['user', 'debug'] —— 已经解析完毕,可以直接使用

验证结果表明,在脚本第一行执行时,请求方法已经是 GET,查询参数 user 和 debug 也已经解析并存入 $_GET。这一点值得牢记,因为它决定了请求处理的方式。脚本并不是通过等待请求到来来“接收”请求。在代码执行之前,PHP 已经完成了解析,并通过这些超全局变量提供了解析结果。脚本的职责是读取现成的数据并生成响应。请求数据是代码执行前就已准备好的输入,而不是需要监听的事件。

第二步,脚本自上而下执行一次 ​

随后,脚本从第一行到最后一行执行一次。函数在被调用时运行,类在首次被引用时加载,控制流程按照通常的方式经过代码。验证结果显示,单次请求中的执行顺序与代码编写的顺序完全一致:

text
1. 脚本开头:超全局变量已填充完毕
2. 主体代码自上而下运行
3. 函数在被调用时执行
4. 请求状态仅存续至脚本结束

执行顺序本身并不令人意外,而这恰恰是重点。用于处理 Web 请求的 PHP 脚本,本质上就是一个从头运行到尾、生成一次响应的程序,就像命令行脚本从头运行到尾并产生输出一样。容易让人误入歧途的认知,是把脚本想象成一个会“持续开启”的长驻服务。事实并非如此。脚本执行一次,生成响应,然后结束。无共享模型的所有行为都源自这一事实:一个每次从头运行、运行完便结束的程序,本身就不可能在两次运行之间记住任何内容。

第三步,同一个请求内的状态确实会累积 ​

这里有一个细节会让问题更令人困惑:在同一个请求内,状态的行为完全符合预期,只有在请求之间才会重置。静态变量可以在同一个请求内的多次调用中递增;单例在该请求中每次被获取时,返回的也是同一个实例。单次请求内的验证如下:

php
function counter(): int { static $n = 0; return ++$n; }
counter();   // 1
counter();   // 2 —— 静态变量确实在同一请求内的多次调用之间保留了状态

验证结果表明,在同一个请求中,静态计数器依次返回了 1 和 2,单例在两次访问时也返回了相同的值。因此,这些用于保存状态的机制确实能够保存状态。静态变量会在函数调用之间保留其值,单例会在多次获取之间保留同一个实例。只是这些状态的存续时间,仅限于它们所属的那一次请求。这正是问题令人困惑的原因:在单次请求测试中,代码运行得完全正常,因为所有状态在该请求内都按预期工作。只有当第二个请求到达、进入自己的全新环境时,静态变量才会重新回到零,单例也会重新变为空白。状态并没有保存失败,它恰好保存到了所属环境结束的那一刻,也就是一次请求的结束。

第四步,用实际请求证明状态重置 ​

这是问题的核心。下面向同一个脚本发送三个独立请求,观察全局计数器、静态变量,以及保存随机值的单例。如果其中任何一种状态能够跨请求保留,数字就会体现出来:

text
--- 请求 1 ---   全局请求计数:1   静态计数器:先 1 后 2   单例:2371
--- 请求 2 ---   全局请求计数:1   静态计数器:先 1 后 2   单例:7699
--- 请求 3 ---   全局请求计数:1   静态计数器:先 1 后 2   单例:7728

以上结果来自三个真实请求。全局请求计数器在脚本加载时递增,但每次读取到的值都是 1,因为每次请求都会清除其原有状态,然后重新递增。静态计数器在每次请求中都从 1 开始,随后在同一请求内增加到 2,与第三步的结果一致。单例采用延迟计算的方式生成并缓存一个随机值,但每次请求保存的随机值都不同,依次为 2371、7699 和 7728,说明前一个请求缓存的值已经消失,新请求重新计算了一个值。三种不同的“记忆”机制,三次确认了同一个事实:请求之间不会保留这些状态。这就是无共享模型的具体表现:放入 PHP 内存的内容只存续一次请求,不会再多保留片刻。

第五步,销毁环境之前先执行关闭函数 ​

当脚本执行完毕、调用 exit 或运行到末尾时,PHP 会销毁整个请求环境:释放所有变量,销毁所有对象,回收全部内存。不过,在彻底结束之前,还有最后一个钩子。通过 register_shutdown_function 注册的函数会在最后执行,位于脚本主体之后,即使输出已经发送,也仍然会执行。验证结果显示,在脚本开头注册的关闭函数最后才运行,位于请求中其他所有操作之后:

text
1. 脚本开头……
2. 主体代码运行……
3. 函数执行……
4. 请求状态仅存续至脚本结束
5. 关闭函数运行(在此进行清理)   ← 位于所有操作之后

验证结果表明,关闭函数在主体代码输出之后触发,完全符合设计。对于无论脚本如何结束都必须完成的请求末尾清理工作,例如刷新日志缓冲区、释放资源、记录耗时,这里就是合适的位置。此外,在某些致命错误发生后,这个钩子仍然会执行,因此生产应用也会在这里记录致命错误,并将原本难看的报错转为友好的错误页面。关闭函数执行完毕后,整个环境随即消失,PHP 则准备为下一个请求构建全新的空白环境。

析构函数会在环境销毁时触发 ​

环境销毁还有一个具体且实用的表现:请求结束时,对象的析构函数会自动运行。如果类定义了 __destruct,PHP 就会在销毁阶段回收对象时调用它,即使代码从未显式销毁对象或关闭任何资源。验证时,在请求中创建了一个类似连接的对象,并且没有显式关闭它,输出如下:

text
打开连接
关闭连接(在销毁阶段执行 __destruct)

验证结果表明,析构函数在请求结束时触发,自行关闭了连接,脚本中没有任何显式清理调用。这是无共享模型在自动完成清理工作:对象持有的文件句柄、连接和其他资源,会在其所属环境被销毁时释放,因为对象本身也被销毁了。这也是为什么 PHP 脚本即使没有严谨地关闭各种资源,通常仍不会造成跨请求泄漏:请求结束时的销毁过程会提供全面的清理保障。(不过,在长时间运行的运行时中,情况恰好相反。对象可能比请求存续得更久,此时就不会自动发生这种释放。这也再次说明,这种“遗忘”机制的价值往往被低估了。)

这是一项特性,而非局限 ​

读到这里,很容易认为 PHP 很原始:“它甚至无法在请求之间记住一个变量。”但这种遗忘行为是有意设计的,也是 PHP 长期以来在运维层面保持可靠的重要原因。由于每个请求都从干净的状态开始,某个请求内的内存泄漏不会跨请求累积;无论如何,内存都会在请求结束时被回收。一个请求即使破坏了自身状态,也无法破坏下一个请求,因为后者拥有全新的环境。一次崩溃影响的是一个请求,而不是整个应用。多个进程可以并行处理大量请求,而不会相互干扰内存,因为根本没有共享内存可供干扰。这一模型用跨请求保留内存状态的能力,换取了依靠遗忘机制实现的稳健性,而这种稳健性具有切实价值。这也是为什么“重启一下”很少是解决 PHP 问题的答案:每次请求本来就相当于重新启动。

值得理解的对照,是 Swoole、RoadRunner 和 FrankenPHP 等工具为 PHP 带来的长驻运行模型。在这种模型中,进程会在请求之间持续存活,因此能够快速地将状态保留在内存中。但与此同时,无共享模型原本避免的问题也重新出现了。内存泄漏会开始累积,一个请求留下的状态可能泄漏到下一个请求中。原本只在单次请求内保留、不会造成问题的静态变量,现在可能一直保留。有时这正是预期行为,有时却会成为隐蔽的缺陷。这些工具功能强大,也日益流行,但它们要求开发者放弃传统 PHP 所提供的遗忘机制,转而谨慎管理状态。理解默认的无共享模型,才能理解这些工具改变了什么,以及这种改变为何同时带来收益和风险。

编写顺应这一模型的代码 ​

一旦理解这一模型,实践规则便可以直接推导出来,不再像是缺乏依据的建议。任何需要跨请求保留的内容,都应存入外部存储:持久数据使用数据库,快速共享状态使用 Redis 之类的缓存,用户级状态使用会话,在适当的场景下也可以使用文件。全局变量、静态变量和单例适合保存请求内的状态,例如传递已配置好的服务,或记忆化缓存同一请求稍后还会使用的计算结果。但它们完全不适合保存需要跨请求持久存在的内容,因为请求结束后,这些状态就会被清除。如果期望静态属性“缓存”能够加速后续请求,它不会产生预期效果,因为后续请求并不共享这块内存;要真正实现共享,缓存必须存放在 Redis 等存储中。

无法递增的计数器、从未命中的缓存、不断遗忘的单例,背后都是同一个错误:期望 PHP 的请求级内存像长驻程序的内存一样工作。将需要持久保存的内容放到真正能够持久保存的位置,问题就会消失。反过来,在迁移到长驻运行时时,还需要警惕另一种误区:以为遗忘机制会始终提供保护。一旦进程持续存活,就必须开始考虑请求之间在内存中留下了什么,因为这些状态不再会被自动清除。

哪些状态会重置,又该如何处理 ​

如果只能记住一点,就记住哪些内容能够在请求结束后继续存在,哪些不能:

  • 全局变量 → 每次请求结束时都会消失。验证结果:全局计数器在每次请求中读到的值都是 1。
  • 静态变量和静态属性 → 在请求之间重置,仅在同一请求内的多次调用之间保留状态。
  • 单例和内存中的对象 → 每次请求都会重新创建。验证结果:缓存的随机值在每次请求中都不同。
  • 超全局变量($_GET、$_POST、$_SERVER、$_COOKIE)→ 在代码执行前,针对当前请求重新填充。
  • 定义了 __destruct 的对象 → 在销毁阶段被销毁,并自动释放其资源。
  • 任何需要比请求存续更久的内容 → 存入数据库、缓存(Redis)、会话或文件。只有这些存储中的状态能够延续到后续请求。

决策始终归结为同一个问题:这个状态是否需要在请求结束后继续存在?如果不需要,PHP 内存就是合适的位置,而且清理工作会自动完成;如果需要,它就应当放在外部存储中,因为当前请求的整个运行环境即将被销毁。

将这一判断养成习惯 ​

理解这些内容后,最有用的习惯是:每当打算通过变量、静态成员或单例记住某些内容时,都先问一句,这个状态是否需要在请求结束后继续存在?如果答案是否定的,它只是当前工作所需的状态,那么 PHP 内存正是合适的位置,请求结束时也会自动清理。如果答案是肯定的,PHP 内存就不适合,这些内容应当放入数据库、缓存或会话中,因为响应一经发送,请求的运行环境就会被销毁。

这一个问题,就能化解让许多开发者不断排查并不存在的缺陷的困惑。代码从未出错,它只是在准确履行无共享模型所保证的行为:从空白状态开始运行,完成工作,发送响应,然后遗忘一切。PHP 的这一标志性行为,并不是需要设法绕开的局限,而是设计时应遵循的约定。真正理解它的开发者,不再对无法累计的计数器感到意外,而会自然而然地选择真正能够保存状态的存储。

第一次接触无共享模型时,你遇到的那个无法持久保存的东西是什么?对许多人而言,那是一个静态属性缓存:测试时运行得无可挑剔,上线后却毫无作用。

本作品采用《CC 协议》,转载必须注明作者和本文链接