Mybatis-Plus批量插入技巧
1. 批量插入的背景挑战
1.1 场景描述
在实际开发中,如考试系统、订单处理、日志存储等场景,经常需要批量插入大量的数据
例如,在一个在线考试系统中,创建一份试卷需要插入多张表的数据:
- 试卷表(exam): 存储试卷的基本信息
- 题目表(question): 存储题目信息
- 选项表(option): 存储题目下所有选项信息
在保存试卷时,需要关联保存试卷、题目以及题目选项,此时对于保存的性能就有较高的要求了
1.2 性能瓶颈
- 逐条插入:效率低下,传统的逐条插入模式效率不好,每次插入数据时都要与数据库进行交互,从而产生较高的网络开销以及数据库解析成本
- 外键关系处理复杂:题目与选项之间存在外键关联,这就需要在插入数据后获取主键ID,无疑增加了操作的复杂程度
- 批量操作性能有限:默认使用Mybatis-Plus提供的savaBatch方法,其性能提升并不显著,难以满足高并发、大数据量的实际需求
2. 初探Mybaits-Plus的saveBatch方法
2.1 saveBatch 方法简介
在 MyBatis-Plus 中,saveBatch 方法是用于批量保存数据的方法。它能够在单次操作中将多条数据同时插入数据库,从而提高插入效率,减少数据库连接次数,提升性能
boolean saveBatch(Collection<T> entityList);
boolean saveBatch(Collection<T> entityList, int batchSize);
- entityList: 要插入的实体类集合,可以是实现了Collection接口的集合类型,如List、Set等
- batchSize: 指定每次批量插入的大小,默认情况下,Mybatis-Plus会一次性插入所有数据,如果设置了batchSize,则会按照指定大小分批插入,避免一次性插入大量数据时出现性能问题或内存溢出
2.2 常用场景
- 批量插入数据:当需要插入大量数据时,使用saveBatch可以显著提升性能
- 提高数据库写入效率:减少数据库连接和插入次数,有效提升性能
- 处理大数据量时的内存优化:通过分批插入,避免一次性插入大量数据导致内存溢出
2.3 默认实现的局限性
-
不支持多条SQL合并:在默认情况下,即便使用saveBatch,也有可能是逐条发送SQL语句。这会导致生成的SQL冗长、性能较低,尤其是在数据量较大的情况,执行效率会明显下降,无法充分利用数据库批量插入的特性
这里为什么说是有可能是逐条发送SQL语句呢
saveBatch的批量并不是指 MyBatis-Plus 直接生成一条多 values 的 INSERT SQL,而是指它基于 MyBatisExecutorType.BATCH和 JDBC Batch,把多次 insert 先加入批处理,再通过executeBatch()统一执行所以从 MyBatis 调用层面看,它确实是逐条遍历、逐条调用
insert;但从最终发送到数据库的 SQL 形态看,是否会被合并成一条多 values SQL,还要取决于 JDBC 驱动是否支持并开启了批处理重写,例如 MySQL 的rewriteBatchedStatements=true所以,这里说“有可能逐条发送 SQL”,强调的是:saveBatch 本身并不保证最终一定是单条大 SQL。
-
性能提升有限:默认实现并未针对批量插入进行特殊优化。例如,它可能无法充分利用 JDBC 的批量操作特性,导致性能不如手动实现的批量插入逻辑。对于大批量插入,性能可能不理想
-
主键生成方式局限性:如果实体类中主键是由数据库自动生成(如自增主键),默认实现会多次与数据库交互获取主键值,这会增加额外的数据库开销,尤其是当数据量较大时,主键生成的额外查询操作会显著降低性能
-
外键关系处理复杂:需要在插入后获取主键ID,这导致无法在批量插入时建立关联关系,使得外键关系处理变得复杂
-
缺乏灵活性:默认实现只能进行简单的插入操作,不能处理条件性插入(如:插入前判断是否已存在相同记录)或插入冲突处理(如主键冲突时自动更新数据)。对需要动态逻辑的场景不适用
3. 深度解析 rewriteBatchedStatements=true的作用
3.1 JDBC批处理机制
JDBC 批处理机制是一种优化数据库操作性能的技术,允许将多条 SQL 语句作为一个批次发送到数据库服务器执行,从而减少客户端与数据库之间的交互次数,显著提高性能。通常用于 批量插入、批量更新 和 批量删除 等场景。具体的流程如下:
//创建 PreparedStatement 对象,用于定义批处理的 SQL 模板。
PreparedStatement pstmt = conn.prepareStatement(sql);
for (Data data : dataList) {
// 多次调用 addBatch() 方法,每次调用都会将一条 SQL 加入批处理队列。
pstmt.addBatch();
}
//执行批处理,调用 executeBatch() 方法,批量发送 SQL 并执行。
pstmt.executeBatch();
3.2 MySQL JDBC驱动的默认行为对批处理的影响
- 未开启重写:在默认状态下,MySQL JDBC驱动会逐一条目地发送批处理中的SQL语句,未开启重写功能
- 性能瓶颈:频繁的网络交互以及数据库解析操作,使得批量操作的性能提升效果有限,形成了性能瓶颈
3.3 rewriteBatchedStatements=true 的魔力
- 启用批处理重写:启用批处理重写功能后,驱动能够将多条同类型的 SQL 语句进行合并,进而发送给数据库执行
- 减少网络交互:一次发送多条 SQL,可有效降低网络延迟,减少网络交互次数
- 提高执行效率:当所有数据都通过一条 SQL 插入时,MySQL 只需要解析一次 SQL,降低了解析和执行的开销
- 减少内存消耗:虽然批量操作时将数据合并到一条 SQL 中,理论上会增加内存使用(因为需要构建更大的 SQL 字符串),但相比多次单条插入的网络延迟和处理开销,整体的资源消耗和执行效率是更优的
未开启参数时的批处理SQL:
INSERT INTO question (exam_id, content) VALUES (?, ?);
INSERT INTO question (exam_id, content) VALUES (?, ?);
INSERT INTO question (exam_id, content) VALUES (?, ?);
开启参数后的批处理SQL:
INSERT INTO question (exam_id, content) VALUES (?, ?), (?, ?), (?, ?);
4. 预先生成ID:解决外键关系的关键
4.1 问题分析
在插入题目和选项时,选项需要引用对应题目的主键 ID。如果等待题目插入后再获取 ID,会导致无法进行批量操作,影响性能。所以,预先生成ID就成了我们解决问题的关键
5.2 预先生成ID的解决方案
使用ID主键生成器,例如UUID,或者一些其他的工具,这样就能预先生成不会重复,保证唯一性的主键,这样做的好处有:
- 全局唯一:生成的ID在全局范围内唯一,避免了主键冲突
- 本地生成:无需依赖数据库生成,减少了数据库交互
- 支持批量生产:提升获取分布式唯一ID的效率
5. 综合实践优化
5.1 配置 rewriteBatchedStatements=true
修改数据库连接配置
实现这个配置的方式很简单,只需要在我们现有的数据库连接后面直接加上就好。例如:jdbc:mysql://localhost:3306/db_name?rewriteBatchedStatements=true
注意事项
- 驱动版本:确保使用的MySQLJDBC 驱动版本支持该参数(5.1.13及以上)
- 参数位置:如果有多个参数,用&符号连接
5.2 完整示例代码
@Service
public class ExamServiceImpl implements ExamService {
@Autowired
private ExamMapper examMapper;
@Autowired
private QuestionService questionService;
@Autowired
private OptionService optionService;
private static final int BATCH_SIZE = 2000;
@Override
@Transactional(rollbackFor = Exception.class)
public void createExam(Exam exam, int questionCount, int optionCountPerQuestion) {
// 预先生成试卷 ID
long examId = zzidc.nextId();
exam.setId(examId);
examMapper.insert(exam);
List<Question> questionList = new ArrayList<>();
List<Option> allOptionList = new ArrayList<>();
for (int i = 0; i < questionCount; i++) {
// 预先生成题目 ID
long questionId = zzidc.nextId();
Question question = new Question();
question.setId(questionId);
question.setExamId(examId);
question.setContent("题目内容" + i);
questionList.add(question);
// 构建选项数据
for (int j = 0; j < optionCountPerQuestion; j++) {
Option option = new Option();
option.setQuestionId(questionId);
option.setContent("选项内容" + j);
allOptionList.add(option);
}
}
// 批量插入题目和选项
questionService.saveBatch(questionList, BATCH_SIZE);
optionService.saveBatch(allOptionList, BATCH_SIZE);
}
}
5.3 性能测试
测试数据
数据量:总共插入100道题目,每道题目3个选项
测试方案
- 未优化方案:逐条插入试卷、题目和选项数据
- 使用 saveBatch:使用 saveBatch 批量插入,但未配置 rewriteBatchedStatements,且未预先生成 ID
- 综合优化方案:配置 rewriteBatchedStatements=true,预先生成 ID,使用 saveBatch 批量插入
测试结果
||方案耗时(毫秒)|性能提升| |未优化方案|4023|-| |仅使用saveBatch|2744|↑30%| |综合优化方案|149|↑2700%| 以上数据为多次测试平均值
数据分析
- 未优化方案:由于逐条插入,每次插入都需要与数据库交互,导致耗时最长
- 使用 saveBatch:减少了与数据库的交互次数,性能有所提升,但未充分利用 JDBC 驱动的批处理优化
- 综合优化方案:通过配置
rewriteBatchedStatements=true,使 JDBC 驱动将多条 SQL 合并为一条,显著减少网络和数据库开销;同时预先生成 ID,解决了外键关系的问题,支持批量插入,最终实现了性能的大幅提升
注意事项
如果要使用多线程调用saveBatch犯法并发插入的话,需要注意以下问题:
- 线程安全性:在 MyBatis 中,SqlSession 在默认情况下并非线程安全的。若在多线程环境下共享同一个 SqlSession,极有可能导致数据错误或引发异常
- 事务管理:对于多线程操作而言,需要独立的事务管理机制,以此来确保数据的一致性
- 资源竞争:过多的并发线程有可能致使数据库连接池被耗尽,进而降低性能
正确使用多线程,利用Spring的@Async注解,实现异步方法调用,每个异步方法都有自己独立的事务和sqlSession
6. 实践总结
- 将 rewriteBatchedStatements 配置为 true:以此启用 JDBC 驱动的批处理重写功能,可显著提高批量插入的性能表现
- 预先生成 ID:采用 zzidc 等方式预先生成主键 ID,有效解决外键关系问题,进而支持批量插入操作
- 使用异步方法进行多线程批量插入:运用异步方法来进行多线程批量插入,确保线程安全与事务独立,避免出现资源竞争的情况
- 调整数据库连接池和线程池参数:对数据库连接池和线程池的参数进行调整,以满足多线程并发操作的实际需求
- 监控异步任务和数据库性能:对异步任务和数据库性能进行实时监控,以便能够及时发现并解决性能瓶颈问题
文章来源:摘自转转技术团队 原文链接:https://mp.weixin.qq.com/s/bmqRiv_LwZRgZDfiZInJpA