我的小工作室接了个订单系统的活,客户的数据量不大,但有个「导出全部订单」的功能,跑着跑着内存就飙到 512M 上限,直接白屏。环境是 PHP 8.2.10(cli 和 fpm 都试了),MySQL 8.0,数据大概 12 万条订单,每单平均 8 个商品明细。现象很奇怪:在本地 M1 Mac 上跑没事,一到客户的阿里云 ECS(2 核 4G)上就必崩,错误日志里是 Allowed memory size of 536870912 bytes exhausted。
我先用最笨的办法:在导出循环里每 500 条打一次 memory_get_usage(true),发现数值只涨不跌,哪怕 unset 了临时数组也一样。然后我上了 Xdebug,用的是 3.2.2 版本,配置如下:
[xdebug]
zend_extension=xdebug.so
xdebug.mode=develop
xdebug.start_with_request=yes
xdebug.output_dir=/tmp/xdebug
xdebug.profiler_output_name=cachegrind.out.%p
命令行跑:
php -d xdebug.mode=profile -d xdebug.start_with_request=yes export.php
然后用 KCachegrind 打开 profile 文件,发现 mysqli_fetch_assoc 占用的时间不多,但「内存分配」那里有个 PDOStatement::fetchAll 的调用每次都留下很大的块。问题定位在:我为了图省事,在循环里用了 $stmt->fetchAll(PDO::FETCH_ASSOC) 一次性取 12 万行。改成 fetch() 逐行取,再配合 unset($row) 和每 1000 条 gc_collect_cycles(),内存峰值从 512M 降到了 40M 左右。
为什么这样有效:fetchAll 会在 PHP 用户态一次分配一个巨大的哈希表,而且 PDO 的底层结果集缓存不一定立刻释放;逐行 fetch 让内存分配和释放的节奏跟循环同步,gc_collect_cycles 则是手动触发循环引用回收——虽然 PHP 8 的 GC 已经聪明很多,但在长循环里手动清一下能压住峰值。代价是导出速度慢了大概 15%,12 万条从 38 秒变成 44 秒,能接受。
但这里有个坑:gc_collect_cycles() 不是万能的。如果你的循环里持有对象引用(比如闭包里 use 了外部变量),GC 也救不了。我一开始在闭包里引用了 $this->logger,结果每行都多留一个引用,内存还是涨。后来把闭包改成传参才彻底干净。另外,Xdebug 的 profiler 本身会吃内存,在 2G 内存的机器上跑大文件时,最好把 xdebug.mode 切回 off 再用 memory_get_usage 打点,不然 profiler 文件能把磁盘写满——我客户那台机器就被我写满了 /tmp,尴尬。
还有一个边界:fetch() 逐行取的时候,如果 MySQL 的 net_buffer_length 太小或者查询用了 ORDER BY 大字段,服务器端会先把结果集缓存在临时表里,客户端内存降了但 MySQL 那边可能撑不住。这个得看 SHOW STATUS LIKE 'Created_tmp_disk_tables'。
你们在 PHP 8.x 上做大数据量导出时,是习惯用 fetch() 逐行、还是用 yield 生成器包一层?我试过生成器,但配合 PDO 的游标有点别扭,想听听你们的实际写法。


0 条评论
还没有评论,写下第一条。