各位开发界的“内存架构师”们,大家晚上好!
今天我们不聊怎么写优雅的 Laravel 路由,也不聊怎么优化 MySQL 的索引,我们来聊点“硬核”的,聊点让人半夜惊醒的——PHP 内存泄漏。
如果你是一个标准的 PHP-FPM 开发者,你可能觉得“内存泄漏”这个词离你很远。因为你的代码执行完一个请求,内存就被 PHP 自己的垃圾回收机制(GC)打扫得干干净净,就像你吃完泡面,把碗扔进洗碗机一样。但是,如果你的应用是常驻内存的——比如 Swoole、Workerman、或者你自己写的一个守护进程脚本——那情况就完全不一样了。
这就好比你是住在同一个房子里五年的室友。你吃一口饭,扔个垃圾,然后下一秒还有新人进来吃饭。如果你每次进来都往沙发缝里塞个苹果核,五年后,你的房子就堆满了垃圾,最后只能搬家。
在常驻内存应用中,Global 变量就是那个死皮赖脸不扔垃圾的室友。今天,我就要带大家用 php-meminfo 这把手术刀,来解剖一下这些潜伏在代码深处的“内存僵尸”。
第一部分:PHP 的“快餐式”内存哲学
首先,我们要纠正一个经典的误区。很多人认为 PHP 是动态语言,内存管理是自动的。这话没错,但这个“自动”是有前提的。
在 PHP 的世界里,每一次 HTTP 请求都是一个独立的“世界”。请求一来,内存分配;请求一走,世界毁灭(或者说,内存回收)。这就像你吃快餐,吃完一口就扔盒子。
但是,当 PHP 变成常驻内存进程时,这个模型就被打破了。
你写了一个脚本,启动了它,它就一直跑,直到你手动 kill 或者重启。在这个漫长的生命周期里,Global 变量是最大的受害者(也是最严重的嫌疑人)。
Global 变量在内存中是常驻的。它们不像局部变量那样,函数执行完就消失了。它们就像是刻在石头上的字,只要石头还在,字就在。
我们常用的 $GLOBALS 数组,以及全局作用域下的变量 $myConfig,都是潜在的内存炸弹。它们持有的对象引用,会像胶水一样,把那些本来该被释放的资源死死粘住,导致内存泄漏。
第二部分:手握“php-meminfo”这件神器
好,废话不多说,我们来聊聊工具。虽然市面上有 Blackfire 和 Xhprof 这种高级货,但今天我们要用一种更接地气、更接近底层的方式来理解。想象一下,php-meminfo 就是一个不知疲倦的清洁工,它每隔几秒就拍拍你的肩膀,问:“嘿,兄弟,刚才这段时间你又往内存里塞了多少垃圾?”
为了演示,我们假设环境已经安装了相关扩展或者我们可以模拟它的输出。php-meminfo 的工作原理通常是:内存快照。
它会截取当前时刻的内存状态,然后过一段时间再截取一次,对比两次快照,告诉你哪些变量在“长胖了”。
代码演示:最简单的泄漏模型
让我们先写一个最简单的脚本,模拟一个糟糕的常驻内存应用:
<?php
// memory_leak_demo.php
// 这是一个典型的“烂写法”
// 我们故意保留一个全局变量,并且在循环中不断往里面塞东西
global $bigBag;
$bigBag = []; // 初始化
// 模拟运行 1000 次任务
for ($i = 0; $i < 1000; $i++) {
// 每次循环往全局变量里塞 1MB 的数据
$bigBag[] = str_repeat('x', 1024 * 1024);
// 模拟业务逻辑处理
usleep(10000);
}
echo "Process finished.n";
如果我们在脚本运行过程中,每隔一段时间运行一次 php-meminfo(或者调用我们的监控函数),我们会发现内存占用像坐火箭一样往上冲。
这就是我们要解决的问题:追踪路径。
第三部分:从宏观到微观——追踪变量的堆积路径
光看内存数字(比如 1024MB),虽然很直观,但它是无情的。它告诉你“有病了”,但没有告诉你“病根在哪”。我们需要通过 php-meminfo 的逻辑,反推变量的来源。
通常,Global 变量的堆积路径有以下几个阶段,我们称之为“漏桶理论”。
1. 引用计数与所有权
在 PHP 内部,每一个变量都有一个 zval 结构体,里面有一个引用计数器。
当你在全局作用域定义 $var = 'hello' 时,引用计数是 1。
当你把它赋值给 $a = $var 时,引用计数变成 2。
如果 $a 变量被销毁了,引用计数减 1 变回 1。
常驻内存应用的特点:
在常驻进程中,全局变量的引用计数永远不会归零,除非你显式地 unset() 它,或者脚本结束。
2. 追踪“粘性”对象
很多时候,泄漏不是因为直接把大数组赋值给全局变量,而是因为对象引用。
假设你有一个 Service 类:
class BadService {
public $data;
public function addData($content) {
// 每次都往这个大对象里 append 数据
$this->data[] = $content;
}
}
// 在全局作用域实例化这个类
global $badService;
$badService = new BadService();
// 业务代码中不断调用
$badService->addData('data');
这里发生了什么?$badService 是全局变量,它持有了 BadService 对象的引用。而 BadService 对象里有一个数组属性 $data,这个数组在不断增长。
诊断路径:
- 监控发现:
$badService对象的内存占用从 1KB 飙升到 100MB。 - 定位变量:
$badService是全局变量。 - 追踪源头:是谁给
$badService赋值的?是谁一直在调用它的方法?
第四部分:实战演练——编写一个简易的 php-meminfo 追踪器
既然我们不能保证你机器上一定装了 php-meminfo 扩展,那我们就用原生代码模拟一下它的核心逻辑。这才是资深专家该做的事情——理解原理,然后自己造轮子。
我们要写一个 MemoryMonitor 类,它能够记录内存快照,并打印出差异。
<?php
class MemoryMonitor {
private $snapshots = [];
// 记录快照
public function takeSnapshot($label = '') {
$info = [
'label' => $label,
'time' => microtime(true),
'memory' => memory_get_usage(true), // 获取真实分配的内存
'peak' => memory_get_peak_usage(true),
'backtrace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5)
];
$this->snapshots[] = $info;
return $info;
}
// 分析差异
public function analyzeLeaks() {
if (count($this->snapshots) < 2) {
echo "需要至少两个快照才能分析。n";
return;
}
echo "=== 内存泄漏诊断报告 ===n";
for ($i = 1; $i < count($this->snapshots); $i++) {
$prev = $this->snapshots[$i - 1];
$curr = $this->snapshots[$i];
$diff = $curr['memory'] - $prev['memory'];
$diffPercent = ($diff / $prev['memory']) * 100;
echo "时间段 [{$prev['label']} -> {$curr['label']}]n";
echo " 内存增长: " . number_format($diff) . " bytes (" . number_format($diffPercent, 2) . "%)n";
// 尝试找到最近的定义源
echo " 关键调用栈:n";
foreach ($curr['backtrace'] as $trace) {
if (isset($trace['file']) && strpos($trace['file'], 'php-meminfo') === false) {
echo " - {$trace['file']} (Line: {$trace['line']})n";
break; // 只显示最近的调用
}
}
echo "------------------------n";
}
}
}
// --- 模拟场景 ---
// 1. 启动监控
$monitor = new MemoryMonitor();
$monitor->takeSnapshot('Start');
// 2. 模拟一个全局变量被填充
global $problematicGlobal;
// 这是一个很常见的场景:在配置加载时,把整个数据库查询结果集存到了全局变量里
$problematicGlobal = new stdClass();
$problematicGlobal->hugeArray = [];
for ($j = 0; $j < 1000; $j++) {
$monitor->takeSnapshot('Loop Start');
// 这里的操作消耗内存
$problematicGlobal->hugeArray[] = str_repeat('A', 1024);
$monitor->takeSnapshot('Loop End');
if ($j % 100 === 0) {
// 每100次打印一次,看看数字
echo "Memory Usage: " . memory_get_usage(true) . "n";
}
}
$monitor->analyzeLeaks();
运行这段代码,你会看到类似这样的输出:
时间段 [Start -> Loop Start]
内存增长: 0 bytes (0.00%)
关键调用栈:
- memory_leak_demo.php (Line: 42)
------------------------
时间段 [Loop Start -> Loop End]
内存增长: 1048576 bytes (0.10%)
关键调用栈:
- memory_leak_demo.php (Line: 45)
------------------------
看到了吗?这就是堆叠路径。每次循环,内存都增加了约 1MB。而 php-meminfo 的魔法就在于,它告诉你这增加的 1MB 是在 memory_leak_demo.php 的第 45 行,由某个操作引起的。
第五部分:深入陷阱——那些“隐形”的 Global 变量
如果上面的例子太简单,那下面我们来点更有挑战性的。在真实的常驻内存应用中,Global 变量往往不是直接定义的,而是通过作用域提升或者魔术方法悄悄溜进去的。
陷阱一:类内部的静态变量
很多开发者为了追求“性能”,会在类中定义静态变量来缓存数据。
class GlobalCache {
public static $cache = [];
public function put($key, $val) {
self::$cache[$key] = $val;
}
public function get($key) {
return self::$cache[$key] ?? null;
}
}
这看起来没问题,对吧?但在常驻进程中,这个静态数组的内存永远不会释放。如果 put 方法被恶意调用(或者逻辑错误),不断向里面写入巨大的对象,整个进程的内存都会被撑爆。
陷阱二:闭包与引用捕获
闭包是 PHP 7+ 的强力特性,但它也是内存泄漏的高发区。如果你在全局作用域定义了一个闭包,并且它引用了一个局部变量,那么这个局部变量在闭包销毁前都不会被释放。
$globalContainer = [];
// 我们定义一个闭包,并把它当做回调传给某个长期运行的任务队列
$callback = function($job) {
// 这个函数需要用到外部的 $globalContainer
$globalContainer[] = $job;
};
// 假设这个 $callback 被注册到了一个 EventLoop 中
// 并且 EventLoop 持有了 $callback 的引用
// 此时,$globalContainer 就像被锁在保险箱里一样,永远无法被 GC
陷阱三:$_SESSION 和 $_SERVER 的滥用
虽然 $_SESSION 和 $_SERVER 是超全局变量,但很多时候开发者会把它们当普通数组用。
// 错误示范
function processRequest() {
// 不要把整个请求对象存到 $_SERVER 里
$_SERVER['RAW_REQUEST_BODY'] = get_large_file_content();
}
一旦你修改了超全局变量,它们在请求周期内(甚至是整个进程生命周期)都会一直存在。
第六部分:如何利用 php-meminfo 进行系统性的排查
既然我们要用 php-meminfo,我们就得懂它的逻辑。php-meminfo 的工作流通常是这样的:
- 采样:在系统的关键节点(比如每次任务执行后、每次定时器触发后)抓取一个内存快照。
- 对比:对比当前快照与基准快照的差异。
- 筛选:找出那个“在增长”的变量。
实战案例:寻找“定时炸弹”
假设你有一个每分钟执行一次的脚本,一个月后内存泄漏了。
// 每分钟执行一次
set_time_limit(0);
global $timer_var;
$timer_var = new stdClass();
while (true) {
// 1. 执行业务逻辑
simulateBusinessLogic();
// 2. 记录快照
// 这里我们调用 php-meminfo 的逻辑,或者自定义函数
report_memory("After Tick " . time());
// 3. 等待 1 分钟
sleep(60);
}
function simulateBusinessLogic() {
global $timer_var;
// 假设这里有一段代码,每次都往全局变量里加东西
// 比如记录日志,但日志没有轮转机制
$timer_var->logs[] = date('Y-m-d H:i:s') . " - Processing requestn";
// 模拟内存消耗
$timer_var->memory_snapshot = memory_get_usage(true);
}
通过 php-meminfo,你会看到 timer_var 这个对象的内存呈线性增长。
核心诊断技巧:
如果发现某个 Global 变量在增长,不要急着 unset 它。先追踪是谁在给它赋值。
你可以手动在代码里加一行打印:
// 在赋值的那一行打断点或者打印
debug_print_backtrace();
你会得到类似这样的输出:
#0 processRequest() called at [/app/index.php:45]
#1 TaskQueue->run() called at [/app/daemon.php:100]
这就找到了路径!原来是 TaskQueue 这个类在疯狂往里塞数据。
第七部分:应对策略——与 Global 变量“分手”
诊断出来不是为了让我们在那儿哀叹,而是为了解决它。在常驻内存应用中,我们要对抗 Global 变量,通常有以下几招:
1. 强制使用单例容器(Service Container)
不要用 global $db,也不要用 class DB { static $conn; }。你要用依赖注入容器。
// 好的做法
$container = new Container();
$container->singleton('db', function() {
return new DatabaseConnection();
});
$db = $container->get('db');
这样,数据的生命周期就被容器控制了,容器可以决定何时销毁对象(如果实现了自动清理机制的话)。
2. 善用 WeakReference(PHP 7.2+)
如果必须要在全局缓存数据,比如缓存一个巨大的用户列表对象,但这个对象在别的地方可能已经被销毁了,你不能让它占用内存。
这时,WeakReference 是神器。
global $weakCache;
$obj = new stdClass();
$ref = new WeakReference($obj);
$weakCache[$id] = $ref;
// 当外部没有任何变量引用 $obj 时
// PHP GC 会自动回收 $obj,$ref 依然存在,但获取到的是 null
3. 定期“大扫除”
如果你实在离不开全局变量(为了兼容旧代码),那就必须要有定期清理的机制。
global $dirtyData;
setInterval(function() {
// 每小时清理一次
if (count($dirtyData) > 1000) {
// 只保留最新的 100 条,或者清理过期数据
$dirtyData = array_slice($dirtyData, 0, 100);
}
}, 3600 * 1000);
4. 监控即生命
别忘了我们今天的主角 php-meminfo。
你可以写一个简单的报警脚本:
#!/bin/bash
# check_memory.sh
# 获取当前内存使用
CUR_MEM=$(php -r "echo memory_get_usage(true);")
LIMIT_MEM=$((1024 * 1024 * 512)) # 512MB
if [ $CUR_MEM -gt $LIMIT_MEM ]; then
echo "WARNING: Memory limit exceeded at $CUR_MEM bytes!"
# 发送邮件或钉钉通知
# php /path/to/send_alert.php
fi
第八部分:总结与反思
各位,写代码其实是在与计算机的底层逻辑博弈。
我们经常为了图方便,随手写一个 global,随手写一个 static,随手把一个巨大的对象塞进 $GLOBALS。这些行为在单次请求的短跑中可能看不出任何问题,就像在食堂里偷偷塞一口饭,没人会发现。但是,当你的应用变成了一个马拉松跑者(常驻进程),你的这些“小动作”就会汇聚成巨大的阻力。
php-meminfo 给我们的最大启示不是那个工具本身,而是“快照对比”的思维方式。
不要只看“当前用了多少内存”,要看“刚才这段时间,内存是怎么变化的”。
如果内存在增长,说明有东西被遗忘了。
如果变量是 Global 的,说明它在请求结束的那一刻还在盯着不放。
最后的忠告:
在常驻内存的世界里,显式优于隐式,控制优于自由。不要让 Global 变量成为你代码里的幽灵。当你看到内存曲线像心电图一样飙升时,请记得深呼吸,打开 php-meminfo,哪怕只是在代码里加一个 debug_backtrace(),你都能找到那个让你头痛的罪魁祸首。
好了,今天的讲座就到这里。现在,去检查一下你的守护进程,看看你的室友是不是又往沙发缝里塞东西了!