常驻内存环境下的 PHP 全局变量治理:防止长周期自动化脚本中的内存物理泄露

兄弟们,把手机收一收,把手里的咖啡放下。今天我们不聊怎么写漂亮的 Controller,也不聊怎么优化 SQL,今天我们要聊点硬核的,聊点甚至有点“重口味”的话题。

咱们来谈谈内存

我知道,提到 PHP,很多人的第一反应是:“嘿,那不是个语言吗?写个脚本跑完就死,用不着操心内存。”

错!大错特错!

在传统的 Web 开发里,PHP 就像个过河的卒子,过河就把棋子收走,内存交还给操作系统,干净利落。但在这个常驻内存的时代——比如你用了 Swoole、Workerman,或者写了那种跑好几天的自动化脚本、监控机器人、定时任务队列——PHP 的画风突然就变了。

现在的 PHP,不再是那个过河的卒子,它成了一头坐下来的大象。你喂它数据,它就把数据嚼碎了存在肚子里。如果你不懂得怎么治理那些全局变量,不懂得怎么防止物理泄露,这头大象很快就会撑死你的服务器,或者更糟,变成一个内存溢出的“僵尸”。

准备好了吗?让我们把手术刀拿出来,开始解剖这些潜伏在代码深处的内存怪兽。


一、 PHP 的“腹部构造”:引用计数与幽灵绳结

在开始治理之前,我们得先搞清楚 PHP 的内存到底是怎么管理的。这玩意儿有点像图书馆的借阅制度,但也像是一个爱打结的毛线团。

1. 引用计数:简单的加减法

PHP 的底层(ZVAL)使用的是引用计数机制。简单说,每个变量都有一个计数器。
比如你写:

$a = "Hello World";
$b = $a;

这个时候,内存里的 “Hello World” 字符串,它的引用计数是 2(一个属于 $a,一个属于 $b)。当你写 unset($a) 时,计数器减到 1,垃圾被回收了。

听起来很完美,对吧?但现实中,程序员最喜欢干的一件事就是循环引用

2. 循环引用:孙悟空的金箍棒

想象一下,你有一个大胖子对象 $obj。这个胖子对象身上挂满了各种数据、数组、甚至还有个引用指着另一个胖子对象 $obj2。而 $obj2 里面又引用回了 $obj

这就好比孙悟空用金箍棒把自己缠了一圈,再把绳子头递给自己。

此时,引用计数是:$obj 引用数 1(在变量里),$obj2 引用数 1(在变量里)。但 $obj 里面的 $obj2 引用,$obj2 里面的 $obj 引用……它们互相看着,谁都不放手。

在普通的 Web 请求里,这没问题。请求结束了,变量销毁,引用计数归零,垃圾回收(GC)机制会把这一团乱麻清理掉。但在常驻内存环境下呢?

这就好比这个胖子 $obj 在你的服务器上“成精”了。只要你的脚本还在跑,$obj 就一直赖着不走。它的引用计数永远不会变成 0,因为它肚子里还“吞”着另一只肚子。

3. 物理泄露:真的泄露了吗?

这里有个误区。很多人说:“没报错,没内存溢出,那就是没泄露。”

错!PHP 的 GC 有个默认的超时机制,叫 GC_ENABLED。在常驻内存里,PHP 不会傻到每一微秒都去扫描你的内存垃圾。它通常会在一段时间(比如请求结束后)或者内存占用超过某个阈值时,才去触发一次大扫除。

在扫描之前,那些有着“孙行者绳结”的全局变量,会一直占用着物理内存(RAM)。虽然 PHP 内部觉得自己内存还挺大,但操作系统觉得你霸占内存了。这就是物理泄露


二、 守护进程里的“隐形杀手”:全局变量治理

在常驻内存环境下,全局变量是最大的敌人。为什么?因为它的生命周期是永生

让我们看一个典型的错误示范,来自某个运维自动化脚本:

<?php

class JobWorker
{
    // 这是一个静态属性,它是个黑洞
    private static $longTermCache = [];

