☰
MybatisPlus从入门到实战:条件构造器、分页与乐观锁详解
2026/10/13 18:26:51 网站建设 项目流程

1. 实训任务概览与核心思路

1.1 第六天实训到底要解决什么问题

先说说这次实训的背景。到了Day06,前面的课程基本已经把Spring Boot的基础用法、Controller层开发、Service层事务处理这些常规套路过了一遍。这个阶段最常见的痛点是:单表CRUD的代码重复度实在太高了,几乎每个实体都要写一套Mapper接口、XML文件、基础增删改查的SQL,而且大部分SQL还只是换个表名而已。第六天的实训目标很直接——引入MybatisPlus,用一套通用机制把这些机械劳动干掉,把精力腾出来处理真正有业务逻辑的部分。

实训对应的模拟场景是一个典型的电商后台系统雏形,比如商品管理、订单管理这类业务模块。在Day05的时候,这些模块的DAO层还在用传统MyBatis的方式,Mapper XML里堆了几十个标签,写完一个模块再复制到下一个模块,改表名、改字段名,枯燥不说,还容易漏改。Day06的任务就是把这些模块逐一改造为MybatisPlus实现,并且新增两个需要用条件查询、分页查询的新模块作为练习。

1.2 为什么选MybatisPlus而不是别的方案

其实在实训选型时,JPA、MyBatis通用Mapper、MybatisPlus这几个方案都对比过。JPA的自动建表能力确实方便,但对于Java开发者群体来说,MyBatis的学习曲线更平滑,而且国内大量业务项目就是MyBatis栈,实训选型要贴近实际生产环境。MyBatis通用Mapper和MybatisPlus相比,后者在条件构造器、乐观锁插件、逻辑删除、代码生成器这些生态组件上更完整,社区活跃度也更高,出了问题能找到的参考案例更多。

从团队协作的角度来说,MybatisPlus没有侵入性,它不会强制你重构所有代码,而是“想用哪块用哪块”。你可以在老项目里只引入分页插件,而不必把所有Mapper都换成BaseMapper;也可以在新模块里全面使用LambdaQueryWrapper,把条件拼接从XML字符串切换到Java代码。这种渐进式改造的特性,对实训课这种“既有历史代码要兼容,又要快速出新功能”的项目场景非常友好。一句话总结我的选型逻辑:上手门槛低,改造风险小,写出来的代码还好维护。

2. 环境准备与配置要点

2.1 引入依赖时的版本坑

Day06实训的第一步是在pom.xml里引入MybatisPlus的依赖。这个步骤看起来简单,却是第一个容易埋雷的地方,核心是版本兼容问题。我当时的配置是这样的:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>

这里我特意选了3.5.3.1这个版本,而不是最新的3.5.4或更早的3.4.x,原因有两个。第一,3.5.3.x系列对Spring Boot 2.6.x兼容性测试做得比较充分,实训环境用的就是Spring Boot 2.6.13;第二,3.5.x之后的分页插件用法和旧版有细微差别,旧版的PaginationInterceptor已经在3.5.0里移除了,网上很多教程还在用旧写法,盲目照抄会出现启动报错。如果你用的是Spring Boot 3.x,那就需要选mybatis-plus-spring-boot3-starter这个专门的依赖,不要用普通的boot-starter,否则会直接启动失败。

另外有一点容易忽略:MybatisPlus的starter自带MyBatis依赖,但如果你原来pom里手动引入了mybatis-spring-boot-starter,就会产生依赖冲突,启动时经常出现莫名其妙的mapper扫描异常。实训中我的处理方式是,先移除旧的mybatis和mybatis-spring依赖,再引入mybatis-plus-boot-starter,这样最干净。

2.2 核心配置文件的参数选择

依赖引入后,需要在application.yml里做几个关键配置。这是实训第六天项目的一个核心环节,配置不对,后面所有功能都会“看起来能用但实际有问题”。下面是我在实际项目中总结出来的标准配置方案:

mybatis-plus: # 实体类扫描路径 type-aliases-package: com.example.jingtao.entity # 驼峰命名自动转换开关,默认就是true,但建议显式写出来 configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: # 主键策略:ID_WORKER是雪花算法,适合分布式场景 id-type: assign_id # 逻辑删除字段名 logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 # 乐观锁字段名 version-field: version

