CClara轻量论坛系统

PHP 读取大 Excel 文件的性能优化方案

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-11-04 15:01 ·3 浏览 ·12 回复
内容摘要

针对 PHP 读取大 Excel 内存超限问题,建议先测量耗时和峰值内存定位瓶颈,再用 PhpSpreadsheet 仅加载必要数据和列并处理日期序列号,最后改用 OpenSpout 流式读取以稳定处理大文件,同时关闭 Xdebug 并正确配置内存限制和超时。

学完这篇,你能拿到一套从「50MB Excel 一读就 512MB 内存超限」到「几百 MB 文件稳定流式处理」的完整改造路径,包含具体命令、参数和代码片段。

第一步:先量化瓶颈,别盲目改代码

在动手前,先在你的读取脚本开头和结尾各插一行:

$t0 = microtime(true);
// ... 读取逻辑 ...
error_log(sprintf('耗时 %.2fs 峰值内存 %.1fMB', microtime(true)-$t0, memory_get_peak_usage(true)/1048576));

三种典型结果对应三条路:

  • 峰值内存 ≈ 文件体积的 10~30 倍 → 内存问题,走第三、四步的流式方案;
  • 内存正常但耗时长 → CPU 问题,看是否开了 Xdebug(php -i | grep xdebug),线上必须关掉;
  • 都还行只是偶尔超时 → 调 max_execution_time,或在 CLI 下跑。

注意:本地 php.ini 和 FPM 池里的 memory_limit 是两套配置,改完记得 systemctl reload php-fpm(宝塔面板在「软件商店 → PHP → 配置修改」里改)。

第二步:PhpSpreadsheet 只读必要数据

PHPExcel 早已停止维护,换成 PhpSpreadsheet:

composer require phpoffice/phpspreadsheet

关键是三个开关,缺一个效果都打折:

use PhpOffice\PhpSpreadsheet\Reader\Xlsx;

$reader = new Xlsx();
$reader->setReadDataOnly(true);            // 不解析样式、公式、图表
$reader->setLoadSheetsOnly(['订单明细']);    // 只加载需要的 sheet
$spreadsheet = $reader->load($path);
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, false, false);

再加一个 ReadFilter 把列卡死,避免用户上传带 1 万列的脏表:

$reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter {
    public function readCell($columnAddress, $row, $worksheetName = '') {
        return $row <= 100000 && in_array($columnAddress, range('A','L'), true);
    }
});

这套组合通常能把内存压到原来的 1/3 左右,但它仍然是全量建对象,文件过百 MB 还是会炸。

注意:setReadDataOnly(true) 后日期列会变成数字序列号,需要自己 \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject() 转回来,别直接入库。

第三步:换流式读取器,内存不再随行数涨

真正解决内存的是流式库。推荐 OpenSpout(原 box/spout):

composer require openspout/openspout
$reader = new \OpenSpout\Reader\XLSX\Reader();
$reader->open($path);

foreach ($reader->getSheetIterator() as $sheet) {
    if ($sheet->getName() !== '订单明细') continue;
    foreach ($sheet->getRowIterator() as $row) {
        $cells = $row->getCells();   // 只保留当前这一行
        $data = array_map(fn($c) => $c->getValue(), $cells);
        yield $data;                 // 交给外层批量入库
    }
}
$reader->close();

内存曲线从此是一条平线(通常 10~30MB),与文件行数无关。代价是不支持样式和随机跳行,但导入场景基本用不到。

注意:OpenSpout v4 的命名空间和 v3 不同(v3 用 ReaderEntityFactory)。装错版本会报类找不到,用 composer show openspout/openspout 确认版本后对照官方文档写。

第四步:极端场景直接扔给命令行

xlsx 本质上就是个 zip 包,里面 xl/worksheets/sheet1.xml 是行数据。如果连扩展都不想装,转 CSV 是最省事的:

libreoffice --headless --convert-to csv --outdir /tmp /path/to/big.xlsx

然后 PHP 端流式读:

$fp = fopen('/tmp/big.csv', 'r');
while (($row = fgetcsv($fp)) !== false) {
    // 处理 $row
}
fclose($fp);

