1. 背景

在使用 MyBatis-Plus(以下简称 MP)的过程中,经常会遇到批量插入数据的场景。
MP 提供了多种批量插入的实现方式,但它们在实现原理、性能表现以及对主键自动填充的支持程度上存在明显差异。

本文结合源码和实际使用经验,对这些方式进行一次系统梳理。

2. 批量插入的实现方式

统一说明:关于 saveBatch 的执行方式

在理解MyBatis-Plus提供的多种批量插入方式之前,需要先澄清一个常见误解

常见误解

saveBatch 并不等价于“循环执行数据库写操作”。

虽然在实现层面上,saveBatch 会对待插入的数据集合进行遍历,但该遍历仅用于向 JDBC Batch 中追加 SQL 语句,并不会在每次循环时都向数据库发送请求。

其核心机制是:

  • 使用 ExecutorType.BATCH
  • 多次 insert 调用仅将 SQL 暂存于 JDBC Batch
  • 每累计到指定的 batchSize,才会触发一次 flushStatements
  • flush 只是将 SQL 发送至数据库,事务提交仍由外层事务控制

从执行效果上看:

1
2
3
4
5
N 次 insert 调用

约 N / batchSize 次数据库交互

1 次事务提交

因此,saveBatch(list, batchSize) 的语义应理解为:

batchSize 对数据进行分片(分批)执行 JDBC Batch,而非逐条写入数据库。

仍需注意

这种方式不同于数据库层面的单条多值 SQL(如 insert into values (...), (...)),而是 JDBC 层面的批处理机制

2.1 saveBatch(最常用)

用法

1
2
3
4
5
6
7
8
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
public void batchInsert(List<User> list) {
this.saveBatch(list);
// 或指定批量大小
// this.saveBatch(list, 1000);
}
}

实现原理

  • 方法来自 IService
  • 底层使用 SqlSession,并指定 ExecutorType.BATCH
  • 每累计 batchSizeinsert 后触发一次 flushStatements

简化后的核心逻辑如下:

1
2
3
4
sqlSession.insert("insert");
if (i % batchSize == 0) {
sqlSession.flushStatements();
}

其本质是 JDBC Batch,而不是数据库层面的单条多值 SQL。

特点
  • 官方推荐的批量写入方式
  • 支持自动分批,避免一次性加载过多数据导致内存问题
  • 实现的是“多条 insert + JDBC batch”
  • 对数据库自增主键的回填支持依赖 JDBC 驱动,不稳定

适用场景

  • 普通业务批量写入
  • 数据量在 1k~5w 之间

2.2 saveOrUpdateBatch(批量 Upsert)

用法

1
this.saveOrUpdateBatch(list);

实现原理

  • 根据主键是否为空判断是 insert 还是 update
  • 内部仍然使用 JDBC batch
  • 会包含额外的判断逻辑
特点
  • 性能低于 saveBatch
  • 存在额外的判断和更新开销
  • 适合新增与更新混合的场景

2.3 Mapper XML 自定义批量 Insert(单 SQL)

Mapper 方法

1
int insertBatch(@Param("list") List<User> list);

XML 实现(MySQL 示例)