这个配置里有几个细节值得展开说说。map-underscore-to-camel-case这行配置,虽然MybatisPlus默认是true,但如果你同时还在用原生MyBatis的XML和ResultMap,务必确认ResultMap里没有手动映射了带下划线的字段,不然会出现重复映射。log-impl这里设置成StdOutImpl是为了实训调试方便,可以直接在控制台看到SQL执行的具体语句和参数;但生产环境一定要去掉这个配置,否则日志量会大到让你怀疑人生。

关于主键策略,我用的是assign_id,对应MybatisPlus的雪花算法。常见的课程作业里很多人图省事直接设成auto让数据库自增,但在这个电商模拟场景里,订单号和商品ID在分库分表场景下自增ID会有冲突风险,雪花算法生成的Long型ID更贴近真实业务。当然,如果你用的是MySQL并明确没有分库分表需求,auto确实更简单,这个看实训要求,没有绝对的对错。

3. Mapper层的通用化改造

3.1 继承BaseMapper带来的变化

Day06的核心任务之一,是把原来手写的Mapper接口改造为继承MybatisPlus的BaseMapper。改造前的代码长这样:

public interface ProductMapper { int insertProduct(Product product); int deleteProduct(Long id); int updateProduct(Product product); Product selectProductById(Long id); List<Product> selectProductList(); }

每一行方法都要配一个XML标签,虽然SQL都简单,但写多了确实烦。改造后整个接口可以精简成一行:

