PHP 常驻内存下的全局变量污染防治:解析 RequestContext 作用域的物理实现逻辑

各位好,我是你们的 PHP 资深侦探。今天我们不聊怎么把代码写得更漂亮,也不聊怎么优化 SQL 查询,我们要聊的是那个躲在代码角落里、让人又爱又恨的家伙——全局变量

特别是当我们的 PHP 跑在常驻内存模式下,比如 Swoole、OpenSwoole 或者 RoadRunner 的时候,这个家伙就变成了一只张着血盆大口的怪兽。今天,我就要带大家拆解一下,我们是如何通过 RequestContext(请求上下文) 来驯服这只怪兽的。

准备好你们的白板笔了吗?我们要开始解剖了。


第一幕:这是一个什么样的世界?

首先,我们要搞清楚背景。传统的 PHP 是怎么工作的?

传统 PHP: 每来一个 HTTP 请求,PHP 引擎启动,加载代码,跑完扔掉,引擎关闭。这就像是一群服务员(请求)去一家快餐店(Web 服务器),点完餐,吃完,拍拍屁股走人,服务员换一拨。快餐店里的桌子(变量)每天都在被擦干净,脏的、乱的,从来不会留到第二天。

常驻内存 PHP: 想象一下,这家快餐店变成了一家24小时营业的火锅店。服务员(请求)进进出出,但厨房(PHP 进程)是不关门的。炉火一直烧着,底料一直留着。

这时候,如果你在厨房的桌子上放了一块写着“张三的账单”的纸条,服务员李四进来一看,也以为这是给他的。这叫什么?这叫数据污染

在常驻内存模式下,$_GET$_POST$_SERVER 甚至是你写的 $GLOBALS,它们不再是临时的了。如果你不小心,请求 A 设置了 $userId = 1001,请求 B 读的时候,如果底层机制没做好隔离,它可能就拿到了 1001。这就像你用别人的手机发了朋友圈,还得自己去把点赞数删了,头疼不?

第二幕:RequestContext 是谁?

为了解决这个问题,我们引入了 RequestContext。它的核心思想非常简单粗暴:给每个请求分配一个独立的“背包”。

你可以把它想象成一个超级富豪的私人储物柜

  • 请求 A 来了,拿到柜子 A。
  • 请求 B 来了,拿到柜子 B。
  • 请求 A 结束了,柜子 A 被清空,释放给请求 C 使用。
  • 请求 C 放入自己的数据,互不干扰。

在物理实现层面,RequestContext 的本质就是一个跨越请求周期的 Key-Value 存储映射

第三幕:物理实现逻辑 —— 从 PHP 到 C

如果只是让你写一个 PHP 类,那太简单了,我们来看点硬核的。RequestContext 的“物理实现逻辑”主要解决两个核心问题:

  1. 隔离性:不同的请求怎么区分?
  2. 生命周期管理:请求结束怎么销毁数据?

1. 隔离性的实现:Key 的艺术

最简单的实现思路,是在 PHP 类里搞一个静态数组:

class SimpleContext {
    private static $data = [];

    public static function set(string $key, $value) {
        // 问题来了:$key 怎么写?
        // 如果写死 'user_id',那请求 A 和请求 B 就会互相覆盖。
        // 所以,我们要引入一个唯一标识符。
        $requestId = self::getRequestUniqueId(); 
        self::$data[$requestId][$key] = $value;
    }

    public static function get(string $key) {
        $requestId = self::getRequestUniqueId();
        return self::$data[$requestId][$key] ?? null;
    }
}

这里有个巨大的坑!

如果我们的服务有 10 个进程(Master 进程 spawn 了 10 个 Worker 进程),那么每个 Worker 进程里都有一个 SimpleContext。如果请求 A 在进程 1,请求 B 在进程 2,它们是完全不知道对方存在的。

这导致什么问题?数据只能存单机内存,无法存跨进程。

在物理实现的高级逻辑中,RequestContext 的 Key 通常不仅仅是 uniqid(),而是结合了 Socket FD(文件描述符) 或者 协程 ID

