关于 PHP 内存溢出的思考

背景

最近做大批量数据导出和数据导入的时候,经常会遇到PHP内存溢出的问题,在解决了问题之后,总结了一些经验,整理成文章记录下。

优化点

  1. 优化SQL语句,避免慢查询,合理的建立索引,查询指定的字段,sql优化这块在此就不展开了。
  2. 查询的结果集为大对象时转数组处理,框架中一般有方法可以转,如Laravel中有toArray(),Yii2中有asArray()。
  3. 对于大数组进行数据切割处理,PHP函数有array_chunk()、array_slice()。
  4. 对于大型的字符串和对象,使用引用传递&。
  5. 用过的变量及时unset。
  6. 导出的文件格式由excel改为csv
  7. ini_set('memory_limit',''),设置程序可以使用的内存(不建议这样做)。

思考

内存管理

PHP的内存什么怎么管理的呢?
在学C语言时,开发者是需要手动管理内存。在PHP中,Zend引擎提供为了处理请求相关数据提供了一种特殊的内存管理器。请求相关数据是只需要服务单个请求,最迟会在请求结束时释放数据。

Laravel

上图是来自于官网的描述截图

防止内存泄漏并尽可能快地释放所有内存是内存管理的重要组成部分。因为安全原因,Zend引擎会释放所有上面提到的API锁分配的内存。

垃圾回收机制

简单说下:

PHP5.3之前,采用引用计数的方式管理。PHP中的变量存在zval的变量容器中,变量被引用的时,引用计数+1,变量引用计数为0时,PHP将在内存中销毁这个变量。但是在引用计数循环引用时,引用计数就不会消减为0,导致内存泄漏。

PHP5.3之后做了优化,并不是每次引用计数减少都进入回收周期,只有根缓冲区满额后才开始进行垃圾回收,这样可以解决循环引用的问题,也可以将总内存泄漏保持在一个阈值之下。

代码

由于使用phpexcel时经常会遇到内存溢出,下面分享一段生成csv文件的代码:

<?php

namespace api\service;

class ExportService
{

    public static $outPutFile = '';

    /**
     * 导出文件
     * @param string $fileName
     * @param $data
     * @param array $formFields
     * @return mixed
     */
    public static function exportData($fileName = '', $data, $formFields = [])
    {
        $fileArr = [];
        $tmpPath = \Yii::$app->params['excelSavePath'];

        foreach (array_chunk($data, 10000) as $key => $value) {
            self::$outPutFile = '';
            $subject          = !empty($fileName) ? $fileName : 'data_';
            $subject          .= date('YmdHis');
            if (empty($value) || empty($formFields)) {
                continue;
            }

            self::$outPutFile = $tmpPath . $subject . $key . '.csv';
            if (!file_exists(self::$outPutFile)) {
                touch(self::$outPutFile);
            }
            $index  = array_keys($formFields);
            $header = array_values($formFields);
            self::outPut($header);

            foreach ($value as $k => $v) {
                $tmpData = [];
                foreach ($index as $item) {
                    $tmpData[] = isset($v[$item]) ? $v[$item] : '';
                }
                self::outPut($tmpData);
            }
            $fileArr[] = self::$outPutFile;
        }

        $zipFile = $tmpPath . $fileName . date('YmdHi') . '.zip';
        $zipRes = self::zipFile($fileArr, $zipFile);
        return $zipRes;
    }

    /**
     * 向文件写入数据
     * @param array $data
     */
    public static function outPut($data = [])
    {
        if (is_array($data) && !empty($data)) {
            $data = implode(',', $data);
            file_put_contents(self::$outPutFile, iconv("UTF-8", "GB2312//IGNORE", $data) . PHP_EOL, FILE_APPEND);
        }
    }

    /**
     * 压缩文件
     * @param $sourceFile
     * @param $distFile
     * @return mixed
     */
    public static function zipFile($sourceFile, $distFile)
    {
        $zip = new \ZipArchive();
        if ($zip->open($distFile, \ZipArchive::CREATE) !== true) {
            return $sourceFile;
        }

        $zip->open($distFile, \ZipArchive::CREATE);
        foreach ($sourceFile as $file) {
            $fileContent = file_get_contents($file);
            $file        = iconv('utf-8', 'GBK', basename($file));
            $zip->addFromString($file, $fileContent);
        }
        $zip->close();
        return $distFile;
    }

