三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

MyBatis批量操作实战:从原理到避坑,提升数据库性能

MyBatis批量操作实战:从原理到避坑,提升数据库性能

1. 从单条到批量:为什么我们需要批量操作?

在任何一个处理数据的业务系统里,你肯定写过这样的代码:一个for循环,里面是insertupdate语句,一条一条地往数据库里塞数据。当数据量只有几十条时,这没什么问题。但一旦数据量上升到几百、几千甚至上万,这种方式的弊端就暴露无遗了。我经历过一个数据同步任务,需要将上游系统的几万条用户变更记录同步到本地库,最初就是用单条循环,结果跑了快二十分钟,数据库连接池差点被拖垮,监控告警响个不停。

问题的核心在于网络I/O和数据库事务开销。每执行一条SQL,应用服务器和数据库服务器之间就要完成一次完整的网络往返(请求-响应),这个时间成本是固定的。假设一次网络往返是1毫秒(这已经是非常理想的内网环境了),执行一万条就是10秒,这10秒纯粹花在了“路上”。更致命的是,在默认的自动提交模式下,每条语句都是一个独立的事务,数据库需要为每条语句维护事务日志、执行锁管理、刷写日志,这个开销远比执行SQL本身要大得多。批量操作,就是把多条数据“打包”成一条或少数几条SQL发送给数据库,一次网络往返,一次事务开销,性能的提升是指数级的。实测下来,将一万条单条插入改为合适的批量插入,耗时可以从分钟级降到秒级,这对于用户体验和系统吞吐量是质的飞跃。

所以,当你的业务涉及数据导入、数据同步、批量处理(如批量审核、批量打标签)时,掌握MyBatis的批量插入与更新,就不是一个“锦上添花”的技能,而是一个“必须掌握”的生存技能。它能直接帮你解决性能瓶颈,避免在深夜被运维的电话叫醒。

2. 批量插入的三种武器:从基础到高效

实现批量插入,MyBatis给了我们好几条路。选择哪一条,取决于你的数据量、对性能的极致要求以及对代码简洁度的偏好。

2.1 利器一:foreach标签拼接SQL

这是最直观、也是新手最常用的方法。它的原理很简单,就是利用MyBatis的动态SQL功能,在XML映射文件中,通过<foreach>标签,将传入的List集合拼接成一条包含多个VALUES子句的巨型SQL语句。

