PHP 内存溢出常见原因与优化排查步骤

PHP 内存溢出常见原因与优化排查步骤

引言

线上环境最让人头疼的报错之一:Fatal error: Allowed memory size of xxx bytes exhausted。

它不像语法错误那样一眼能看出来,往往是在数据量一上来、跑个定时任务、导个几万行 Excel 的时候突然炸掉。

本文从内存机制讲起,系统梳理 PHP 内存溢出的 6 大常见原因、5 步排查法、以及生产环境可直接落地的优化方案。


目录

  1. PHP 内存管理机制简述
  2. 内存溢出的 6 大常见原因
  3. 5 步快速排查法(生产可用)
  4. 代码级优化方案
  5. 配置与运行环境调优
  6. 特殊场景:CLI / FPM / 队列差异
  7. 预防体系:从编码到监控

一、PHP 内存管理机制简述

PHP 使用**引用计数 + 垃圾回收(GC)**管理内存:

  • 每个变量有引用计数,计数归零时释放

  • 循环引用由 GC 定期回收(默认 10000 次分配触发一次检查)

  • 单次请求的内存上限由 memory_limit 控制

  • FPM 模式下请求结束自动释放;CLI 常驻进程不会自动释放

    // 查看当前内存使用
    echo memory_get_usage(); // 当前分配
    echo memory_get_peak_usage(); // 峰值(排障关键)

⚠️ memory_limit 不是"已用内存上限",而是"PHP 进程最多能从 OS 申请多少"。超限直接 Fatal,脚本终止。


二、内存溢出的 6 大常见原因

原因 1:一次性加载大量数据到内存

最常见,占生产事故的 60%+

复制代码
// ❌ 致命写法:10万行数据全部读入数组
$rows = $pdo->query("SELECT * FROM orders")->fetchAll();

// ✅ 逐行读取
$stmt = $pdo->query("SELECT * FROM orders");
while ($row = $stmt->fetch()) {
    process($row);
}

典型场景:

  • 导出 Excel 一次性读全表
  • 后台统计把所有记录 fetchAll() 后 foreach
  • API 返回不分页的大列表

原因 2:循环内累积变量不释放

复制代码
// ❌ 数组无限增长
$result = [];
foreach ($bigList as $item) {
    $result[] = expensiveTransform($item);
}
// $result 可能膨胀到几百 MB

// ✅ 边处理边释放,或分批
foreach ($bigList as $item) {
    $transformed = expensiveTransform($item);
    sendToQueue($transformed);
    unset($transformed); // 手动解除引用
}

原因 3:全局变量 / 静态属性跨请求累积

复制代码
// ❌ 静态变量在常驻进程中永不释放
class Cache {
    public static $data = [];
}
Cache::$data[] = $hugeArray; // 每次请求追加,直到 OOM

// ✅ 用 TTL 缓存或请求级容器
// Laravel: app('cache')->remember(...)
// Swoole: 用协程上下文 Swoole\Coroutine::getContext()

原因 4:循环引用导致 GC 无法回收

复制代码
// ❌ 父子互相引用,GC 无法自动回收
class Parent_ {
    public $child;
}
class Child {
    public $parent;
}
for ($i = 0; $i < 100000; $i++) {
    $p = new Parent_();
    $c = new Child();
    $p->child = $c;
    $c->parent = $p;
}
// 循环引用:即使 $p/$c 超出作用域,引用计数不为 0

// ✅ 手动断开
$p->child = null;
$c->parent = null;

// ✅ 或主动触发 GC
gc_collect_cycles();

PHP 7+ 的 GC 能处理循环引用,但回收有延迟且不是实时的。大数据量循环里要主动 unset()。


原因 5:大字符串 / 大数组操作

复制代码
// ❌ 字符串拼接产生大量临时副本
$html = '';
for ($i = 0; $i < 100000; $i++) {
    $html .= "<tr><td>{$i}</td><td>{$data[$i]}</td></tr>";
}
// 每次 .= 都可能分配新内存,旧内存等 GC

// ✅ 用数组收集后 join
$lines = [];
for ($i = 0; $i < 100000; $i++) {
    $lines[] = "<tr><td>{$i}</td><td>{$data[$i]}</td></tr>";
}
$html = implode('', $lines);

