兄弟们,把手机收一收,把手里的咖啡放下。今天我们不聊怎么写漂亮的 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,其实就是在这个充满了内存陷阱的迷宫里走钢丝。
我们要记住的核心原则就三条:
- 敬畏引用计数: 理解 PHP 是怎么数引用的,警惕那些互相抱团的死循环对象。
- 显式释放: 不要指望 PHP 会自动帮你打扫房间。
unset是你的扫帚,记得用。把不再需要的全局变量清空,把静态变量归零。 - 监控是生命线: 哪怕你的代码写得再完美,你也得看着内存曲线走。一旦发现曲线向上平移,立刻检查是不是哪个全局变量成了“巨婴”。
在长周期自动化脚本的世界里,内存是稀缺资源。你省下的每一兆内存,都是在为你的服务器延寿,也是在为你自己省下半夜被报警电话叫醒的痛苦。
所以,下次写代码的时候,看着那些全局变量,问问自己:“这家伙,待会儿会死吗?如果不会,它是不是该被扔进垃圾桶了?”
代码要写得漂亮,内存也要管得漂亮。这才是资深程序员的修养。
好了,今天的讲座就到这儿。我还要去检查一下我那几个跑了一周的 Workerman 进程,看看内存是不是又涨了。拜拜!