public interface ProductMapper extends BaseMapper<Product> { // 只需在此添加自定义复杂SQL方法 }

这里我想特别说明一下:继承BaseMapper并不意味着从此用不上XML了。项目里总有些复杂查询,比如多表联查、动态条件特别多的统计报表,这种场景还是要老老实实写XML或者用注解SQL。实训第六天的教训是:不要为了“炫技”把所有查询都强行用BaseMapper自带方法实现,遇到多表关联查询时条件构造器写起来比XML复杂得多,反而得不偿失。合理的方式是“通用CRUD用BaseMapper,复杂查询继续写XML”,两者混用完全不冲突。

3.2 实体类注解的正确姿势

Mapper接口精简后,实体类就要承担更多职责。MybatisPlus通过注解来映射表名、主键策略、字段填充规则等元信息。下面是我们实训项目中商品实体经过改造后的核心片段:

@Data @TableName("tb_product") public class Product { @TableId(value = "id", type = IdType.ASSIGN_ID) private Long id; @TableField("product_name") private String productName; @TableField("price") private BigDecimal price; @TableField(value = "create_time", fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic @TableField("deleted") private Integer deleted; @Version @TableField("version") private Integer version; }

每个注解都不是随便加的,它们分别解决不同问题。@TableName用于指定数据库表名,Java类名和表名不一致时尤其重要,比如这里的Product对应tb_product。@TableField上的fill属性是自动填充功能,配合MetaObjectHandler处理器,可以在插入或更新时自动写入时间字段,比代码里手动set创建时间要干净得多,这个后面实操环节我会详细展开。

@TableLogic注解是逻辑删除的开关。加上这个注解后,调用BaseMapper的deleteById方法时,MybatisPlus不会执行DELETE语句,而是自动转化为UPDATE ... SET deleted = 1。这个设计在电商项目里特别实用,尤其是订单、用户这类敏感数据,物理删除会让后续对账、审计无从下手。需要注意的一点是:逻辑删除字段在查询时会自动追加条件WHERE deleted = 0,这既是优点也是“坑”。如果某天你确实需要查询包含已删除数据的全量记录,需要自己写SQL绕过这个自动条件。

@Version是乐观锁的核心,配合分页插件一起使用,可以在更新时自动拼接version = ?条件并做CAS校验。关于乐观锁的机制细节和实际踩坑,在下一个部分展开讲。

4. 条件构造器:把SQL搬进Java代码

4.1 QueryWrapper与LambdaQueryWrapper怎么选

条件构造器是MybatisPlus最让人“用了就回不去”的功能。它用Java方法链代替了XML里的动态SQL拼接,让条件判断、字符串拼接、参数传递都在类型安全的前提下完成。

实训第六天主要用了两种Wrapper:QueryWrapper和LambdaQueryWrapper。前者直接传字段名,后者用方法引用。举个例子,按商品名称模糊查询、价格区间过滤、按销量倒序排列,三者的写法对比:

// 方式一:QueryWrapper,字段名硬编码 QueryWrapper<Product> wrapper = new QueryWrapper<>(); wrapper.like("product_name", queryParam.getKeyword()) .between("price", queryParam.getMinPrice(), queryParam.getMaxPrice()) .orderByDesc("sales_count"); // 方式二:LambdaQueryWrapper,方法引用,编译期检查 LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.like(Product::getProductName, queryParam.getKeyword()) .between(Product::getPrice, queryParam.getMinPrice(), queryParam.getMaxPrice()) .orderByDesc(Product::getSalesCount);

我的习惯是:新项目一律用LambdaQueryWrapper,因为字段名一旦重构,硬编码字符串不会报编译错误,只会留到运行时才暴露问题。这种“编译期错误”比“运行时错误”好处理一个数量级。但要注意LambdaQueryWrapper也有个性能上的小知识:每次方法引用都会生成一个SerializedLambda,如果你在一个超大循环里反复创建Wrapper,会有一点性能损耗;不过对于实训项目的并发量来说,这个损耗完全可以忽略,不必为此过度设计。

4.2 复杂条件的链式写法实战

条件构造器最爽的地方在于“按需拼接”。比如商品列表页的筛选条件是可选的,用户不传某个参数时,SQL里就不应该出现这个条件。传统写法用XML的<if>标签,或者用StringBuilder手动拼SQL,过程既繁琐又容易出SQL注入的漏洞。用Wrapper的话,逻辑就变成了:

LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.isNotBlank(param.getCategoryId()), Product::getCategoryId, param.getCategoryId()) .like(StringUtils.isNotBlank(param.getProductName()), Product::getProductName, param.getProductName()) .ge(param.getMinPrice() != null, Product::getPrice, param.getMinPrice()) .le(param.getMaxPrice() != null, Product::getPrice, param.getMaxPrice()) .apply("DATE(create_time) = DATE({0})", param.getCreateDate());

第一个参数是布尔类型condition,为true时后续条件才会拼接进SQL。这个设计的好处是,代码的可读性比一堆if语句包裹setSqlSegment的方法调用要直观得多。而且因为用的是参数的占位符形式,不会有人手动拼接字符串导致SQL注入的问题。

这里我想提醒一个容易忽略的点:apply方法虽然可以传任意的原生SQL片段,但尽量只在万不得已时使用,比如需要调用数据库特定函数、日期函数这类Wrapper不支持的能力时才用。参数一定要用{0}这种占位符传进去,不要直接进行字符串拼接。我在实训过程中见过有同学图省事写成.apply("DATE(create_time) = DATE('" + dateStr + "')"),这就是典型的SQL注入漏洞,哪怕在实训项目里,代码规范也必须要做到位。

4.3 条件构造器使用中的边界情况

Wrapper用多了,你会碰到几个边界情况,这是实训容易忽略的细节。

第一个是and和or的优先级问题。and优先于or是数据库SQL的天然规则,但写Wrapper时很多人会误以为链式调用的顺序就是执行顺序。举个例子:要查询“分类A或分类B,且价格大于100”的商品,如果这样写:

wrapper.eq(Product::getCategoryId, "A") .or() .eq(Product::getCategoryId, "B") .gt(Product::getPrice, 100);

实际生成的SQL是你绝对不想要的:WHERE category_id = 'A' OR (category_id = 'B' AND price > 100)。正确的写法是要有用and(消费者)方法实现嵌套分组:

wrapper.and(w -> w.eq(Product::getCategoryId, "A").or().eq(Product::getCategoryId, "B")) .gt(Product::getPrice, 100);

这样生成的SQL是WHERE (category_id = 'A' OR category_id = 'B') AND price > 100,逻辑才正确。

第二个是in条件超出数据库限制。如果传给in的参数集合元素特别多,比如几千个ID,一些数据库会报错或者性能骤降。在实际项目里,大批量ID的in查询一般会改成分批处理,每批500个。MybatisPlus没有对in的数量做内置限制,这个逻辑得自己在Service层控制。

第三个是last方法。它的作用是直接拼接一段SQL到语句末尾,比如.last("LIMIT 5")。这个方法是把双刃剑,它的优先级最高,会无视你之前设置的所有其他条件,而且一旦使用不当,比如拼接了; DROP TABLE这类内容,后果会很严重。所以在实训项目中,我规定了一个铁律:last方法的使用必须经过评审,而且只能拼接静态常量或白名单参数,绝不接受外部输入。

5. 内置插件:分页、乐观锁与逻辑删除

5.1 分页插件的配置与使用细节

分页是后台管理系统里的高频需求。MybatisPlus的分页插件配置非常简单,但有几个细节体验不好容易踩坑,这里一起说明。

分页插件需要在配置类里注册一个Bean:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件放在第一位 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 乐观锁插件放在其后 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }

