Skip to content

百万行数据导入:流式读取、批量写入与失败处理 ​

运营上传一个 100 万行的 Excel,服务先是 OOM,改成分批后又慢得超时,好不容易跑完,又发现其中几百行格式错误的数据让整批都没写进去。导入功能的三个难点是内存、速度和错误处理,而且三者的解法互相牵制。

本文先用 JDBC 实测几种写入方式的速度差异,再看一个容易被忽略的细节:开启 rewriteBatchedStatements 后,批处理失败时的行为会完全改变,这直接决定了错误处理该怎么设计。

一、整体流程 ​

上传文件xlsx / csv流式读取每次 1000 行校验与转换格式 · 必填有界队列满了就阻塞写入线程 1写入线程 NMySQL批量 INSERT整批失败降级逐行写入定位坏行错误报告:行号 + 原因其余行写入成功
图 1 · 读取端按批次流式解析,经有界队列交给多个写入线程;某一批失败时降级为逐行写入,把坏数据记入错误报告
  1. 读取线程流式解析文件,每 1000 行组成一批。
  2. 校验与转换:格式、必填、取值范围、字典映射,不合法的行直接记入错误报告,不进入队列。
  3. 有界队列连接读取和写入:写入跟不上时队列变满,读取线程被阻塞,内存不会无限增长。
  4. 多个写入线程从队列取批次,用批处理写入数据库,每批一个事务。
  5. 批次失败时降级为逐行写入,定位出错的行。
  6. 导入结束生成结果:成功行数、失败行数、每个失败行的行号与原因。

二、读取:别把整个文件读进内存 ​

Apache POI 的 XSSFWorkbook 会把整个工作簿解析成对象树。实测一个 100 万行、10 列、约 63 MB 的 xlsx:XSSFWorkbook 在 6 GB 堆下抛出 OutOfMemoryError,给到 8 GB 才读完;同一个文件用下面的 Fesod 按批读取,256 MB 堆就够了,堆使用峰值约 159 MB。流式读取只在内存中保留当前这一批。

常用的做法:

  • Excel:EasyExcel 是国内使用最广的流式读取库,已停止维护;原作者团队后续的 FastExcel 在 2025 年 9 月进入 Apache 孵化器,改名为 Apache Fesod。新项目可以直接使用 Fesod,入口类是 FesodSheet。
  • CSV:能让用户上传 CSV 就优先 CSV,用 BufferedReader 逐行读取即可,解析成本远低于 Excel。

使用 Fesod 按批读取的写法如下(代码片段,依赖 org.apache.fesod:fesod-sheet:2.0.2-incubating,同样的调用方式在配套实验中编译运行过):

java
BlockingQueue<List<OrderRow>> queue = new ArrayBlockingQueue<>(16);   // 最多积压 16 批

FesodSheet.read(file, OrderRow.class, new PageReadListener<OrderRow>(batch -> {
    try {
        queue.put(validate(batch));          // 队列满时阻塞读取线程
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException(e);
    }
}, 1000)).sheet().doRead();

常见建议中的「多线程并发读取多个 Sheet」只适用于数据本来就分布在多个 Sheet 的文件。单个 Sheet 的解析本身是顺序的,瓶颈通常也不在解析,而在写入。

三、写入:几种方式实测 ​

3.1 测试方法 ​

表结构带一个自增主键和一个业务唯一索引,更接近真实业务表:

sql
CREATE TABLE import_order (
  id       BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no BIGINT      NOT NULL,
  user_id  BIGINT      NOT NULL,
  sku      VARCHAR(32) NOT NULL,
  qty      INT         NOT NULL,
  remark   VARCHAR(64) NOT NULL,
  UNIQUE KEY uk_order_no (order_no)
);

每种方式重新建表后写入 20 万行(逐行插入太慢,只写 2 万行),采样 3 次取中位数。环境是 JDK 21、MySQL Connector/J 8.0.27、默认配置的 MySQL 8.4.11(开启 binlog,innodb_flush_log_at_trx_commit 与 sync_binlog 都为 1)。

3.2 结果 ​

