各位开发者朋友,大家晚上好(或者下午好,不管几点,只要在写代码,时间就没意义)。
欢迎来到今天的“PHP 内存诊断”专场。我是你们今天的讲师,一个因为服务器半夜报警、全公司趴在桌上睡觉时爬起来看日志而练就了“剩人”体质的老司机。
今天我们要聊的话题有点吓人:隐蔽的内存溢出。特别是那种发生在长连接常驻任务里的,像是“幽灵”一样的内存泄漏。你摸不着它,抓不住它,但当你发现的时候,它已经把你的内存吃成了流沙,你的 PHP 进程变成了一个巨大的、缓慢的废铁。
我们要解决的核心问题很简单:怎么在 PHP 的引用循环里,揪出那个偷吃内存的小偷?
好了,废话不多说,让我们直接进入“案发现场”。
第一部分:PHP 的“记账本”与“死循环”
首先,我们需要搞清楚,PHP 是怎么处理内存的。很多初学者以为 PHP 是“用完即焚”的,这是错的。对于长连接(比如 Swoole、Workerman、ReactPHP 这些基于 EventLoop 的框架),PHP 就像是一个必须24小时营业的便利店,店员(变量)来了又走,但你必须把那个破旧的柜台(内存)留着,不能随便扔。
PHP 的内存管理主要靠一个叫“Zval”的结构体。你可以把它想象成每个变量都有一个“借书证”。
- 引用计数: 每个人都记录了这本书被几个人借了。
- GC Roots: 这是图书馆管理员手里的账本,里面记录了谁正在借书。
当脚本结束时,PHP 会检查所有人手里的借书证。如果一本书的借书证上写着“0个人借”,管理员就会把书扔进垃圾桶,内存回收。这就叫“自动垃圾回收”。
但是! 这里有一个致命的漏洞,或者说是一个陷阱。
引用循环:
这就好比你借了一本书(对象A),然后借书的人又借了一本书(对象B),对象B又借了对象A。你手里拿着A,B手里拿着A,A手里拿着B。这时候,所有人的借书证计数都不为0。
管理员走到A面前问:“这本书归谁?” A说:“我在B手里。” 管理员走到B面前问:“这本书归谁?” B说:“我在A手里。”
两人就在那儿互相看着,谁也不放手。于是,垃圾永远不会被回收。
在长连接任务里,这种“互相搂着睡觉”的代码一旦写进去,内存就像滚雪球一样,越滚越大。而且,因为它只是内存占用,而不是报错(比如 Fatal Error),所以它是最隐蔽的。CPU 占用率可能还是 1%,但内存占用率已经 90% 了,最后导致 OOM Killer(内存杀手)直接把进程干掉。
第二部分:实战演练——搭建一个“作死”的常驻环境
为了演示,我们得先搭建一个简单的常驻环境。假设我们用最流行的 Swoole(或者 Workerman,逻辑一样)。我们要模拟一个“在线客服系统”。
错误的代码示例(循环的诱饵):
<?php
// demo.php
require_once 'vendor/autoload.php';
class User {
public $name;
public $connection; // 模拟一个长连接句柄
public function __construct($name, $conn) {
$this->name = $name;
$this->connection = $conn;
}
}
class Server {
// 这是我们的静态变量,常驻内存的罪魁祸首
public static $users = [];
}
// 模拟一个长连接循环
while (true) {
// 1. 模拟接收到一个新连接
$mockConn = new StdClass(); // 真实场景下这里是 swoole/server/conn
$mockConn->fd = rand(1000, 9999);
// 2. 创建用户对象
$user = new User("Alex", $mockConn);
// 3. 危险操作!把用户扔进全局数组
Server::$users[] = $user;
// 4. 模拟用户对象也“记住”了这个服务器(常见的业务逻辑冗余)
$user->server = Server::class;
// 5. 假装业务处理了一会儿
usleep(100000); // 0.1秒
// 6. 程序员以为处理完了,清空了局部变量
unset($user);
unset($mockConn);
}
运行这个脚本,大概运行个几万次循环,你会发现内存占用直线上升,直到撑爆内存。
为什么?
Server::$users是一个静态数组,它一直存在于内存里。- 虽然我们在循环末尾
unset($user)了,但如果业务逻辑里,$user对象持有了一些无法被销毁的引用,或者数组本身还保留着对这个对象的引用…… - 关键点来了:如果一个对象引用了数组,数组引用了对象,这个循环怎么破?
在我们的例子中,Server::$users 是一个静态属性,它是 GC 的根。只要 Server 类还在,$users 数组就在。只要数组里有对象,对象就不会死。哪怕你 unset 了局部变量 $user,如果 Server::$users 还在引用它,它就活得好好的。
第三部分:如何“抓鬼”——诊断工具箱
识别这种鬼东西,不能靠猜,得靠证据。我们需要三样神器:XHprof / Tideways(性能分析)、Debug Dump(自我审视)、内存增长曲线(趋势分析)。
1. 基础版:memory_get_usage 的锯齿状曲线
这是最原始的方法。
echo memory_get_usage() . "n";
如果你的代码写得很正常,memory_get_usage() 应该像是一个锯齿状的波形。处理完请求 -> 内存下降 -> 接到新请求 -> 内存上升 -> 再次下降。
如果这是一个直线上升的波形,恭喜你,你大概率遇到了内存泄漏。
但这个方法太粗糙。它只能告诉你“坏了”,不能告诉你“是谁干的”。
2. 进阶版:XHprof / Tideways 的“对象创建”视角
在长连接场景下,我们不关心 CPU,我们关心对象的生与死。
建议安装 Tideways(XHprof 的现代替代品)。开启 Profiling。
当你发现内存涨了,你去 Tideways 的 UI 界面上看。
不要看 Memory Used,那个只是结果。
你要看 Memory Delta 和 Instances。
找到那些 Instances 数量在不断增加,但对应的 Memory Delta 却一直是正数的类。
你会看到一个恐怖的列表,上面列着你的 User、Request、Database 连接对象。它们像是僵尸一样,只生不灭。
3. 终极版:手动绘制“引用图”
这是解决引用循环的核武器。我们需要一段代码,专门用来分析当前的变量表。
PHP 5.3 以后,有一个函数叫 debug_zval_dump,它能看到 Zval 的引用计数(refcount)和 is_ref(是否是引用传递)。但在现代 PHP 里,我们更推荐手动遍历 xdebug_debug_zval 或者利用 Swoole 的 var_export 配合文本绘图工具。
我写了一个“内存取证”的脚本,放在任何长连接代码里跑一遍,它会自动生成一个引用图:
<?php
// memory_forensics.php
function analyze_memory_graph() {
echo "=== PHP 内存取证报告 ===n";
// 1. 获取所有已定义的变量(仅限当前作用域,如果你想在全局作用域看,需要 global 或者超级全局变量)
// 注意:在长连接里,你要看的关键通常在全局变量或静态属性里
$globals = get_defined_vars();
// 这里我们演示如何递归分析一个特定的对象,比如我们怀疑是 $user
if (isset($globals['user']) && is_object($globals['user'])) {
dump_object_graph($globals['user'], "User Object");
}
// 2. 如果你想看所有的全局变量,逻辑稍微复杂一点,这里为了演示简化
foreach ($globals as $scope => $vars) {
if (is_array($vars)) {
foreach ($vars as $key => $val) {
if (is_object($val)) {
dump_object_graph($val, "Global[$scope][$key]");
}
}
}
}
}
function dump_object_graph($obj, $label, &$visited = []) {
$id = spl_object_id($obj);
if (in_array($id, $visited)) {
echo " ... (循环引用检测,已跳过) ...n";
return;
}
$visited[] = $id;
echo "Node: [$label] (Object ID: $id)n";
echo " Refcount: " . debug_zval_dump($obj) . "n"; // 这是一个简化展示,实际需要解析字符串
// 递归打印属性
foreach ((array)$obj as $property => $value) {
if (is_object($value)) {
$new_label = $label . "->" . $property;
echo " -> Links to: $new_labeln";
dump_object_graph($value, $new_label, $visited);
} elseif (is_array($value)) {
// 简单检查数组里是否有对象
foreach ($value as $item) {
if (is_object($item)) {
$new_label = $label . "->" . $property . "[]";
echo " -> Links to: $new_labeln";
dump_object_graph($item, $new_label, $visited);
}
}
}
}
}
// 在你的常驻代码里调用
// analyze_memory_graph();
这段代码虽然简单,但它能帮你理清谁把谁抱住了。
4. 视觉化大招:文本 Graphviz
如果你觉得上面的文字太枯燥,我们可以把上面的逻辑写得更高级一点,生成 DOT 格式的代码,然后扔给 Graphviz 看。
// 伪代码演示:生成 DOT 文件
$dot = "digraph MemoryLeak {n";
foreach ($users as $index => $user) {
$dot .= " User_$index [label="User $index"];n";
}
// 假设 User_0 引用了 User_1,User_1 引用了 User_0
$dot .= " User_0 -> User_1;n";
$dot .= " User_1 -> User_0;n";
$dot .= "}";
file_put_contents('leak.dot', $dot);
// 然后你在终端运行 dot -Tpng leak.dot -o leak.png
// 你会看到一个死结。
第四部分:案例深挖——在线客服系统的“僵尸”
让我们深入一个具体的、典型的业务场景:IM 在线客服系统。
场景描述:
- 用户连接上来。
- 后端创建一个
User对象,包含用户信息、Session、上下文。 - 后端把这个
User对象挂在Server的OnlineUsers数组里。 - 问题出现: 为了方便查找,我们在
User对象里也存了一个Server的引用,或者存了一个静态的UserFactory实例。
代码逻辑:
class User {
public $info;
public $serverContext; // 错误:这里持有 Server 的引用
public function __construct($info, $server) {
$this->info = $info;
$this->serverContext = $server;
}
}
class ServerContext {
public static $onlineUsers = []; // 这里的静态数组是 GC 根
}
// 在业务循环中
$serverInstance = new ServerContext();
while (true) {
$conn = accept_connection();
$userInfo = get_user_info($conn);
// 创建用户
$user = new User($userInfo, $serverInstance);
// 加入在线列表
ServerContext::$onlineUsers[] = $user;
// 处理消息...
// 逻辑漏洞:$user 离线时,没有从 ServerContext::$onlineUsers 中移除
// 或者,虽然移除了,但是业务逻辑中某个地方又把这个 User 对象引用回来了
}
诊断过程:
- 现象: 随着用户上线下线,内存不降反升。
- 取证: 运行我上面写的
dump_object_graph。
你会发现:ServerContext::$onlineUsers指向User_1。User_1指向ServerContext。ServerContext静态属性是一个“超长数组”,它永远活着,所以它里面的所有User也永远活着。
- 根因:
- 根 1: 数据结构设计错误。静态数组不应该用来存大量的动态对象,应该用更高效的容器(如 SplFixedArray 或 DisjointSet),或者在对象销毁时手动
unset数组里的元素。 - 根 2:
User对象持有ServerContext的引用。这是典型的“回环引用”。虽然这不一定会导致内存泄漏(如果数组会清理),但如果数组清理不及时,或者静态变量被其他地方引用,循环就成立了。
- 根 1: 数据结构设计错误。静态数组不应该用来存大量的动态对象,应该用更高效的容器(如 SplFixedArray 或 DisjointSet),或者在对象销毁时手动
解决方案:
方案 A:打破循环(手动释放)
这是最稳妥的旧时代做法。
function logout_user($userId) {
// 1. 在数组里找到它
$index = array_search($userId, ServerContext::$onlineUsers);
if ($index !== false) {
// 2. 手动销毁对象
$user = ServerContext::$onlineUsers[$index];
unset($user); // 解除局部引用
// 3. 从数组中移除(移除引用)
unset(ServerContext::$onlineUsers[$index]);
// 4. 强制垃圾回收(虽然 PHP 通常会自动做,但在长连接里,显式一点更安全)
gc_collect_cycles();
}
}
方案 B:使用 WeakReference(现代 PHP 的救星)
PHP 7.4 引入了 WeakReference。这是什么?它是一个“纸糊的朋友”。它引用对象,但它不增加对象的引用计数。
这意味着,如果只有 WeakReference 引用某个对象,没有其他东西引用它,垃圾回收器就会把它回收。
我们可以用 WeakReference 来构建在线用户表。即使 ServerContext 被销毁,或者用户下线了,只要没有其他强引用,WeakReference 里的对象就能自动消失,内存就干净了。
use WeakReference;
class ServerContext {
public static $onlineUsers = [];
}
while (true) {
$user = new User($info, $serverInstance);
// 使用 WeakReference 包装
// 此时 User 的引用计数并没有因为放入这里而增加
// 如果外部没有其他东西引用 User,User 可以被 GC
ServerContext::$onlineUsers[] = WeakReference::create($user);
// 处理逻辑...
}
第五部分:进阶心法——如何从根本上杜绝
识别出了问题,我们还得防止下次再掉坑里。作为资深专家,我总结了几条“防漏锦囊”:
1. 警惕“上帝对象”
不要在一个对象里持有其他所有对象的引用,尤其是持有容器类或服务类的引用。这会形成巨大的依赖网,稍微不注意就是一个死结。
2. 静态变量的使用禁忌
在长连接场景下,static 变量比 global 变量更危险,因为它们的生命周期等同于进程生命周期。除非你是为了缓存热点数据,否则不要把用户会话、请求上下文塞进静态变量。
3. 记住 unset 的两个阶段
unset 有两个作用:
- 切断你手上的引用。
- 让引用计数减 1。
如果对象还有别的手(其他地方)抓着它,unset只是切断你的手,对象并不会死。
所以,清除引用必须在“释放资源”之前。比如数据库连接、文件句柄、Socket 连接,一定要先close或closeConnection,再unset对象。
4. 利用 debug_zval_dump 进行代码审查
在写代码的时候,如果你不确定某个对象会不会死,在写完关键逻辑后,立刻在代码里打一个 debug_zval_dump($obj);。看那个红色的 refcount 是多少。如果是 1,那就是安全的;如果是 >1,你就得想想办法切断多余的引用了。
第六部分:总结与实战演练(最后的重击)
好了,理论讲得差不多,嗓子也有点干了。最后,让我们来一场实战演练,看看如何从 0 到 1 解决一个真实的内存泄漏。
假设场景: 你在做一个 WebSocket 聊天室。
症状: 每天早上 9 点,服务器负载突然 100%,进程被杀。
排查步骤:
-
抓 Log: 发现没有报错,只有
Killed process。 -
写监控: 在代码里每隔 60 秒记录一次
memory_get_usage()。
结果:内存每秒增长 10KB。 -
找嫌疑犯: 查看最近的代码提交,发现添加了“消息历史记录”功能。
代码逻辑是:每个User对象里都存了一个Message[]数组,用来显示历史消息。class User { public $messages = []; // 存了 1000 条消息 public $lastMsgId = 12345; } -
定位循环:
聊天室有一个ChatRoom类,它持有一个User[]的数组。
ChatRoom::$users引用User_1。
User_1引用ChatRoom(为了查找最近的聊天记录?)。
或者更简单,User数组里的每个User都引用了一个Database连接对象,而这个连接对象里又引用了ChatRoom。 -
修复:
- 策略 1: 不要把所有消息存在内存里,存数据库,内存里只存 ID 或分页数据。
- 策略 2: 当用户下线(断开连接)时,必须遍历
ChatRoom::$users,找到该用户并执行unset($user),或者清空其messages属性(虽然清空属性不算切断引用,但如果消息对象本身不持有外部引用,清空数组也算释放)。 - 策略 3: 重构数据结构,使用
WeakReference重建在线用户列表。
终极技巧:
有时候,你甚至不需要写代码去清空变量。你只需要重启进程。
如果你的诊断非常精准,确定罪魁祸首是一个引用循环,那么,在业务低峰期(比如凌晨 3 点)写一个脚本,重启你的 PHP 常驻进程。这虽然不是根治方法,但在生产环境中,这是防止服务器在上班时间炸裂的“核弹级”止损手段。
结语
各位朋友,PHP 的内存管理机制其实很优雅,就像一个尽职尽责的图书管理员。但优雅的机制也有它的盲区,那就是“死循环”。
这种隐蔽的内存溢出,就像是你电脑里的“下载残留文件夹”,看着不大,却一直在默默吞噬你的 C 盘空间,直到你发现的时候,一切都晚了。
希望通过今天的讲座,下次当你看到服务器内存曲线那条诡异的直线时,你不会再手忙脚乱地重启服务器,而是淡定地掏出你的 debug_zval_dump 和 xhprof,对着那个看不见的“引用循环”,狠狠地戳它的脊梁骨。
记住,在长连接的世界里,万物皆可引用,万物皆可循环,唯有你的代码逻辑,必须清晰。
祝大家写出没有内存泄漏的代码,睡个安稳觉。下课!