1
2
3
4
5
6
7
<insert id="insertBatch">
insert into user (name, age)
values
<foreach collection="list" item="item" separator=",">
(#{item.name}, #{item.age})
</foreach>
</insert>

生成的 SQL 示例

1
2
3
4
insert into user(name, age) values
('a', 1),
('b', 2),
('c', 3);
特点
  • 单条 SQL,数据库只解析一次,性能最高
  • SQL 长度受数据库限制
  • 不同数据库语法差异较大,可移植性一般
  • MP 无法介入执行过程,主键回填能力缺失

适用场景

  • 大批量数据导入
  • 对性能要求极高
  • 每批数据量可控(通常 1k~5k)

2.4 Db.saveBatch

Db.saveBatch 是什么

Db.saveBatch 是 MyBatis-Plus 提供的静态工具方法,用于在不依赖 Service Bean 的情况下执行批量插入。

1
2
Db.saveBatch(list);
Db.saveBatch(list, 1000);

位于 com.baomidou.mybatisplus.extension.toolkit.Db

Service.saveBatch 的区别

方式 调用方式
Service.saveBatch userService.saveBatch(list)
Db.saveBatch Db.saveBatch(list)

Db.saveBatch 采用静态调用方式,不依赖 Spring 注入。

底层实现

两者底层实现几乎一致,最终都会走到:

1
2
3
SqlHelper.executeBatch
→ ExecutorType.BATCH
→ BaseMapper.insert

简化源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static <T> boolean saveBatch(Collection<T> entityList, int batchSize) {
return SqlHelper.executeBatch(
entityList,
batchSize,
(sqlSession, entity) -> {
sqlSession.insert(
SqlHelper.getSqlStatement(entity.getClass(), SqlMethod.INSERT_ONE),
entity
);
}
);
}
特点

优点:

  • 不依赖 Service 层
  • 适合工具类、脚本、初始化数据等场景

缺点:

  • 绕过 Service 层,事务边界不直观
  • 在复杂业务中可维护性较差

补充说明:Db.saveBatch 为什么事务支持较弱

为什么事务支持较弱

Db.saveBatch 在功能上与 Service.saveBatch 类似,但在事务管理方面存在一定局限,主要原因在于其调用方式和事务边界不够直观

1. 静态工具方法,绕过 Service 层

Db.saveBatch 以静态方法形式存在:

1
Db.saveBatch(list);

它不依赖具体的 Service Bean,也不会触发 Service 层常见的 AOP 逻辑,例如:

  • @Transactional
  • 统一的事务传播策略
  • 业务层的拦截与扩展

因此,在阅读代码时,很难通过方法签名判断其是否运行在 Spring 管理的事务上下文中。

2. 内部自行获取 SqlSession

Db.saveBatch 内部通过 SqlHelper.executeBatch 获取并使用 SqlSession
如果当前线程中不存在已绑定的事务上下文,则会:

  • 创建新的 SqlSession
  • 使用自动提交或独立提交的方式执行 batch

这会导致以下问题:

  • 即使外层方法没有事务注解,代码仍然可以正常写入数据库
  • 多次 Db.saveBatch 调用之间,无法天然形成一个原子事务

3. 对 @Transactional 的依赖更加“隐式”

Db.saveBatch 被调用时,是否真正处于事务中,完全取决于调用点

1
2
3
4
@Transactional
public void method() {
Db.saveBatch(list);
}

在这种情况下,只要:

  • 方法由 Spring 管理
  • 调用未发生在同类内部方法

事务才能生效。

相比之下,Service.saveBatch 通常位于 Service Bean 内部,其调用路径更符合 Spring 事务的设计预期,事务边界也更加清晰。

4. 不利于复杂事务与业务编排

在以下场景中,Db.saveBatch 的事务问题会更加明显:

  • 一个方法中包含多次批量写操作
  • 批量写操作与其他数据库操作需要处于同一事务
  • 需要使用事务传播行为(如 REQUIRES_NEW

由于 Db.saveBatch 缺乏明确的业务语义和 Service 层边界,这类事务编排往往难以维护和理解。

5. 总结

Db.saveBatch 并非完全不支持事务,而是:

  • 事务是否生效依赖调用环境
  • 事务边界不直观,容易被误用
  • 不适合作为核心业务写路径的主要手段

因此,更推荐在核心业务场景中使用 Service.saveBatch,而将 Db.saveBatch 用于:

  • 初始化数据
  • 脚本或工具类
  • 对事务要求较低的简单批量写入场景
Spring 事务传播策略

Spring 通过事务传播策略(Propagation)来定义一个事务方法在被另一个事务方法调用时的行为方式,即:
当前方法应当加入已有事务,还是创建新的事务。

常见的事务传播策略如下:

传播策略行为说明
REQUIRED(默认)如果当前存在事务,则加入该事务;否则新建一个事务
REQUIRES_NEW始终新建一个事务,并挂起当前已有事务
SUPPORTS如果当前存在事务,则加入;否则以非事务方式执行
NOT_SUPPORTED以非事务方式执行,并挂起当前事务
MANDATORY必须存在事务,否则抛出异常
NEVER必须不存在事务,否则抛出异常
NESTED如果存在事务,则创建嵌套事务(基于保存点)

其中:

  • REQUIRED 是最常用、也是默认的传播策略
  • REQUIRES_NEW 适用于需要独立提交或回滚的场景
  • NESTED 依赖数据库对保存点(Savepoint)的支持

事务传播策略通常通过 @Transactional 注解进行配置,例如:

1
2
3
4
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void method() {
// ...
}

合理使用事务传播策略,可以使业务逻辑中的事务边界更加清晰,有助于提升系统的可维护性和可靠性。

3. 主键自动填充支持度

批量方式 自增 ID(AUTO_INCREMENT) 雪花 ID(ASSIGN_ID) UUID(ASSIGN_UUID) 备注
Service.save 稳定支持 稳定支持 稳定支持 单条 insert
Service.saveBatch 不稳定 稳定支持 稳定支持 自增依赖 JDBC
Db.saveBatch 不稳定 稳定支持 稳定支持 同上
XML foreach 单 SQL 不支持 不支持 不支持 MP 无法回填

3.1 为什么批量插入下自增主键不稳定

单条 Insert 的情况

1
insert into user(name) values ('a');
  • JDBC 可以通过 getGeneratedKeys() 获取主键
  • MP 可以完成主键回填

Batch Insert 的真实执行方式

1
2
3
4
ps.addBatch();
ps.addBatch();
ps.addBatch();
ps.executeBatch();

存在的问题:

  • 并非所有 JDBC 驱动都支持返回多条 generated keys
  • executeBatch() 返回结果的结构在不同数据库和驱动中不一致
  • MyBatis 对批量 generated keys 的支持有限

因此在批量场景下,自增主键回填不可作为可靠依赖。

MySQL 驱动情况说明

MySQL 驱动版本 批量自增回填支持
较老版本 不支持
新版本 部分支持
复合主键 不支持

4. 特殊场景:父子表批量插入

在父子表结构中,子表通常需要依赖父表主键作为外键。

核心原则

父表主键必须在插入子表之前就已经确定。

因此,不能依赖数据库自增主键在批量插入后回填。

推荐方案:应用层生成主键

表结构设计

1
2
@TableId(type = IdType.ASSIGN_ID)
private Long id;

父表与子表统一使用应用层生成的主键策略。

示例流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Transactional(rollbackFor = Exception.class)
public void batchInsert(List<TaskDTO> taskList) {

List<Task> tasks = new ArrayList<>();
List<TaskItem> items = new ArrayList<>();

for (TaskDTO dto : taskList) {
Task task = new Task();
task.setId(IdWorker.getId());
task.setName(dto.getName());
tasks.add(task);

for (ItemDTO itemDto : dto.getItems()) {
TaskItem item = new TaskItem();
item.setId(IdWorker.getId());
item.setTaskId(task.getId());
item.setName(itemDto.getName());
items.add(item);
}
}

taskService.saveBatch(tasks, 500);
taskItemService.saveBatch(items, 500);
}
特点
  • 父子关系在内存中即可确定
  • 支持真正的批量插入
  • 不依赖数据库回填能力
  • 事务边界清晰

不推荐的方案

在并发或事务场景下风险较高
  • 依赖 saveBatch 回填自增主键
  • 批量插入后通过 max(id) 或回查补齐关联关系
  • 先插子表,再回填父表 ID

5. 总结

  • saveBatch / Db.saveBatch 本质是 JDBC batch,并非单条多值 SQL
  • 批量插入场景下,不应依赖数据库自增主键回填
  • 推荐统一使用应用层生成主键(如 ASSIGN_ID)
  • 父子表批量插入的关键在于:父表主键必须提前确定
  • 对性能极致敏感的场景,可考虑 XML 方式并手动处理主键