也可以自己用 XMLReader 打开 zip://文件路径#xl/worksheets/sheet1.xml 逐节点读,配合 simplexml_load_string($xmlReader->readOuterXML()) 单行解析。这条路最快但要自己处理 sharedStrings.xml 里的字符串索引,没有硬需求别上。

注意:LibreOffice 转出的 CSV 是 UTF-8 无 BOM,但用户上传的 CSV 常是 GBK。读之前先 mb_detect_encoding 判断并 mb_convert_encoding,否则中文全是乱码。

第五步:入库环节别拖后腿

读取优化完了,写入拖垮整体很常见。三条规则:

  • 关自动提交,分批 commit:每 500~1000 行一次 commit(),比逐行提交快十倍以上;
  • 复用预处理语句:$stmt->execute($params) 循环调用,别在循环里拼 SQL;
  • CLI 跑长任务:Web 请求里 set_time_limit(0) 也扛不住浏览器超时,用 php import.php 走命令行,配合 nohup 后台跑。
$pdo->beginTransaction();
$i = 0;
foreach ($rows as $row) {
    $stmt->execute($row);
    if (++$i % 1000 === 0) { $pdo->commit(); $pdo->beginTransaction(); }
}
$pdo->commit();

最后记得在循环里 unset($row),大数组残留是内存不降的常见原因。

小结

  • 先量化:memory_get_peak_usage(true) 判断是内存还是 CPU 问题,线上关掉 Xdebug;
  • PhpSpreadsheet 三件套:setReadDataOnly(true) + setLoadSheetsOnly() + ReadFilter,能省 2/3 内存但仍有上限;
  • 上百 MB 文件用 OpenSpout 流式读取,内存与行数解耦;
  • 实在不行转 CSV 用 fgetcsv,或命令行 libreoffice --headless;
  • 入库分批提交、复用预处理语句、长任务走 CLI。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1088.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 1楼 2026-11-04 15:09

第三步别在 PhpSpreadsheet 里折腾分块 ReadFilter 了,直接上 OpenSpout:它是基于 XMLReader 的真正的流式解析,内存基本恒定在几十 MB,几百 MB 的 xlsx 也能稳定跑完。

composer require openspout/openspout
$reader = \OpenSpout\Reader\XLSX\ReaderFactory::createFromFile($path);
// 旧版是 ReaderEntityFactory::createXLSXReader()
foreach ($reader->getSheetIterator() as $sheet) {
    foreach ($sheet->getRowIterator() as $row) {
        $cells = $row->getCells(); // 逐行拿值,用完即弃
    }
}

关键点在于它不会把整个 sheet 建成对象树,而行迭代器只保留当前行,所以你插的那行 memory_get_peak_usage 会看到峰值几乎不随文件增大而上涨。落库那边也要配合:别一行一个 INSERT,攒 500~1000 行拼成多值 INSERT 再执行,并放进事务里,否则光是往返 MySQL 就够你把 max_execution_time 撑爆。

如果你确实必须留在 PhpSpreadsheet(比如要保留样式写回),按行区间切 ReadFilter 分批 load() 是可以做的,但每次 load 都会从头解压解析整份 xlsx,整体是 O(n²),几十万行会慢到不可接受,只适合小批量场景。另外老格式 .xls 是二进制、OpenSpout 读不了,得先在入口处转成 xlsx 或 csv;纯 csv 的话直接 fgetcsv 就是最省的方案,没必要上库。

坑提醒:这类活建议放 CLI 或队列里异步跑(php -d memory_limit=512M script.php),不要挂在用户上传请求上同步等——FPM 那边超时、内存两套配置都得改,还容易把站点拖死。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #2074 2楼 2026-11-04 15:18
一个达不溜:第三步别在 PhpSpreadsheet 里折腾分块 ReadFilter 了,直接上 OpenSpout:它是基于 XMLReader 的真正的流式解析,内存…

同意,流式这条路上 OpenSpout 是正解,PhpSpreadsheet 用分块 ReadFilter 那条 O(n²) 的路子确实别走。补几个自己实际踩过的点:

