Skip to content
Go back

JDBC批量插入——从1000次网络IO到1次

JDBC 批量插入优化:从 1000 次网络 IO 到 1 次

一句话结论(30s)

批量插入的 100 倍提速不是魔法,而是三步物理规律的叠加——executeBatch 把 N 次网络往返压成 1 次(30x)、rewriteBatchedStatements=true 把 N 次 SQL 解析合成 1 次(再 3x,累积 100x)、分批事务减少 redo log 刷盘——因为单条插入 90% 时间耗在网络延迟和 SQL 解析,而不是真正执行。

核心原理(2min)

底层深入(5-10min)

一个真实的对比

需求:导入 10000 条用户数据。两种写法,两段代码,两个数量级的差距。

// 写法一:单条插入(新手写法)
for (User user : users) {
    jdbcTemplate.update("INSERT INTO t_user(name, email) VALUES(?, ?)",
        user.getName(), user.getEmail());
}
// 耗时:~15000ms(约 0.67 条/ms)

// 写法二:批量插入
String sql = "INSERT INTO t_user(name, email) VALUES(?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    for (User user : users) {
        ps.setString(1, user.getName());
        ps.setString(2, user.getEmail());
        ps.addBatch();
    }
    ps.executeBatch();
}
// 耗时:~450ms(约 22 条/ms)

30 倍以上的性能差距。 这不是魔法——这是”网络往返”的基本物理规律。

想一想:为什么 executeBatch 能快 30 倍? 因为单条插入的耗时大头不是”数据库执行”,而是”网络往返 + SQL 解析”。每一条 INSERT 都要从应用走到 MySQL、再走回来,10000 条就是 10000 次往返;executeBatch 把 10000 条攒在一起、一次网络往返送过去,把 10000 次”路上的时间”压缩成 1 次——这就是 30 倍的来源。

为什么单条插入这么慢?

一次 INSERT 语句的执行链路:

Java 应用 → JDBC Driver → 网络发送 → MySQL Server → 解析 SQL → 执行 → 返回结果 → 网络返回 → JDBC Driver → Java 应用

10000 条数据就是 10000 次完整的往返——其中网络延迟(每个往返约 0.5~2ms)和 SQL 解析成本占了 90% 以上的时间。而批量插入:

Java 应用 → addBatch × 10000 → executeBatch → 网络发送一次 → MySQL 解析一次 → 批量执行 → 返回一次

核心收益:把 N 次网络往返压缩为 1 次,把 N 次 SQL 解析压缩为 1 次。

MyBatis 的 foreach 标签:方便但有暗坑

<insert id="batchInsert">
    INSERT INTO t_user(name, email) VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.name}, #{user.email})
    </foreach>
</insert>

生成的 SQL:

INSERT INTO t_user(name, email) VALUES
('张三', '[email protected]'),
('李四', '[email protected]'),
...
('王五', '[email protected]')

一条 SQL 搞定,10000 条数据只需一次网络往返。比 JDBC 原生的 executeBatch 更简洁——MyBatis 在驱动层做了 SQL 拼接。

foreach 的两个暗坑

坑一:SQL 长度限制。 MySQL 的 max_allowed_packet 默认 64MB,单条 SQL 不能超过这个值。10000 条数据拼接成一条 INSERT 可能超过限制。解决:每 500-1000 条分批。

// 每 500 条执行一次
List<User> batch = new ArrayList<>(500);
for (User user : users) {
    batch.add(user);
    if (batch.size() >= 500) {
        mapper.batchInsert(batch);
        batch.clear();
    }
}

坑二:SQL 注入风险。 虽然 MyBatis 的 #{} 会做预编译占位(防注入),但 foreach 拼接的值如果直接用 ${} 就有注入风险。绝不要在 foreach 中用 ${} 拼接用户输入的值。

想一想:为什么 max_allowed_packet 是 foreach 拼接的暗坑? 因为 foreach 把 10000 条拼成一条超长 SQL,体积可能超过 MySQL 的 max_allowed_packet(默认 64MB),语句会被服务端直接拒绝。所以拼接式批量不能无脑一次性全拼,要按 500-1000 条分批,把单条 SQL 的体积控制在限制之内。

rewriteBatchedStatements=true:驱动层的最后一块拼图

即使使用了 addBatch() + executeBatch(),MySQL JDBC Driver 默认也不会真的重写成一条 SQL。它会逐条发送:INSERT INTO t VALUES(1);INSERT INTO t VALUES(2);... ——网络往返是少了,但 SQL 解析次数没变。

在 JDBC URL 中加上这个参数:

jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true