比如在 Swoole 的底层实现里,当你调用 $context->set('user', $user) 时,Swoole 会获取当前请求的唯一标识(比如连接的文件描述符)。底层会维护一个类似 Hashmap 的结构,Key 是 FD,Value 是一个 PHP 数组。

举个栗子:

  • 请求 A(FD: 1000)$context->set('user_id', 1)
  • 请求 B(FD: 1001)$context->set('user_id', 2)

底层存储结构长这样:

[
  1000 => ['user_id' => 1, 'token' => 'abc'],
  1001 => ['user_id' => 2, 'token' => 'xyz']
]

这样,物理隔离就完成了。

2. 生命周期管理:请求结束的“自杀式任务”

这是 RequestContext 实现中最有趣的地方。在 PHP 中,没有 __destruct 方法会自动在请求结束时触发(除非你真的在析构函数里引用了变量)。常驻内存模式下,内存是死的,变量是活的,如果不手动释放,它们会一直赖在内存里。

RequestContext 必须利用 PHP 的 register_shutdown_function(注册关闭函数)来执行清理工作。

这是它的物理实现逻辑伪代码:

class RequestContext {
    // ... 上面那些 getter/setter ...

    public function __construct() {
        // 关键点:在构造函数里注册一个“尸体处理员”
        register_shutdown_function([$this, 'cleanUp']);
    }

    public function cleanUp() {
        // 当脚本执行完毕,或者发生致命错误时,这个函数会被调用
        $requestId = $this->getRequestUniqueId();

        // 从全局数组中移除
        unset($this->globalData[$requestId]);

        // 如果是引用计数机制,这里甚至可以直接 $this->globalData = []; 
        // 或者更激进的,把那个静态数组指针指空(不推荐,容易空指针)
    }
}

为什么这个逻辑很重要?
如果你在 RequestContext 里存了一个巨大的数组(比如用户登录后把整张用户表都塞进去了),如果不手动 unset,这个巨大的数组会一直占用内存,直到进程重启。这就是常驻内存应用的内存泄漏

对比一下传统的 $_SERVER
$_SERVER 是 PHP 内置的全局变量数组。它在每次请求结束时,会被 PHP 引擎自动清空(或者至少是重新赋值)。而在常驻内存里,我们必须自己实现这种“自动清空”的逻辑。

第四幕:深入线程安全 —— 并不是只有单线程

很多新手做常驻内存开发,喜欢直接用 PHP 数组作为底层数据结构。但在多线程(如 Swoole 的多 Reactor 模式)环境下,这简直是灾难。

假设我们有个全局的 static $globalBag = []

  • 请求 A 正在往里写:$globalBag[1000] = "A的数据";
  • 请求 B 也正在往里写:$globalBag[1001] = "B的数据";

如果是单线程,没问题。如果是多线程,当请求 A 写到一半,操作系统调度让出 CPU 给请求 B,而请求 B 又来写 globalBag,那么:

  1. 请求 A 的数据可能被覆盖。
  2. PHP 数组的扩容机制可能导致内存越界。
  3. 读写锁失效。

RequestContext 物理实现的进阶逻辑:

在 Swoole 等高性能框架的底层,RequestContext 往往是用 C 语言实现的 Thread Local Storage (TLS) 或者 Thread Specific Data (TSD)

  • 概念:每个线程(或者每个进程,视模型而定)都有自己独立的存储空间。
  • 实现:C 语言有一个 pthread_key_create API。它创建一个“键”。
    • 当 PHP 代码执行 context->set 时,底层 C 代码拿着这个键,去当前线程的存储空间里找对应的值。
    • 请求 A 在线程 1,请求 B 在线程 2。
    • 线程 1 找自己的桶,线程 2 找自己的桶。它们互不干扰。

这就好比:

  • PHP 数组:像是一张巨大的共享白板,谁都可以涂改。
  • RequestContext (TLS):像是你口袋里的钱包。张三(线程 1)拿钱包,李四(线程 2)拿钱包。张三的硬币掉进自己的钱包,李四怎么也拿不出来。

第五幕:实战演练 —— 造一个 RequestContext

