PHP 百万级数据导出方案(多 CSV 文件压缩)
121

准备:

1、PHP设置坑:

  • set_time_limit – 设置脚本最大执行时间:

此配置一般PHP默认是30秒,如果你是数据小的,可能就不会发现有该设置问题,但如果你数据达到了百万级导出,往往30秒是不够的,因此你需要在你的脚本中添加 set_time_limit(0),让该脚本没有执行时间现在

  • memory_limit – PHP的内存限定:

此配置一般php默认是128M,如果之前做过小数据的朋友可能也会动过这个配置就能解决许多问题,或许有人想,你大数据也把这个调大不就行了吗?那么真的是too young too native了,你本地能设置1G或者无限制或许真的没问题,但是正式场,你这么搞迟早会出事的,一个PHP程序占那么大的内存的空间,如果你叫你公司运维帮忙调一下配置,估计运维一定很不情愿,服务器硬件这么搞也是太奢侈了。所以说,我们要尽量避免调大该设置。

2、excel坑:

既然是导出数据,大伙们当然马上想到了excel格式了,多方便查看数据呀,然而万万没想到excel也是有脾气的呀! 

  • 表数据限制:
  1. Excel 2003及以下的版本。一张表最大支持65536行数据,256列。
  2. Excel 2007-2010版本。一张表最大支持1048576行,16384列。

也就是说你想几百万条轻轻松松一次性导入一张EXCEL表是不行的,你起码需要进行数据分割,保证数据不能超过104W一张表。

  • PHPexcel内存溢出:

既然数据限制在104W,那么数据分割就数据分割呗,于是你尝试50W一次导入表,然而PHPexcel内部有函数报内存溢出错误,然后你就不断的调小数据量,直到5W一次导入你都会发现有内存溢出错误。这是为什么呢,虽然你分割数据来导入多个数据表,但是最后PHPexcel内部还是一次性把所有表数据放进一个变量中来创建文件……额,这几百万数据一个变量存储,你想内存不溢出,还真有点困难。
(后来看了一些文章发现PHPExcel也有解决方案,PHPExcel_Settings::setCacheStorageMethod方法更改缓冲方式来减小内存的使用)

3、csv坑:
EXCEL这么麻烦,我不用还不行吗?我用csv文件储存,既不限制数量,还能直接用EXCEL来查看,又能以后把文件导入数据库,一举几得岂不是美哉?咦,少侠好想法!但是CSV也有坑哦!

  • 输出buffer过多:

当你用PHP原生函数putcsv()其实就使用到了输出缓存buffer,如果你把几百万的数据一直用这个函数输出,会导致输出缓存太大而报错的,因此我们每隔一定量的时候,必须进行将输出缓存中的内容取出来,设置为等待输出状态。具体操作是:

  1. ob_flush();
  2. flush();

具体说明介绍:PHP flush() 与 ob_flush() 的区别详解

  • EXCEL查看CSV文件数量限制:

大多数人看csv文件都是直接用EXCEL打开的。额,这不就是回到EXCEL坑中了吗?EXCEL有数据显示限制呀,你几百万数据只给你看104W而已。什么?你不管?那是他们打开方式不对而已?不好不好,我们解决也不难呀,我们也把数据分割一下就好了,再分开csv文件保存,反正你不分割数据变量也会内存溢出。

4、总结做法
分析完上面那些坑,那么我们的解决方案来了,假设数据量是几百万。

1、那么我们要从数据库中读取要进行数据量分批读取,以防变量内存溢出,

2、我们选择数据保存文件格式是csv文件,以方便导出之后的阅读、导入数据库等操作。

3、以防不方便excel读取csv文件,我们需要104W之前就得把数据分割进行多个csv文件保存

4、多个csv文件输出给用户下载是不友好的,我们还需要把多个csv文件进行压缩,最后提供给一个ZIP格式的压缩包给用户下载就好。

代码:

