MyBatis 批量操作深度优化——从 N+1 到批处理的全路径

发布时间:2026/7/8 0:02:21
MyBatis 批量操作深度优化——从 N+1 到批处理的全路径 MyBatis 批量操作深度优化——从 N1 到批处理的全路径一、从功能正确到性能可接受——MyBatis 批量操作的三段式进化MyBatis 在日常增删改查场景中几乎是无感的——实体映射直观、SQL 控制灵活。但当数据量从千级上升到十万级、百万级许多看似正确的写法会成为性能瓶颈。以下是一段真实经历的浓缩某订单导入功能要求支持单次上传 10 万条订单明细。开发同学的实现方案是循环遍历 Excel 解析出的每一行调用OrderMapper.insert(order)外层用Transactional包裹。功能验证阶段100 条一切正常产线验收阶段10 万条耗时 47 分钟且数据库 CPU 持续飙升。这个案例暴露了 MyBatis 批量操作中三个递进的问题层次N1 问题循环内逐条 SQL 执行每次都有网络往返、SQL 解析、事务开销。忽略 JDBC 批处理能力即使使用foreach拼接 SQL也只是减少了网络往返次数并未利用 JDBC 的addBatch()/executeBatch()机制。缺乏分片与流控意识一次性提交 10 万条数据undo log 膨胀、长事务锁持有时间过长。本文将按常规写法 →foreach批量 SQL → JDBC Batch → 分片流控的路径逐步展示每个阶段的优化原理和代码实现。二、底层机制与原理深度剖析flowchart LR subgraph Input[数据源] DS[10 万条待入库数据] end subgraph Anti[反模式请避免] L1[循环逐条 insertbr/10 万次 DB 交互] end subgraph Stage1[第一阶优化] L2[foreach 批量 SQLbr/10 次 DB 交互每次 1 万条] end subgraph Stage2[第二阶优化] L3[JDBC Batch 分片br/利用 PreparedStatement 批量] end subgraph Stage3[第三阶优化] L4[分片 流控 异步br/可中断、可观测] end DS -- Anti Anti --|网络往返O(n)| Stage1 Stage1 --|SQL 长度/事务膨胀| Stage2 Stage2 --|单次不可中断| Stage3阶段分析逐条执行时每条 INSERT 都是一次完整的 Client → DB 往返。以 RTT 0.5ms 计算仅网络时间就达 50 秒10 万 × 0.5ms还不包括 SQL 解析、执行、事务提交的开销。foreach批量拼接将多条 INSERT 合并为一条多 VALUES 的 SQLINSERT INTO t VALUES (...), (...), (...)将网络往返从 O(n) 降低到 O(n/batchSize)。但每批 1 万条意味着一条 SQL 的长度可能达到数 MB数据库解析开销大且出错时需要整体回滚。JDBC Batch 通过PreparedStatement.addBatch()executeBatch()在 JDBC 驱动层面批量发送底层可能优化为合并的网络包同时保持每条语句的独立性。以 MySQL 为例需要在连接 URL 中加rewriteBatchedStatementstrue参数才能将多条独立的 INSERT 合并为一条。三、生产级代码实现与最佳实践3.1 分片批处理核心实现/** * 通用批量插入服务。 * 设计考量不再使用 MyBatis foreach 做应用层 SQL 拼接 * 而是直接利用 JDBC Batch 能力配合手动分片和事务控制 * 确保大批量操作可中断、可观测、可恢复。 */ Service public class BatchInsertService { private final SqlSessionFactory sqlSessionFactory; /** 每批次大小经实测MySQL 单次 Batch 在 500~2000 条时性能最优 */ private static final int BATCH_SIZE 1000; /** * 批量插入支持进度回调。 * * param items 待插入数据列表 * param mapperClass Mapper 接口类 * param progressCallback 进度回调可用于日志、前端轮询 */ public T BatchResult batchInsert(ListT items, Class? mapperClass, ConsumerProgress progressCallback) { if (items null || items.isEmpty()) { return new BatchResult(0, 0, 0); } int total items.size(); int successCount 0; int failCount 0; ListThrowable errors new ArrayList(); // 手动分片每片在一个独立事务中提交 for (int i 0; i total; i BATCH_SIZE) { int end Math.min(i BATCH_SIZE, total); ListT batch items.subList(i, end); try { int affected executeBatch(batch, mapperClass); successCount affected; } catch (Exception e) { log.error(批量插入失败: 分片 [{}, {}), 错误: {}, i, end, e.getMessage()); failCount (end - i); errors.add(e); // 失败后是否继续由调用方决定这里记录但继续执行 // 如果需要严格一致性可以将此处的 continue 改为 throw } // 回调进度 if (progressCallback ! null) { progressCallback.accept(new Progress( Math.min(end, total), total, successCount, failCount)); } } return new BatchResult(successCount, failCount, errors.size()); } /** * 执行单批次 JDBC Batch 操作。 * 设计考量使用 BATCH 执行器类型MyBatis 会委托给 JDBC 的 * PreparedStatement.addBatch/executeBatch性能优于逐条 execute。 * 事务粒度控制在单批次避免长事务。 */ private T int executeBatch(ListT batch, Class? mapperClass) { try (SqlSession sqlSession sqlSessionFactory.openSession( ExecutorType.BATCH, false)) { // false 表示不自动提交由我们手动控制事务边界 Object mapper sqlSession.getMapper(mapperClass); Method insertMethod findInsertMethod(mapperClass); for (T item : batch) { insertMethod.invoke(mapper, item); } // 执行批处理并提交事务 ListBatchResult results sqlSession.flushStatements(); sqlSession.commit(); // 汇总影响行数 int totalAffected results.stream() .filter(r - r instanceof org.apache.ibatis.executor.BatchResult) .flatMap(r - ((org.apache.ibatis.executor.BatchResult) r) .getUpdateCounts().stream()) .mapToInt(Integer::intValue) .sum(); return totalAffected; } catch (Exception e) { throw new BatchExecutionException(批次执行失败, e); } } /** * 反射获取 Mapper 接口的 insert 方法。 * 设计考量通过方法名约定而非硬编码类名保持通用性。 */ private Method findInsertMethod(Class? mapperClass) { return Arrays.stream(mapperClass.getDeclaredMethods()) .filter(m - insert.equals(m.getName())) .findFirst() .orElseThrow(() - new IllegalArgumentException( Mapper 未找到 insert 方法: mapperClass.getName())); } // 内部类进度信息 public record Progress(int completed, int total, int success, int failed) {} public record BatchResult(int success, int failed, int errorCount) {} }3.2 批量更新与批量删除的优化/** * 批量更新服务。 * 设计考量UPDATE 场景与 INSERT 不同MySQL 的 rewriteBatchedStatements * 只对 INSERT 有效。UPDATE 需要结合 CASE WHEN 语法实现真批量。 */ Service public class BatchUpdateService { private final SqlSessionFactory sqlSessionFactory; /** * 使用 CASE WHEN 实现单条 SQL 批量更新。 * 适用于需要将多条记录更新为不同值的场景。 */ public T int batchUpdateWithCaseWhen(ListT items, BiFunctionT, MapString, Object, MapString, Object updateMapper) { if (items null || items.isEmpty()) return 0; // 收集所有 ID 和各字段的目标值 ListLong ids new ArrayList(); MapString, StringBuilder fieldCases new LinkedHashMap(); for (T item : items) { MapString, Object updates updateMapper.apply(item, new HashMap()); Long id (Long) updates.remove(id); ids.add(id); for (Map.EntryString, Object entry : updates.entrySet()) { fieldCases.computeIfAbsent(entry.getKey(), k - new StringBuilder()) .append(String.format(WHEN %d THEN %s , id, entry.getValue())); } } // 构造 SQL StringBuilder sql new StringBuilder(UPDATE target_table SET ); for (Map.EntryString, StringBuilder entry : fieldCases.entrySet()) { sql.append(String.format(%s CASE id %s END, , entry.getKey(), entry.getValue())); } sql.setLength(sql.length() - 2); // 去掉最后一个逗号 sql.append( WHERE id IN ().append( ids.stream().map(String::valueOf) .collect(Collectors.joining(,))) .append()); try (SqlSession session sqlSessionFactory.openSession(true)) { return session.getConnection() .createStatement() .executeUpdate(sql.toString()); } catch (SQLException e) { throw new BatchExecutionException(CASE WHEN 批量更新失败, e); } } }3.3 避免 N1 的关联查询优化/** * 使用 MyBatis 的嵌套结果映射解决 N1 关联查询。 * * 设计考量N1 问题在列表查询中尤为常见——先查主表获取 ID 列表 * 再逐条查关联表。解决方案是使用一条 JOIN SQL resultMap 的 * collection 关联映射一次查询带回所有数据。 */ // Mapper XML 配置 /* resultMap idorderWithItemsMap typeOrder id propertyid columnorder_id/ result propertyorderNo columnorder_no/ !-- 嵌套结果映射使用同一结果集的列 -- collection propertyitems ofTypeOrderItem resultMaporderItemMap/ /resultMap resultMap idorderItemMap typeOrderItem id propertyid columnitem_id/ result propertyproductName columnproduct_name/ result propertyquantity columnquantity/ /resultMap select idfindOrdersWithItems resultMaporderWithItemsMap SELECT o.id AS order_id, o.order_no, oi.id AS item_id, oi.product_name, oi.quantity FROM orders o LEFT JOIN order_items oi ON o.id oi.order_id WHERE o.created_at #{startDate} ORDER BY o.id /select */3.4 MySQL 必须开启的参数/** * 数据源配置开启 MySQL JDBC 批处理重写。 * 设计考量如果没有 rewriteBatchedStatementstrue * JDBC 驱动会将 batch 中的每条 INSERT 单独发送 * 批处理的性能优势大打折扣。 */ Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource dataSource() { return DataSourceBuilder.create() .url(jdbc:mysql://localhost:3306/mydb? rewriteBatchedStatementstrue // 核心批量 INSERT 多 VALUES 合并 useServerPrepStmtsfalse // 关闭服务端预处理批量场景非必要 cachePrepStmtstrue // 缓存客户端 PreparedStatement useSSLfalse // 内网环境可关闭 allowPublicKeyRetrievaltrue) // MySQL 8.0 认证 .build(); } }四、边界分析与架构权衡批量大小选择对照表批次大小适用场景优点风险100~500单条数据大、高并发事务短、锁竞争低提交次数多、吞吐略低500~2000常规批量推荐性能与稳定性平衡点—2000~5000离线数据迁移吞吐高undo log 占用大、长事务5000不推荐—SQL 过长、事务超时风险foreach批量 SQL versus JDBC Batch维度foreach 拼接JDBC Batch网络往返1 次/批1 次/批合并后SQL 解析每条长 SQL 一次解析每条独立语句一次解析错误定位整批失败不明确哪条可定位到具体行MySQL 支持默认支持需要rewriteBatchedStatementstrue适用场景同构数据大批量插入异构数据处理、需要错误隔离ExecutorType.BATCH的隐形成本使用 BATCH 执行器时MyBatis 不会在每次insert调用后立即返回自增主键。如果业务需要在插入后获取 ID需要以下两种方案之一使用selectKey提前生成 ID如 Snowflake 分布式 ID再执行插入。使用REUSE执行器复用 PreparedStatement配合手动flushStatements获取自增 ID。连接池耗尽与大事务的连锁风险大批量操作中一个容易被忽视的问题是连接池耗尽。当批量任务在执行过程中占用了连接而应用层的其他正常请求仍然需要从同一个连接池获取连接时可能出现池中连接被批处理占满、正常请求超时的雪崩效应。对于这种情况建议为批量操作使用独立的连接池或数据源与常规 CRUD 操作进行物理隔离。以 HikariCP 为例可以将批量数据源的maximumPoolSize控制在较小值如 5避免批处理无节制地抢占连接资源。另一个需要关注的是分片失败后的幂等重试。在逐片提交的策略中若第 3 片成功提交而第 4 片执行失败直接从头重试会导致第 3 片数据重复插入。可行的解法有两条路径一是业务侧引入唯一键约束如唯一索引 INSERT IGNORE或ON DUPLICATE KEY UPDATE让数据库层面处理幂等二是在应用层维护分片执行状态已完成/未开始/失败利用 Redis 或本地内存标记每个分片的执行结果重试时只处理状态为失败或未开始的分片。两者的取舍在于方案一实现成本低但对数据库有写入开销方案二更精准但需要额外的状态管理逻辑。五、总结MyBatis 批量操作优化有清晰的台阶第一阶段避免 N1使用 JOIN 查询或foreach批量拼接替代循环查询。第二阶段JDBC Batch使用ExecutorType.BATCH替代foreach拼接 SQL配合rewriteBatchedStatementstrue发挥 MySQL 最佳性能。第三阶段工程化加入分片控制、事务粒度管理、进度回调、异常隔离确保大批量操作可观测、可恢复。性能优化不是追求极致数值而是在吞吐量、事务安全、可维护性之间找到适合当前场景的平衡点。对于大多数场景本文推荐的 BATCH_SIZE1000 JDBC Batch 手动事务控制的方案已经足够应对。