为了让大家彻底明白,我不写伪代码了,直接写一个稍微靠谱点的 PHP 版 RequestContext 实现(仅用于教学,生产环境请用 Swoole/OpenSwoole 内置的)。

我们需要解决两个核心点:

  1. 如何获取当前请求的唯一标识?
  2. 如何自动清理?
<?php

class RequestContext
{
    /**
     * 存储核心:使用一个静态数组。
     * Key: 请求的唯一标识 (例如: fd 或 协程ID)
     * Value: 该请求下的所有数据
     */
    private static $store = [];

    /**
     * 获取当前请求的唯一标识
     * 在 Swoole/OpenSwoole 环境下,通常可以通过 $server->getClientInfo() 获取 fd
     * 在普通 CLI 模式下,可能需要使用 posix_getpid() 或 uniqid
     */
    private static function getRequestId()
    {
        // 假设我们引入了一个全局的 server 实例
        global $swooleServer;

        if (isset($swooleServer) && $swooleServer instanceof SwooleServer) {
            // 获取当前连接的 fd (File Descriptor)
            // 这是最精准的物理隔离键
            return $swooleServer->getClientInfo($swooleServer->getWorkerId())['fd'] ?? 0;
        }

        // Fallback: 在没有 Swoole 的情况下,使用 uniqid 或者 进程ID
        return getmypid() . '_' . uniqid();
    }

    /**
     * 设置上下文值
     */
    public static function set(string $key, $value)
    {
        $rid = self::getRequestId();
        if (!isset(self::$store[$rid])) {
            self::$store[$rid] = [];
        }

        self::$store[$rid][$key] = $value;
    }

    /**
     * 获取上下文值
     */
    public static function get(string $key, $default = null)
    {
        $rid = self::getRequestId();
        return self::$store[$rid][$key] ?? $default;
    }

    /**
     * 移除上下文值
     */
    public static function remove(string $key)
    {
        $rid = self::getRequestId();
        if (isset(self::$store[$rid][$key])) {
            unset(self::$store[$rid][$key]);
        }
    }

    /**
     * 析构函数:注册关闭回调
     * 这是一个非常高级的技巧。
     * 在 PHP 7.0+ 中,析构函数是会调用的,但在常驻内存下,
     * 我们更推荐 register_shutdown_function。
     */
    public function __construct()
    {
        // 注册一个清理函数
        register_shutdown_function(function () {
            $rid = self::getRequestId();
            if (isset(self::$store[$rid])) {
                // 物理清理:直接断开引用
                self::$store[$rid] = null; 
                // 注意:这里不能直接 unset(self::$store[$rid]),因为静态变量如果全部为空,PHP 7.4+ 会自动回收内存,但在多进程环境下可能会有并发问题,通常保留结构体即可。
            }
        });
    }
}

第六幕:代码里的“脏话”——为什么会炸?

看了上面的代码,你可能觉得:“不就是存个数组嘛,有啥难的?”

但我告诉你,我在生产环境见过因为 RequestContext 用法不当而导致 OOM(内存溢出)的惨案。让我们来模拟一下错误的写法。

场景:

// 假设这是一个框架的中间件
function AuthMiddleware() {
    // 获取用户信息
    $userInfo = dbQuery("SELECT * FROM users WHERE id = 1");

    // 错误示范 1:直接赋值静态变量(非 RequestContext)
    // 在常驻内存下,这是大忌!
    // global $currentUser;
    // $currentUser = $userInfo; 

    // 错误示范 2:把大对象塞进 RequestContext
    // 如果你的 RequestContext 是基于 PHP 数组的,且没有在请求结束时彻底清理
    RequestContext::set('currentUser', $userInfo); 
}

// 调用链
for ($i = 0; $i < 1000000; $i++) {
    AuthMiddleware();
}

物理发生了什么?

  1. 迭代 1$userInfo 被设置进 RequestContext。此时内存里有个数据包。
  2. 迭代 2$userInfo 被重新赋值进 RequestContext。旧的那个 $userInfo 被覆盖了,理论上应该被垃圾回收器回收。
  3. 但是! 在某些 PHP 版本或特定配置下,如果 $userInfo 引用了大量数据,且 RequestContext 内部只是简单的数组覆盖,那个旧的数据包可能依然驻留在内存里,因为 PHP 的垃圾回收(GC)是基于引用计数的,而 RequestContext 的数组引用计数可能没有归零。

