用PHP做大数据汇总时,我们经常会先查出一堆记录,再在内存里去掉空值做统计。一旦数据量到了几百万行,脚本就会报allowed memory size of xxx bytes exhausted,也就是内存溢出。这个问题不是单纯调大memory_limit就能根治的,得从数据结构和处理方式上动手。

为什么去空值会引发内存溢出
很多初学者喜欢把数据库查出来的结果一次性放进数组,然后调用array_filter把空值剔掉。表面上这很方便,但实际上在PHP内部,原数组和过滤后的数组会在一段时间内同时存在于内存中。PHP的写时复制机制意味着,除非发生修改,变量赋值不会立刻拷贝数据,但array_filter会生成新数组,旧数组若仍被变量引用就无法被垃圾回收。
另外,如果空值判断写得不严谨,比如用empty()会把0和空字符串都去掉,为了补回这些数据,有人会再查一次库或者复制备份数组,内存占用直接翻倍。在汇总场景下,我们往往还要对过滤后的数据再做分组求和,这时候临时数组越建越多,内存自然扛不住。
<?php
// 危险写法:一次性载入再过滤
$rows = $pdo->query("SELECT col FROM big_table")->fetchAll(PDO::FETCH_COLUMN);
$clean = array_filter($rows, function($v) {
return $v !== null && $v !== '';
});
// 此时 $rows 和 $clean 同时占内存
$sum = array_sum($clean);
unset($rows, $clean);
?>
用生成器流式处理降低峰值内存
生成器是PHP里非常实用的懒加载工具。它不会一次性把全部数据生成出来,而是每次yield一条,处理完就能释放。把去空值的逻辑放进生成器,原本几个GB的数组就变成了逐行流过的水流,内存占用非常平稳。
下面的例子从PDO里用无缓冲查询配合生成器,边读边过滤边汇总。注意fetch使用了PDO::FETCH_ASSOC并且不一次性fetchAll,这样每次只有一行在内存里。对于汇总类需求,我们甚至可以在生成器内部直接累加,完全不保留过滤后的集合。
<?php
function clean_rows($pdo) {
$stmt = $pdo->prepare("SELECT col FROM big_table");
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$v = $row['col'];
if ($v !== null && $v !== '') {
yield $v;
}
}
}
$total = 0;
foreach (clean_rows($pdo) as $val) {
$total += (int)$val;
}
echo $total;
?>
及时unset与避免隐式引用
即便不用生成器,如果在循环里处理大数组,也应当在每段逻辑结束后主动unset掉不再需要的变量。尤其要小心foreach里的值变量:默认foreach会复制值,如果用引用&$v,循环结束后这个引用若没断开,会阻止数组释放。
另一个坑是函数返回大数组后再赋值给多个变量,看起来是分开写的,其实底层共享同一个zval。建议过滤完立刻覆盖原变量,并调用gc_collect_cycles强制回收周期。下表列出了几种常见写法的内存表现差异。
| 处理方式 | 峰值内存 | 适用场景 |
|---|---|---|
| fetchAll加array_filter | 高,易溢出 | 小数据脚本 |
| 生成器逐行yield | 低且平稳 | 大数据汇总 |
| 游标遍历加unset | 中等 | 中等数据且需复用 |
把去空值下沉到数据库层
最彻底的办法是别把空值拉到PHP里。在写SQL时直接用WHERE col IS NOT NULL AND col != ''在服务器端过滤,网络传输和PHP内存都只承接有效数据。如果还要汇总,干脆用SELECT SUM(col) GROUP BY,让数据库引擎完成计算,PHP只拿结果数字。
这种思路在分布式或者云数据库环境里尤其重要,因为应用层内存扩容成本高,而数据库本身对大批量扫描有优化。当然,如果空值规则很复杂,比如要排除特定占位符,可以写存储过程或者在SQL里用CASE表达,尽量别让PHP当搬运工。
-- 直接在库内去空并汇总 SELECT category, SUM(amount) AS total FROM big_table WHERE amount IS NOT NULL AND amount != '' GROUP BY category;
用SplFixedArray减少元素开销
当确实需要在内存里持有大量标量时,普通PHP数组的bucket结构会带来额外开销。SplFixedArray以连续内存存放固定长度元素,每个元素占用远低于关联数组。我们可以先估算去空后的数量,用SplFixedArray存干净数据,再转回普通数组或直接计算。
要注意SplFixedArray下标必须是整数且连续,所以过滤时得自己维护写指针。虽然代码稍长,但在千万级数值汇总时,往往能省下三分之一以上的内存,为避免内存溢出提供了另一条路径。
<?php
$stmt = $pdo->query("SELECT val FROM big_table");
$clean = new SplFixedArray(1000000);
$idx = 0;
while ($r = $stmt->fetch(PDO::FETCH_NUM)) {
if ($r[0] !== null) {
$clean[$idx++] = $r[0];
}
}
$clean->setSize($idx);
$sum = 0;
foreach ($clean as $v) { $sum += $v; }
?>
总结实践要点
面对PHP大数据去空值的内存溢出,核心原则是减少同时驻留内存的数据量。首选生成器或数据库层过滤,其次用unset和避免引用泄漏,最后可考虑SplFixedArray等紧凑结构。调大memory_limit只是临时止血,重构数据处理流才是长久方案。
在真实业务里,建议先打印memory_get_peak_usage看瓶颈在哪一段,再针对性改造。只要做到流式处理加及时释放,即便每天汇总上亿条记录,单机PHP脚本也能平稳跑完。