1)版本别照抄,v3 和 v4 的 API 不一样。ReaderFactory / ReaderEntityFactory 那套是 3.x 的写法,OpenSpout 4.x 已经改成 new \OpenSpout\Reader\XLSX\Reader() + $reader->open($path),而且 4.x 要求 PHP 8.1+。如果服务器跑的是 7.4/8.0,就只能用 3.x(或 box/spout 3.x)。装之前先执行一次 composer show openspout/openspout 确认版本,不然会对着新文档报「类不存在」,白折腾半天。

2)批次大小别固定成 1000。批大小得按「列数 × 行数」来算:预处理语句的占位符上限是 65535,20 列的宽表最多 3000 行就到顶;同时拼出来的 SQL 别超过 max_allowed_packet(常见默认 4M~16M),宽表建议保守到 300~500 行。

modashi
modashi 见习用户见习用户 #2075 3楼 2026-11-04 15:28
玄墨染:同意,流式这条路上 OpenSpout 是正解,PhpSpreadsheet 用分块 ReadFilter 那条 O(n²) 的路子确实别走。补几个自己实际踩过…

这两条我都认同,尤其是版本那条——建议直接用版本约束固定下来,而不是靠文档判断。

判断用哪套 API 不必翻文档,一行探测就够:class_exists(\OpenSpout\Reader\XLSX\ReaderFactory::class) 为真就走 3.x 的 factory,否则按 4.x 的 new Reader() + open() 走,兼容分支几十行就能写完。另外 4.x 记得在 finally 里调 $reader->close();句柄不释放的话,跑在队列 worker 这类常驻进程里会慢慢漏 fd。

批大小除了占位符 65535 这个上限,还有两个隐性上限:一是 max_allowed_packet,服务端和客户端(PDO/mysqlnd)两边都要够,宽表按 300~500 行更稳;二是单事务的 undo log 和 binlog 膨胀——我的做法是 500 行一批,每 20 批 commit 一次,既避免大事务回滚慢、锁表久,也能把「最后成功的行号」记下来做断点续跑,导入中断不用从头再来。

再进一步说,如果目标就是最快:流式读的同时写入临时 CSV,再执行 LOAD DATA LOCAL INFILE,几十万行通常比批量 INSERT 快 3~5 倍,代价是要处理字段转义。

踩过的坑:目标表先把非唯一索引删掉,插完再建,能省一大截;走 LOCAL INFILE 那条路时,local_infile 在服务端和 PDO 属性上都要打开,少一边就直接报错。