逐行插入 · 自动提交1,918 行/秒批处理 1000 · 未开启重写20,454 行/秒批处理 1000 · 开启重写129,786 行/秒批处理 5000 · 开启重写152,323 行/秒MyBatis foreach · 每批 100079,365 行/秒4 线程 · 批处理 1000 · 重写203,252 行/秒LOAD DATA LOCAL INFILE322,061 行/秒MySQL 8.4.11 默认配置 · Connector/J 8.0.27 · MyBatis 3.5.19 · 表含主键与一个唯一索引 · 3 次采样中位数
图 2 · 同样写入 20 万行,逐行插入每秒不到 2000 行;开启 rewriteBatchedStatements 后快 68 倍,加 4 个线程再快约一半,LOAD DATA 仍然最快;MyBatis 的 foreach 多行插入介于未重写与重写之间(横轴为对数刻度)
方式耗时行/秒相对逐行插入
逐行插入,自动提交(2 万行)10.43s1,9181×
批处理 1000 行一批,未开启重写9.78s20,45411×
批处理 1000 行一批,开启重写1.54s129,78668×
批处理 5000 行一批,开启重写1.31s152,32379×
MyBatis <foreach> 多行插入,1000 行一批,未开启重写2.52s79,36541×
4 个线程,每线程 1000 行一批,开启重写0.98s203,252106×
LOAD DATA LOCAL INFILE0.62s322,061168×

几个观察:

  • 逐行插入慢在每行一次网络往返加一次提交刷盘。 按这个速度,100 万行需要 8 分半。
  • 只用批处理、不开启重写,只省掉了提交次数。 驱动仍然逐条发送 INSERT,网络往返次数没变。
  • 开启 rewriteBatchedStatements=true 后,驱动把一批 INSERT 改写成一条多行插入 INSERT ... VALUES (...), (...), ...,一次往返写入一整批。这是收益最大的一步。
  • 批大小从 1000 加到 5000,只快了 17%,但一批失败时要重试的数据变成 5 倍,1000 左右是比较均衡的选择。改写后的单条语句不能超过 max_allowed_packet(MySQL 8.4 默认 64MB),行很宽时要相应减小批大小。
  • 线程也不是越多越好:4 个线程比单线程快约一半,但数据库的写入能力有上限,导入线程还会和线上业务争抢连接、锁和 I/O,复制延迟也会随之上升。
  • 多线程并行在这台机器上快了约 50%(容器限制 2 CPU)。线程数不是越多越好:数据库的写入能力、唯一索引的检查、自增锁都会成为瓶颈,而且导入会挤占线上业务的数据库资源,要限制并发并安排在低峰期。

LOAD DATA 比 4 线程的批处理还快约 60%,但需要服务端开启 local_infile、客户端开启 allowLoadLocalInfile,出于安全考虑很多生产环境会禁用。在应用内导入时,开启重写的批处理通常已经足够。

3.3 写入代码 ​

java
// 连接参数:jdbc:mysql://host:3306/db?rewriteBatchedStatements=true
void writeBatch(DataSource ds, List<OrderRow> batch) throws SQLException {
    String sql = "INSERT INTO import_order (order_no, user_id, sku, qty, remark) VALUES (?, ?, ?, ?, ?)";
    try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
        c.setAutoCommit(false);
        try {
            for (OrderRow r : batch) {
                ps.setLong(1, r.orderNo());
                ps.setLong(2, r.userId());
                ps.setString(3, r.sku());
                ps.setInt(4, r.qty());
                ps.setString(5, r.remark());
                ps.addBatch();
            }
            ps.executeBatch();
            c.commit();
        } catch (SQLException e) {
            c.rollback();
            throw e;
        }
    }
}

使用 MyBatis 时,常见的做法是用 <foreach> 拼接多行 VALUES。实测每批 1000 行、连接不开启重写,每秒 79,365 行:是未重写批处理的 4 倍,但只有开启重写的批处理的六成。用 <foreach> 还要自己控制每批的行数,避免 SQL 超过 max_allowed_packet;能改连接参数时,JDBC 批处理加 rewriteBatchedStatements=true 更快;MyBatis 的 ExecutorType.BATCH 底层走的也是 JDBC 批处理。

四、失败处理:批次失败时发生了什么 ​

4.1 实测:一批里有一行重复 ​