    public function process($taskId)
    {
        // 假设我们处理一个任务,需要把结果存起来供后续查询
        $result = $this->heavyCalculation($taskId);

        // 危险动作:把结果扔进静态全局变量
        // 注意:这里的引用计数会一直增加,而且极难清理
        self::$longTermCache[$taskId] = serialize($result);

        // 脚本继续运行...
        return true;
    }

    private function heavyCalculation($id) {
        // 模拟生成大量数据,比如 JSON 配置或解析后的结构体
        $data = [];
        for($i = 0; $i < 10000; $i++) {
            $data[] = [
                'id' => $i,
                'content' => str_repeat('x', 1024), // 每个元素 1KB
                'meta' => new stdClass()
            ];
        }
        return $data;
    }
}

// 启动守护进程
$worker = new JobWorker();

while (true) {
    // 模拟处理 1000 个任务
    for ($i = 0; $i < 1000; $i++) {
        $worker->process($i);
    }

    // 看看内存...
    echo memory_get_usage(true) . " bytesn";

    // 假装休息一下
    usleep(100000);
}

兄弟们,睁开眼睛看看这段代码。

process 函数里,我们每次都把 $result 序列化后扔进 self::$longTermCache。这个 $result 是一个包含 10000 个元素的数组,每个 1KB。这意味着每次 process 调用,你就在内存里永久增加了约 10MB 的垃圾数据。

如果你写个死循环跑个 24 小时,这个脚本就能吃掉 240GB 的内存。服务器崩不崩?崩了。

这就是典型的静态变量滥用。还有更狠的,就是滥用 $GLOBALS

$GLOBALS['config']['db']['password'] = '123456';

这句代码在脚本结束时,$config 变量其实已经被销毁了,但是 $GLOBALS['config'] 永远活着。你在脚本里改了它,下一秒又改了它,结果发现内存越来越大,却找不到是谁在占坑。


三、 诊断:如何发现这些“内存癌症”

治癌先得确诊。在 PHP 里,确诊内存泄露,得靠“X光片”。老版本的 PHP 有个很给力的函数,叫 xdebug_debug_zval。虽然它不能在生产环境一直开着(太慢),但它是最好的教学工具。

让我们重现一下那个“孙行者绳结”的场景:

<?php

class A {
    public $b;
    public function __construct($b) {
        $this->b = $b;
    }
}

// 创建两个对象,互相引用
$a = new A(null);
$b = new A($a);
$a->b = $b;

// 现在我们把 $a 放进一个全局变量
$GLOBALS['a'] = $a;

// 去诊断一下
xdebug_debug_zval('a');

// 输出结果大概是这样的(简化版):
// a: (refcount=1, is_ref=0):
//   object(A)#1 (1) { 
//     "b" => (refcount=2, is_ref=0):
//       object(A)#2 (1) { 
//         "b" => (refcount=1, is_ref=0): *这里指向了 a*
//       }
//   }

看清楚了吗?refcount 是多少?
a 变量本身引用数是 1。
但是 $a->b 指向了 $b$b->b 指向了 $a
因为它们在全局作用域里,这俩家伙就像两只互相拉手的狗,谁都放不开。

如果这时候你 unset($a),引用计数会变成 0 吗?不会!因为 $b->b 还指着它。你必须同时 unset($a->b) 并且 unset($b->b),引用计数才会归零,垃圾回收才会介入。

但在常驻内存里,你永远不会 unset 那些互相引用的对象,除非你手动把它们从全局数组里移除。

现代 PHP 的福音:WeakReference

PHP 8.0+ 引入了 WeakReference。这简直是常驻内存开发的救命稻草。

WeakReference 允许你引用一个对象,但是不增加它的引用计数

<?php

class MyClass {
    public $data;
    public function __construct($data) {
        $this->data = $data;
    }
}

$target = new MyClass("I am data");

// 创建一个弱引用
$ref = WeakReference::create($target);

var_dump($ref->get()); // 能拿到数据

// 销毁目标对象
$target = null;

// 检查一下
var_dump($ref->get()); // null!因为目标对象已经没用了,弱引用自动失效,没占用内存!

如果你在常驻内存的缓存机制里使用 WeakReference,你就可以存储大量临时的数据对象。当脚本不需要这些数据时,它们会自动被 GC 回收,而不会在你的全局缓存里堆积成山。


