PHP 导出大 CSV 时内存溢出的一次排查

上周给后台报表模块加导出功能,需求是直接把订单列表导出成 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,这点在复杂业务里很容易踩雷。

对比一下几种方案的取舍:

  1. 全量查出放进数组:最简单,小数据好用,大数据必爆内存,不推荐大报表;
  2. limit offset 分页:适合小偏移,数据量大查询性能雪崩;
  3. 流式游标 + php://output:内存友好,适合在线即时导出,受网关超时限制;
  4. 队列异步生成文件:没有超时问题,适合几十万、百万级超大报表,缺点用户不能立刻拿到文件。

最后上线就按这个规则做判断。导出接口加参数校验,预估数据行数,自动走不同策略。上线跑了一段时间,内存告警再也没有出现。

其实这个问题不算多难,属于 PHP 后台很常规的场景。只是写业务久了,遇到导出第一反应还是老的全量加载写法,很容易忽略流式输出这个选项。

相关推荐
hai_android1 小时前
LiveEventBusCore 跨 Activity 通讯源码解析
android·kotlin·android jetpack
YSL0701241 小时前
一文通关-----栈和队列
开发语言·数据结构
维克兜率天2 小时前
【维克】统计套利建模:从因子到信号的完整流程
android·开发语言·python·算法·kotlin
Pin_Qing2 小时前
同一天,谷歌半价卖旗舰,阶跃翻倍收缓存
大数据·开发语言·人工智能·程序人生·缓存
2501_930707782 小时前
使用C#代码在 Word 文档中添加、插入或删除页面
开发语言·c#·word
hunterandroid2 小时前
Room 数据库迁移与多表关联查询实战
android
没有梦想的咸鱼185-1037-16632 小时前
从通量数据到SCI论文——AI-R语言生态系统碳水热通量及其机制分析与论文
开发语言·人工智能·数据分析·r语言
weixin_461769402 小时前
conda create 创建容器 Failed to resolve ‘repo.anaconda.com‘问题
开发语言·python·conda·repo·网络问题·无法创建
Want5952 小时前
Python生日蛋糕
开发语言·python