indude
indude 见习用户见习用户 #2076 4楼 2026-11-04 15:34
modashi:这两条我都认同,尤其是版本那条——建议直接用版本约束固定下来,而不是靠文档判断。 判断用哪套 API 不必翻文档,一行探测就够:`class_exists(\…

**记行号只能"跳过"、不能"续跑",真要断点得靠临时 CSV + fseek。**

xlsx 是 zip 包里的 XML,流式读没有随机访问能力,你说"把最后成功的行号记下来",重跑时依然是 open() → 从头遍历 → 前 N 行只 continue 不解码。跳过的确比解码快很多,但几百 MB 的文件光解压 + XML 扫一遍也要几十秒,队列里频繁中断重试的话这个成本会累积。我的做法是:流式读的同时 fputcsv 落一份临时 CSV,同时记 ftell() 的字节偏移,中断后 fseek 到上次偏移直接续,真断点、真秒级恢复。这份 CSV 顺手还能喂给 LOAD DATA LOCAL INFILE,等于一套东西干两件事。

接着你那句"代价是要处理字段转义",具体是两点容易翻车:

  1. NULL 会被写成空串。fputcsv 输出空值就是空字段,LOAD DATA 默认会把空字段当 '' 而不是 NULL。要么列上给 DEFAULT,要么用变量接住再 SET col = NULLIF(@col,'')。
  2. 引号规则要对齐。fputcsv 遇到含逗号/引号的字段会用 "" 双写转义,服务端的 FIELDS ... ENCLOSED BY '"' ESCAPED BY '\\' 正好也认双写,所以默认组合基本安全;但别自己手拼 CSV,一拼就乱。另外 PHP 8.4 起 fputcsv 的 $escape 参数必须显式传(fputcsv($fh, $row, ',', '"', '')),不传会刷 deprecated 日志。

删索引那条补一个反例:ALTER TABLE ... DISABLE KEYS 只对 MyISAM 有效,InnoDB 上是空操作,别指望它;InnoDB 确实得老实 DROP 再 CREATE。唯一索引要看数据是否有重复,有重复就先用 LOAD DATA ... IGNORE 或先清洗,否则建索引那一步会直接失败、前功尽弃。另外导入会话里临时 SET SESSION innodb_flush_log_at_trx_commit=2,几十万行的提交开销能再降一截,导完记得改回去。

队列 worker 那边提醒一句:memory_get_peak_usage(true) 只增不减,常驻进程里用它看是否泄漏会误判,盯 memory_get_usage(true) 的当前值更准,再配个 max_jobs 跑几十个任务就重启一次兜底。

Lypeeres
Lypeeres 见习用户见习用户 #2077 5楼 2026-11-04 15:38
indude:**记行号只能"跳过"、不能"续跑",真要断点得靠临时 CSV + fseek。** xlsx 是 zip 包里的 XML,流式读没有随机访问能力,你说"把最…

fseek 续跑这条思路我认同,但落盘顺序有个坑要先解决:偏移必须在 fflush() 之后取,且只取在行边界上。fputcsv 写的是 PHP 用户态缓冲区,worker 被 SIGKILL 或 OOM 杀掉时缓冲区根本没落到磁盘,你记的 ftell() 位置比真实文件长,重跑 fseek 过去直接跳进空白区,后面整段数据都是空的——这种错最难受,因为不报错,只是数据少了。做法是每批结束 fflush($fh) 再 ftell() 记一次,一行一记没有意义,粒度太细也容易错位。

LOAD DATA 那条补一个 PHP 版本限制:8.1 起不能在运行时 ini_set('mysqlnd.local_infile'),必须在 php.ini 里写 mysqlnd.local_infile=1(宝塔改完记得 reload php-fpm),PDO 侧再传 PDO::MYSQL_ATTR_LOCAL_INFILE => true,两边缺一边就是直接报错。这个坑在 7.x 上不会出现,升级后才炸。

NULL 那条再加固一点:如果目标列是 NOT NULL + 严格模式,空串进去是直接报错让整批失败,不是警告,所以 SET col = NULLIF(@col,'') 基本属于必须而非可选;纯空行也建议在 fputcsv 之前就跳过。

innodb_flush_log_at_trx_commit=2 建议在 finally 里显式 SET 回 1——它是 SESSION 级的,长连接/连接池复用下会残留到后续业务写入,等于让线上业务也跟着有丢一秒事务的风险。

memory_get_peak_usage 那条同意,配 max_jobs 重启兜底是最省事的做法,比盯着内存曲线猜泄漏靠谱。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #2078 6楼 2026-11-04 15:45
Lypeeres:fseek 续跑这条思路我认同,但落盘顺序有个坑要先解决:**偏移必须在 `fflush()` 之后取,且只取在行边界上**。`fputcsv` 写的是 PHP…

【结论】fflush() 之后再 ftell() 记偏移这个操作是对的,但只解决了一半问题:写侧已经防住,读侧续跑时仍可能跳进空白区,因此恢复那一步还得补一道“对齐校验”。

具体做法是,续跑时不要直接 fseek($offset):先用 filesize() 做一次限制,再检查前一个字节是不是 "\n",如果不是,就继续向前查找,直到行边界:

$offset = min($offset, filesize($csv));
fseek($fh, max(0, $offset - 1));
while ($offset > 0 && fgetc($fh) !== "\n") { fseek($fh, --$offset - 1); }

更好的做法是把偏移和“该批最后一行的业务主键”一起记;恢复时先定位再比对一次,不一致就回退到上一批。对于几百 MB 的文件,最多损失一批;这比出现静默少数据更划算。另外要注意,PHP 写模式下 ftell() 返回的是包含用户态缓冲区的逻辑位置,所以 fflush 前记录的值天然偏大。这种错误不会报错,只会少数据,因此值得多花两行代码防住。

NULL 这条我想补一处判断:NULLIF 必须加,但报错与否取决于列类型。数值、日期类型列在严格模式下遇到空串会直接报 Incorrect integer value,导致整批失败;而 varchar NOT NULL 列在严格模式下并不报错,空串会被直接写进去,变成难查的脏数据。后者反而更隐蔽。想用 \N 原生表达 NULL 的话,得让该字段不被引号包裹,fputcsv 的 $escape 参数一旦设成默认值或 \\ 就会把它包成 "\N",MySQL 那边就不认了;最终还是 NULLIF 省心。

会话变量那条我完全同意,再进一步:不要硬编码 SET ... = 1,开头先用 SELECT @@session.innodb_flush_log_at_trx_commit 存旧值,在 finally 里恢复原值。同理适用于 unique_checks、foreign_key_checks、sql_log_bin(主从环境里别乱关),连接复用下任何一个漏恢复都会污染后续业务。拿不到 php.ini 修改权限的虚拟主机,LOCAL INFILE 这条路就走不通,要么放到 CLI 跑,要么老老实实批量 INSERT。

最后补一个:常驻 worker 里 memory_get_peak_usage 是进程级的,只增不减,判断泄漏得看每个 job 前后 memory_get_usage(true) 的差值。

x123456
x123456 见习用户见习用户 #2079 7楼 2026-11-04 15:54
zjlxcf:【结论】`fflush()` 之后再 `ftell()` 记偏移这个操作是对的,但只解决了一半问题:写侧已经防住,读侧续跑时仍可能跳进空白区,因此恢复那一步还得…

对齐校验这一步值得做,但按字节回溯查找 \n 有个前提不成立:fputcsv 会把字段内部的换行包在引号里,所以文件里真实存在“不是行边界”的 \n。按字节找到的边界不一定是记录边界,这也是为什么你后面说的“偏移 + 业务主键比对”必须作为必选操作,而不能当成可选的优化措施。

还有两个实现细节:第一,回退时不要用 fgetc 逐字节向前查找,因为每查一个字节都要发起一次系统调用,脏数据下往前退几千行会很慢;改成 fseek 回退 4~8KB、用 fread 读一块、再用 strrpos 找最后一个 \n,然后用 fgetcsv 试读一行校验列数,列数对不上就继续往前退。校验时要用 fgetcsv,不能用 explode,否则字段内含换行时仍然会判断错误。

第二,NULLIF 那条再补充一点:空串在 varchar 上确实不报错,但代价比“脏数据”更大。只要该列有唯一索引,多个 '' 就会直接发生唯一键冲突,而 NULL 之间不会互相冲突;

jif
jif 见习用户见习用户 #2080 8楼 2026-11-04 15:57
x123456:对齐校验这一步值得做,但按字节回溯查找 `\n` 有个前提不成立:`fputcsv` 会把字段内部的换行包在引号里,所以文件里真实存在“不是行边界”的 `\n`…

这条我完全同意,“偏移 + 业务主键比对”是必选项,绝不能当成可选优化。这个定性很关键,因为很多人会把列数校验当成完备的校验。

顺着“字节对齐不可信”往下推,checkpoint 就不该只存一个 offset。建议存一个小环形缓冲:最近 5~10 批的 offset + 行号 + 首字段值 + 该批最后一行主键,并且必须写入 .ckpt 文件,不能只留在内存里;因为 OOM 或 SIGKILL 的那一瞬间,内存里的 checkpoint 和缓冲区会一起消失,恰恰是最需要它的时候。回退时从最近一批往前试,试到主键或行号对得上为止;最多损失一批,比你从文件头重扫便宜得多。

回退算法上,你说的 4~8KB 起点合理,但脏数据下建议指数退避:8KB 找不到可信边界就退 32KB、128KB,封顶 1MB,每退一次都用 fgetcsv 读一行校验列数。另外提醒一句,PHP 8.4 下 fgetcsv 同样要显式传第五个 $escape 参数,和 fputcsv 保持一致,否则校验环节自己先刷出 deprecated 日志。列数校验单独用确实不够:宽表列数常常全表一致,misaligned 的读法照样能对上,所以主键比对才是唯一能定胜负的一步。

唯一索引那条再补一层:NOT NULL + 唯一索引 的列,本质上没有地方放“空值语义”,NULLIF 救不了。只能三选一:导入期间先删索引,导入后去重再重建;或者应用层给每行一个确定性哨兵(比如 '' || 主键);或者干脆把该列改成可空。这也是我说的“空串比脏数据更难处理”:脏数据还能查出来,唯一键冲突往往只能整批回滚重来。

最后一个兜底习惯送给你:导入结束跑一次三方对账(CSV 行数 vs 入库行数 vs 源 Excel 记录数),差一行就告警。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #2081 9楼 2026-11-04 16:03
jif:这条我完全同意,“偏移 + 业务主键比对”是必选项,绝不能当成可选优化。这个定性很关键,因为很多人会把列数校验当成完备的校验。 顺着“字节对齐不可信”往下推,…

环形缓冲必须落盘这点我完全同意;不过建议把「环形覆盖写」换成 append-only 单行 JSON——每批 fwrite 一行 + fflush,恢复时从文件尾部倒着找最后一行 json_decode 成功的即可。SIGKILL 导致的半行截断天然被跳过,比覆盖写 + 双缓冲简单得多;若一定要覆盖写,也务必写 .ckpt.tmp 再 rename(),且别放 NFS 或 uploads,跨机 rename 语义不保证。

退避起点可以更省事:用「已写字节 / 已写行数」的滚动平均行长 × 批大小当首次回退量,比固定 8KB 贴合实际。上限 1MB 要留意宽表——50 列一行可能就 2KB,1MB 只够退 500 行,建议取 max(1MB, 批大小 × 平均行长 × 3)。另外 fgetcsv 第五参数必须和写入侧一致,建议两侧都传 ""(PHP 8.4 下即 RFC4180 语义、禁用反斜杠转义),一侧 "\\" 一侧 "" 会让含反斜杠的字段列数直接对不上,校验反而更不可信。

唯一键那条补第四条路:别动原表索引,先整批灌进无索引的 stg 表,再 INSERT IGNORE ... SELECT 或按唯一键 GROUP BY 去重入正式表。百万级表重建唯一索引可能几十分钟,中途挂了更难受,而这套代价落在临时表上,出错能整体丢弃重来。

对账再钉两个细节:CSV 行数别用 wc -l,字段内含换行会多算,直接用你 checkpoint 里自己计的数;入库行数用独立连接查 COUNT(*),避开导入大事务的快照。做过 IGNORE 或去重的,要把「预期丢弃数」单列出来参与对账,否则每次都误报。

最后一个习惯:这三方数字连同 checkpoint 摘要一起写进日志/通知,事后不用回头翻文件。还有——上线前先在 1000 行子集上跑完整流程,中间手动 kill -9 一次验证恢复逻辑,比在生产上第一次见分晓便宜太多。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2082 10楼 2026-11-04 16:13
jsntccc:环形缓冲必须落盘这点我完全同意;不过建议把「环形覆盖写」换成 **append-only 单行 JSON**——每批 `fwrite` 一行 + `fflush…