四、 战术演练:如何治理全局变量

好了,诊断清楚了,工具也有了。现在我们回到实战,看看在常驻内存的自动化脚本里,该怎么写代码才不背锅。

1. 静态变量:只在必要时用,用完就清空

很多人为了方便,喜欢在类里搞静态变量存单例或者缓存。

错误示范:

class TaskManager {
    private static $tasks = [];

    public function addTask($task) {
        self::$tasks[] = $task;
    }

    // 很多人的想法:任务处理完了,我就不管了,反正静态变量还在,下次来用
}

正确做法:
如果你确定任务处理完了就删,那就必须显式删除。

class TaskManager {
    private static $tasks = [];

    public function addTask($task) {
        // 每次添加之前,先检查内存,是不是爆了?
        if (count(self::$tasks) > 1000) {
            // 执行清理策略,比如 LRU(最近最少使用)淘汰
            array_shift(self::$tasks);
        }
        self::$tasks[] = $task;
    }

    public function cleanup() {
        // 强制清理方法
        self::$tasks = [];
        gc_collect_cycles(); // 触发垃圾回收,专门处理循环引用
    }
}

或者,对于不再需要的静态变量,及时赋值为 null

function handleRequest() {
    static $config = [];
    // ... 使用 config ...

    // 任务结束,把 config 扔了
    $config = null; 
}

2. $GLOBALS:慎用,慎用,慎用

$GLOBALS 是 PHP 最大的历史包袱之一。它是一个超级大的全局关联数组。在常驻内存里,它是内存泄露的重灾区。

场景:
你在写一个中间件,想把请求日志存到一个全局数组里。

// 伪代码
$Logs = [];
function log($msg) {
    global $Logs;
    $Logs[] = $msg;
}

如果你的日志是“常驻内存”的,那 $Logs 会无限增长。你应该把它放在一个单例类或者 Redis 里,而不是内存里。

3. 队列处理器的内存陷阱

这是自动化脚本中最常见的坑。我们用 PHP 处理消息队列,比如从 RabbitMQ 拉取消息。

经典 Bug:

$worker = new Worker();
while (true) {
    $message = $queue->get();
    if ($message) {
        // 处理消息
        $worker->handle($message);

        // 这里如果不手动释放 $message,它就一直留在内存里
        // 而且 $worker->handle 可能会创建巨大的临时对象
    }
}

解决方案:显式释放

$worker = new Worker();
while (true) {
    $message = $queue->get();
    if ($message) {
        try {
            $worker->handle($message);
        } catch (Exception $e) {
            // 处理异常
        }

        // 关键步骤:处理完必须释放引用!
        unset($message);
        unset($worker->lastResult); // 如果有类属性存了结果

        // 可选:手动触发 GC,确保循环引用被处理
        if (mt_rand(1, 100) === 1) { 
            gc_collect_cycles();
        }
    }
}

4. SplObjectStorage 的陷阱

SplObjectStorage 看起来很方便,可以存对象和值。但是,它默认是强引用。

$storage = new SplObjectStorage();
$obj = new stdClass();
$storage[$obj] = 'data';

// 如果你在 storage 里存的是对象,那个对象就不会被 GC 掉
// 除非你从 storage 里 remove
$storage->remove($obj);

在常驻内存里,如果你不小心把一个长期运行的对象(比如连接池里的连接对象)扔进了 SplObjectStorage,你就等于把连接池锁死了,且内存无法释放。


五、 长周期自动化脚本的特殊视角

现在,我们来谈谈那些跑个三天三夜的脚本。

1. 内存监控的必要性

在 Web 开发里,我们看 Nginx 日志。但在常驻内存开发里,你必须看内存日志

// 这是一个简单的监控函数
function monitorMemory($label = '') {
    $usage = memory_get_usage(true);
    $peak = memory_get_peak_usage(true);

    // 记录到日志文件
    $log = sprintf(
        "[%s] %s | Usage: %s MB | Peak: %s MBn", 
        date('Y-m-d H:i:s'),
        $label,
        round($usage / 1024 / 1024, 2),
        round($peak / 1024 / 1024, 2)
    );
    file_put_contents(__DIR__ . '/memory.log', $log, FILE_APPEND);
}

