PHP 一般不太需要担心内存泄漏——请求结束进程就清理了。但常驻进程不一样。记录一次排查过程。
背景
有个用 Swoole 跑的 WebSocket 推送服务,常驻内存。上线之后稳定运行大概 3~5 天,然后内存涨到上限被杀掉重启。
单次重启影响不大(客户端会自动重连),但每天都有几次,监控告警很烦。
第一步:确认是泄漏还是缓存
先看内存曲线。如果是缓存导致的增长,会有一个平台期——涨到某个值就平稳了。我们的是一直线性上涨,没有平台,基本可以确认是泄漏。
粗略计算:进程大概每秒处理 200 条消息,一天 1700 万条。内存一天涨 400MB 左右,平均每条消息泄漏约 25 字节。
25 字节很小,所以找起来费劲。
第二步:定位泄漏点
Swoole 提供了内存统计:
Swoole\Runtime::getMemoryUsage(); // 当前使用的内存
我在几个关键位置打点,输出内存的差量:
function memDiff(string $label, callable $fn) {
gc_collect_cycles();
$before = memory_get_usage(true);
$result = $fn();
gc_collect_cycles();
$after = memory_get_usage(true);
$diff = $after - $before;
if ($diff !== 0) {
log_debug(sprintf('[mem] %s: %+d bytes', $label, $diff));
}
return $result;
}
注意要先 gc_collect_cycles() 排除循环引用的干扰,否则每次都有一堆待回收的对象,测不准。
跑了一段时间,发现消息广播这一步每次固定涨 100 多个字节。
第三步:看广播代码
简化后的代码:
class Broadcaster
{
/** @var array<int, Connection> */
private array $connections = [];
/** @var array<string, array<int>> 频道 -> 连接 ID */
private array $channels = [];
public function onOpen(Connection $conn): void
{
$this->connections[$conn->id] = $conn;
}
public function subscribe(int $connId, string $channel): void
{
$this->channels[$channel][] = $connId;
}
public function onClose(Connection $conn): void
{
unset($this->connections[$conn->id]);
// ❌ 忘了从 $this->channels 里移除!
}
public function broadcast(string $channel, string $msg): void
{
foreach ($this->channels[$channel] ?? [] as $connId) {
$this->connections[$connId]?->send($msg);
}
}
}
问题就在 onClose。
连接关闭时,$connections 里的条目删了,但 $channels 里还留着那个 $connId。
这个数组只增不减,每个连接关闭就多一个失效的整数 ID。时间长了:
$channels数组本身占内存(每个 int 在 PHP 数组里约 36 字节,加上哈希桶)- 广播时遍历到失效 ID,虽然
?->send会跳过,但白遍历
而且——这才是关键——如果连接 ID 是自增的、会重用,那些失效的 ID 可能指向新连接,导致给错误的用户发消息。这是个安全问题。
第四步:验证
修之前的复现:
$b = new Broadcaster();
for ($i = 1; $i <= 100000; $i++) {
$conn = new Connection($i);
$b->onOpen($conn);
$b->subscribe($i, 'default');
$b->onClose($conn); // 关闭
}
// $b->channels['default'] 长度 = 100000,全是失效 ID
10 万次连接开闭之后,channels 数组里有 10 万个死 ID。
修复
public function onClose(Connection $conn): void
{
unset($this->connections[$conn->id]);
// 从所有频道移除
foreach ($this->channels as $channel => &$ids) {
$idx = array_search($conn->id, $ids, true);
if ($idx !== false) {
unset($ids[$idx]);
}
// 频道空了就删掉整个 key,避免留下空数组占位
if (empty($ids)) {
unset($this->channels[$channel]);
}
}
unset($ids); // 断开引用,防止下一个坑
}
注意几个细节:
一、array_search 传 true 严格比较。 不传的话 0 和 '0' 会混淆。
二、unset($ids) 必须加。 这是 PHP 引用循环的经典陷阱——&$ids 之后 $ids 还引用着最后一个元素,后面如果再用 $ids 会破坏数组。
三、删空频道。 不留 [] 空数组,因为 PHP 里空数组也占内存(大概 56 字节,哈希表结构)。
但这样每次关闭连接都遍历所有频道,如果频道很多会很慢。改用每个连接记录自己订阅了哪些频道:
class Broadcaster
{
private array $connections = [];
private array $channels = [];
/** @var array<int, array<string, true>> 连接 ID -> 频道集合 */
private array $subscriptions = [];
public function subscribe(int $connId, string $channel): void
{
// 用 true 作为值,这样用 isset 判断比 in_array 快
$this->subscriptions[$connId][$channel] = true;
$this->channels[$channel][$connId] = true;
}
public function onClose(Connection $conn): void
{
unset($this->connections[$conn->id]);
// 只处理这个连接订阅过的频道
foreach ($this->subscriptions[$conn->id] ?? [] as $channel => $_) {
unset($this->channels[$channel][$conn->id]);
if (empty($this->channels[$channel])) {
unset($this->channels[$channel]);
}
}
unset($this->subscriptions[$conn->id]);
}
}
顺带把 $channels 的结构从"列表 + array_search"改成"集合(值恒为 true)",删除变成 O(1)。
其它的泄漏点
修完上面那个之后,内存增长变慢了但没有完全停止。继续查,又找到两处。
泄漏点二:闭包捕获了大对象
// 每次消息处理都注册一个定时器
Swoole\Timer::after(5000, function () use ($message, $user) {
$this->logger->info('消息已处理', ['msg' => $message, 'user' => $user]);
});
这个闭包会持有 $message 和 $user 直到定时器触发。如果 $user 是个大对象(含头像、权限列表等),5 秒内积压几千个定时器就是几百 MB。
改法:只捕获 ID,回调里重新查,或者只捕获需要的小字段。
$msgId = $message->id;
$userId = $user->id;
Swoole\Timer::after(5000, function () use ($msgId, $userId) {
$this->logger->info('消息已处理', ['msg_id' => $msgId, 'user_id' => $userId]);
});
泄漏点三:事件监听器重复注册
public function handle(Message $msg): void
{
// 每处理一条消息都注册一次!
$this->dispatcher->addListener('message.ack', function ($e) {
// ...
});
}
事件总线里的监听器数组每次都在增长。一条消息注册一次,跑一天就是几千万个闭包。
改法:监听器在构造函数里注册一次,或者加去重逻辑。
我总结的常驻进程内存检查清单
一、所有"注册"都要有对应的"注销"。 事件监听、频道订阅、定时器、缓存条目,全都是。
这一点是最重要的。常驻进程的内存模型和请求-响应完全不同,没有自动清理。
二、小心闭包捕获。 闭包会持有捕获变量的引用,包括间接持有的大对象图。定时器、回调、事件监听里的闭包要特别注意。
三、用 ID 代替对象。 内部缓存里存对象引用容易形成引用环;存 ID 然后按需查,内存和正确性都更好。
四、定期检查容器大小。 加个定时任务,每 5 分钟打印一次关键容器(连接表、会话表、队列)的长度。长度持续增长就是信号。
Swoole\Timer::tick(300000, function () {
log_info('容器统计', [
'connections' => count($this->connections),
'channels' => count($this->channels),
'subscriptions' => count($this->subscriptions),
'memory_mb' => round(memory_get_usage(true) / 1048576, 1),
]);
});
这个日志后来帮我提前发现了两次类似问题。
五、主动重启兜底。 即使排查干净了,我也保留一个"内存超过 X 就优雅重启"的机制。不是掩盖问题,是安全网。
Swoole\Timer::tick(60000, function () {
if (memory_get_usage(true) > 512 * 1024 * 1024) {
log_warning('内存超限,准备优雅重启');
$this->server->shutdown(); // 触发 worker 平滑重启
}
});
六、gc_collect_cycles 不是万能的。 它只能回收循环引用,回收不了被外部引用的对象。而且频繁调用有开销。
七、不要用 memory_get_usage() 判断泄漏。 它受 GC 时机影响会有波动,要多次采样看趋势。用 memory_get_usage(true) 看实际分配(不受 GC 影响)。
最后
这三个泄漏点的共同特征:都是"忘记清理",而不是什么深奥的内存管理问题。
PHP 开发者习惯了"请求结束一切归零",写常驻进程时这个直觉会误导人。
修完之后服务跑了两个多月没重启。我保留的自动重启机制一次都没触发过。