更严重的是,如果在 register_shutdown_function 的清理逻辑写得不够彻底(比如只清空了 Key,没清空 Value 的引用),内存就会像滚雪球一样,越滚越大,直到服务器崩溃。

正确的做法:
在 RequestContext 的 cleanUp 方法里,我们不仅要删除 Key,最好能强制执行 unset 或者让 Value 重新赋值为 null,确保 PHP 引擎能感知到这些数据不再被使用了。

第七幕:RequestContext vs $GLOBALS

现在你可能会问:“既然有了 RequestContext,我能不能扔掉 $GLOBALS?”

我的建议是:能,尽量扔。

$GLOBALS 是 PHP 的原生机制,它的物理实现是:一个全局的 HashTable。在常驻内存下,它是所有变量的“总仓库”。

  • RequestContext:就像是仓库里的独立储物柜,贴着标签,互不干扰。
  • $GLOBALS:就像是仓库里的地上一堆乱七八糟的箱子

如果你把数据库连接句柄放在 $GLOBALS['db'] 里,那么:

  • 进程 1 连接了数据库 A。
  • 进程 2 连接了数据库 B。
  • 因为它们共享 $GLOBALS,所以进程 2 的代码可能会误用进程 1 的连接,或者两个进程同时抢占资源,导致死锁。

RequestContext 的物理逻辑就是为了打破这种“共享”,实现“隔离”。

第八幕:进阶技巧 —— 协程环境下的实现

如果你用的是 Swoole 4.0+ 的协程模式,RequestContext 的实现逻辑还得加一层。

在协程环境下,一个 TCP 连接(FD)可能会被拆分成成百上千个协程任务。

  • 请求 A:建立连接 -> 开启协程 1(查用户)-> 开启协程 2(查日志)-> 结束。
  • 请求 B:建立连接 -> 开启协程 3(查用户)-> 开启协程 4(查日志)-> 结束。

如果只基于 FD 存储,那么 FD 1 下的协程 1 和 协程 2 可能会共享数据。一旦协程 1 修改了 $user_id,协程 2 读到的就变了。

解决方案:使用 Swoole 的 Coroutine::getUid()

底层实现逻辑会变成:

  1. fd 作为主 Key。
  2. coroutine_id 作为子 Key。
  3. 或者,直接使用 Swoole 提供的 Context 类,它底层已经封装好了协程隔离的逻辑。
use SwooleCoroutineContext;

$ctx = new Context();
$ctx->set('request_id', '123');
$ctx->set('user', 'Tom');

// 在另一个协程里
echo $ctx->get('request_id'); // 输出 123

Swoole 的 Context 底层其实就是一个 Map<CoroID, Map<Key, Value>>。当协程挂起(比如等待数据库返回)时,它把自己的 ID 存起来;当它恢复运行时,它去取回自己的那份数据。这就是协程下的物理隔离逻辑。

第九幕:总结一下——如何做合格的开发者

回到我们的讲座主题。在常驻内存下管理全局变量,核心就是“抢占地盘”“及时撤退”

  1. 不要相信“全局”:哪怕它只是在一个文件里 static 了一下,在常驻内存里它也是有毒的。
  2. 使用 RequestContext:这是官方推荐的标准做法。
  3. 关注底层实现:搞清楚它是基于 FD 隔离,还是基于协程 ID 隔离。如果搞错了,你可能会遇到跨请求数据穿透的 Bug。
  4. 手动清理:代码里写 RequestContext::set,就要写 RequestContext::remove,或者信任你的框架的 register_shutdown_function。不要让垃圾在内存里过夜。

这就像是在玩俄罗斯方块,如果你不快速消除,内存就会堆满,最后游戏结束。

好了,今天的物理实现解析就到这里。希望大家在下次写代码的时候,不要忘记给你的变量建一个只属于它的“储物柜”。保持代码的整洁,保持内存的清爽,我们下期见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注