这里有个容易忽略的点:PaginationInnerInterceptor构造器里最好显式传入DbType.MYSQL,而不是用无参构造器让MybatisPlus自动判断。原因是在某些连接池配置下,数据库类型探测会走一条比较复杂的逻辑链路,如果探测失败,分页SQL的方言拼接就会出错,典型症状是SQL最后拼接了LIMIT但没拼上OFFSET,或者LIMIT和OFFSET的顺序不对。

分页查询的Service层标准写法是这样的:

public Page<Product> pageProducts(ProductPageQuery query) { Page<Product> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.isNotBlank(query.getCategoryId()), Product::getCategoryId, query.getCategoryId()) .orderByDesc(Product::getCreateTime); return productMapper.selectPage(page, wrapper); }

这里有一个从旧版MybatisPlus过渡来的旧写法需要特别提醒:3.4.0之前的分页用法是直接配置PaginationInterceptor,而3.5.x之后统一用MybatisPlusInterceptor容器来管理。网上很多历史帖子用的还是旧写法,如果你版本比较新还按旧写法配置,代码能启动但分页不生效,执行SELECT时不会自动拼LIMIT,排查起来很让人头疼。

分页结果的获取也有讲究。很多人用page.getPages()来算总页数,用page.getTotal()获取总数,这是没问题的。但要注意的是,selectPage方法会自动执行一条COUNT查询来统计总数。如果你的列表查询条件特别复杂且联表很多,COUNT查询的性能可能会很慢。这时候可以使用page.setSearchCount(false)来禁用自动COUNT,然后手动构造一条更轻量的COUNT语句。这是实训课程里没有讲到的细节,但在实际生产里经常会遇到,属于加分项。

5.2 乐观锁的机制与实战踩坑

先解释一下为什么需要乐观锁。电商后台里,运营人员A和B同时打开同一个商品的编辑页面,A把价格从100改成120,B把库存从50改成30。如果没有任何并发控制,A的提交可能会覆盖B的修改,或者反过来。悲观锁适合并发冲突多的场景但性能开销大,而乐观锁利用版本号机制,在读多写少的场景下有天然优势。

MybatisPlus的乐观锁实现原理很简单:更新时自动在SQL的WHERE子句里带上version = 当前版本号,同时执行SET version = version + 1。如果更新影响行数为0,说明版本号不匹配,本次更新失败。这个过程完全由插件自动完成,不需要在Mapper里写任何特殊代码。

实训第六天我专门设计了几个并发场景来验证这个机制。先说结论:95%的情况都能正确拦截并发冲突,但有一个细节坑了很多同学——乐观锁字段没有在实体类上加@Version注解,或者加了这个注解但在前端Form表单里没有把version值跟数据一起提交。当更新时version是null,SQL就会变成WHERE version = null,可以想象,这种情况下条件永远不成立,更新影响行数永远是0,导致所有更新操作都会失败。

官方文档建议在实体类上加上@Version注解后,每次更新前从数据库重新查询一次并带上新的version,这是标准做法。但在实际开发中,更常见的做法是从前端提交的JSON数据里带上原version,Service层校验非空后再执行updateById。这样既能减少一次数据库查询,又不影响乐观锁效果,属于一个实用优化技巧。

还有一个小问题需要注意:updateById这个方法执行时,MybatisPlus默认会把实体的所有非null字段都crafted进SET子句。如果你只想更新部分字段,比如只改价格,但实体的stock字段不小心被赋值了,也会被一并更新。这就是我在实训中经常强调的“Dirty Checking”风险。解决办法是使用UpdateWrapper来精确指定要更新的字段,或者使用同一实体对象,只给需要更新的字段赋值,其他字段保持null。

