
我最早接触MyBatis-Plus是在刚接手一个老后端项目的时候。那会儿项目里有二十多张表每新增一张表都要先写一遍Mapper接口、XML文件里的insert、delete、update、selectById再补两个多条件查询——光这部分机械重复的代码就能耗掉大半天。后来把基础CRUD全部切换到MyBatis-Plus从建表到跑通接口速度直接上了一个台阶。这篇文章就围绕MyBatis-Plus的核心玩法来写定位是“速成版”所以不讲太多底层源码直接讲怎么搭、怎么用、有哪些坑。面向的读者是已经会用Spring Boot和MyBatis但还没系统用过MyBatis-Plus或者用过一点但想补全知识点的Java后端开发。看完以后你至少能独立完成一个项目的MP集成、CRUD、条件查询、分页、逻辑删除、乐观锁这些高频需求并且知道哪里容易出事。1. 为什么是MyBatis-Plus从手写CRUD的疲惫感说起1.1 原生MyBatis的单表CRUD体验很多人最初用MyBatis是被它的灵活SQL吸引的。复杂多表联查、动态SQL、自定义结果映射确实比JPA直白也比JDBC手动拼参数舒服。但随着项目越做越大一个让人烦躁的问题会浮出来单表CRUD太重复了。每来一张新表你就得写这些方法insert(User user)deleteById(Long id)selectById(Long id)updateById(User user)selectList(UserQuery query)每个方法都要在XML里来一遍INSERT INTO user (...)、DELETE FROM user WHERE id ?、SELECT * FROM user WHERE id ?大同小异。如果项目里再用MyBatis Generator生成一遍基础代码生成的实体类、Mapper、XML一大坨看着很多其实全是模板。关键是业务查询一有变化比如多个筛选条件、排序规则变了你还得手动改XML效率很低。1.2 MP的设计哲学只增强不侵入MyBatis-Plus简称MP是一个国产的MyBatis增强工具官网有一句原话叫“只做增强不做侵入”。这句话怎么理解不改变MyBatis原有的写法你之前手写的Mapper、XML照样能用。它在你现有的Mapper接口之上内置了一个BaseMapperT把单表的增删改查、批量查询、分页查询全部封装好。查询条件多变的问题用它提供的Wrapper条件构造器解决不用写XML。分页、逻辑删除、乐观锁、自动填充这些高频能力用插件机制加进去。做了这些事以后你手写SQL的量会大幅下降。大部分单表操作不再需要XML只有真正复杂的多表联查、子查询、报表统计才需要自己写。1.3 什么时候该用MP什么时候别硬上用MP前先明确它的适用边界不是所有项目都无脑上。适合用MP的项目以单表CRUD为主的业务系统比如后台管理系统、中台服务、各种内部平台。快速迭代的项目连表结构都不稳定用MP可以少写很多重复代码改字段直接改实体就行。团队里MyBatis水平参差不齐用MP可以把基础数据访问层的门槛降下来。不适合用MP或需要谨慎的场景大量复杂的多表关联查询、报表统计、动态列查询。这类需求MP帮不上太多忙还是得手写SQL甚至会因为实体映射的约定影响SQL可读性。对SQL执行完全可控要求极高的金融、账务系统。MP内置方法生成的SQL虽然能看但毕竟多了一层封装部分团队会忌讳这种“不可控”。已有完善的MyBatis代码规范、生成体系和大量XML的存量项目硬引入MP可能收益不大还要解决共存问题。一句话总结MP解决的是“单表CRUD写起来烦”的问题它不解决“复杂SQL怎么写”的问题。带着这个认知去用你就不会对它抱有不切实际的期待。2. 环境搭建Spring Boot集成MP的几个关键点2.1 依赖引入与版本避坑我直接给结论。如果是Spring Boot 3.x项目用这个dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency如果是Spring Boot 2.x项目用这个dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency注意这个差异。MP从3.5.4左右开始适配Spring Boot 3官方拆分了新的starter。如果用的是Spring Boot 3却引入了旧的mybatis-plus-boot-starter启动时很可能遇到Failed to introspect Class或者MyBatis相关Bean装配失败。这种版本错位问题在社区里很常见排查起来还容易绕远路。另外引入MP starter后不要再重复引入mybatis-spring-boot-starter否则会有冲突。MP自带了一套MyBatis的整合逻辑重复引入会导致SqlSessionFactory重复创建或Bean覆盖。2.2 yml配置中最容易被忽略的两项下面是一份最小可用的配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0第一个容易忽略的是log-impl。不配置这个控制台看不到SQL日志出了问题全靠猜。加上以后每次执行数据库操作都会在控制台打印完整的SQL和参数排错效率直线上升。生产环境记得关掉。第二个是map-underscore-to-camel-case。MP默认是true但如果你在多个项目之间复制配置可能被改成false导致user_name字段映射不到实体的userName属性查询结果全是null且不报错。这个问题排查起来非常坑建议显式写出来。global-config.db-config下面的几个配置是MP的全局策略id-type: assign_id表示主键默认使用雪花算法生成Long型ID适合分布式场景。logic-delete-field声明所有实体里叫deleted的字段都走逻辑删除逻辑后面会细说。banner: false是关掉启动时的MP那个大Banner纯属图清爽。2.3 第一个继承BaseMapper的Mapper配置完成后写一个实体类和一个Mapper接口就算是环境通了。Data TableName(sys_user) public class User { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private Integer age; private String email; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; Version private Integer version; }public interface UserMapper extends BaseMapperUser { }然后在启动类或者配置类上加上MapperScanSpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }到这一步UserMapper就已经有了一整套单表操作方法。接下来直接注入调用即可。3. CRUD接口先掌握这些就够用了3.1 BaseMapper单表操作的底牌BaseMapperT里提供的方法实际开发中百分之八十用得到。我整理了一份速查表方法作用备注insert(entity)插入一条记录主键策略由TableId决定deleteById(id)按主键删除开启逻辑删除后自动转为UPDATEdeleteBatchIds(ids)按主键批量删除同上updateById(entity)按主键更新实体中为null的字段不会更新selectById(id)按主键查询返回实体对象selectBatchIds(ids)按主键批量查询返回实体列表selectOne(wrapper)按条件查询一条查到多条会抛异常要注意selectCount(wrapper)按条件统计数量返回LongselectList(wrapper)按条件查询列表wrapper传null就是查询全部selectPage(page, wrapper)分页查询需要分页插件selectMaps(wrapper)按条件查询列表返回ListMapString, Object看几个常用写法// 新增 User user new User(); user.setName(张三); user.setAge(20); user.setEmail(zhangsanexample.com); userMapper.insert(user); // 查询单个 User u userMapper.selectById(1L); // 条件查询列表 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getAge, 20); ListUser users userMapper.selectList(wrapper); // 修改 User u userMapper.selectById(1L); u.setAge(21); userMapper.updateById(u); // 删除 userMapper.deleteById(1L);有一个细节特别值得注意updateById只更新实体中非null的字段。如果业务上需要把某个字段置为null用这个方法做不到得用后面讲的UpdateWrapper显式 set。很多新手在这里踩坑想清空某个字段发现执行成功了但数据库完全没变化。3.2 IService批量操作和链式调用的体验如果你用的是Service层开发模式MP还提供了一个IServiceT接口和对应的ServiceImplM, T实现类。继承它以后除了拿到CRUD能力还有一些Service层很实用的方法。public interface UserService extends IServiceUser { } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }这样写完之后UserService自带的能力包括save(entity)单条插入saveBatch(list)批量插入内部会分批执行saveOrUpdate(entity)存在则更新不存在则插入list(wrapper)查询列表count(wrapper)统计getById(id)按主键查removeById(id)按主键删page(page, wrapper)分页更重要的是链式查询写法代码可读性比传统写法高不少// 查询名字叫张三且年龄大于18的用户列表 ListUser users userService.lambdaQuery() .eq(User::getName, 张三) .gt(User::getAge, 18) .list(); // 把id为1的用户年龄更新为30 boolean updated userService.lambdaUpdate() .set(User::getAge, 30) .eq(User::getId, 1) .update();用IService的好处是Service层语义清晰且自带批量、链式能力不用自己定义一堆重复方法。比较适合标准的三层架构项目。3.3 自写SQL MP内置方法的组合拳有些朋友用上MP以后误以为不能再手写XML了其实不是。MP和手写SQL完全不冲突而且经常要配合使用。比如要在UserMapper上加一个多表关联查询public interface UserMapper extends BaseMapperUser { User selectUserWithOrders(Param(userId) Long userId); }XML里正常写select idselectUserWithOrders resultTypecom.example.demo.entity.User SELECT u.* FROM sys_user u LEFT JOIN sys_order o ON u.id o.user_id WHERE u.id #{userId} /select这里的UserMapper既继承了BaseMapper的通用方法又能定义自己的自定义方法两者共存互不干扰。实际项目中我用MP处理单表CRUD手写SQL处理多表联查和复杂统计配合得很舒服。4. 条件构造器Wrapper最值得吃透的部分4.1 QueryWrapper与LambdaQueryWrapper的选择逻辑MP里最核心也是最容易掌握不透的就是Wrapper条件构造器。它有两种主要形态QueryWrapper用字符串指定列名例如.eq(name, 张三)。LambdaQueryWrapper用方法引用指定属性例如.eq(User::getName, 张三)。我强烈建议优先用LambdaQueryWrapper。原因很简单编译期就能检查属性名是否拼错而QueryWrapper的字符串写错了要运行时报错。实体字段重命名时IDE能自动同步修改方法引用字符串可是不会变的。字符串方式在SQL注入和列名合法性上虽然有内部处理但可读性和维护性都差一截。当然QueryWrapper也不是完全没用。比如你从外部动态接收一个字段名做排序LambdaQueryWrapper不太好表达这时用QueryWrapper.orderByAsc(column)更方便。但主体查询条件还是Lambda写法为主。4.2 动态条件condition参数的正确打开方式一个典型的查询场景是用户列表接口支持按姓名模糊搜索、按年龄范围筛选条件是前端可选的。如果手动拼SQL你得写一堆if判断。用MP的Wrapper可以这样public ListUser listUsers(String name, Integer minAge, Integer maxAge) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(name), User::getName, name) .ge(minAge ! null, User::getAge, minAge) .le(maxAge ! null, User::getAge, maxAge) .orderByDesc(User::getCreateTime); return userMapper.selectList(wrapper); }这里的关键就是ge这类方法的第一个参数boolean condition。当condition为true时当前条件才拼进SQL为false时直接跳过。这样把原本要写好几层的if判断压缩成一行一个条件代码干净得多。这是MP最实用的一个特性建议形成肌肉记忆所有动态筛选条件都走条件参数。4.3 UpdateWrapper只更新你想更新的字段前面提到updateById不会更新null字段。如果要把某个字段更新成null或者要按复杂条件更新多条记录就需要UpdateWrapper。LambdaUpdateWrapperUser wrapper new LambdaUpdateWrapper(); wrapper.eq(User::getName, 张三) .set(User::getAge, null) .set(User::getEmail, newexample.com); userMapper.update(null, wrapper);这里第一个参数传null表示不按实体更新所有更新内容都由wrapper的set决定。这种方式还能做“把年龄小于18的用户状态改为禁用”这类批量更新比先查出来再逐个updateById高效得多。4.4 Wrapper的三个“坑”找出来or优先级、like转义、last注入这部分是实际项目中高频踩坑的地方每个都值得单独说说。第一个坑是or的优先级。看这个写法wrapper.eq(User::getName, 张三) .eq(User::getAge, 18) .or() .eq(User::getStatus, 1);生成的SQL是WHERE name 张三 AND age 18 OR status 1因为SQL里AND优先级高于OR这条SQL实际含义是(name 张三 AND age 18) OR status 1和你心里想的“名字是张三并且年龄18或状态1”完全不一样。如果要做括号内的OR组合得用嵌套wrapper.eq(User::getName, 张三) .and(w - w.eq(User::getAge, 18).or().eq(User::getStatus, 1));这样生成的SQL才是WHERE name 张三 AND (age 18 OR status 1)。第二个坑是like的百分号转义。MP的.like(User::getName, name)生成的SQL是LIKE % ? %也就是说它帮你把参数用百分号包好了你传参时不需要自己再加%%。但如果你查询的关键字本身包含%比如用户搜“100%”这个%会直接作为通配符进SQL导致结果不准确。处理方式是手动转义或者对输入做过滤。第三个坑是last方法。wrapper.last(limit 10)、wrapper.last(for update)这类写法能把任意SQL片段拼到查询语句末尾非常方便但这也是最危险的入口。绝对不要直接把前端传参拼进last比如wrapper.last(limit pageSize)如果pageSize是用户可控的这就是SQL注入点。建议只用固定的安全片段或者限制参数类型再做白名单校验。5. 分页插件配置一次收效长久5.1 拦截器配置与分页原理MP的分页依赖一个内置拦截器。不配置这个拦截器调用selectPage不会生效那就要注意了它在查询时会查出全表数据到内存里但total始终为0页面数据却是全量的特别误导人。正确的配置方式如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); // 单页最大条数限制防止恶意大分页 pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }原理上分页拦截器会在SQL执行前做两件事改写原SQL生成一条COUNT查询语句用于统计总条数。在原SQL后面拼接LIMIT offset, size执行分页查询。这两步对一个请求来说是一次完成的Page对象里既带records又有total所以前端只要拿到Page对象就能直接渲染。5.2 分页查询的正确用法public PageUser pageUsers(int pageNum, int pageSize, String name) { PageUser page new Page(pageNum, pageSize); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(name), User::getName, name) .orderByDesc(User::getCreateTime); return userMapper.selectPage(page, wrapper); }这里有几个点要注意Page的页码从1开始前端传0的话需要转换。selectPage的第一个参数是Page对象第二个是查询条件wrapperwrapper可以传null表示无条件分页。查询结果通过page.getRecords()获取列表page.getTotal()获取总条数。page.getCurrent()和page.getSize()后端可以直接用传入的页码和条数不需要再多声明两个参数。5.3 大分页与count的优化思路分页用的多了必然遇到性能问题。最典型的是深度分页用户翻到第10000页SQL会被改写成LIMIT 1000000, 20MySQL会扫描前100万行再丢弃性能非常差。几个优化方向按我的使用经验排列限制最大页码和每页条数从产品层面避免用户翻到很深的页。用游标分页keyset分页替代传统page分页。做法是记录上一页最后一条记录的ID查询条件改成WHERE id ? ORDER BY id DESC LIMIT 20这样即使数据量很大也能走索引快速定位。优化count查询。默认生成的count在某些复杂查询下效率不高可以手动指定。Page对象有一个setSearchCount(false)方法可以关掉自动count然后自己单独执行一次count查询对结果做精确控制。这套分页插件在中小型项目里已经够用但一旦数据量上了千万级就要考虑换更针对性的方案。6. 逻辑删除、乐观锁、自动填充三个高频企业需求6.1 逻辑删除删除变成更新查询自动过滤逻辑删除的思路很简单数据不物理删除而是用一个字段标记为已删除。好处是数据可追溯、可恢复这在企业系统里几乎是刚需。MP里开启逻辑删除有两种方式全局配置logic-delete-field: deleted所有实体的deleted字段自动生效。实体字段加TableLogic注解只对当前实体生效。配置之后MP的默认行为是deleteById变成UPDATE ... SET deleted 1 WHERE id ? AND deleted 0selectList、selectById等查询自动追加AND deleted 0逻辑删除字段的值默认是0未删除和1已删除可以在全局配置里改。看起来很方便但有两个必须在设计阶段想清楚的坑。第一个坑是唯一索引冲突。如果用户表给username加了唯一索引逻辑删除老用户后再注册一个同名的用户会插入失败因为老用户那条记录还在表里username仍然冲突。处理方案通常有两种把逻辑删除字段也放进唯一索引里形成联合唯一索引或者删除时给deleted字段写入主键ID、时间戳等唯一值保证删除后不会和别人冲突。如果一直用固定值1且唯一约束是(username, deleted)那么多次删除不同用户不会冲突但同一条业务数据被删除后想再插入相同业务主键就要评估它本身已经不存在有效记录理论上可以插入。问题通常出在历史数据残留上所以最稳妥的还是让逻辑删除标记值每次不同。第二个坑是手写SQL不会自动带deleted 0条件。MP内置方法会自动加但你自己写的XML里的查询MP没能力帮你改。所以在手写关联查询、多表查询时记得自己把逻辑删除条件带上否则会出现“已删除”的数据出现在列表里的情况。6.2 乐观锁拦截器没配上Version就是个摆设并发更新是后端绕不开的话题。悲观锁用数据库for update代价高乐观锁则是基于版本号判断适合读多写少的场景。MP的乐观锁实现很轻量实体里加一个Version注解的字段比如version。在拦截器链路里加上OptimisticLockerInnerInterceptor。更新时MP自动把version带上SQL变成UPDATE user SET age ?, version ? WHERE id ? AND version ?如果更新影响行数为0说明version已经被别人改了业务上可以提示“操作冲突请刷新后重试”。需要特别提醒的是Version注解本身不会生效必须配合拦截器。很多人只在实体里加了注解忘配拦截器结果并发问题照样出还以为MP失效了。另外更新时要保证version是从库里查出来的最新值User u userMapper.selectById(1L); u.setAge(u.getAge() 1); userMapper.updateById(u);如果自己 new 一个 User 然后随便 set version 0更新时where条件AND version 0匹配不到记录导致更新总是不成功。乐观锁特别适合做“点赞数1”“阅读量1”这类轻量并发递增但如果是高并发扣减库存这种强一致性场景还是得用数据库行锁或分布式锁这点要有数。6.3 自动填充createTime/updateTime不再手动赋值很多表都有create_time和update_time字段。最原始的做法是每次插入、更新时手动 set 一下写多了就烦漏了就出问题。MP的字段自动填充可以解决。步骤分两步第一步实体字段指定填充时机TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;INSERT表示插入时填充INSERT_UPDATE表示插入和更新时都填充。第二步实现MetaObjectHandlerComponent public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }这里有一个小经验strictInsertFill的第二个参数是实体属性名不是数据库列名写错了不会报错但字段不会被填充。第三个参数需要和实体字段类型保持一致类型不一致也会静默失败。我遇到过实体字段用LocalDateTime、填充值却传Date的情况查了半天才发现是类型不匹配。自动填充适合所有实体都有的审计字段。除了创建时间和更新时间像create_by、update_by这类操作人字段也可以从当前登录用户上下文里取出来填充效果是一样的。7. 代码生成器从建表到代码十分钟一整套7.1 FastAutoGenerator的极简玩法MP从3.5.1开始主推FastAutoGenerator相比旧版AutoGenerator配置方式更简洁链式结构一目了然。先看一眼极简配置FastAutoGenerator.create( jdbc:mysql://localhost:3306/demo, root, root) .globalConfig(builder - builder.author(zhangsan) .outputDir(System.getProperty(user.dir) /src/main/java)) .packageConfig(builder - builder.parent(com.example) .moduleName(system)) .strategyConfig(builder - builder.addInclude(sys_user, sys_order) .addTablePrefix(sys_)) .execute();这个配置的意思是连接demo数据库把sys_user、sys_order两张表生成代码去掉表名前缀sys_输出到当前项目的src/main/java目录包结构为com.example.system。生成的代码包括entity实体类带TableName、TableId、Lombok注解。mapperMapper接口继承BaseMapper。serviceService接口继承IService。service.implService实现类继承ServiceImpl。controllerController带基础的REST接口。这一套下来一张表的增删改查接口基本就齐了。7.2 生成之后的第一轮检查清单生成器不是万能的生成完不要直接拿来用按下面这个清单过一遍实体类是否加了TableName如果表名和类名对不上一定要检查特别注意复数表名。主键策略是否正确生成器默认按表主键生成TableId但自增主键需要设为IdType.AUTO雪花ID策略则保持默认。逻辑删除字段是否带TableLogic如果全局配置了可以不用在实体上标但生成器可能没帮你生成deleted字段需要手动补。有版本号的表实体里是否加VersionController返回的是什么类型如果公司有统一返回体要把生成的Result里的返回类型改过来。生成器生成的service和controller如果不符合项目规范可以只保留entity和mapper其他删掉自己写。这个检查清单每次生成后都过一遍能避免很多“生成的代码为什么跑不通”的问题。7.3 生产项目里的补充配置如果项目里用了逻辑删除字段、乐观锁字段、自动填充字段可以给生成器加上额外策略.strategyConfig(builder - builder.addInclude(sys_user) .addTablePrefix(sys_) .entityBuilder() .enableLombok() .logicDeleteColumnName(deleted) .versionColumnName(version) .addTableFills(new Column(create_time, FieldFill.INSERT)) .addTableFills(new Column(update_time, FieldFill.INSERT_UPDATE)))这样生成的实体类会自动带上TableLogic、Version和填充字段注解省去手动补注解的步骤。用代码生成器最大的体会是建表、生成、微调、联调一套流程下来非常顺。不过要强调的是生成器解决的是“机械重复劳动”不是“业务设计”。表结构设计得合理生成代码才可能好用表设计乱生成器只会帮你快速生成一堆到处是坑的代码。最后再分享一个实操中的小习惯。我一般会在项目的开发环境把SQL日志打开观察MP内置方法实际生成的SQL语句尤其是update和delete看它where条件到底带了什么。很多人说MP“黑盒”、“不放心”其实底层SQL完全可见你只要看过一遍就明白它做了什么。理解了它生成SQL的规律踩坑的概率会大幅下降用起来也会踏实很多。