一批 6 行,第 4 行的 order_no 与第 2 行重复,捕获异常后故意提交,观察哪些行被写入:

设置getUpdateCounts()提交后表中的数据
未开启重写[1, 1, 1, -3, 1, 1]1, 2, 3, 5, 6
开启重写[-3, -3, -3, -3, -3, -3]空

-3 是 Statement.EXECUTE_FAILED。

  • 未开启重写时,驱动逐条执行,出错的那条失败,其余照常执行(Connector/J 的 continueBatchOnError 默认为 true)。如果捕获异常后提交,就会得到一批「部分成功」的数据。
  • 开启重写后,整批是一条语句,一行出错整条语句失败,这一批一行都没有写入。

两种行为都不能直接用来「跳过坏行继续导入」:前者的结果依赖驱动的执行方式,后者整批丢失。

4.2 降级逐行写入 ​

可靠的做法是:批次失败就回滚这一批,再逐行重试。

java
void writeWithFallback(DataSource ds, List<OrderRow> batch, ImportReport report) {
    try {
        writeBatch(ds, batch);
        report.success(batch.size());
    } catch (SQLException batchError) {
        for (OrderRow row : batch) {
            try {
                writeBatch(ds, List.of(row));
                report.success(1);
            } catch (SQLException rowError) {
                report.fail(row.lineNo(), rowError.getMessage());
            }
        }
    }
}

降级只发生在出错的批次上,正常批次仍然走批处理。坏数据很多时降级会很慢,所以要把能提前发现的问题放到校验阶段:格式、必填、长度、枚举值、文件内部的重复键。

4.3 让导入可以重跑 ​

导入可能在任何位置中断:应用发布、超时、数据库切换。设计成可重跑,比实现精确的断点续传简单得多:

  • 用业务唯一键(如订单号)建唯一索引;
  • 按需求选择冲突时的行为:INSERT IGNORE 跳过已存在的行,或 INSERT ... ON DUPLICATE KEY UPDATE 覆盖为文件中的值;
  • 记录导入任务的状态和已处理的批次号,重跑时可以跳过已确认完成的批次,作为优化而不是正确性的保证。

注意 INSERT IGNORE 会把一些其他错误也降级为警告,例如数据被截断。对数据质量要求严格时,用 ON DUPLICATE KEY UPDATE,并在校验阶段拦住格式问题。

4.4 关于「整个导入放在一个事务里」 ​

常见的「用 @Transactional 保证失败时整体回滚」,对百万行导入不适用:一个事务写 100 万行会产生巨大的 Undo 和 Binlog,锁持有时间长,失败回滚同样耗时,replica 也会出现明显的复制延迟,原因与 千万级大表怎么清理数据 中的大删除相同。

如果业务确实要求「全部成功才生效」,可以先导入到临时表或带批次号的暂存表,校验通过后再用一条语句切换状态,或者分批从暂存表迁移到正式表。

4.5 先落暂存表:保留原始值,给每个坏行一个原因 ​

5.3 提到 INSERT IGNORE 会把错误降级为警告。实测(MySQL 8.4.11,严格模式开启)一份 7 行的文件,其中 5 行各有一种问题,用 INSERT IGNORE 直接写入带类型的表:

文件里的值表里存成了
金额 12.5abc12.50
日期 2026-02-300000-00-00
状态 UNKNOWN(列是 ENUM('PAID','REFUNDED'))空串
订单号 ' A005 ',金额 -5原样写入,带空格、负数
与第 1 行重复的订单号跳过

7 行写入了 6 行,没有一行报错,其中 4 行的内容是错的,而且事后无法从表里知道原来的值是什么。

另一种做法是分两步。第一步把文件原样写进暂存表:所有列都是字符串,加上批次号和行号,不做任何转换,所以不会失败。第二步用规则给每一行分类:

sql
INSERT INTO rejects (batch_id, line_no, rule_version, reason)
SELECT batch_id, line_no, 'r1', reason FROM (
  SELECT batch_id, line_no, CASE
           WHEN order_no NOT REGEXP '^[A-Z][0-9]{3}$' THEN '订单号格式不符'
           WHEN amount NOT REGEXP '^-?[0-9]+(\\.[0-9]{1,2})?$' THEN '金额不是数字'
           WHEN CAST(amount AS DECIMAL(10,2)) < 0 THEN '金额为负'
           …
           WHEN ROW_NUMBER() OVER (PARTITION BY batch_id, order_no ORDER BY line_no) > 1 THEN '文件内订单号重复'
         END AS reason
  FROM raw_orders WHERE batch_id = ?) c