@Transactional(rollbackFor = Exception.class) public boolean updateProductPrice(Long id, BigDecimal newPrice, Integer version) { Product product = new Product(); product.setId(id); product.setPrice(newPrice); product.setVersion(version); // 携带版本号 int rows = productMapper.updateById(product); // rows为0说明版本冲突 return rows > 0; }

这里的version是从前端传来的原版本号,更新时插件自动在WHERE里加上version = 原版本号,并set为version + 1。如果版本号不对,update返回0,Service层可以根据返回值给前端提示“数据已被其他用户修改,请刷新后重试”。

5.3 逻辑删除的隐藏联系

逻辑删除和查询的联动说过了,这里再展开一个容易忽略的点:MybatisPlus的逻辑删除默认是全局单字段名deleted,你可以在配置里修改logic-delete-field来指定。如果想让不同的表用不同的逻辑删除字段名(比如有的表用is_deleted,有的用del_flag),可以通过在每个实体类上用@TableLogic注解来单独指定,这样就覆盖了全局配置。

在实际使用中,逻辑删除最大的坑在于“唯一索引冲突”。比如用户在表里注册了一个手机号,然后执行删除逻辑,只是把deleted置为1,但物理记录还在表里。此时如果同一个手机号再次注册,插入操作会因为唯一索引而失败,因为老记录虽然逻辑删除了但依然占着索引位置。解决办法有两种:一是把唯一索引改成包含了deleted字段的联合索引,比如UNIQUE KEY uk_phone_deleted(phone, deleted);二是注册前先物理删除或调整deleted字段的值。这个坑在实训项目里只会在删除后再新增时暴露,如果没有提前做好预案,排查起来很费时间。

6. 核心实操:从ServiceImpl到分页接口的落地

6.1 Service层的封装套路

Day06实训不只是让你把Mapper换成BaseMapper,Service层也有对应的简化套路。MybatisPlus提供了IService接口和ServiceImpl实现类,它们把单表的增删改查、批量操作、分页等方法都封装好了,Service类只要继承ServiceImpl再实现自定义接口即可。

以我们的ProductService为例,改造前后的对比挺明显的。改造前,每个Service实现类都要注入对应Mapper,然后手动写insert、update、delete、select的逐个调用。改造后:

@Service public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService { @Override public Page<Product> pageProducts(ProductPageQuery query) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(query.getKeyword()), Product::getProductName, query.getKeyword()) .eq(query.getCategoryId() != null, Product::getCategoryId, query.getCategoryId()) .orderByDesc(Product::getId); return baseMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); } }

ServiceImpl还提供了saveBatch、updateBatchById、listByIds这些批量方法,它们内部对每批数据默认使用批量SQL注入的方式,可以减少JDBC网络往返次数。使用的时候有两点要提醒:一是saveBatch默认批处理大小是1000,如果你的数据量特别大,可以重载方法传batchSize;二是批量更新方法在部分数据库方言下并不会真正生成批量UPDATE语句,而是逐条执行,这时候性能优化效果有限,最好先做压测验证。

6.2 生成接口完整流程演示

为了便于理解,我要完整走一遍Day06实训项目中一个典型接口的实现过程:商品条件分页查询。

第一步,定义分页查询参数类。这里有个命名上的小技巧:不要用Product本身来做接收参数,尤其是带了id、createTime等字段时,接收页面参数会污染实体语义。单独建一个ProductPageQuery:

@Data public class ProductPageQuery { private Integer pageNum = 1; private Integer pageSize = 10; private String keyword; private String categoryId; private BigDecimal minPrice; private BigDecimal maxPrice; private String sortField; private String sortOrder; }

第二步,Controller层直接调Service,参数简单校验后可放置于Service内:

@RestController @RequestMapping("/api/product") public class ProductController { @Resource private ProductService productService; @GetMapping("/page") public Result<Page<Product>> page(ProductPageQuery query) { return Result.success(productService.pageProducts(query)); } }

第三步,Service实现里注意对sortField和sortOrder做白名单校验处理。排序字段不能直接拼接任意字符串进wrapper的orderBy方法,否则就有注入风险,这在前面的last方法里也提到过。安全做法是定义一个允许排序的字段白名单映射:

private static final Map<String, SFunction<Product, ?>> SORT_FIELD_MAP = Map.of( "price", Product::getPrice, "salesCount", Product::getSalesCount, "createTime", Product::getCreateTime, "id", Product::getId ); if (StringUtils.isNotBlank(query.getSortField())) { SFunction<Product, ?> sortField = SORT_FIELD_MAP.get(query.getSortField()); boolean asc = "asc".equalsIgnoreCase(query.getSortOrder()); if (sortField != null) { wrapper.orderBy(true, asc, sortField); } }