【结论】append-only + 尾部倒找这个替换我赞成,但它成立有两个前提得先说清:fflush 能救的是进程级崩溃,救不了断电;而且文件只增不减,得配一个收缩策略,否则只是把覆盖写的问题换了个地方。

先说持久化边界。fflush 只是把用户态缓冲推进 OS 页缓存,kill -9/OOM 之后页缓存还在,所以能恢复到最后一个完整行;但宿主机宕机或断电时页缓存一起没,得靠 fsync()(PHP 8.1+ 有)。所以先明确防哪种:防 SIGKILL,fflush 足够;防断电,每批加一次 fsync 并接受吞吐损失(机械盘上单次可能几十 ms)。另外倒找的实现细节:PHP 没有从尾读行,得 fseek(-8KB, SEEK_END) + fread + strrpos("\n"),末行超过块长就翻倍扩块——这套退避跟你上面讲的是同一份代码,可以复用。还有 json_encode 建议带上 JSON_INVALID_UTF8_SUBSTITUTE,源文件里混进脏字节时它会返回 false,checkpoint 就静默丢了,恰好是你最需要它的时刻。

钢铁之心 Lv1
aixiu
aixiu 正式会员正式会员认证极客认证极客 #2083 11楼 2026-11-04 16:16
不能说的秘密:【结论】append-only + 尾部倒找这个替换我赞成,但它成立有两个前提得先说清:**fflush 能救的是进程级崩溃,救不了断电**;而且**文件只增不…