// 在你的主循环里插入
while ($running) {
    // ... 业务逻辑 ...

    // 每处理 100 条任务检查一次
    if ($count % 100 === 0) {
        monitorMemory("Processing batch $count");

        // 如果内存涨得太离谱,比如 10 分钟涨了 50MB,果断重启
        if ($usage > 500 * 1024 * 1024) {
            exit("Memory leak detected! Restarting...");
        }
    }
}

2. 避免闭包带来的隐式引用

闭包是现代 PHP 的神器,但在常驻内存里,它是个深坑。

class Context {
    private $data = [];

    public function getContext() {
        // 返回一个闭包
        return function() {
            // use 引用了外部变量
            return $this->data; 
        };
    }
}

$c = new Context();
$fn = $c->getContext();

// 这里有个问题:
// $fn 闭包对象持有对 Context 对象的引用(通过 use)。
// Context 对象持有对 $fn 的引用吗?
// 如果 Context 里没有存 $fn,那没关系,脚本结束 GC。
// 但如果你把 $fn 存到了全局变量里,或者静态变量里,Context 对象就泄露了!

教训: 不要在闭包里 use 大对象。如果必须用,尽量 use 简单的变量(如 ID、字符串),让闭包本身保持轻量级。


六、 实战案例:一个完整的内存泄漏修复故事

假设我们有一个爬虫服务,需要每隔 5 分钟抓取一次数据,并解析成对象存入数据库。

之前的代码(垃圾代码):

class Spider {
    public $allData = [];

    public function run() {
        $raw = $this->fetchData();

        foreach ($raw as $item) {
            // 解析数据
            $parsed = $this->parse($item);

            // 存入全局对象
            $this->allData[] = $parsed;
        }
    }
}

// 主循环
$spider = new Spider();
while (true) {
    $spider->run();
    sleep(5);
}

问题分析:
$spider->allData 会随着每次 run() 调用无限增长。跑一天,allData 里可能有几百万条数据。

修复后的代码(健康代码):

class Spider {
    // 不再存全局,而是提供一个处理函数
    public function processBatch(array $rawData) {
        foreach ($rawData as $item) {
            $parsed = $this->parse($item);

            // 直接入库,或者通过异步队列发送,不要把大对象留在内存
            $this->saveToDatabase($parsed);

            // 如果必须缓存(比如做去重),使用弱引用或者定时清理
            $this->cache->set(md5($item['url']), $parsed, 300); // 缓存5分钟
        }
    }
}

// 主循环
$spider = new Spider();
while (true) {
    // 每次只拉取一小批数据
    $batch = $spider->fetchBatch(100); 
    if (!empty($batch)) {
        $spider->processBatch($batch);

        // 释放批次数据
        unset($batch);

        // 也可以触发一次垃圾回收
        gc_collect_cycles();
    }
    sleep(5);
}

七、 总结一下(虽然我不喜欢总结,但道理必须讲清楚)

兄弟们,写常驻内存的 PHP,其实就是在这个充满了内存陷阱的迷宫里走钢丝。

我们要记住的核心原则就三条:

  1. 敬畏引用计数: 理解 PHP 是怎么数引用的,警惕那些互相抱团的死循环对象。
  2. 显式释放: 不要指望 PHP 会自动帮你打扫房间。unset 是你的扫帚,记得用。把不再需要的全局变量清空,把静态变量归零。
  3. 监控是生命线: 哪怕你的代码写得再完美,你也得看着内存曲线走。一旦发现曲线向上平移,立刻检查是不是哪个全局变量成了“巨婴”。

在长周期自动化脚本的世界里,内存是稀缺资源。你省下的每一兆内存,都是在为你的服务器延寿,也是在为你自己省下半夜被报警电话叫醒的痛苦。

所以,下次写代码的时候,看着那些全局变量,问问自己:“这家伙,待会儿会死吗?如果不会,它是不是该被扔进垃圾桶了?”

代码要写得漂亮,内存也要管得漂亮。这才是资深程序员的修养。

好了,今天的讲座就到这儿。我还要去检查一下我那几个跑了一周的 Workerman 进程,看看内存是不是又涨了。拜拜!

发表回复

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