ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

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

MyBatis批量操作实战:从原理到避坑,提升数据库性能 1. 从单条到批量为什么我们需要批量操作在任何一个处理数据的业务系统里你肯定写过这样的代码一个for循环里面是insert或update语句一条一条地往数据库里塞数据。当数据量只有几十条时这没什么问题。但一旦数据量上升到几百、几千甚至上万这种方式的弊端就暴露无遗了。我经历过一个数据同步任务需要将上游系统的几万条用户变更记录同步到本地库最初就是用单条循环结果跑了快二十分钟数据库连接池差点被拖垮监控告警响个不停。问题的核心在于网络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 idbatchInsert parameterTypejava.util.List INSERT INTO user (name, email, age) VALUES foreach collectionlist itemitem indexindex separator, (#{item.name}, #{item.email}, #{item.age}) /foreach /insert对应的Java接口和调用// Mapper接口 int batchInsert(Param(list) ListUser userList); // 调用方 ListUser userList new ArrayList(); // ... 填充userList userMapper.batchInsert(userList);核心要点与避坑指南参数命名在foreach标签的collection属性中我们写了list。这是因为在Mapper接口的方法参数上我们使用了Param(list)注解。如果你不写Param注解那么MyBatis默认会把这个List参数放到一个名为list的键下如果只有一个集合参数或者param1下。为了清晰和避免混淆我强烈建议始终使用Param注解显式命名。SQL长度限制这是这种方法最大的坑。数据库服务器对单条SQL语句的长度是有限制的例如MySQL的max_allowed_packet。如果你试图一次性插入10万条数据拼接出来的SQL可能会长达几MB甚至几十MB直接导致数据库拒绝执行报错“Packet too large”。所以这种方法只适用于数据量较小通常建议在1000条以内的场景。事务管理即使你拼接成了一条SQL它仍然是在一个数据库事务中执行的。要么全部成功要么全部失败这符合业务预期。你需要确保在Service层的方法上添加Transactional注解。个人心得foreach拼接法简单粗暴在开发测试、处理小批量数据时非常方便。但它就像一把水果刀切水果顺手但你不能指望它去砍树。面对海量数据我们需要更专业的工具。2.2 利器二ExecutorType.BATCH与sqlSession.flushStatements()这是MyBatis官方推荐的、用于处理真正大批量数据的方式。它的原理不是拼接SQL而是利用JDBC的addBatch和executeBatch机制。在默认的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); ListUser userList new ArrayList(); // ... 填充userList for (User user : userList) { // 注意这里调用的是单条插入的Mapper方法 mapper.insert(user); } // 关键执行所有缓存的语句 sqlSession.flushStatements(); // 提交事务 sqlSession.commit(); }在Spring集成环境中我们通常不直接操作SqlSession。可以通过以下几种方式使用Batch模式在Service方法上开启推荐Service public class UserService { Transactional // 确保事务 public void batchInsertUsers(ListUser users) { // 获取当前SqlSession并切换执行器需通过SqlSessionTemplate // 但更常见的做法是使用第三种方式 } }使用SqlSessionTemplateAutowired private SqlSessionTemplate sqlSessionTemplate; public void batchInsert(ListUser 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(); } }配置全局Batch执行器不推荐在配置文件中将defaultExecutorType设置为BATCH但这会让所有操作都变成批处理影响简单查询的性能通常不是好主意。核心要点与避坑指南必须手动flushStatements()和提交事务在Batch模式下调用mapper.insert(user)并不会立即执行只是将语句加入了批处理缓存。你必须显式调用sqlSession.flushStatements()来触发JDBC真正执行批处理然后调用commit()提交事务。如果忘了flushStatements()数据可能不会插入如果忘了commit()事务会回滚。性能与批处理大小JDBC驱动会有一个批处理大小batch size的阈值。攒够一定数量的语句后会自动flush。你也可以通过flushStatements()手动控制。找到一个合适的批处理大小比如500或1000有助于在内存占用和执行效率间取得平衡。获取生成的主键这是一个巨坑在Batch模式下由于多条语句一起执行通过useGeneratedKeys和keyProperty获取自增主键的行为在部分数据库驱动如某些版本的MySQL JDBC驱动中可能会失效或者只能获取到最后一条插入数据的主键。如果你的业务强依赖插入后立即使用生成的主键需要特别测试或考虑其他方案如使用UUID等非自增主键。个人心得ExecutorType.BATCH是处理大批量数据万级以上的“标准答案”。它稳定、高效是很多数据同步、ETL任务的基石。但你需要像对待一个精密仪器一样牢记它的操作步骤flush和commit并小心主键生成这个暗礁。2.3 利器三MyBatis-Plus的saveBatch魔法如果你在使用MyBatis-Plus以下简称MP那么批量插入就变成了一行代码的事。MP的IService接口提供了saveBatch方法它内部封装了批处理的逻辑让你无需关心ExecutorType和flushStatements。Service public class UserServiceImpl extends ServiceImplUserMapper, User implements IUserService { // 直接使用父类方法 public void batchAddUsers(ListUser userList) { // 默认分批大小是1000 boolean result saveBatch(userList); // 或者指定分批大小 // boolean result saveBatch(userList, 500); } }看起来非常简单但我们需要理解它背后做了什么。查看MP的源码以3.x版本为例saveBatch方法大致流程如下检查当前SqlSession的执行器类型。如果不是BATCH它会临时切换到BATCH执行器。遍历集合循环调用基础的save即insert方法。每达到指定的batchSize默认1000或遍历完成后自动执行sqlSession.flushStatements()。方法执行完毕后如果之前切换过执行器会恢复原来的执行器类型。核心要点与避坑指南它帮你做了脏活累活自动管理执行器切换、批处理刷新避免了手动操作的繁琐和遗漏极大地提升了开发效率和代码整洁度。性能有保障其底层依然是JDBC的addBatch机制性能与手动使用ExecutorType.BATCH相当。主键问题依旧存在和原生的Batch模式一样使用自增主键时在批量插入后实体对象列表中的主键字段可能不会被正确回填通常只有最后一个实体的主键有效。MP官方文档也指出了这一点。如果你的后续逻辑需要用到这些主键这可能是个问题。事务saveBatch方法本身不开启事务。你需要确保在调用它的方法上添加Transactional注解以保证原子性。个人心得对于大多数业务场景MP的saveBatch是首选。它用极简的API掩盖了底层的复杂性符合“约定优于配置”的理念。但在引入它之前务必在测试环境中验证主键回填是否符合你的业务预期。对于超大批量例如一次性插入数十万条且对主键无立即依赖的场景它堪称完美。3. 批量更新的复杂博弈策略选择与实战批量更新比批量插入要复杂得多因为更新操作通常带有条件WHERE子句而每条记录要更新的值和条件可能各不相同。这就无法像插入那样简单地拼接VALUES。我们来分析几种主流策略。3.1 策略一多条SQL逐条执行最不推荐就是文章开头提到的for循环单条update。除非更新条数极少10否则在任何严肃的场景下都应避免。它是性能的杀手。3.2 策略二使用foreach标签实现“CASE WHEN”动态更新这是处理“根据ID更新不同字段”这类需求的经典方案。它生成一条形如以下结构的SQLUPDATE user SET name CASE id WHEN 1 THEN Alice WHEN 2 THEN Bob ... END, email CASE id WHEN 1 THEN aliceexample.com WHEN 2 THEN bobexample.com ... END WHERE id IN (1, 2, ...);MyBatis的XML实现如下update idbatchUpdate parameterTypejava.util.List UPDATE user trim prefixSET suffixOverrides, trim prefixname CASE suffixEND, foreach collectionlist itemitem WHEN id #{item.id} THEN #{item.name} /foreach /trim trim prefixemail CASE suffixEND, foreach collectionlist itemitem WHEN id #{item.id} THEN #{item.email} /foreach /trim /trim WHERE id IN foreach collectionlist itemitem open( separator, close) #{item.id} /foreach /update核心要点与避坑指南适用场景非常适合根据主键批量更新多个字段且每条记录的新值都不同的场景。例如批量修改用户信息。性能它仍然是一条SQL网络和事务开销只有一次。数据库执行一条复杂的CASE WHEN语句通常比执行多条简单UPDATE要快尤其是在更新量较大时。SQL长度限制和批量插入的foreach拼接法一样存在单条SQL过长的问题。当更新的记录数很多或字段很多时生成的SQL会非常庞大。数据库兼容性CASE WHEN语法是SQL标准主流数据库MySQL, PostgreSQL, Oracle都支持但写法上可能需要微调。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进行配置。核心要点与避坑指南BATCH模式更新的性能陷阱对于UPDATEBATCH模式并不总是最优。数据库处理UPDATE WHERE id?时每条语句可能涉及不同的数据页。如果更新的记录非常离散会导致大量的随机磁盘I/O性能可能反而不如一条复杂的CASE WHEN语句后者可能进行更有序的扫描。对于批量更新性能测试至关重要不能想当然。事务与锁无论用哪种方式大批量更新都会持有锁较长时间可能阻塞其他读写操作。务必在业务低峰期进行并评估锁范围。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为例它的批处理优化有几个关键点重写BatchedStatements这是一个至关重要的连接参数rewriteBatchedStatementstrue。默认是false必须手动开启开启后驱动会将一批INSERT语句重写为多值语句INSERT INTO t (c1) VALUES (v1), (v2), (v3)...这比发送多条独立的INSERT语句性能高出一个数量级。对于UPDATE它也可能进行优化合并。没有这个参数Batch模式的性能提升会大打折扣。批处理大小驱动内部会缓存批处理语句executeBatch()被调用时才会真正发送。你可以通过com.mysql.cj.jdbc.StatementImpl.getQueryInfo()等内部机制或监控观察批处理是否生效。事务提交批处理通常与事务绑定。在autocommitfalse时批处理的所有语句会在同一个事务中提交保证了原子性。不同数据库的JDBC驱动对批处理的实现和支持程度不同PostgreSQL其JDBC驱动对批处理支持很好通常不需要特殊参数。Oracle也需要特定的优化参数如defaultExecuteBatch。SQL Server建议使用useBulkCopyForBatchInsert等参数来启用更高效的批量复制机制。实操建议在你的数据源配置中为MySQL连接字符串加上这个参数jdbc:mysql://localhost:3306/db?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrueuseSSLfalse加上rewriteBatchedStatementstrue你的批量插入性能可能会有数倍到数十倍的提升。这是无数人踩坑后总结出的宝贵经验。5. 生产环境避坑全指南理论懂了工具会了但在生产环境真正实施时还有一堆坑等着你。下面是我从真实故障中总结出的血泪经验。5.1 内存溢出OOM与流式处理无论是foreach拼接还是BATCH缓存都需要在内存中持有全部数据对象。如果你要处理100万条数据每条数据1KB那就是1GB的内存占用很容易引发OOM。解决方案分片Batch处理。这是必须掌握的模式。public void hugeBatchInsert(ListHugeData dataList) { int batchSize 1000; // 根据内存和性能调整 int total dataList.size(); for (int i 0; i total; i batchSize) { int end Math.min(i batchSize, total); ListHugeData 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。这会导致其他会话被长时间阻塞。如果事务失败回滚耗时极长。可能打满数据库的日志空间。解决方案分批次提交事务如上例所示每处理batchSize条数据后就提交一次事务。但这会破坏原子性——如果第5批失败前4批已经提交了。你需要根据业务容忍度设计补偿机制如记录成功批次支持重试。使用非事务或只读事务对于允许部分失败的数据导入场景可以考虑不用事务或者用编程式事务精细控制。选择业务低峰期这是最基本的运维常识。5.3 主键回填与业务逻辑依赖如前所述在Batch插入后除了最后一条或可能全部没有实体对象的主键ID可能拿不到。如果你的业务逻辑是“插入A表然后用A表生成的主键插入B表”这就会断裂。解决方案业务设计规避使用分布式ID生成器如雪花算法在应用层生成唯一ID而不是依赖数据库自增。这样在插入前你就拥有了完整数据。分批获取如果必须用自增ID可以改为分小批比如100条进行插入每批插入后执行一次flushStatements()这样可能能正确拿到这一批的主键取决于驱动然后再进行关联操作。但这会牺牲一些性能。事后关联如果业务允许先插入所有A表数据不拿ID然后再通过其他业务唯一键如订单号、用户手机号来关联查询出ID再处理B表。这增加了复杂度。5.4 监控与性能调优上了批量操作一定要加监控。监控SQL执行时间通过APM工具或数据库慢查询日志观察批量SQL的实际执行时间。监控数据库负载关注CPU、I/O、锁等待情况。调整批处理大小batchSize不是越大越好。需要测试找到一个甜点通常在500-2000之间。太大可能造成单次数据库操作压力大太小则网络往返开销占比高。调整JDBC参数如前面提到的rewriteBatchedStatements以及prepStmtCacheSize预处理语句缓存等。6. 超越CRUD在分布式与大数据场景下的思考当你的系统从单机单库走向分布式、微服务或者需要处理海量数据TB/PB级时上述基于单数据库连接的批量操作可能就不再是最优解。分库分表下的批量操作数据分散在多个物理库表中你无法再用一条SQL操作所有数据。常见的做法是应用层分片根据分片键如用户ID将数据分组分别路由到对应的数据库连接上执行批量操作。这需要你的批量操作API支持分片路由逻辑。中间件层解决使用ShardingSphere这类中间件它可以在代理层对批量SQL进行解析和重写分发到不同的库但对复杂CASE WHEN的跨库更新支持可能有限。异步化与最终一致性对于非实时强一致要求的批量任务如批量发站内信、批量更新用户画像可以引入消息队列如Kafka、RocketMQ。将批量任务拆解为一个个独立的消息由消费者异步消费处理。这能极大缓解数据库瞬时压力并提高系统的整体吞吐量和容错性。大数据量导入对于一次性初始化或迁移数亿条数据的场景直接使用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的框架从数据库原理、架构设计和数据流的角度去寻求更优解。记住没有最好的方案只有最适合当前场景的方案。在编码之前先评估数据量、性能要求和架构约束这才是资深工程师的思考方式。
返回列表