        /**
     * 下载文件
     * @param $filePath
     * @param $fileName
     */
    public static function download($filePath, $fileName)
    {
        if (!file_exists($filePath . $fileName)) {
            header('HTTP/1.1 404 NOT FOUND');
        } else {
            //以只读和二进制模式打开文件
            $file = fopen($filePath . $fileName, "rb");

            //告诉浏览器这是一个文件流格式的文件
            Header("Content-type: application/octet-stream");
            //请求范围的度量单位
            Header("Accept-Ranges: bytes");
            //Content-Length是指定包含于请求或响应中数据的字节长度
            Header("Accept-Length: " . filesize($filePath . $fileName));
            //用来告诉浏览器,文件是可以当做附件被下载,下载后的文件名称为$file_name该变量的值
            Header("Content-Disposition: attachment; filename=" . $fileName);

            //读取文件内容并直接输出到浏览器
            echo fread($file, filesize($filePath . $fileName));
            fclose($file);
            exit();
        }
    }
}           

调用处代码

$fileName = "库存导入模板";
$stockRes = []; // 导出的数据
$formFields = [
    'store_id'  => '门店ID',
    'storeName' => '门店名称',
    'sku'       => 'SKU编码',
    'name'      => 'SKU名称',
    'stock'     => '库存',
    'reason'    => '原因'
];
$fileRes    = ExportService::exportData($fileName, $stockRes, $formFields);
$tmpPath    = \Yii::$app->params['excelSavePath']; // 文件路径
$fileName   = str_replace($tmpPath, '', $fileRes);

// 下载文件
ExportService::download($tmpPath, $fileName);

原文地址:https://tsmliyun.github.io/2019/09/26/关于PHP内存溢出的思考
欢迎交流。如有错误,请指出,谢谢。

php
本作品采用《CC 协议》,转载必须注明作者和本文链接
不积跬步,无以至千里;不积小流,无以成江海
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 8

分批写入文件,然后压缩打包 :+1:

4年前 评论
pikalu (楼主) 4年前

这样下载出来的是压缩文件吧

4年前 评论
pikalu (楼主) 4年前

PHP 并不是不能 导出/读取 海量数据的Excel文件, https://github.com/viest/php-ext-xlswriter 提供了:

1、固定内存模式导出;

2、几乎没有任何内存消耗的回调读取模式,最大内存消耗等于最大单元格所需内存;

4年前 评论
pikalu (楼主) 4年前
喜欢悠闲独自在

也可以使用Yield生成器来处理这种大数据导出/导入的:https://segmentfault.com/img/bVZT02?w=339&h=256

4年前 评论
pikalu (楼主) 4年前

再补充两个,文章中提到的:

  1. 对于大型的字符串和对象,使用引用传递 &。
  2. 用过的变量及时 unset。
  1. 传递引用在 PHP 内并不能帮助节省内存。可以了解一下 Copy-on-writing 机制。
  2. 如果能够保持变量的作用域(例如:方法、函数)极小,那么就不需要手动 unset,同时也可以提高代码可读性。
4年前 评论
pikalu (楼主) 4年前

@Wi1dcard
感谢建议;
因此我也重新去查了COW的原理,可以理解为资源延迟分配。
查阅资料和实践后,发现php $a = $b 与 $a = &$b,PHP在内存上的使用确实没什么区别;但是当值发生变化时,内存占用还是有很大区别的;
代码如下:

// 测试1
$m1 = memory_get_usage(); 
var_dump($m1); 

$arr1 = range(1,100000); 
$m2 = memory_get_usage(); 
var_dump($m2);  

$arr2 = $arr1; 
$arr2[] = 'aaaa'; 
$m3 = memory_get_usage(); 
var_dump($m3);  

// 输出内容:
int(392104)
int(4590616)
int(8789096)

// 测试2
$m1 = memory_get_usage(); 
var_dump($m1); 