原因 6:第三方库/框架的内存泄漏

  • 日志库缓存所有日志到数组(Monolog BufferHandler 未 flush)

  • ORM 的 Identity Map 缓存所有查询出的实体

  • Excel 库(PhpSpreadsheet)一次性加载整个文件

    // PhpSpreadsheet 读大文件必须用只读模式
    reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx(); reader->setReadDataOnly(true);
    $reader->setReadEmptyCells(false);
    // 或改用生成器模式逐行读


三、5 步快速排查法(生产可用)

第 1 步:确认内存上限和当前峰值

复制代码
// 在脚本入口和出口各打一次
$startMem = memory_get_usage(true);
$peakMem  = memory_get_peak_usage(true);
$limit    = ini_get('memory_limit');

error_log(sprintf(
    'Memory: start=%s peak=%s limit=%s',
    formatBytes($startMem),
    formatBytes($peakMem),
    $limit
));

# 命令行直接看
php -i | grep memory_limit
php -r 'echo memory_get_peak_usage(true)/1024/1024 . "MB\n";'

第 2 步:定位哪行代码吃内存

复制代码
// 在关键位置埋点
function memMark($label) {
    static $last = null;
    $curr = memory_get_usage(true);
    $diff = $last !== null ? $curr - $last : 0;
    error_log(sprintf(
        '[MEM] %s: %s (+%s)',
        $label,
        formatBytes($curr),
        formatBytes($diff)
    ));
    $last = $curr;
}

memMark('start');
$rows = $db->fetchAll($sql);
memMark('after fetchAll');
foreach ($rows as $r) { ... }
memMark('after loop');

输出示例:

复制代码
[MEM] start: 4MB (+0)
[MEM] after fetchAll: 512MB (+508MB)  ← 问题在这!
[MEM] after loop: 514MB (+2MB)

第 3 步:用 xdebug 生成内存分析文件

复制代码
; php.ini
xdebug.mode = profiler
xdebug.start_with_request = yes
xdebug.output_dir = /tmp/xdebug

# 跑一次请求/脚本
# 用 cachegrind 工具分析
kcachegrind /tmp/xdebug/cachegrind.out.*

能看到每个函数调用的内存分配量,精确定位。


第 4 步:用 PHP 内置函数追踪大变量

复制代码
// 找出当前作用域中最大的变量
$declared = get_defined_vars();
arsort($declared, SORT_NUMERIC);
foreach (array_slice($declared, 0, 5, true) as $name => $value) {
    error_log(sprintf(
        'Var $%s: %s',
        $name,
        formatBytes(strlen(serialize($value)))
    ));
}

第 5 步:线上日志关联排查

复制代码
# 搜索 OOM 错误
grep -r "Allowed memory size" /var/log/php/ /var/log/php-fpm/

# 看慢日志里是否有大请求
grep -A5 "slowlog" /var/log/php-fpm/www-slow.log

# 看 FPM 进程内存
ps aux --sort=-rss | head -20

四、代码级优化方案

1. 数据库:游标 / 生成器

复制代码
// PDO 非缓冲查询(真正流式)
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query("SELECT * FROM huge_table");
while ($row = $stmt->fetch()) {
    process($row);
}

// Laravel 游标(内存友好)
foreach (Order::cursor() as $order) {
    // 每次只加载一条到内存
    process($order);
}

2. 分批处理

复制代码
// 每 500 条一批
$query = Order::where('status', 'pending');
$query->chunkById(500, function ($orders) {
    foreach ($orders as $order) {
        process($order);
    }
    // 批次结束,Laravel 自动释放集合
});

3. 及时 unset + 主动 GC

复制代码
for ($i = 0; $i < 100000; $i++) {
    $data = loadBigData($i);
    process($data);
    unset($data); // 立即解除引用

    if ($i % 1000 === 0) {
        gc_collect_cycles(); // 每 1000 次主动回收
    }
}

4. 大文件处理用流式读写

复制代码
// ❌ file_get_contents 读 500MB 文件
$content = file_get_contents('huge.json');

// ✅ 流式逐行
$handle = fopen('huge.json', 'r');
while (!feof($handle)) {
    $line = fgets($handle);
    processLine($line);
}
fclose($handle);

5. 用生成器(Generator)延迟计算

