MyBatis-Plus实战:CRUD提速、批量操作优化与避坑指南

发布时间:2026/10/13 12:48:32
MyBatis-Plus实战:CRUD提速、批量操作优化与避坑指南 从写Mapper XML写到手软到换用MyBatis-Plus之后一发不可收拾这个转变我猜很多Java后端都经历过。这里说的MyBatis-Plus后面统一叫MP是基于MyBatis的增强工具它在不改变MyBatis核心能力的前提下把单表CRUD、分页、批量操作这些高频重复劳动直接内置掉让开发者少写大量模板代码。这篇文章适合正在用或者准备用MyBatis-Plus做项目的人尤其是被原生MyBatis的XML映射、手写单表SQL搞得烦不胜烦又想在Service层写出干净代码的后端工程师。我会从选型理由、核心用法、批量操作优化再到和Spring Data JPA的对比最后用几个实际踩过的坑收尾尽量把高效两个字说透。1. 从原生MyBatis到MyBatis-Plus我为什么愿意换掉半个持久层1.1 原生MyBatis的重复劳动到底有多烦先问一句用原生MyBatis写过多少单表操作如果你和我一样经历过那种新增一个实体类就要配套写一套Mapper接口MapperXML的日子就知道痛点在哪。一个User对象查出来需要selectById、selectByUsername、selectListByStatus、insert、updateById、deleteById六七个方法在XML里每个都要写一遍SQL。单表还好等表一多那些逻辑几乎一样的SQL占了大半个XML文件。我接手过一个老系统光UserMapper.xml就接近600行其中大量是selectById、selectByPage这种高度雷同的SQL。改字段要把insert、update、select全部翻一遍遗漏一个地方就是线上事故。最关键的是这些重复SQL根本不产生业务价值它们只是在消耗开发时间。有人会说你用通用Mapper之类的工具也行但MyBatis-Plus在国内社区活跃度和文档完整度都更高而且背靠MyBatis生态改造风险小。1.2 MP不是替代MyBatis而是在补完MyBatis的单表能力必须说清楚一个容易被误解的点MyBatis-Plus不是替换MyBatis它底层仍然是MyBatis的SqlSession和Mapper机制在干活。它做的是把单表CRUD这部分从开发者手写变成框架内置。怎么实现的核心是BaseMapper接口加上一套SQL注入器。MP在启动时扫描实体类通过实体上的注解TableName、TableId、TableField拿到表名、主键、字段映射关系然后动态生成一批SQL注入到Mapper中。比如调用selectById时表面上看是你写的userMapper.selectById(1L)底层执行的是框架根据实体类拼出来的SELECT id,name,age FROM user WHERE id?。这个设计带来的直接好处是单表操作不需要你碰XML和SQL。而当你需要多表关联、复杂子查询或特殊优化SQL的时候MP又提供自定义XML的入口随时可以写手写SQL。所以它不是把你圈死在ORM里而是把简单的事变得更简单把复杂的事留给你。1.3 什么项目适合引入MP根据我自己的使用经验下面几类项目收益最明显以单表操作为主的业务系统比如后台管理系统、运营平台大量需求是对某张表做增删改查分页筛选这正是MP的舒适区。快速迭代的互联网项目需求节奏快没时间磨SQL用MP能省出大量开发人力。老MyBatis项目改造无论你是3.x还是4.xMapper层的改造成本都很低Service层几乎不用动。但如果是报表分析类、复杂JOIN密集型的项目MP的优势就没那么突出这时候还是要以手写SQL为主。不过这不妨碍你用MP处理其中简单的单表操作。2. BaseMapper和ServiceImpl真正把CRUD写短的那些方法2.1 BaseMapper的内置方法哪些用得最多MP提供BaseMapper接口给你继承一旦你的Mapper继承了它就等于自动获得了二十多个常用单表方法。我用下来频率最高的几个是selectById(id)按主键单查。selectList(Wrapper)按条件查列表替代大量selectByXxx。selectCount(Wrapper)按条件计数。insert(entity)插入一条。updateById(entity)按主键更新非空字段。deleteById(id)按主键删除。selectPage(page, wrapper)分页查询。举个例子你以前要自己写select idselectByStatus resultTypecom.example.entity.User SELECT * FROM user WHERE status #{status} /select用MP之后只需要Mapper public interface UserMapper extends BaseMapperUser { }然后查询ListUser users userMapper.selectList( new LambdaQueryWrapperUser() .eq(User::getStatus, 1) .orderByDesc(User::getCreateTime) );注意我用了LambdaQueryWrapper而不是传统的QueryWrapper。为什么因为它用User::getStatus这种方法引用代替字符串status编译期就能发现字段名拼写错误重构字段时IDE也能同步过来。这一点在项目后期维护中特别值钱。2.2 Service层封装IService的批量接口价值如果说BaseMapper解决的是单表方法少写那IService和ServiceImpl解决的就是Service层代码也少写。正常情况下你的Service接口继承IService实现类继承ServiceImpl框架直接给你一套含事务控制的CRUD模板。public interface UserService extends IServiceUser { } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }写完这两行你就在Service层免费获得了save、saveBatch、updateById、list、page、removeById等一堆方法。在这其中最有价值的是三个批量方法saveBatch(CollectionT entityList)批量插入。updateBatchById(CollectionT entityList)批量更新。listByIds(Collection? extends Serializable idList)按主键集合查询。为什么说它们有价值因为很多表操作天生是批量的。比如导入Excel批量建账号、批量修改状态、批量分配负责人。如果这些逻辑你都在Service层自己for循环调用单条方法性能上会非常难看。MP把批量方法给你封装好具体底层怎么做、性能问题出在哪我放到后面单独说。2.3 条件构造器的正确打开方式条件构造器是MP日常用得最多的API但也是很多人用得最乱的API。我见过有人一个方法里new了五六个Wrapper各种if嵌套代码完全没法看。我建议的做法是始终使用LambdaWrapper并配合函数式条件判断LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(username), User::getUsername, username); wrapper.like(StringUtils.hasText(nickname), User::getNickname, nickname); wrapper.between(ageMin ! null ageMax ! null, User::getAge, ageMin, ageMax); ListUser users userMapper.selectList(wrapper);看到关键点了吗eq方法第一个参数是布尔值只有为true时这个条件才被拼接到SQL里。这样就不用在外面写一堆if (username ! null) wrapper.eq(...)条件越多代码越清爽。再提一个容易翻车的点and和or的括号问题。如果你构造条件时只用默认的eq、likeMP会自动用AND连接没问题。但如果你混用or尤其是(A OR B) AND C这类组合就需要自己控制wrapper.and(w - w.eq(User::getStatus, 0).or().eq(User::getStatus, 2)); wrapper.eq(User::getDeleted, 0);这里.and(...)内部是一个独立括号最终SQL是WHERE (status 0 OR status 2) AND deleted 0。忘记包这个括号查出来的结果就会和你预期差很远而且特别难排查因为SQL表面上看起来还像那么回事。3. 批量操作把性能从循环单条拉到批量提交3.1 为什么不能靠for循环单条插入很多人刚用MP时会顺手写出这样的代码for (User user : userList) { userService.save(user); }这能跑但只适合数据量极小的情况。一旦列表里有几百几千条数据性能就直线下降。原因很本质save是调用一次MyBatis的SqlSession去执行一次INSERT。每执行一次就要走一次连接获取、事务处理、SQL解析、结果返回。假设1000条数据就是1000次网络交互和数据库执行。我用本地一个测试表做过对比循环单条插入1000条数据耗时大概在5到8秒如果用saveBatch分批插入Mysql本机环境下1000条耗时通常不到500毫秒。这个差距在数据量越大时越夸张。那有人问为什么5000条的时候saveBatch反而报内存异常这是因为MP在批量插入时会一次性拼接大量SQL到Statement上数据太大会撑爆JDBC的Buffer。所以不能五千条一次梭哈应该分批次。3.2 saveBatch的底层原理和rewriteBatchedStatementssaveBatch底层走的是JDBC的addBatch和executeBatch机制。MyBatis执行到JDBC层面后多个INSERT会合并成一次批量发送数据库端再批量执行。这个机制下真正决定性能的往往是JDBC连接参数。我在生产环境遇到过很尴尬的情况代码从循环改为saveBatch结果性能没提升多少。排查半天最后发现问题是数据库连接串里少了关键参数jdbc:mysql://localhost:3306/mydb?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstruerewriteBatchedStatements如果为false早期驱动默认值JDBC会把批量语句一条条拆开执行等于白批。改成true之后驱动会把多条INSERT语句重写合并成INSERT INTO user (...) VALUES (...),(...),(...)这种多值语句下发性能立刻飙升。所以在引入saveBatch之前先确认你的连接池用的是不是MySQL驱动且这个参数开了。驱动版本不要太老MySQL Connector/J 5.1.13以上对这个参数的支持才比较靠谱。实际操作时我一般这样写分批public void saveUserList(ListUser userList) { int batchSize 200; for (int i 0; i userList.size(); i batchSize) { int end Math.min(i batchSize, userList.size()); userService.saveBatch(userList.subList(i, end)); } }batchSize在200到500之间是我自己多年用下来的稳定区间兼顾内存和性能。数据字段少可以往500靠字段多比如40个以上列就用200甚至更小。3.3 批量更新的正确姿势和批量插入比批量更新稍微麻烦一点。MP提供了updateBatchById用法和saveBatch一样ListUser users userService.list(); for (User user : users) { user.setAge(user.getAge() 1); } userService.updateBatchById(users);但要注意updateBatchById内部实现是循环调用单条UPDATE然后再批量提交。它比手动for循环单独update好在省了多次提交事务的开销但因为UPDATE语句没法像INSERT那样合并成多值SQLMySQL的UPDATE ... CASE WHEN语法需要手写所以性能提升没批量插入那么明显。如果你的批量更新是大数据量实时批处理场景比如定时任务里几万条数据要更新状态我建议绕开updateBatchById直接走自定义SQL用CASE WHEN拼一次语句执行update idbatchUpdateStatus UPDATE user SET status CASE id foreach collectionlist itemitem WHEN #{item.id} THEN #{item.status} /foreach END WHERE id IN foreach collectionlist itemitem open( separator, close) #{item.id} /foreach /update这样一条SQL完成所有更新性能比循环updateBatchById高好几倍。不要害怕手写SQLMP的本意也不是让你完全放弃SQL而是让你在简单场景更省力复杂场景该出手时就出手。4. Spring Data JPA和MyBatis-Plus两个主流方案的选型对照关于Spring Data JPA和MyBatis-Plus的区别我几乎每次技术分享都会被问到。这两个框架我都大量用过说下实际感受不带踩一捧一。4.1 开发效率的差别比想象中更微妙从开发效率上JPA的Repository接口风格其实非常优雅public interface UserRepository extends JpaRepositoryUser, Long { ListUser findByStatusAndAgeGreaterThan(Integer status, Integer age); }你只要按命名规范定义方法名Spring Data JPA会自动生成实现连查询条件都不用写对象。MP这边你得用BaseMapper配合条件构造器代码上会比JPA的方法名略啰嗦ListUser users userMapper.selectList( new LambdaQueryWrapperUser() .eq(User::getStatus, 1) .gt(User::getAge, 18) );不过在复杂筛选场景下MP的Wrapper更占优势。比如动态条件非常多比如用户列表页有10个筛选项JPA要么拆一堆方法名长得吓人要么用Specification去做动态条件拼接写起来并不轻松。MP的LambdaQueryWrapper配合条件布尔参数10个筛选也很轻松。所以我的看法是单表简单查询两者效率接近但复杂动态查询MP更好写规范的方法名读取起来更直观。4.2 从SQL掌控力看两者差异这是JPA最容易被吐槽的点。JPA底层自动生成SQL对很多老手来说像一个黑盒。虽然Hibernate支持HQL、Criteria也允许你用Query写JPQL或者原生SQL但一旦你想调整SQL写法比如强制索引、优化子查询、处理复杂的多表关联你对最终SQL的控制力天然弱于MyBatis系。MP这边单表SQL由框架生成但多表复杂SQL你自己写XML想怎么写就怎么写写出来的就是最终执行的SQL。从排查慢SQL和数据库调优角度看MP的这个优势非常实在。我遇到过不止一次同样的分页查询JPA自动生成的SQL走了错误索引导致慢查询换成手写SQL指定索引后快了一个量级。4.3 从团队熟悉度和项目形态选选型不能只看框架本身还要看团队和项目。项目以复杂查询、报表、多表聚合为主我更推荐MP。写SQL出生的人会觉得顺手后期调优也容易。项目以标准CRUD、领域模型为核心实体关系复杂比如电商的订单、商品、库存之间耦合深用JPA的实体关系映射也许更省力。但前提是团队得Hibernate玩得溜否则关联关系一旦写错N1查询问题会让数据库崩溃。团队维护成本MP的学习曲线比JPA平滑太多。JPA的概念丰富包括懒加载、级联、持久化上下文、一级二级缓存新手上手容易踩坑出了问题排查起来也费劲。MP则是一个MyBatis工具方法写起来几乎没有学习门槛。我个人的项目选型习惯是能上MP就尽量不碰JPA除非团队里每个人都精通Hibernate且业务模型够复杂。大多数互联网项目快速迭代的特征下MP的上手效率和排查问题的直接性都是加分项。5. 实战踩坑字段填充、逻辑删除、乐观锁和分页插件的连环坑5.1 自动填充失效createTime和updateTime不生效怎么办MP的TableField(fill FieldFill.INSERT)可以自动填充创建时间和更新时间。我一开始配的好好的后来某天发现insert时这两个字段居然是null排查过程很典型。检查链路是这样的先确认实体类注解没问题。检查Handler实现类是否注册成Spring Bean。最终发现问题出在自己重写了insert的SQL注入逻辑。我写了一个自定义SQL注入器覆盖了部分BaseMapper方法的SQL结果框架的MetaObjectHandler压根没被触发因为MP的自动填充依赖它内置的那几条INSERT方法的预处理逻辑。一旦你把insert替换成自己的填充逻辑就失灵了。解决办法不要轻易覆盖insert相关的注入方法如果必须自定义重写时在方法里显式调用this.fillStrategy(...)或者在自定义SQL里手动设置时间字段。简单场景下最好别自定义SQL注入器老老实实用框架原生的insert和updateById。5.2 逻辑删除的坑unique约束和关联查询会一起踩MP的逻辑删除用起来很简单TableLogic加在deleted字段上之后所有单表操作的SQL都会自动带WHERE deleted0。表面上看很完美但有一个特别隐蔽的坑逻辑删除和历史记录同时存在时的唯一索引冲突。比如用户表里有unique key(username,deleted)以前逻辑删除后重复插入同名用户没问题因为deleted自动变成了1。但如果你批量删除多个同名用户虽然是不同业务场景就会因为deleted都是从0变成1而产生唯一冲突。我的方案是需要逻辑删除且又需要唯一性约束的表不要用简单unique组合直接把deleted字段改成delete_time bigint默认0删除时写入当前时间戳唯一索引改成(username,delete_time)。这样同一条数据即使被删除多次时间戳都不同不会冲突逻辑判断用delete_time 0来表示未删除。另外逻辑删除的表在自定义多表JOIN时特别容易漏条件。MP只在你走他内置方法时自动拼接deleted0你一旦写XML自定义SQL就得自己在JOIN条件里手动加上t.deleted0这个没有任何工具能帮你兜底。5.3 乐观锁插件Version字段和拦截器缺一不可乐观锁是MP在并发更新场景下的推荐方案。我一直用的Version注解加MybatisPlusInterceptor里的OptimisticLockerInnerInterceptor。第一次配置时我遇到一个问题updateById明明带着版本号条件但数据就是更新不进去返回的影响行数一直是0却又没报错。排查产物是Version标注的字段类型是Integer值也从0开始自增表面上都正常。最后发现自己把OptimisticLockerInnerInterceptor放在拦截器链的最后一位前面还排了分页、逻辑删除拦截器。按MP要求乐观锁拦截器必须放在最前面顺序错了它拿到的不再是原始SQL乐观锁条件拼接就失效了。正确配置Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }还有一个细节更新时实体对象不能只传部分字段必须把version字段带着传进去否则乐观锁判断条件少一个值同样更新失败。我见过有同事为了省事update的时候new一个只包含id和业务字段的实体然后抱怨乐观锁是坏的。5.4 分页插件page查不出来和count自动优化MP的分页查询依赖PaginationInnerInterceptor不加这个插件的话selectPage返回的数据会莫名其妙全是0。这个是新手最常踩的坑之一。加了插件之后还要注意PaginationInnerInterceptor(DbType.MYSQL)必须指定数据库类型因为内部要判断是拼接LIMIT还是ROWNUM类型错了生成的SQL就是错的。更隐蔽的一个坑是count语句。MP默认会给分页查询自动生成count SQL然后返回total。但某些复杂查询比如你的条件写了DISTINCT、GROUP BY自动生成的count SQL可能会语法报错或者性能极差。解决方法是开启optimizeCountSql属性默认就是开的它会尝试优化count SQLPaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOptimizeJoin(true);如果还不行手写分页时自己提供一个count查询方法即可或者直接用selectCount单独查询总数再手动组装IPage。还需要提醒的是分页查询里如果JOIN了多张表且主表某条记录对应从表多条记录列表数据可能会因为JOIN而出现重复行总数也偏大。这其实不是MP的bug而是SQL本身的问题。这种情况下要么用EXISTS改子查询要么对主表做DISTINCT不能全指望框架自动处理。6. 一些关于优化和习惯的小结回到标题说的高效CRUD开发利器。MP确实在CRUD这个维度上把我的开发效率提高了不少尤其是新项目从0到1搭接口的速度。但我用了几年之后最大的体会反而是框架解决了80%的重复劳动剩下20%需要你对底层有足够了解。比如前面提到的rewriteBatchedStatements很多人根本不知道saveBatch性能上不去是因为一个连接参数。又比如逻辑删除和唯一索引的冲突官方文档写得很简单实际业务里各种坑都要靠真踩过才知道。能把这些点说出来比单纯罗列API有用得多。如果你正准备在项目里推进MP我给几个习惯性建议实体类字段严格遵循驼峰转下划线数据库表字段规范命名MP的自动映射能省掉大量TableField注解。所有单表查询优先用LambdaQueryWrapper少用字符串QueryWrapper。涉及批量数据操作先评估数据量选择分批策略别一把梭。Controller层和Service层方法别混用MP的实体直接暴露给前端DTO隔离还是要做。新成员加入团队先让他们读一遍BaseMapper内置方法列表很多人写CRUD还在抄以前的XML根本不知道有这些现成方法。另外我个人的一个小技巧是项目里统一封装一个PageResult和PageQuery把IPage转换成前端友好的结构。MP的Page类本身带了records、total这些字段但直接在Controller里返回给前端会让接口结构不够统一。自己包一层之后后面接各种前端框架都方便。踩过坑之后才会意识到真正的高效不只是代码写得快还包括出了问题能快速定位。MP在这两点上做得都不错前提是你愿意花时间把它的机制摸透一些。这篇文章提到的每个坑都是我实打实排查过的你要是也遇到类似问题按着这个思路去查应该能少走不少弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询