function export_csv($filename, $data, $columns = [], $chunk = 1000000)
{
    if (!$columns) {
        $firstRow = $data instanceof Generator ? $data->current() : current($data);

        if (!$firstRow) {
            exit;
        }

        foreach ($firstRow AS $key => $value) {
            $columns[] = [
                'title' => $key,
                'index' => $key,
            ];
        }
    }

    header('Content-Type: application/csv; charset=UTF-8');
    header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
    header('Cache-Control: max-age=0');

    $storageDir = rtrim(sys_get_temp_dir(), '/');
    $prefix = str_random(10);

    $fileList = []; // 文件集合
    $fileList[] = $file = "$storageDir/${prefix}_${filename}_1.csv";

    $fp = fopen($file, 'w');
    fputs($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
    $head = array_pluck($columns, 'title');
    fputcsv($fp, $head);

    // 计数器
    $i = 0;
    // 每隔$limit行刷新一下输出buffer,不要太大,也不要太小
    $limit = 10000;
    // 行上限
    $maxLimit = 100000000;

    foreach ($data as $item) {
        if ($i >= $maxLimit) {
            break;
        }

        if ($i > 0 && $i % $chunk == 0) {
            fclose($fp);  // 关闭上一个文件
            $j = $i / $chunk + 1;
            $fileList[] = $file = "$storageDir/${prefix}_${filename}_$j.csv";

            $fp = fopen($file, 'w');
            fputs($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
            fputcsv($fp, $head);
        }

        $i++;

        if ($i % $limit == 0) {
            ob_flush();
            flush();
        }

        $row = [];

        foreach ($columns AS $column) {
            $value = isset($column['index'])
                    ? (is_object($item) ? $item->{$column['index']} : $item[$column['index']])
                    : null;
            $render = array_get($column, 'render');
            if ($render && $render instanceof Closure) {
                $row[] = $render($value, $item);
            } else {
                if (is_numeric($value) && strlen($value) > 10) {
                    $value .= "\t";
                }
                $row[] = $value;
            }
        }

        fputcsv($fp, $row);
        unset($row);
    }

    fclose($fp);

    if (count($fileList) > 1) {
        $zip = new ZipArchive();
        $oldFilename = $filename;
        $filename = "$storageDir/${prefix}_${filename}.zip";
        $zip->open($filename, ZipArchive::CREATE); // 打开压缩包

        foreach ($fileList as $file) {
            $zip->addFile($file, str_replace("${prefix}_", '', basename($file)));   // 向压缩包中添加文件
        }
        $zip->close(); // 关闭压缩包

        foreach ($fileList as $file) {
            @unlink($file); // 删除csv临时文件
        }

        // 输出压缩文件提供下载
        header("Cache-Control: max-age=0");
        header("Content-Description: File Transfer");
        header('Content-disposition: attachment; filename=' . $oldFilename . '.zip');
        header("Content-Type: application/zip"); // zip格式的
        header("Content-Transfer-Encoding: binary");
        header('Content-Length: ' . filesize($filename));
    } else {
        $filename = head($fileList);
    }

    @readfile($filename);
    @unlink($filename); // 删除临时文件

    exit;
}

$sql = "SELECT * FROM users";
$users = DB::cursor($sql);
$columns = [
    [
        'title' => '用户ID',
        'index' => 'id',
    ],
    [
        'title' => '用户名称',
        'index' => 'name',
    ],
    [
        'title' => '电子邮箱',
        'index' => 'email',
    ],
    [
        'title' => '注册日期',
        'index' => 'created_at',
        'render' => function ($createdAt, $item) {
            return substr($createdAt, 0, 10);
        }
    ],
];

export_csv('用户列表', $users, $columns);
本帖由系统于 1个月前 自动加精
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 5
BradStevens

技术要点不错,感觉这个需求没有实际场景吧?产品有这个需求的话,真的可以砍一砍了

1个月前
GanymedeNil

@BradStev 财务对账真可能有这需求 :joy:

1个月前

有收获,感谢大佬分享 :kissing_heart:

1个月前
BradStevens

@GanymedeNil 不能吧,几百万条在一个文件里对账,也是另一种麻烦事情了

1个月前

超过10W条还是得用CSV, excel 真的是慢

2周前

  • 请注意单词拼写,以及中英文排版,参考此页
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`, 更多语法请见这里 Markdown 语法
  • 支持表情,使用方法请见 Emoji 自动补全来咯,可用的 Emoji 请见 :metal: :point_right: Emoji 列表 :star: :sparkles:
  • 上传图片, 支持拖拽和剪切板黏贴上传, 格式限制 - jpg, png, gif
  • 发布框支持本地存储功能,会在内容变更时保存,「提交」按钮点击时清空
  请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!