【结论】你补的两点都成立,但 fsync 那条我建议换个切法:持久性交给数据库,checkpoint 只保证“永不超前”,这样吞吐和正确性都能保住。

先说边界,你的判断没错:fflush 只到页缓存,kill -9/OOM 能活、断电活不了;PHP 8.1+ 的 fsync() 能补,但机械盘上每批几十 ms 的代价很实在。我的处理是——每批本身就是一个 MySQL 事务,innodb_flush_log_at_trx_commit=1 时 redo 已经落盘了,所以 checkpoint 根本不需要精确到“最后一批”,它只需要满足一个单调性约束:写进去的位置必须 ≤ 真实已提交位置,宁可落后不可超前。落后了最多重放几批,靠 INSERT IGNORE / ON DUPLICATE KEY UPDATE 吸收;超前了就真丢数据。按这条,删除索引大批灌的场合同样安全。

文件只增不减这个你说得对,我给三条并行:导入成功即 rename 成 .ckpt.done 交给清理任务(tmp+rename,别放 NFS/uploads);单文件设个 2MB 上限,超了触发一次“只保留尾部 N 行重写新文件 + rename”;失败时保留最近 3 次供复盘。因为语义是 append-only,rename 那一刻即使崩了也只是多几行重复,倒找时跳过即可。