6.3 自动填充字段的处理器实现

前面实体类上标注了fill = FieldFill.INSERT和fill = FieldFill.INSERT_UPDATE,但光注解还不够,需要提供一个MetaObjectHandler的Spring Bean,MybatisPlus才能在插入和更新时自动填充这些字段:

@Component 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()); this.strictInsertFill(metaObject, "deleted", Integer.class, 0); this.strictInsertFill(metaObject, "version", Integer.class, 1); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }

这里有两个容易踩坑的细节。第一是strictInsertFill这个“strict”的含义:如果实体类对应字段已经有值,就不会覆盖;如果实体类是null,才会填充。这个特性很重要,因为有时候业务需要传入自定义的createTime(比如数据迁移、补录历史数据),首次插入时不应被强制覆盖。

第二个细节是:MetaObjectHandler在填充时拿不到对应表的元信息,它是通过实体类的@TableField(fill = FieldFill.INSERT)注解来感知哪些字段需要填充的。所以如果你实体类里没有对应注解,MetaObjectHandler即使写了填充字段也不会生效。这两个配置“缺一不可”,少任何一个自动填充都不会工作。

7. 排查实录:实训中高频报错速查

7.1 常见问题对照表

实训第六天过程中,大家遇到的问题高度集中在几类,几乎每个人都会至少碰到其中一个。这里整理成一张速查表,方便你直接对照排查。

现象根本原因解决方案
启动报错“Invalid bound statement (not found)”Mapper接口没有扫描到,或XML的namespace不对检查@MapperScan路径是否覆盖到Mapper接口包;检查XML namespace是否与接口全限定名一致
分页不生效,查询返回全量数据分页插件没有注册,或者注册方式用了旧版PaginationInterceptor使用MybatisPlusInterceptor + PaginationInnerInterceptor配置
逻辑删除后相同记录插入失败数据库唯一索引与逻辑删除字段冲突将唯一索引改为包含deleted的联合索引,或在插入前处理旧数据
乐观锁更新总是返回0实体类缺少@Version注解,或version字段传值为null给版本字段加@Version注解;确保更新时传入非null版本号
自动填充时间没有生效实体类缺少fill属性,或MetaObjectHandler未注册两者需同时配置,缺一不可
LambdaQueryWrapper编译报错实体类的属性名和方法引用写错检查是否引用了不存在的getter,IDEA对LambdaQueryWrapper有良好提示,优先用IDE开发
查询结果中逻辑删除字段被设置成null@TableField逻辑删除字段和fill填充顺序问题在insertFill中给deleted字段设置初始值,并加上@TableLogic后确认查询自动过滤
批量插入数据量大时性能极差默认batchSize过大导致SQL语句超长调整saveBatch的batchSize参数,或减少每批数据量

7.2 三个魔鬼细节

除了上面表格里那些一眼能看出的问题,还有几个隐藏得比较深的“魔鬼细节”,是实训现场花了很长时间才定位的,写在这里帮大家提前避开。

第一个是@TableField(exist = false)的使用。如果你的实体类里有数据库表中不存在的字段,比如一个“确认密码”字段,或者一个临时计算用的字段,必须加上exist = false注解,否则MybatisPlus在生成SQL时会把该字段当成真实表字段,然后报“字段不存在”的错误。这是新手极容易忽略的一步。

第二个是Mapper XML和MybatisPlus的实体映射共存时的顺序。如果在XML里定义了一个selectProductPage查询,但同时又在实体类上加了@TableField,那么XML里的resultType如果写的是实体类,所有映射规则都会走MyBatis的原生映射逻辑,不会自动走MybatisPlus的驼峰转换之外的规则。换句话说,XML里的SQL要自己处理好字段别名和实体属性的映射关系,驼峰转换可以帮一部分忙,但不要100%依赖它。

第三个是updateById方法的“选择性更新”容易被误解。MybatisPlus的官方文档说updateById会忽略null字段,默认只更新非null字段。但注意,如果某个字段被设置为空字符串"“或者0,它不算null,会被照常更新。所以如果你不想把空串或0更新进数据库,需要在Service层先做空值判断和过滤。我在实训中对这个问题提供了一个通用解法:创建一个专门用于做部分字段更新的UpdateDTO,并用UpdateWrapper的set方法精确控制更新范围。