驱动会在 executeBatch() 时,自动把多条 INSERT 重写成 INSERT INTO t VALUES (1),(2),(3)... 的形式——真正的一条 SQL,解析也只需一次。

实测对比(10000 条数据)

方案耗时vs 单条网络往返SQL解析
单条 for 循环15000ms1x10000次10000次
executeBatch(不加 rewrite)2000ms7.5x1次10000次
executeBatch + rewrite150ms100x1次1次
foreach 拼接180ms83x1次1次

executeBatch + rewrite 是理论上的最优解——SQL 在服务端仍走预编译(Prepare Statement),安全性不受影响。

想一想:为什么加了 rewriteBatchedStatements=true 还能再快一截? 因为不加它时,驱动只是”少了几次网络往返”,但每条 INSERT 仍是独立 SQL,MySQL 要解析 10000 次;加了之后驱动把多条 INSERT 重写成 VALUES (1),(2),(3)... 一条 SQL,解析也从 10000 次降到 1 次。网络往返和 SQL 解析是两个独立的成本,所以要分两步各砍一刀,而不是指望一个参数全包。

事务管理:批量操作的生命线

批量插入 10000 条,如果第 9000 条失败了怎么办?

// 方案一:全部成功或全部回滚(一致性优先)
@Transactional
public void batchInsert(List<User> users) {
    for (User user : users) {
        mapper.insert(user);
    }
}

// 方案二:逐条提交,失败跳过(可用性优先)
public void batchInsertBestEffort(List<User> users) {
    for (User user : users) {
        try {
            mapper.insert(user);
        } catch (Exception e) {
            log.error("插入失败: {}", user, e);
            // 记录到死信表,后续人工处理
        }
    }
}

方案二还需要注意:如果每条 INSERT 单独一个事务,10000 条数据 → 10000 次事务提交(每次 flush redo log)→ 性能急剧下降。分批事务是折中:每 500 条一个事务,既保证部分失败不影响整体(粒度可控),又避免逐条提交的 IO 开销。

想一想:为什么逐条提交会拖慢批量插入? 因为每次 commit 都要把事务的 redo log 刷到磁盘(保证持久性),这是最贵的 IO 操作之一。10000 条逐条提交 = 10000 次刷盘;每 500 条一个事务 = 20 次刷盘,IO 成本直接砍了两个数量级。这也解释了为什么”分批事务”是批量插入的生命线。

总结

批量插入的优化路径只有三步,每一步都能带来数量级的提升:

  1. 减少网络往返forexecuteBatch(30x)
  2. 减少 SQL 解析:加 rewriteBatchedStatements=true(3x,累积 100x)
  3. 减少事务提交次数:逐条提交 → 分批事务(视场景 2-5x)

100 倍的性能提升不是传说——这是网络和数据库底层机制决定的物理规律。写批量插入的时候,脑子里想想那个”数据包在路上跑”的画面,你就知道每一层优化在做什么了。

章末提问

Q1:executeBatch 为什么能提速,本质减少了什么? 结论先行:本质是把 N 次网络往返压缩成 1 次,把”数据包在路上跑”的时间省掉。 因为单条插入 90% 以上时间耗在网络延迟和 SQL 解析,而不是真正执行;addBatch × N 后一次 executeBatch,让 N 条 INSERT 只走一次网络、MySQL 只收一次。注意:此时每条仍是独立 SQL,SQL 解析次数没变,所以只有约 30 倍的提升。

Q2:rewriteBatchedStatements=true 做了什么,和 executeBatch 有什么区别? 结论先行:它把多条 INSERT 重写成 VALUES (1),(2),(3) 一条 SQL,进一步把 N 次 SQL 解析压成 1 次。 因为默认驱动即使收到 executeBatch 也是逐条发送,网络往返少了但解析还是 N 次;加了 rewriteBatchedStatements=true 后,驱动在 executeBatch 时合成真正的一条 SQL,解析也只 1 次,累积到 100 倍。两者是叠加关系:executeBatch 砍网络,rewrite 砍解析。

Q3:批量插入 10000 条,第 9000 条失败了怎么办?事务怎么设计? 结论先行:折中方案是”每 500 条一个事务”——粒度可控、失败只影响一批,又避免逐条提交的 IO 开销。 因为全部一个事务意味着 1 条失败全部回滚(一致性优先但粒度太粗),逐条提交又意味着 10000 次 redo log 刷盘(性能灾难)。每 500 条一个事务,把”失败的影响范围”和”刷盘次数”都控制在可接受的水平;若要求可用性优先,则逐条 try-catch 把失败记录到死信表。


Share this post on:

Previous Post
JMeter压测方法论——从QPS概念到渐进式性能优化
Next Post
HikariCP连接池优化——从参数配置到底层原理