上周给后台报表模块加导出功能,需求是直接把订单列表导出成 CSV。一开始图省事,沿用老代码的写法:先把符合条件的数据一次性查出来装进数组,再循环拼装内容,最后一次性写入响应返回。
测试少量数据的时候一切正常,翻页导出、几十条记录秒出。拉了一份十万级的测试数据跑导出,直接 500。看服务日志,Allowed memory size of xxx bytes exhausted,标准的内存溢出。
第一反应,调大 php.ini 的 memory_limit。改成 512M 之后再跑,能导出了,但监控看 php-fpm 进程内存直接顶到四百多兆。这个方案肯定不能上线,多个人同时导出,服务器内存很容易被打满。
分页查?最开始想的就是分页,limit offset 分批取数据。写好循环,每次查 2000 条,拼一段内容输出。跑了几次,数据量越大越慢,到后面 offset 很大的时候,数据库查询耗时暴涨。
订单表数据量大,offset 大的时候 MySQL 需要扫描前面所有行,越往后越卡。而且订单表有多个筛选条件,复合索引不好建,这个路子走不通。
之前只记得游标查询,但是实际项目里很少写,这次拿出来试。用 PDO 的游标模式,PDO::MYSQL_ATTR_USE_BUFFERED_QUERY关掉缓冲查询。默认情况下 PDO 拿到结果集之后,会把所有数据一次性拉到客户端内存里,游标模式改成服务端逐行返回。
这里踩了第一个小坑,这个属性不能在实例化 PDO 之后再设置,必须放到构造函数的驱动选项里。写在 setAttribute 里面是不生效的,试了两次才反应过来。
$pdo = new PDO( 'mysql:host=127.0.0.1;dbname=order_db', 'user', 'pass', [ PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false ] );拿到 statement 之后,用 fetch 逐行读取,不把全部数据放到 PHP 数组里。读一行,处理一行,直接输出到浏览器。
第二个坑,CSV 输出的时候,很多人直接 echo 拼接字符串。大量循环 echo,会频繁触发缓冲区操作,速度会拉胯。PHP 有输出缓冲区,我改成每次攒一小段再 flush,同时设置响应头,告诉浏览器这是下载文件。
还有表头的问题,中文乱码。很多人只记得加 BOM,直接在最开头输出chr(0xEF).chr(0xBB).chr(0xBF)。这里要注意,不能在已经有任何输出之后再加,否则 BOM 会混到数据行里面,导出的文件打开全是乱码。
简化后的核心代码大概是这样:
header('Content-Type: text/csv;charset=utf-8'); header('Content-Disposition: attachment;filename="order.csv"'); $fp = fopen('php://output', 'w'); // utf8 bom fwrite($fp, chr(0xEF) . chr(0xBB) . chr(0xBF)); // 表头 fputcsv($fp, ['订单号','用户ID','金额','创建时间']); $stmt = $pdo->prepare($sql); $stmt->execute($params); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { // 简单处理字段,转义交给fputcsv fputcsv($fp, $row); // 控制频率,避免频繁刷缓冲区 if (ob_get_level() > 0) { ob_flush(); flush(); } } fclose($fp);改完之后跑十万条数据,内存占用稳定在十几兆,不会持续上涨。但新问题来了,导出到一半,偶尔连接中断,浏览器报下载失败。
查了 Nginx 日志,fastcgi_read_timeout。Nginx 默认等待后端响应有超时时间,导出大文件耗时久,Nginx 直接把连接断掉。
两个处理方式,一个是单独给导出接口加长超时,另一个是如果数据量超大,不实时在线导出,改成异步任务,后台生成文件存到对象存储,生成完给用户发通知去下载。
结合业务,十万以内在线导出,直接走上面这套流式方案,Nginx 单独调整超时;超过阈值,丢队列异步生成。
还有几个容易忽略的细节。
fputcsv 本身会自动处理逗号、换行、引号,不要自己手动拼接字段、加转义,很容易把数据搞坏,单元格内容一旦自带换行,表格直接错位。
不要在循环里面写复杂业务逻辑、调用其他接口,导出的时候只做字段映射,复杂校验、数据计算提前放到异步预处理,不然导出速度会非常慢。
另外,非缓冲查询有局限,一个连接同一时刻只能有一个未读完的结果集,游标没读完之前,不能再执行别的 SQL,这点在复杂业务里很容易踩雷。
对比一下几种方案的取舍:
- 全量查出放进数组:最简单,小数据好用,大数据必爆内存,不推荐大报表;
- limit offset 分页:适合小偏移,数据量大查询性能雪崩;
- 流式游标 + php://output:内存友好,适合在线即时导出,受网关超时限制;
- 队列异步生成文件:没有超时问题,适合几十万、百万级超大报表,缺点用户不能立刻拿到文件。
最后上线就按这个规则做判断。导出接口加参数校验,预估数据行数,自动走不同策略。上线跑了一段时间,内存告警再也没有出现。
其实这个问题不算多难,属于 PHP 后台很常规的场景。只是写业务久了,遇到导出第一反应还是老的全量加载写法,很容易忽略流式输出这个选项。