$arr1 = range(1,100000); 
$m2 = memory_get_usage(); 
var_dump($m2);  

$arr2 = &$arr1; 
$arr2[] = 'aaaa'; 
$m3 = memory_get_usage(); 
var_dump($m3);  

// 输出内容:
int(392104)
int(4590616)
int(4590640)
4年前 评论

感谢,以 csv 格式导出数据,超级快。

private function exportAction($query)

{
    header('Content-Type: application/vnd.ms-excel');

    Header( "Content-Disposition:  attachment;  filename=simple.csv");

    header('Cache-Control: max-age=0');
    //将数据通过fputcsv写到文件句柄
    $fopen = fopen('php://output', 'a');

    $headList = [
        '序号',
        '产品',
        '上报类型',
        '上报时间',
        '用户ID',
        '用户端',
        '版本号',
        '网络环境',
        '设备/浏览器品牌',
        '设备/浏览器系统版本',
        '其他信息',
        'channelId',
        'url类型'
    ];

    fputcsv($fopen, $headList);
    foreach ($headList as $key => $value) {
        //CSV的Excel支持GBK编码,一定要转换,否则乱码
        $headList[$key] = iconv('utf-8', 'gbk', $value);
    }

    $type = [
        1 => '直播卡顿',
        2 => '点播卡顿'
    ];
    $urlTypeConfig = [
        'ali-l' => '阿里',
        'ali' => '阿里',
        'yf' => '云帆',
        'yd-l' => '云端',
        'hw-l' => '华为',
        '' => '',
    ];
    //计数器
    $num = 0;
    //每隔$limit行,刷新一下输出buffer
    $limit = 10000;
    //逐行取出数据,不浪费内存
    $data = $query->execute()->toArray();
    $count = count($data);
    for ($i = 0; $i < $count; $i++) {
        $num++;
        //刷新 biu biu biu
        if ($limit == $num) { 
            ob_flush();
            flush();
            $num = 0;
        }
        $item = $data[$i];
        $other = trim($item['other']);
        $explod = explode("|", $other);

        $item['type'] = $type[$item['type']];
        $item['time'] = date('Y-m-d H:i:s', $item['time']);
        if(strlen($explod[0]) <= 3)
            $item['channelId'] = $explod[0];
        else 
            $item['channelId'] =  explode(':', $explod[0])[1];

        if(count(explode("http://", $other)) >= 2){
            $item['urlType'] = $urlTypeConfig[explode(".", explode("://", $other)[1])[0]];
        }else{
            $item['urlType'] = '';
        }
        unset($item['create_at']);
        fputcsv($fopen, $item);
    }
    fclose($fopen);
}

```

3年前 评论

"2.查询的结果集为大对象时转数组处理,框架中一般有方法可以转,如 Laravel 中有 toArray (),Yii2 中有 asArray ()。" 将大对象转数组,大数组会导致PHP内存溢出;

        //逐行取出数据,不浪费内存
        $data = $query->execute();

        $count = count($data);

        for ($i = 0; $i < $count; $i++) {
            $num++;
            //刷新
            if ($limit == $num) { 
                ob_flush();
                flush();
                $num = 0;
            }
// 将子对象 toArray 就避免了内存溢出
            $item = $data[$i]->toArray();

            $other = trim($item['other']);
            $explod = explode("|", $other);

            $item['type'] = $type[$item['type']];
            $item['time'] = date('Y-m-d H:i:s', $item['time']);
            if(strlen($explod[0]) <= 3)
                $item['channelId'] = $explod[0];
            else 
                $item['channelId'] =  explode(':', $explod[0])[1];

            if(count(explode("http://", $other)) >= 2){
                $item['urlType'] = $urlTypeConfig[explode(".", explode("://", $other)[1])[0]];
            }else{
                $item['urlType'] = '';
            }
            foreach ($item as $key => $value) {
                //CSV的Excel支持GBK编码,一定要转换,否则乱码
                $item[$key] = iconv('utf-8', 'gbk', $value);
            }
            unset($item['create_at']);
            fputcsv($fopen, $item);
        }
3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!