7.3 排查思路分享

遇到报错时,我的排查流程是这样的:先看控制台打印的SQL语句,看看MybatisPlus最终生成的SQL和参数是什么样的。这一步能解决80%的问题,因为大部分问题本质上是“SQL不是你想要的”。第二步是检查实体类注解,把@TableName、@TableField、@TableLogic、@Version这四类注解逐一核对。实体类注解是配置和代码之间的桥梁,90%的“看起来没问题但运行不对”都出在这里。

第三步是检查配置类。分页插件、乐观锁插件都有没有注册,配置类有没有被Spring容器扫描到。如果启动时MybatisPlusConfig这个类都没加载,那所有插件都不会生效。第四步才是上网搜索,搜索时注意看报错信息和MybatisPlus版本号,很多旧帖子的解决方案在新版本里已经不适用了,需要结合版本甄别。

8. 实训后的自我检验与进阶方向

8.1 自测清单

实训第六天结束后,如果你不确定自己是否真正掌握了MybatisPlus的核心用法,可以对照下面这份自测清单逐一检查。能不看文档完成80%以上,说明这次实训已经达标了。

  • 能否在Spring Boot项目里正确引入MybatisPlus,并且完成基础的单表CRUD?
  • 能否熟练使用LambdaQueryWrapper写出包含模糊查询、范围查询、排序和逻辑分组条件的复杂查询?
  • 能否配置并解释分页插件的工作原理?能否说出分页插件自动生成的COUNT语句和分页SQL的结构?
  • 能否解释逻辑删除的底层机制以及它对查询、唯一索引、数据恢复的影响?
  • 能否解释乐观锁的原理并动手实现一个带版本控制的更新接口?
  • 能否编写MetaObjectHandler实现自动字段填充?
  • 能否在同一个项目中合理混用MybatisPlus的BaseMapper和自定义XML的Mapper方法?

8.2 往深了走的方向

实训学完,如果你觉得还想往更深的方向拓展,我这里有几个方向值得探索。

第一个是MybatisPlus的多租户插件,它的原理是启动时在SQL末尾自动拼接租户条件,实现一套数据库多租户隔离。电商后台系统里商家之间数据隔离很有可能是刚需,如果你要做一个真正商业化的东西,这个知识点逃不掉。

第二个是MybatisPlus的SQL注入器,它允许你自定义一些全局通用的SQL方法。比如我们实训里有一个“批量根据业务编码查询”的需求,用BaseMapper自带方法会发现每个表都要重复写,SQL注入器就是干这个事的,把通用方法注册进去,所有Mapper都能用。这个属于进阶玩法,但面试聊起来很加分。

第三个是MybatisPlus与MyBatis原生拦截器的集成。当你理解了MybatisPlus所有插件都是基于MyBatis的Interceptor机制实现的,你就可以自己编写拦截器做一些统一的数据权限控制、SQL链路追踪等事情。这个机制才是MybatisPlus后续所有扩展能力的根基。

8.3 最后分享一点个人体会

实训第六天这个节点很有意思,它几乎是整个MyBatis学习路线里的一个分水岭。前五天的CRUD练习虽然基础扎实,但重复性太高,人的精力被磨掉了一大半;Day06把通用操作全部交给MybatisPlus之后,后面几天的实训项目开发速度明显变快,能把更多时间花在业务查询逻辑和接口设计上。

我个人觉得,MybatisPlus这种“约定优于配置”的框架,学起来核心要抓两件事:一是它的注解体系,理解了每个注解作用在什么生命周期,你就理解了它怎么帮你生成SQL;二是它的插件机制,理解了插件如何与MyBatis原生Executor协作,你就能举一反三地组合运用分页、乐观锁、多租户这些能力。这两条主线把握住了,以后无论MybatisPlus迭代到哪个版本,你都能快速适应新变化。

另外一个小建议是:实训中遇到的问题,一定要自己动手敲一遍解决方案,不要光看文档或者光看同学的代码。像逻辑删除和唯一索引冲突那个问题,课堂上讲一百遍不如自己删一次再插一次来得印象深刻。MybatisPlus是个工具型的框架,只有亲手踩过坑、亲手填上坑,才能真正把它变成自己的熟练技能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询