WHERE reason IS NOT NULL;

没有进拒绝表的行才写入目标表。同一份文件的结果:

行数内容
写入目标表2两个合格的订单
拒绝表5第 2 行金额不是数字;第 3 行日期不存在;第 4 行状态不在允许范围;第 5 行文件内订单号重复;第 6 行订单号格式不符
对账原始 7 = 写入 2 + 拒绝 5

这样得到三样直接写入得不到的东西:

  • 原始值还在。 暂存表里第 2 行的金额仍是 12.5abc。规则写错了可以改完重新分类,不需要重新要一份文件。
  • 每个坏行有原因和规则版本。 可以直接导出给数据的提供方;规则调整后,能区分「按旧规则拒绝的」和「按新规则拒绝的」。
  • 可以对账。 原始行数等于写入加拒绝,不相等就说明有行被悄悄丢掉或重复处理了。重跑同一批,实测目标表和拒绝表的行数都不变。

规则写在 SQL 里还是应用代码里都可以,要点是「原样落地」和「分类」分成两步。暂存表按批次保留一段时间后清理。

4.6 全量快照里的删除,与迟到的旧数据 ​

ON DUPLICATE KEY UPDATE 只处理了「新增」和「修改」,还有两种情况它不管。

来源删除了一行。 来源每次给的是全量快照,第 2 批里少了一个订单。只做 upsert,目标表里那个订单还在,而且永远不会再被更新,成了一条陈旧数据。做法是让每一行记录「最后一次出现在哪个批次」,导入完成后把没有出现在本批的行标记为已删除:

sql
UPDATE orders_target SET deleted = 1 WHERE last_batch < ?;       -- 本批的批次号

实测标记之后,未删除的是本批出现的两个订单,缺失的那个被标记。这只适用于全量快照;增量文件里没有某一行不代表它被删了,需要来源显式给出删除记录。标记而不直接删除,是为了在来源文件本身有问题(比如只传了一半)时还能恢复:发现本批行数比上一批少了很多,应该先停下来确认,而不是标记掉一大片。

旧数据后到。 重传、乱序、补数都会让一份较旧的文件在较新的之后到达。目标表里某订单已经是来源的第 2 版(金额 120),这时来了它的第 1 版(金额 100):

写法结果
无条件覆盖金额变回 100,版本变回 1
只在来源版本更新时才覆盖保持金额 120、版本 2
sql
ON DUPLICATE KEY UPDATE
  amount     = IF(r.source_ver > orders_target.source_ver, r.amount, orders_target.amount),
  source_ver = GREATEST(orders_target.source_ver, r.source_ver)

来源版本可以是来源系统的更新时间或自增版本号,要由来源提供,不能用导入时间代替。source_ver 的赋值要放在其他列之后:MySQL 的单表赋值一般按从左到右的顺序计算,先改了它,后面的比较用到的就是新值。同样的「旧写入不能覆盖新写入」在应用内存状态的保存里也会遇到,见自动保存显示成功了,最后一次修改却没存上。

五、上线前的检查清单 ​

  • [ ] 读取使用流式 API,内存占用与文件大小无关
  • [ ] 读写之间有有界队列,写入慢时读取会被阻塞
  • [ ] 连接参数开启 rewriteBatchedStatements=true,批大小在 500—2000 之间
  • [ ] 写入线程数有上限,导入任务限制在业务低峰期
  • [ ] 批次失败时降级逐行写入,错误报告带行号和原因
  • [ ] 业务唯一键有唯一索引,导入可以安全重跑
  • [ ] 文件先原样落暂存表,坏行进拒绝表并带原因;原始行数 = 写入 + 拒绝
  • [ ] 全量快照处理了来源删除的行;覆盖已有数据时比较来源版本
  • [ ] 导入是异步任务,前端轮询进度,不占用 HTTP 请求线程等待
  • [ ] 文件大小和行数有上限,防止恶意或误操作上传超大文件

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权