PHP 内存溢出常见原因与优化排查步骤
引言
线上环境最让人头疼的报错之一:Fatal error: Allowed memory size of xxx bytes exhausted。
它不像语法错误那样一眼能看出来,往往是在数据量一上来、跑个定时任务、导个几万行 Excel 的时候突然炸掉。
本文从内存机制讲起,系统梳理 PHP 内存溢出的 6 大常见原因、5 步排查法、以及生产环境可直接落地的优化方案。
目录
- PHP 内存管理机制简述
- 内存溢出的 6 大常见原因
- 5 步快速排查法(生产可用)
- 代码级优化方案
- 配置与运行环境调优
- 特殊场景:CLI / FPM / 队列差异
- 预防体系:从编码到监控
一、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 自动拉起
}
}
七、预防体系:从编码到监控
编码规范
- 禁止
fetchAll()全表查询(Code Review 拦截) - 循环内禁止数组累积超 1000 条
- 导出功能必须流式输出
- 常驻进程禁止静态变量存业务数据
- 第三方库评估内存占用(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 内存溢出排查的核心思路:
看日志 → 确认峰值 → 埋点定位 → 优化代码 → 监控预防
记住三条铁律:
- 永远不要一次性加载你不需要的全部数据
- 循环内产生的变量,循环外要释放
- memory_limit 是安全网,不是解决方案
调大
memory_limit能让你今晚睡个好觉,但修好代码才能让你永远睡好觉。