<!-- UserMapper.xml --> <insert id="batchInsert" parameterType="java.util.List"> INSERT INTO user (name, email, age) VALUES <foreach collection="list" item="item" index="index" separator=","> (#{item.name}, #{item.email}, #{item.age}) </foreach> </insert>

对应的Java接口和调用:

// Mapper接口 int batchInsert(@Param("list") List<User> userList); // 调用方 List<User> userList = new ArrayList<>(); // ... 填充userList userMapper.batchInsert(userList);

核心要点与避坑指南:

  1. 参数命名:在<foreach>标签的collection属性中,我们写了"list"。这是因为在Mapper接口的方法参数上,我们使用了@Param("list")注解。如果你不写@Param注解,那么MyBatis默认会把这个List参数放到一个名为"list"的键下(如果只有一个集合参数)或者"param1"下。为了清晰和避免混淆,我强烈建议始终使用@Param注解显式命名
  2. SQL长度限制:这是这种方法最大的坑。数据库服务器对单条SQL语句的长度是有限制的(例如MySQL的max_allowed_packet)。如果你试图一次性插入10万条数据,拼接出来的SQL可能会长达几MB甚至几十MB,直接导致数据库拒绝执行,报错“Packet too large”。所以,这种方法只适用于数据量较小(通常建议在1000条以内)的场景
  3. 事务管理:即使你拼接成了一条SQL,它仍然是在一个数据库事务中执行的。要么全部成功,要么全部失败,这符合业务预期。你需要确保在Service层的方法上添加@Transactional注解。

个人心得foreach拼接法简单粗暴,在开发测试、处理小批量数据时非常方便。但它就像一把水果刀,切水果顺手,但你不能指望它去砍树。面对海量数据,我们需要更专业的工具。

2.2 利器二:ExecutorType.BATCH与sqlSession.flushStatements()

这是MyBatis官方推荐的、用于处理真正大批量数据的方式。它的原理不是拼接SQL,而是利用JDBC的addBatchexecuteBatch机制。

在默认的ExecutorType.SIMPLE执行器下,MyBatis每执行一条Mapper语句,就会立即向数据库发送并执行。而ExecutorType.BATCH执行器则会进行“批处理”优化:它将多条相同模式的SQL语句(例如多个INSERT INTO user ...)缓存起来,攒到一定数量后,再一次性发送给数据库执行。

// 非Spring托管环境下的原生用法 SqlSessionFactory sqlSessionFactory = ...; // 关键:创建SqlSession时指定执行器类型为BATCH try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); List<User> userList = new ArrayList<>(); // ... 填充userList for (User user : userList) { // 注意:这里调用的是单条插入的Mapper方法 mapper.insert(user); } // 关键:执行所有缓存的语句 sqlSession.flushStatements(); // 提交事务 sqlSession.commit(); }

在Spring集成环境中,我们通常不直接操作SqlSession。可以通过以下几种方式使用Batch模式:

  1. 在Service方法上开启(推荐):
    @Service public class UserService { @Transactional // 确保事务 public void batchInsertUsers(List<User> users) { // 获取当前SqlSession并切换执行器(需通过SqlSessionTemplate) // 但更常见的做法是使用第三种方式 } }
  2. 使用SqlSessionTemplate
    @Autowired private SqlSessionTemplate sqlSessionTemplate; public void batchInsert(List<User> users) { // 临时切换执行器 SqlSession session = sqlSessionTemplate.getSqlSessionFactory() .openSession(ExecutorType.BATCH); UserMapper mapper = session.getMapper(UserMapper.class); try { for (User user : users) { mapper.insert(user); } session.flushStatements(); session.commit(); } finally { session.close(); } }
  3. 配置全局Batch执行器(不推荐):在配置文件中将defaultExecutorType设置为BATCH,但这会让所有操作都变成批处理,影响简单查询的性能,通常不是好主意。

核心要点与避坑指南:

  1. 必须手动flushStatements()和提交事务:在Batch模式下,调用mapper.insert(user)并不会立即执行,只是将语句加入了批处理缓存。你必须显式调用sqlSession.flushStatements()来触发JDBC真正执行批处理,然后调用commit()提交事务。如果忘了flushStatements(),数据可能不会插入;如果忘了commit(),事务会回滚。
  2. 性能与批处理大小:JDBC驱动会有一个批处理大小(batch size)的阈值。攒够一定数量的语句后会自动flush。你也可以通过flushStatements()手动控制。找到一个合适的批处理大小(比如500或1000)有助于在内存占用和执行效率间取得平衡。
  3. 获取生成的主键:这是一个巨坑!在Batch模式下,由于多条语句一起执行,通过useGeneratedKeyskeyProperty获取自增主键的行为,在部分数据库驱动(如某些版本的MySQL JDBC驱动)中可能会失效,或者只能获取到最后一条插入数据的主键。如果你的业务强依赖插入后立即使用生成的主键,需要特别测试或考虑其他方案(如使用UUID等非自增主键)。

个人心得ExecutorType.BATCH是处理大批量数据(万级以上)的“标准答案”。它稳定、高效,是很多数据同步、ETL任务的基石。但你需要像对待一个精密仪器一样,牢记它的操作步骤(flushcommit),并小心主键生成这个暗礁。

2.3 利器三:MyBatis-Plus的saveBatch魔法

如果你在使用MyBatis-Plus(以下简称MP),那么批量插入就变成了一行代码的事。MP的IService接口提供了saveBatch方法,它内部封装了批处理的逻辑,让你无需关心ExecutorTypeflushStatements

@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { // 直接使用父类方法 public void batchAddUsers(List<User> userList) { // 默认分批大小是1000 boolean result = saveBatch(userList); // 或者指定分批大小 // boolean result = saveBatch(userList, 500); } }

看起来非常简单,但我们需要理解它背后做了什么。查看MP的源码(以3.x版本为例),saveBatch方法大致流程如下:

  1. 检查当前SqlSession的执行器类型。如果不是BATCH,它会临时切换BATCH执行器。
  2. 遍历集合,循环调用基础的save(即insert)方法。
  3. 每达到指定的batchSize(默认1000),或遍历完成后,自动执行sqlSession.flushStatements()
  4. 方法执行完毕后,如果之前切换过执行器,会恢复原来的执行器类型。

核心要点与避坑指南:

  1. 它帮你做了脏活累活:自动管理执行器切换、批处理刷新,避免了手动操作的繁琐和遗漏,极大地提升了开发效率和代码整洁度。
  2. 性能有保障:其底层依然是JDBC的addBatch机制,性能与手动使用ExecutorType.BATCH相当。
  3. 主键问题依旧存在:和原生的Batch模式一样,使用自增主键时,在批量插入后,实体对象列表中的主键字段可能不会被正确回填(通常只有最后一个实体的主键有效)。MP官方文档也指出了这一点。如果你的后续逻辑需要用到这些主键,这可能是个问题。
  4. 事务saveBatch方法本身不开启事务。你需要确保在调用它的方法上添加@Transactional注解,以保证原子性。

个人心得:对于大多数业务场景,MP的saveBatch是首选。它用极简的API掩盖了底层的复杂性,符合“约定优于配置”的理念。但在引入它之前,务必在测试环境中验证主键回填是否符合你的业务预期。对于超大批量(例如一次性插入数十万条)且对主键无立即依赖的场景,它堪称完美。

3. 批量更新的复杂博弈:策略选择与实战

批量更新比批量插入要复杂得多,因为更新操作通常带有条件(WHERE子句),而每条记录要更新的值和条件可能各不相同。这就无法像插入那样简单地拼接VALUES。我们来分析几种主流策略。

3.1 策略一:多条SQL,逐条执行(最不推荐)

就是文章开头提到的for循环+单条update。除非更新条数极少(<10),否则在任何严肃的场景下都应避免。它是性能的杀手。

3.2 策略二:使用<foreach>标签实现“CASE WHEN”动态更新

这是处理“根据ID更新不同字段”这类需求的经典方案。它生成一条形如以下结构的SQL:

UPDATE user SET name = CASE id WHEN 1 THEN 'Alice' WHEN 2 THEN 'Bob' ... END, email = CASE id WHEN 1 THEN 'alice@example.com' WHEN 2 THEN 'bob@example.com' ... END WHERE id IN (1, 2, ...);

MyBatis的XML实现如下:

<update id="batchUpdate" parameterType="java.util.List"> UPDATE user <trim prefix="SET" suffixOverrides=","> <trim prefix="name = CASE" suffix="END,"> <foreach collection="list" item="item"> WHEN id = #{item.id} THEN #{item.name} </foreach> </trim> <trim prefix="email = CASE" suffix="END,"> <foreach collection="list" item="item"> WHEN id = #{item.id} THEN #{item.email} </foreach> </trim> </trim> WHERE id IN <foreach collection="list" item="item" open="(" separator="," close=")"> #{item.id} </foreach> </update>

核心要点与避坑指南:

  1. 适用场景:非常适合根据主键批量更新多个字段,且每条记录的新值都不同的场景。例如,批量修改用户信息。
  2. 性能:它仍然是一条SQL,网络和事务开销只有一次。数据库执行一条复杂的CASE WHEN语句,通常比执行多条简单UPDATE要快,尤其是在更新量较大时。
  3. SQL长度限制:和批量插入的foreach拼接法一样,存在单条SQL过长的问题。当更新的记录数很多或字段很多时,生成的SQL会非常庞大。
  4. 数据库兼容性CASE WHEN语法是SQL标准,主流数据库(MySQL, PostgreSQL, Oracle)都支持,但写法上可能需要微调。
  5. NULL值处理:需要小心。如果List中某个对象的某个字段为null,那么生成的CASE WHEN分支可能会将该字段更新为NULL。这符合预期,但如果你不想更新为NULL,需要在Java代码或<foreach>内使用<if>标签进行判断,这会进一步增加SQL复杂度。

个人心得:这是一个“优雅的暴力”方案。在几千条数据量以内,它是非常有效的。我曾经用它来批量更新商品价格,性能提升显著。但一旦数据量上去,或者更新字段非常多,你就得时刻盯着数据库的max_allowed_packet参数了。

3.3 策略三:使用ExecutorType.BATCH执行多条Update语句

和批量插入一样,我们可以使用ExecutorType.BATCH模式来执行多条UPDATE语句。

// 在BATCH模式的SqlSession中 UserMapper mapper = sqlSession.getMapper(UserMapper.class); for (User user : userList) { // 假设 updateById 是单条更新的Mapper方法 mapper.updateById(user); } sqlSession.flushStatements(); sqlSession.commit();

或者使用MyBatis-Plus:

// MP的 updateBatchById 方法,内部默认使用了优化策略,并非简单的BATCH循环 boolean success = updateBatchById(userList);

这里需要重点区分MP的updateBatchById:在较早版本中,它可能采用简单的BATCH循环。但在较新版本中,MP对updateBatchById进行了优化。默认情况下,当更新的数据量超过一定阈值(可配置),MP会自动将foreach拼接方案作为后备,以规避BATCH模式可能存在的性能问题(特别是在MySQL的InnoDB引擎下,大量随机主键更新可能导致严重的锁竞争和索引维护开销)。你可以通过mybatis-plus.global-config.db-config.update-strategy进行配置。

核心要点与避坑指南:

  1. BATCH模式更新的性能陷阱:对于UPDATE,BATCH模式并不总是最优。数据库处理UPDATE WHERE id=?时,每条语句可能涉及不同的数据页。如果更新的记录非常离散,会导致大量的随机磁盘I/O,性能可能反而不如一条复杂的CASE WHEN语句(后者可能进行更有序的扫描)。对于批量更新,性能测试至关重要,不能想当然。
  2. 事务与锁:无论用哪种方式,大批量更新都会持有锁较长时间,可能阻塞其他读写操作。务必在业务低峰期进行,并评估锁范围。
  3. MP的智能选择:得益于MP的封装,updateBatchById帮我们做了一个折中的选择。对于小批量数据,它可能用BATCH;对于大批量,它可能改用拼接SQL。了解这个机制,能让你在出现性能问题时,更快地定位方向。

个人心得:批量更新没有银弹。我的经验法则是:先使用MP的updateBatchById,因为它最省事且经过优化。如果遇到性能问题,再根据实际情况分析:如果更新条件单一(如WHERE status=‘OLD’),可以尝试用一条UPDATE ... WHERE ...语句配合IN子句或CASE WHEN;如果是根据主键更新不同值,且数据量在几千条,CASE WHEN拼接法值得一试。一定要在预生产环境用真实数据量进行压测。

4. 深入原理:MyBatis批处理与JDBC驱动的协作

理解了怎么用,我们还得稍微探一下底,知道它为什么快,以及不同数据库的差异。这能帮助你在遇到诡异问题时,有排查的思路。

MyBatis的批处理,本质是委托给了JDBC的Statement.addBatch()executeBatch()方法。当你使用ExecutorType.BATCH时,MyBatis底层会使用PreparedStatement,并为每一条数据调用ps.addBatch(),而不是ps.executeUpdate()

以MySQL的JDBC驱动(Connector/J)为例,它的批处理优化有几个关键点:

  1. 重写BatchedStatements:这是一个至关重要的连接参数:rewriteBatchedStatements=true默认是false,必须手动开启!开启后,驱动会将一批INSERT语句重写为多值语句(INSERT INTO t (c1) VALUES (v1), (v2), (v3)...),这比发送多条独立的INSERT语句性能高出一个数量级。对于UPDATE,它也可能进行优化合并。没有这个参数,Batch模式的性能提升会大打折扣。

  2. 批处理大小:驱动内部会缓存批处理语句,executeBatch()被调用时才会真正发送。你可以通过com.mysql.cj.jdbc.StatementImpl.getQueryInfo()等内部机制(或监控)观察批处理是否生效。

  3. 事务提交:批处理通常与事务绑定。在autocommit=false时,批处理的所有语句会在同一个事务中提交,保证了原子性。

不同数据库的JDBC驱动对批处理的实现和支持程度不同:

  • PostgreSQL:其JDBC驱动对批处理支持很好,通常不需要特殊参数。
  • Oracle:也需要特定的优化参数,如defaultExecuteBatch
  • SQL Server:建议使用useBulkCopyForBatchInsert等参数来启用更高效的批量复制机制。

实操建议:在你的数据源配置中,为MySQL连接字符串加上这个参数:

jdbc:mysql://localhost:3306/db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true&useSSL=false

加上rewriteBatchedStatements=true,你的批量插入性能可能会有数倍到数十倍的提升。这是无数人踩坑后总结出的宝贵经验。

5. 生产环境避坑全指南

理论懂了,工具会了,但在生产环境真正实施时,还有一堆坑等着你。下面是我从真实故障中总结出的血泪经验。

5.1 内存溢出(OOM)与流式处理

无论是foreach拼接还是BATCH缓存,都需要在内存中持有全部数据对象。如果你要处理100万条数据,每条数据1KB,那就是1GB的内存占用,很容易引发OOM。

解决方案:分片(Batch)处理。这是必须掌握的模式。

public void hugeBatchInsert(List<HugeData> dataList) { int batchSize = 1000; // 根据内存和性能调整 int total = dataList.size(); for (int i = 0; i < total; i += batchSize) { int end = Math.min(i + batchSize, total); List<HugeData> subList = dataList.subList(i, end); // 使用前面介绍的任意一种批量插入方法处理 subList userMapper.batchInsert(subList); // 或 saveBatch(subList) // 每处理完一批,可以稍微清理一下,提示GC if (i % 10000 == 0) { System.gc(); // 谨慎使用,仅作提示 } log.info("已处理 {}/{} 条记录", end, total); } }

对于数据源本身就是流式的情况(如从一个大文件或消息队列中读取),更佳实践是边读边处理,永远不一次性加载全部数据到内存。

5.2 事务过大与锁等待

一个大事务(包含数万条更新)可能会持有大量锁,并产生巨大的回滚日志(undo log)。这会导致:

  • 其他会话被长时间阻塞。
  • 如果事务失败回滚,耗时极长。
  • 可能打满数据库的日志空间。

解决方案

  1. 分批次提交事务:如上例所示,每处理batchSize条数据后就提交一次事务。但这会破坏原子性——如果第5批失败,前4批已经提交了。你需要根据业务容忍度设计补偿机制(如记录成功批次,支持重试)。
  2. 使用非事务或只读事务:对于允许部分失败的数据导入场景,可以考虑不用事务,或者用编程式事务精细控制。
  3. 选择业务低峰期:这是最基本的运维常识。

5.3 主键回填与业务逻辑依赖

如前所述,在Batch插入后,除了最后一条(或可能全部没有),实体对象的主键ID可能拿不到。如果你的业务逻辑是“插入A表,然后用A表生成的主键插入B表”,这就会断裂。

解决方案

  1. 业务设计规避:使用分布式ID生成器(如雪花算法)在应用层生成唯一ID,而不是依赖数据库自增。这样在插入前你就拥有了完整数据。
  2. 分批获取:如果必须用自增ID,可以改为分小批(比如100条)进行插入,每批插入后执行一次flushStatements(),这样可能能正确拿到这一批的主键(取决于驱动),然后再进行关联操作。但这会牺牲一些性能。
  3. 事后关联:如果业务允许,先插入所有A表数据(不拿ID),然后再通过其他业务唯一键(如订单号、用户手机号)来关联查询出ID,再处理B表。这增加了复杂度。

5.4 监控与性能调优

上了批量操作,一定要加监控。

  • 监控SQL执行时间:通过APM工具或数据库慢查询日志,观察批量SQL的实际执行时间。
  • 监控数据库负载:关注CPU、I/O、锁等待情况。
  • 调整批处理大小batchSize不是越大越好。需要测试找到一个甜点,通常在500-2000之间。太大可能造成单次数据库操作压力大,太小则网络往返开销占比高。
  • 调整JDBC参数:如前面提到的rewriteBatchedStatements,以及prepStmtCacheSize(预处理语句缓存)等。

6. 超越CRUD:在分布式与大数据场景下的思考

当你的系统从单机单库走向分布式、微服务,或者需要处理海量数据(TB/PB级)时,上述基于单数据库连接的批量操作可能就不再是最优解。

  1. 分库分表下的批量操作:数据分散在多个物理库表中,你无法再用一条SQL操作所有数据。常见的做法是:

    • 应用层分片:根据分片键(如用户ID)将数据分组,分别路由到对应的数据库连接上执行批量操作。这需要你的批量操作API支持分片路由逻辑。
    • 中间件层解决:使用ShardingSphere这类中间件,它可以在代理层对批量SQL进行解析和重写,分发到不同的库,但对复杂CASE WHEN的跨库更新支持可能有限。
  2. 异步化与最终一致性:对于非实时强一致要求的批量任务(如批量发站内信、批量更新用户画像),可以引入消息队列(如Kafka、RocketMQ)。将批量任务拆解为一个个独立的消息,由消费者异步消费处理。这能极大缓解数据库瞬时压力,并提高系统的整体吞吐量和容错性。

  3. 大数据量导入:对于一次性初始化或迁移数亿条数据的场景,直接使用JDBC批处理可能仍然太慢。这时应该考虑数据库原生的批量加载工具:

    • MySQL:LOAD DATA INFILE命令,从文件直接加载,比任何INSERT语句都快几个数量级。
    • PostgreSQL:COPY命令。
    • 大数据平台:如果数据源是HDFS、S3等,可以直接使用Spark、Flink等计算引擎的DataFrameWriter,它们能并行、高效地将数据写入各种数据库。

所以,当你面对超大规模数据的“批量”需求时,首先要问自己的是:这个操作真的应该在业务运行时,通过ORM框架来做吗?很多时候,答案是否定的。选择一个适合数据体量和架构阶段的工具,比优化MyBatis的批量语句更重要。

回到我们最初的主题,MyBatis的批量插入与更新,是Java后端工程师武器库中的一件高效常规武器。熟练掌握foreach拼接、ExecutorType.BATCH以及MyBatis-Plus的封装,能解决日常开发中90%的批量数据处理需求。而剩下的10%,则需要你跳出ORM的框架,从数据库原理、架构设计和数据流的角度去寻求更优解。记住,没有最好的方案,只有最适合当前场景的方案。在编码之前,先评估数据量、性能要求和架构约束,这才是资深工程师的思考方式。

← 返回列表