平时在群里看PHP开发者问得最多的问题,除了"这个报错啥意思",大概就是内存溢出了。尤其像批量Excel处理、远程接口拉取大数据、图片压缩、视频处理这类脚本,跑着跑着突然弹出一句:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate ... bytes)
脚本直接中断,数据没处理完,日志还不好查。这个问题我在生产环境踩过很多次,也在不同的项目里试过各种方案,今天把这几年积累的定位思路、排查工具、实战解法一次性聊透。无论你是刚入门还在用宝塔面板跑PHP,还是在搞队列消费、容器打包,这篇文章应该都能给你省不少时间。
1. 内存溢出的常见场景与报错解读
很多初学者一看到内存溢出就觉得很玄学,其实它就是一个非常直白的问题:PHP程序运行时,需要申请的内存大小超过了当前环境允许的上限。要理解它,先看两件事:报错长什么样,以及它通常出现在哪些业务里。
1.1 典型的报错形态以及含义
拿最常见的报错来说:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) in /www/wwwroot/project/task.php on line 56这里面的134217728 bytes,换算一下正好是128M,就是php.ini里memory_limit的默认值。后边的tried to allocate 20480 bytes表示程序在尝试申请20KB额外内存时失败了。实际情况往往是内存已经满了,哪怕你再申请1KB都拿不到,报错里的数字只是最小的一次申请记录而已。
我见过不少新手看到这串报错,第一反应就是调大memory_limit,从128M调到256M,还不行就调到1024M,好像只要把天花板掀了问题就没了。但这是治标不治本。如果脚本本身在无限循环里积累数据,或者一次性把一个超大文件读进了内存,你就算把memory_limit改成-1(不限制),最终也会把服务器的物理内存耗尽,甚至把机器拖死,连MySQL、Nginx都跟着遭殃。
1.2 哪几类项目最容易触发内存溢出
根据我实际接触过的项目,下面这几种业务场景是内存溢出的"高发区":
- 批量数据处理:Excel批量导入、批量更新数据库、定时拉取第三方接口数据。比如用
PHPExcel读取一个几十MB的Excel,因为库的特性会把所有行都载入内存,几百行还行,几万行直接爆。 - 图片/视频处理:
GD库或Imagick处理大尺寸图片,图片像素越高,内存消耗呈指数级上涨。视频压缩、转码也类似,码流数据全在内存里跑。 - 队列消费者:常驻内存的
PHP进程,比如用ThinkPHP或Laravel写的队列脚本。如果业务代码里有累积型变量或全局缓存,跑的时间越长,内存越大,直到溢出。 - 复杂计算或代码审计类脚本:递归算法没写退出条件、
foreach循环里不停往数组里丢数据、正则回溯次数爆炸,都会在不知不觉中吃满内存。
我自己有一次印象很深的经历:帮客户排查一个PHP脚本,功能是用GD库给商品图片加水印,图不算大,但脚本跑几十张就崩。后来用工具一量,发现每次循环里创建的图片对象都没有销毁,一张2MB的图片在内存里以像素数组形式存在时能膨胀到几十MB,循环累积下来必炸。
1.3 PHP内存限制的机制和初衷
PHP设置memory_limit并不是故意找茬,而是为了避免单个请求把服务器资源耗尽。对于Web请求来说,每个请求是短生命周期进程,加上内存限制可以防止恶意或错误的脚本消耗掉整个FPM进程池的资源。但对于CLI命令行脚本来说,生命周期可能很长,内存限制依然有效,这就导致了很多通过命令行跑批量任务时频繁报错。
理解了这个机制,你就能明白:调大memory_limit是一种临时手段,而让程序在有限内存下跑完任务才是核心目标。下面我们从原理开始,一步步拆解内存为什么会涨上去。
2. 深入理解PHP内存管理的底层逻辑
想真正解决内存溢出,只改配置是不够的,你得清楚PHP内部是怎么管理内存的。这一节我会用比较通俗的方式讲一下引用计数、垃圾回收和常见的内存累积写法,这是后面所有排查手段的理论基础。
2.1 变量的生命周期与引用计数机制
PHP脚本里的每个变量,内部都通过一个zval结构体来存储,这个结构体里有一个字段叫refcount,用来记录这个变量被多少个符号引用。当refcount变为0时,这个变量的内存就能被释放了。
举个例子:
$a = str_repeat('x', 1024 * 1024); // 占用1MB内存 $b = $a; // 这里并没有复制1MB,而是refcount++,内存还是1MB unset($b); // refcount--,内存还是1MB,由$a继续持有 unset($a); // refcount归零,1MB内存被释放很多初学者不理解为什么unset($b)没有释放内存,因为变量并没有真正独立复制一份,这其实是PHP内存优化的手段。在COW(Copy On Write,写时复制)机制下,只有当一个变量被修改时,才会真正分裂出一份副本。
但这套机制在某些写法下会失效,最常见的就是循环里"自己引用自己":
$arr = []; $arr['self'] = &$arr; // 写时复制在引用面前失效 unset($arr); // 变量没了,但内部引用计数还是1,内存释放不掉这就是垃圾回收里常说的"循环引用泄漏",在简单的unset下无法靠引用计数归零来清理,只能等GC(垃圾回收器)的cycle collector定期扫描。
2.2 引用计数清零与垃圾回收机制
从PHP 5.3开始,引入了基于Concurrent Cycle Collection的垃圾回收器,专门处理循环引用。但GC不是实时执行的,它有自己的触发条件:当根缓冲区里的疑似垃圾节点数量达到一定阈值(默认10000)时,才会启动回收流程。
所以,如果你的脚本只是循环几百次,产生的垃圾不够触发阈值,内存可能一直挂在那;如果循环几万次,GC启动之后会有一波卡顿。很多人会手动调用gc_collect_cycles()来强制回收,但这不能滥用,因为GC本身也消耗CPU。
我做过一个测试:一个循环里不断创建匿名对象,并且让对象之间互相引用,如果不手动干预,跑10万次循环,内存峰值会达到50MB以上;如果每1000次调用一次gc_collect_cycles(),内存峰值可以控制在10MB以内,但CPU耗时增加了约15%。在CLI批处理脚本中,这个取舍很划算;在高频Web请求中,则不建议主动调用。
2.3 内存膨胀的真实原因:累积、持有与占用
抛开底层机制,从业务代码的角度看,内存溢出本质上是三类原因:
- 累计型:循环里往数组或对象里不停添加数据,比如把所有查询结果都攒在一个数组里,最后才统一处理。
- 持有型:变量被全局对象、静态属性、闭包长期引用,无法释放。比如Laravel事件监听器里绑定了大对象,整个请求生命周期内都释放不掉。
- 占用型:单次操作本身就非常耗内存,比如读取一个1GB的日志文件用
file_get_contents(),或把一张5000x5000的图片用imagecreatetruecolor()转换成真彩色画布,这部分内存是跳跃式增长的。
以下面这段代码为例,看似简短,内存却会爆得很迅速:
$data = []; $file = fopen('large_log.txt', 'r'); while ($line = fgets($file)) { $data[] = json_decode($line, true); // 每行JSON都存进数组 }当large_log.txt有50万行时,$data数组会持有50万行解析结果,即使每行只有1KB,累积起来也有500MB。正确做法应该是逐行处理完就释放,或者分批落库。
3. 定位内存溢出的排查工具与实践方法
遇到内存溢出,别急着改代码或改配置,先定位到底是谁在"吃内存"。这一节分享几个我常用的方法,从简单的打点到工具辅助,一步步缩小范围。
3.1 用memory_get_usage打点定位
PHP内置了memory_get_usage()和memory_get_peak_usage()两个函数,分别返回当前内存使用量和峰值内存使用量。在关键节点打印这两个值,可以把问题缩小到某段代码。
我在排查脚本时经常这么干:
// task.php echo '初始内存: ' . memory_get_usage() / 1024 / 1024 . " MB\n"; $users = getUsers(); // 从数据库读取用户列表 echo '读取用户后: ' . memory_get_usage() / 1024 / 1024 . " MB\n"; foreach ($users as $user) { processUser($user); } echo '处理完用户后: ' . memory_get_usage() / 1024 / 1024 . " MB\n"; echo '峰值内存: ' . memory_get_peak_usage() / 1024 / 1024 . " MB\n";如果发现"读取用户后"和"处理完用户后"内存几乎一样,说明$users数据量太大,或者processUser里并没有真正释放资源。如果内存是随着循环次数稳步上升,那基本就是累积型问题,比如循环里追加了数组或没有清理临时变量。
这种打点方式虽然"原始",但胜在简单可靠,尤其在生产环境不方便装扩展的时候,这是最快的一招。
3.2 二分法隔离嫌疑代码
如果脚本非常长,内存峰值又很高,逐行打断点效率太低。我的习惯是先按功能块划分,然后分批注释,用memory_get_peak_usage()对比前后差异。
- 先注释掉一半代码,跑一次,看内存峰值是否下降。
- 如果下降了,说明问题在被注释掉的那一半里,继续二分。
- 如果没下降,说明问题在保留的这一半里,继续二分。
用这种方式,通常四到五次就能定位到一个几十行的函数。我在一次排查中遇到的是一个图片缩放库,所有图片先被base64_encode后放入数组等待批量上传,一次500张图,内存峰值直接飙升到1GB。定位到这一行后,改成每处理一张就上传一张,内存瞬间降到80MB以内。
3.3 借助Xdebug和日志分析工具
Xdebug除了调试断点外,也能输出内存分析报告。配置好xdebug.auto_trace=1、xdebug.collect_params=1之后,它会生成一份非常详细的函数调用日志,能看到每个函数的参数和耗时,但内存信息还不够直观。
更推荐的是Xdebug+Webgrind的组合,或者直接用phpstorm的Profile功能,图形化界面能直观看到内存消耗最高的调用栈。但Xdebug在生产环境会大幅拖慢性能,不建议长期开启,我一般是在本地或测试环境复现问题后再开。
还有一个办法是利用error_log记录内存变化曲线:
register_shutdown_function(function () { $error = error_get_last(); if ($error && strpos($error['message'], 'Allowed memory size') !== false) { error_log(json_encode([ 'time' => date('Y-m-d H:i:s'), 'memory' => memory_get_peak_usage(true), 'file' => $error['file'], 'line' => $error['line'], ]), 3, '/tmp/php_memory.log'); } });这样生产环境报错时,至少能记录下当时的峰值内存和出错文件行号,方便事后分析。
4. 针对不同原因的解决方案与优化实践
定位到原因之后,就是对症下药了。这一节我会按配置调整、代码优化、工具选型三个层面给出具体方案,都是我在实际项目中验证过的做法。
4.1 合理调整memory_limit与运行环境配置
先说说什么时候该调配置。如果你的脚本确实存在单次大内存操作的需求,比如解析一个几十MB的JSON文件,而机器内存又充足,适当提高memory_limit是合理的。
- 临时提升:在脚本开头用
ini_set('memory_limit', '512M'),只影响当前脚本。 - 永久调整:修改
php.ini或php-fpm的php_admin_value,对整个环境生效。 - CLI脚本:可以通过命令行参数指定
php -d memory_limit=512M task.php,不用改任何配置文件。
但我不建议把memory_limit设成-1。一旦去掉限制,程序真的失控时,服务器会被直接拖垮,连SSH都卡。线上环境我一般只允许CLI脚本临时设置,Web请求仍保持128M或256M。
另外,PHP-FPM的pm.max_requests也值得关注,合理的设置能让进程在累计消耗过多内存后自动回收。
4.2 用生成器节省循环内存
如果瓶颈是数据量太大,比如从数据库读取50万行记录,传统写法是->get()一次性取出所有数据,这非常耗内存。推荐用**生成器(Generator)**配合游标或分页处理。
以Laravel为例:
// 传统写法:一次性加载50万条到内存 $users = User::query()->get(); foreach ($users as $user) { // 处理逻辑 } // 推荐写法:每次只取一条,内存占用极低 foreach (User::query()->cursor() as $user) { // 处理逻辑 }cursor()就是基于生成器实现的,它每次从数据库取出一条记录,用完即弃。用这种方式处理50万条用户数据,内存占用几乎恒定在几十MB级别。
如果你用的是原生PHP和MySQL,也一样可以用unbuffered query:
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '123456'); $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); $stmt = $pdo->query('SELECT * FROM large_table'); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { // 逐行处理,而不是全部缓存到内存 }4.3 大文件读取与Excel批处理的优化技巧
处理大文件时,千万别用file_get_contents()或file()(后者会把每一行都存成数组元素),而是要逐行读取:
$handle = fopen('large_file.csv', 'r'); while (($row = fgetcsv($handle, 0, ',')) !== false) { // 逐行处理CSV数据 } fclose($handle);fgetcsv是流式读取,内存占用基本是固定的。对于Excel文件,尽量用PhpSpreadsheet的setReadDataOnly(true)和setReadFilter()组合,只读取需要的列和行,避免把样式、公式也加载进内存。不过更推荐把Excel转成CSV后再用fgetcsv处理,效率更高且内存更省。
如果数据量非常大,还可以配合分批落库,处理1000行就INSERT一次,处理完unset($batch),保证内存里面同时存在的只有当前批次的数据。
4.4 图片压缩与视频处理场景的内存控制
用PHP处理图片时,最耗内存的操作是imagecreatetruecolor()。一张1920x1080的图片,每个像素需要4字节(RGBA),算下来就是192010804≈8.3MB,如果图片是5000x5000,那就是100MB。所以处理大图前,先压缩画布尺寸,或者先压缩源图尺寸再处理。
我常用的一个做法:
function resizeImage($source, $maxWidth = 800, $maxHeight = 600) { $info = getimagesize($source); $src = match($info['mime']) { 'image/jpeg' => imagecreatefromjpeg($source), 'image/png' => imagecreatefrompng($source), 'image/gif' => imagecreatefromgif($source), default => throw new RuntimeException('Unsupported image type'), }; $dst = imagecreatetruecolor($maxWidth, $maxHeight); imagecopyresampled($dst, $src, 0, 0, 0, 0, $maxWidth, $maxHeight, $info[0], $info[1]); imagedestroy($src); // 用完立即销毁 // 输出或保存 $dst ... imagedestroy($dst); // 最后也要销毁 }注意imagecreatefrom*和imagecreatetruecolor创建的图像资源,在不再需要时务必imagedestroy(),否则它们会一直占着内存直到请求结束。
视频处理场景通常不建议用纯PHP,而是直接调用外部工具如FFmpeg。用shell_exec或proc_open执行FFmpeg命令,PHP只负责发起和接收结果,内存占用极小。之前帮人优化一个视频压缩脚本,原本用PHP读取整个视频进内存,改造成exec("ffmpeg -i input.mp4 -vf scale=... output.mp4")之后,PHP侧的内存开销几乎可以忽略。
4.5 队列消费和常驻进程的防泄漏设计
对于队列消费者这类常驻进程,问题往往不是单个任务吃内存,而是任务间内存无法回收导致慢慢涨。我用Laravel队列时走过一个弯路:在handle()方法里用了static变量缓存数据,结果进程不退出,静态变量永远不清空,跑几百个任务后内存就上去了。
后来我总结了一套队列进程的内存控制规范:
- 任务类里尽量不用
static属性或全局变量来缓存数据,非用不可时在任务结束时手动清理。 - 在循环处理任务的
while里,每隔一定数量(比如1000个)主动调用gc_collect_cycles()。 - 给队列进程设置最大执行时间或最大处理数量,超过后自动退出,由
Supervisor或宝塔的进程守护重新拉起。比如Laravel队列可以通过--max-time=3600让进程每小时退出一次。
php artisan queue:work --tries=3 --max-time=36005. 框架与运维环境中的内存溢出问题排查
不同框架、不同运行环境下,内存溢出的表现和排查入口也有差异。这一节专门说一下我在Laravel、ThinkPHP、宝塔面板和Docker容器里遇到过的情况。
5.1 Laravel与ThinkPHP中的常见坑
在Laravel里,最经典的内存坑是模型关联的懒加载。如果查询100条文章数据,再逐条访问$article->comments,模型关联会被重复查询,不但慢,还会在内存里堆积大量重复的模型对象。用with('comments')预加载可以避免,但预加载本身也会一次性把关联数据装入内存,数据量很大时仍需分批。
另一种情况是队列任务里捕获了异常却没有释放资源。比如日志类绑定了句柄,catch之后句柄还挂着,下次任务继续追加。我的建议是:在finally里显式关闭句柄、释放资源。
ThinkPHP的情况类似,我之前遇到过用Db::name('table')->select()不加分页,一下查10万条,直接把内存打满。解决办法是改用chunk方法分批处理,或者用游标查询。
5.2 宝塔面板下排查PHP进程内存
换到宝塔面板环境,排查思路也差不多,但操作入口有些不同。我常用的操作是:
- 在宝塔的软件商店里打开
PHP扩展,确认是否安装了opcache,opcache会缓存编译后的文件,节省内存。 - 找到
php.ini里memory_limit的配置,如果需要调到512M或更高,建议只改CLI模式的,避免Web请求也放开限制。 - 用宝塔自带的
终端功能,执行ps aux | grep php,看各个php-fpm进程的RSS内存占用。如果某个进程的RSS一直在涨,可能就是请求泄漏或队列进程泄漏。 - 配置文件写入临时路径时,如果磁盘不够也可能报其他错误,但内存溢出还是优先看
/tmp目录是否被缓存文件塞满。
宝塔的网站监控报表功能也能看到每个站点的PHP-FPM状态,如果某个站点平均内存使用率持续走高,大概率是那个站点的代码有问题。
5.3 Docker容器中PHP应用的资源限制
如果是通过Docker部署的PHP应用,除了PHP自身的memory_limit,还有一层容器内存限制。docker run --memory=512m会在操作系统层面限制容器使用的内存,即使用户态PHP代码把memory_limit设成-1,超过512M依然会被OOM Killer杀掉。
我做过一次PHP应用容器化排查:明明PHP脚本设置的memory_limit已经调到1G,但进程跑到500多MB就崩溃,docker logs里看到Killed。后来才发现是docker-compose.yml里mem_limit: 512m起的作用,改成1G后问题解决。
如果你在打包镜像时遇到类似问题,建议在Dockerfile里用CMD指定内存参数,比如:
CMD ["php", "-d", "memory_limit=1G", "artisan", "queue:work"]这样至少能让项目的内存配置跟镜像走,部署到哪台机器都一样。
6. 一次完整的内存溢出事故排查复盘
聊了这么多理论和方法,最后用一个我亲历的案例,把从报错到修复的完整过程串一遍,方便你直观感受整套排查思路。
6.1 事故现场
客户反馈:后台有一个"批量导出报表"功能,选一个月的数据量大概30万行,点击导出后,页面一直转圈,过一会儿Nginx返回502,查看PHP-FPM日志,发现报错:
WARNING: [pool www] server reached pm.max_children setting (5), consider raising it继续看PHP错误日志,内存溢出的Fatal error一条接一条。初步判断是导出功能把当月所有数据SELECT *出来后存在数组里,再循环拼接CSV字符串,最后一次性输出。
6.2 排查过程
我直接在服务器上跑了一条模拟命令:
php -d display_errors=1 -r "require 'export.php';"手动执行脚本,打印出memory_get_peak_usage(),峰值来到900MB+。于是进一步打点,发现在查询所有数据后内存就占了750MB,后面拼CSV反而没涨多少。确认瓶颈就是一次性取出30万行数据。
接下来的修复思路很清晰:改成chunk分批查询,每批5000行,处理完就写文件,最终完美解决了内存问题。
6.3 最终修复方案
// 原写法 $rows = Db::name('orders')->where('create_time', '>=', $start) ->where('create_time', '<', $end) ->select(); // 一次性取30万行,直接爆炸 // 修复方案 $fp = fopen('php://output', 'w'); // 输出CSV头 fputcsv($fp, ['订单号', '金额', '时间']); Db::name('orders') ->where('create_time', '>=', $start) ->where('create_time', '<', $end) ->chunk(5000, function ($rows) use ($fp) { foreach ($rows as $row) { fputcsv($fp, [$row['order_no'], $row['amount'], $row['create_time']]); } }); fclose($fp);chunk(5000)每次只查5000行,内存占用基本恒定,CSV用流式写入,也不会在内存中累积。改完之后内存峰值从900MB降到了60MB左右,导出速度还快了,因为PHP-FPM进程不再被大内存请求锁死。
6.4 防止再次出现的护城河
这次事故之后,我给项目加了几道防线:
- 代码层面:凡是列表导出的方法,统一用
chunk或游标方式;凡是读取远程接口或文件,统一做分批/流式处理。 - 日志层面:全局注册了
shutdown function,一旦发生内存溢出,自动记录峰值内存和所在文件行号。 - 监控层面:给
php-fpm进程加了一个简单的内存监控脚本,当某个php-fpm进程RSS超过300MB时自动告警。 - 测试层面:把
memory_limit降到32M进行本地自测,倒逼自己写出内存友好的代码。
这就是一套完整的护城河思路。内存管理不是"碰到问题再调大"的事,而是要养成构造代码时就有内存意识的习惯。
7. 常用工具与函数速查
最后放一个我在排查和优化内存问题时常用的函数/工具清单,方便你直接复制使用。
7.1 PHP内置函数速查
| 函数 | 作用 | 使用场景 |
|---|---|---|
memory_get_usage() | 获取当前已使用内存 | 打点查看阶段性内存变化 |
memory_get_peak_usage() | 获取脚本运行以来峰值内存 | 确认脚本整体内存消耗 |
ini_set('memory_limit','512M') | 动态调整内存上限 | 临时提升单个脚本限制 |
gc_collect_cycles() | 强制触发垃圾回收 | 常驻内存循环大任务 |
unset($var) | 解除变量引用 | 释放大变量或数组 |
imagedestroy($img) | 销毁图像资源 | 图片处理的循环中 |
7.2 命令行排查技巧
# 查看php.ini位置 php --ini # 临时提升内存执行脚本 php -d memory_limit=512M task.php # 查看php-fpm进程的内存占用,按RSS排序 ps aux --sort=-rss | grep php-fpm # 代码内存曲线打点 php -r "echo memory_get_peak_usage(true);"7.3 一个通用监控脚本模板
如果项目里经常有定时任务,建议在crontab里每5分钟跑一次这个脚本,把内存异常提前揪出来:
#!/bin/bash # 监控php-fpm内存使用,超过400MB输出告警 threshold=409600 for pid in $(pgrep php-fpm); do rss=$(awk '/VmRSS/{print $2}' /proc/$pid/status) if [ "$rss" -gt "$threshold" ]; then echo "[$(date)] php-fpm pid $pid memory: ${rss}KB" >> /var/log/php_memory_alert.log fi done这个脚本不用装任何额外软件,只要有/proc文件系统就能跑,很多集群服务器上我都是这么用的。
8. 最后想说的几句实在话
我在实际排查内存溢出问题时的感受是:80%的情况不是PHP本身的问题,而是代码写法的问题。memory_limit只是告诉你"到此为止",真正要解决的是为什么程序在有限内存里活不下去。
如果你正被这个问题折磨,记住三步走:先用memory_get_peak_usage()打点确认位置,再用二分法缩小范围,最后用分批/流式/释放资源的方法修复。做完这三步,你至少能解决绝大多数场景下的内存溢出。
另外一个小技巧:优化内存时把memory_limit故意调小一些,比如32M,然后用php -l跑一遍脚本,看到报错就说明你的代码还是太"胖"了。用这种自虐的方式来写代码,后面线上你反而会轻松很多。