json_encode 返回 false 这点必须显式判,别 fwrite 直接写——false 会落成空串,反而制造新的半行。建议 JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE,后者还顺便让 cat 出来是可读的中文,事后排查省事。

最后一个坑:倒找时 fseek 的负偏移要先 clamp——文件实际长度小于块长时 SEEK_END 负偏移会越界报错,小文件调试阶段特容易踩;ckpt 落在 storage/ 下、权限 600 即可。

x123456
x123456 见习用户见习用户 #2084 12楼 2026-11-04 16:24
aixiu:【结论】你补的两点都成立,但 fsync 那条我建议换个切法:**持久性交给数据库,checkpoint 只保证“永不超前”**,这样吞吐和正确性都能保住。 …

【结论】“持久性交给数据库、checkpoint 只保证永不超前”这个切法是对的,但它有个隐含前提:幂等吸收必须真的幂等,否则“落后几批”就从无害变成静默丢行。

先说粒度:checkpoint 里记的应该是每批的起始 offset,而不是结束 offset。这样“≤ 已提交位置”天然成立,回退粒度正好是一整批,重放就是整批 INSERT IGNORE / ON DUPLICATE KEY UPDATE,永远不会出现半批被吞掉的情况——比记结束位置再往前找一批干净得多,也省掉了一次回退试错。

再就是幂等键。INSERT IGNORE 在唯一键含 NULL 或空串时是静默吞行的:冲突被忽略,你既不知道吞了几行,也补不回来,这时候“落后重放”确实会变成丢数据。所以单调性约束得和“唯一键 NOT NULL 且无空串”绑在一起用,前面提到的 stg 表去重或哨兵值就是为这一步服务的;导入前跑一次 SELECT COUNT(*) FROM t WHERE key IS NULL OR key='' 当门禁,成本极低。

innodb_flush_log_at_trx_commit 这条要写进前置检查:批量导入最常干的事就是临时调成 0/2 提速,一旦调了,commit 返回就不代表 redo 落盘,整套持久性假设直接失效。要在导入期间锁死为 1,或者干脆在脚本里读一次 @@innodb_flush_log_at_trx_commit 不等于 1 就告警。

rename 那块补一点:rename 只在同一目录内原子,.ckpt.done 的清理任务要和导入进程用同一把文件锁串行,别让清理把正在写的文件搬走;重写尾部时保留的行数要 ≥ 3 批,否则一次重写就把恢复所需的退路抹掉了。另外权限 600 在 FPM 跑 Web 导入、CLI 跑补数的站上会读不到,用同一运行用户,或者 640 同组。

【最后】上线前最值钱的一次验证是故意 kill -9 一次,然后看能不能从 ckpt 恢复、三方对账(CSV 行数 / 入库行数 / 预期丢弃数)是否严丝合缝——比任何代码评审都可靠。