复制代码
function readLargeFile($path) {
    $handle = fopen($path, 'r');
    while (!feof($handle)) {
        yield fgets($handle);
    }
    fclose($handle);
}

// 调用方
foreach (readLargeFile('huge.log') as $line) {
    processLine($line);
}
// 同一时刻只有一行在内存里

五、配置与运行环境调优

memory_limit 设置建议

场景 建议值
普通 Web 请求 128M - 256M
API 接口 64M - 128M
后台任务/导出 512M - 1G
CLI 数据处理 1G - 2G
不要全局设 2G+ 掩盖代码问题
复制代码
; php.ini
memory_limit = 256M

; 特定脚本临时调大
php -d memory_limit=1G script.php

FPM 进程内存上限(防雪崩)

复制代码
; php-fpm.conf pool
php_admin_value[memory_limit] = 256M

单个 FPM 进程 OOM 不影响其他进程,但 pm.max_children × memory_limit 不能超过服务器 RAM。


六、特殊场景:CLI / FPM / 队列差异

场景 特点 注意点
FPM 请求结束自动释放 单次请求别超 256M
CLI 脚本 常驻执行,无自动释放 主动 unset + GC
队列 Worker 循环处理,内存累积 处理 N 个任务后重启
Swoole/Workerman 进程级常驻 静态变量、连接池、上下文隔离

队列 Worker 内存保护

复制代码
// Laravel 队列自带
// config/queue.php
'max_memory' => 512, // MB,超过自动重启

// 或手动
$processed = 0;
while ($task = $queue->pop()) {
    process($task);
    $processed++;
    if ($processed % 100 === 0 && memory_get_usage(true) > 400 * 1024 * 1024) {
        exit(0); // Supervisor 自动拉起
    }
}

七、预防体系:从编码到监控

编码规范

  1. 禁止 fetchAll() 全表查询(Code Review 拦截)
  2. 循环内禁止数组累积超 1000 条
  3. 导出功能必须流式输出
  4. 常驻进程禁止静态变量存业务数据
  5. 第三方库评估内存占用(PhpSpreadsheet 等)

CI/CD 检测

复制代码
# PHPStan 规则检测大数组
# phpstan.neon
rules:
    - PHPStan\Rules\Functions\PrintfParametersRule
    # 自定义规则:检测 fetchAll 调用

线上监控

复制代码
# 定时记录 FPM 进程内存
ps aux | grep php-fpm | awk '{sum+=$6} END {print sum/1024 "MB"}'

# 日志告警:内存超阈值
grep "Allowed memory size" /var/log/php-fpm/*.log | tail -100

告警规则建议

  • 单进程 RSS > 200MB 持续 5 分钟 → 告警
  • 单请求 memory_get_peak_usage() > 128MB → 记录
  • OOM 错误出现 → 立即告警

总结

PHP 内存溢出排查的核心思路:

复制代码
看日志 → 确认峰值 → 埋点定位 → 优化代码 → 监控预防

记住三条铁律:

  1. 永远不要一次性加载你不需要的全部数据
  2. 循环内产生的变量,循环外要释放
  3. memory_limit 是安全网,不是解决方案

调大 memory_limit 能让你今晚睡个好觉,但修好代码才能让你永远睡好觉。

相关推荐
美狐美颜SDK开放平台2 小时前
开发直播APP、电商短视频与视频社交平台时,能否共用一套美颜SDK?
android·图像处理·人工智能·音视频·直播美颜sdk
事圆则缓2 小时前
Android 中的 MQTT 协议:从设备状态推送到可靠消息处理
android
三雷科技2 小时前
Android 开发环境搭建指南
android
赵庆明老师2 小时前
Android Studio Google源代理配置
android·ide·android studio
事圆则缓3 小时前
Android 核心名词手册跟读版
android
snysy3 小时前
WordPress伪静态404故障排查笔记
android·笔记·wordpress
zhangphil3 小时前
海量小文件造成Android存储碎片化与I/O变慢
android
Kapaseker4 小时前
Kotlin 2.5 Value Class 终于能放多个属性了
android·kotlin
恋猫de小郭4 小时前
Flutter Android 性能修复:8 个大核的手机,Raster 居然只跑在 2 个核上?
android·flutter