Spring Boot 集成 MyBatis-Plus 进阶实战——复杂查询、分页、乐观锁全套
Spring Boot 集成 MyBatis-Plus 进阶实战——复杂查询、分页、乐观锁全套
适读人群:有 MyBatis-Plus 基础、想深入掌握进阶用法的 Java 工程师 | 阅读时长:约20分钟 | 核心价值:掌握 MyBatis-Plus 的复杂查询构建、分页优化、乐观锁实现,以及自动填充和代码生成器的实战配置
一、老李的 MyBatis-Plus 翻车经历
老李的项目刚引入 MyBatis-Plus 时,他很兴奋,觉得终于可以告别写 XML 了。结果用了一个月,他发现了几个问题:
第一,分页查询没问题,但 total 字段一直是 -1,不显示总数。他查了很久,才知道 PaginationInnerInterceptor 没配置,或者配置了但没加到 MybatisPlusInterceptor 里。
第二,并发更新订单状态时,偶尔出现数据被覆盖的问题——线程 A 读出来状态是"待支付",线程 B 也读出来"待支付",A 更新成"已支付",B 也更新成"已支付",但 B 是基于过期数据更新的,相当于把 A 的更新覆盖了。他问我怎么解决,我说两个字:乐观锁。
第三,插入数据时要手动设置 createTime、updateTime,一不小心就漏了,导致数据库里有很多时间字段是 null。我说:用 MetaObjectHandler 自动填充,一次配置,永久省心。
这三个问题是 MyBatis-Plus 使用中最高频的坑点。这篇文章把这些坑以及更多进阶用法全部覆盖。
二、基础配置(完整版)
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.4</version>
</dependency># application.yml
mybatis-plus:
mapper-locations: classpath*:mapper/**/*Mapper.xml
type-aliases-package: com.example.model
global-config:
db-config:
id-type: ASSIGN_ID # 雪花算法ID,推荐
logic-delete-field: deleted # 全局逻辑删除字段名
logic-delete-value: 1
logic-not-delete-value: 0
update-strategy: NOT_NULL # 更新时只更新非 null 字段
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境打印 SQLMyBatis-Plus 配置类(核心):
package com.example.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.example.mapper")
public class MybatisPlusConfig {
/**
* MyBatis-Plus 插件配置。
* 注意:所有插件都加入同一个 MybatisPlusInterceptor 实例,
* 不要创建多个 MybatisPlusInterceptor,否则后者会覆盖前者。
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 分页插件(必须配置,否则 page.getTotal() 返回 -1)
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(500L); // 单页最大条数限制,防止大查询
paginationInterceptor.setOverflow(false); // 超出总页数不自动回到第一页,直接返回空
interceptor.addInnerInterceptor(paginationInterceptor);
// 2. 乐观锁插件(@Version 注解配合使用)
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}三、自动填充(createTime、updateTime)
package com.example.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 自动填充处理器。
* 配合 @TableField(fill = FieldFill.INSERT) 和 @TableField(fill = FieldFill.INSERT_UPDATE) 使用。
* 在 insert 或 update 时自动设置时间字段,不需要业务代码手动赋值。
*/
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
// strictInsertFill 只有字段为 null 时才填充,不会覆盖业务代码的设值
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
// 如果有创建人字段,可以从 SecurityContext 里取当前用户 ID
// this.strictInsertFill(metaObject, "createBy", Long.class, getCurrentUserId());
}
@Override
public void updateFill(MetaObject metaObject) {
// 更新时只填充 updateTime,不动 createTime
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}实体类配置:
package com.example.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("t_order")
public class OrderDO {
@TableId(type = IdType.ASSIGN_ID) // 雪花算法 ID
private Long id;
private String orderNo;
private Integer status;
private Long userId;
/** 乐观锁字段,配合 OptimisticLockerInnerInterceptor 使用 */
@Version
private Integer version;
/** 逻辑删除字段,配合全局配置使用 */
@TableLogic
private Integer deleted;
/** 创建时间,自动填充 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 更新时间,自动填充 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}四、复杂查询构建
4.1 LambdaQueryWrapper——类型安全的查询
package com.example.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.model.OrderDO;
import com.example.mapper.OrderMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class OrderQueryService {
private final OrderMapper orderMapper;
public OrderQueryService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
/**
* 复杂条件查询示例:支持多条件动态拼接。
* LambdaQueryWrapper 使用方法引用,编译时检查字段名,避免写错字符串。
*/
public List<OrderDO> queryOrders(Long userId, Integer status,
LocalDateTime startTime, LocalDateTime endTime) {
LambdaQueryWrapper<OrderDO> wrapper = Wrappers.lambdaQuery(OrderDO.class)
// eq 等值条件,第一个参数为 false 时忽略此条件(动态拼接的关键)
.eq(userId != null, OrderDO::getUserId, userId)
.eq(status != null, OrderDO::getStatus, status)
// 时间范围查询
.ge(startTime != null, OrderDO::getCreateTime, startTime)
.le(endTime != null, OrderDO::getCreateTime, endTime)
// 排除已删除(@TableLogic 会自动加,但也可以手动加)
.eq(OrderDO::getDeleted, 0)
// 按创建时间倒序
.orderByDesc(OrderDO::getCreateTime)
// 只查需要的字段,减少数据传输量
.select(OrderDO::getId, OrderDO::getOrderNo, OrderDO::getStatus,
OrderDO::getUserId, OrderDO::getCreateTime);
return orderMapper.selectList(wrapper);
}
/**
* OR 条件:查询状态为"待支付"或"支付失败"的订单。
*/
public List<OrderDO> queryPendingOrders() {
return orderMapper.selectList(
Wrappers.lambdaQuery(OrderDO.class)
.eq(OrderDO::getStatus, 1) // 待支付
.or()
.eq(OrderDO::getStatus, 3) // 支付失败
);
}
}4.2 分页查询
package com.example.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.model.OrderDO;
import com.example.mapper.OrderMapper;
import org.springframework.stereotype.Service;
@Service
public class OrderPageService {
private final OrderMapper orderMapper;
public OrderPageService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
/**
* 分页查询,自动返回 total、pages、records 等分页信息。
* 前提:MybatisPlusInterceptor 里已经添加 PaginationInnerInterceptor。
*
* @param pageNum 页码(从 1 开始)
* @param pageSize 每页条数
* @param userId 用户 ID(可为 null)
*/
public Page<OrderDO> pageOrders(int pageNum, int pageSize, Long userId) {
Page<OrderDO> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<OrderDO> wrapper = Wrappers.lambdaQuery(OrderDO.class)
.eq(userId != null, OrderDO::getUserId, userId)
.orderByDesc(OrderDO::getCreateTime);
// selectPage 会自动执行 COUNT 查询获取总数
Page<OrderDO> result = orderMapper.selectPage(page, wrapper);
// result.getTotal() - 总记录数
// result.getPages() - 总页数
// result.getRecords() - 当前页的数据
// result.hasPrevious() / result.hasNext() - 是否有上/下一页
return result;
}
}五、乐观锁实战
乐观锁的核心思想:更新时检查版本号,如果版本号变了(说明中间有人更新过),本次更新失败,业务层决定重试还是报错。
package com.example.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.mapper.OrderMapper;
import com.example.model.OrderDO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderUpdateService {
private final OrderMapper orderMapper;
public OrderUpdateService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
/**
* 乐观锁更新订单状态。
* 关键:查出来的对象里 version 字段有值,
* updateById 时 MyBatis-Plus 会自动在 SQL 里加 WHERE version = #{version} AND version = version + 1。
* 如果更新影响行数为 0,说明版本冲突,业务层需要处理。
*
* 生成的 SQL 类似:
* UPDATE t_order SET status=?, version=version+1, update_time=?
* WHERE id=? AND version=? AND deleted=0
*/
@Transactional
public boolean payOrder(Long orderId) {
// 查询时 version 字段会被读取
OrderDO order = orderMapper.selectById(orderId);
if (order == null) {
throw new RuntimeException("订单不存在");
}
if (order.getStatus() != 1) { // 1: 待支付
throw new RuntimeException("订单状态不允许支付");
}
// 更新状态为"已支付"
order.setStatus(2); // 2: 已支付
// 不需要手动设置 version,MyBatis-Plus 的乐观锁插件自动处理
int rows = orderMapper.updateById(order);
if (rows == 0) {
// 更新失败,说明版本冲突(有并发更新)
// 根据业务决定:重试 or 报错
throw new RuntimeException("并发冲突,请重试");
}
return true;
}
}六、踩坑实录
坑1:乐观锁更新实体时必须先查询,不能直接 set
现象:我用 new OrderDO() 设置 id 和 status 后直接 updateById,乐观锁不生效,并发下还是出现了数据覆盖。
原因:乐观锁要生效,实体的 version 字段必须有值(是从数据库读出来的当前版本号)。直接 new 出来的对象 version 为 null,OptimisticLockerInnerInterceptor 不会处理 null 版本,相当于没有乐观锁检查。
解法:乐观锁场景必须先 selectById 查出来,然后修改字段,再 updateById。这是乐观锁的使用约定,不能省略查询步骤。这个坑我也踩过,坑在于它不会报错,只是乐观锁静默失效。
坑2:逻辑删除和 COUNT 查询的配合
现象:用 selectCount 统计订单数,结果不对,包含了已逻辑删除的记录。
原因:逻辑删除字段需要配置在全局配置里,或者在每个实体类的字段上加 @TableLogic。如果只在某些地方配置,其他地方的 count 查询不会自动加 deleted=0 条件。
解法:确保全局配置 logic-delete-field 和所有实体类的 @TableLogic 对齐,不要混用。
坑3:大数据量分页性能差
现象:数据量 100 万,分页到第 5000 页(每页 20 条,offset 10 万),SQL 响应时间 3 秒以上。
原因:MySQL 的 LIMIT 100000, 20 需要扫描 100020 行数据再丢弃前 10 万行,深分页性能极差。
解法:改用游标分页(keyset pagination):记录上次翻页的最后一条记录的 ID,下次查询用 WHERE id > lastId LIMIT 20,走索引扫描,性能稳定在毫秒级。只需要"上一页/下一页"功能而不需要"跳到第 N 页"时,这是最佳方案。
七、代码生成器配置
MyBatis-Plus 的代码生成器能节省大量重复劳动,一键生成 Entity、Mapper、Service、Controller:
package com.example.generator;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.Collections;
/**
* MyBatis-Plus 代码生成器。
* 执行 main 方法,自动根据数据库表生成 Entity、Mapper、Service、Controller。
* 用于初始化项目结构,生成后按需修改,不要频繁重跑覆盖已修改的文件。
*/
public class CodeGenerator {
public static void main(String[] args) {
FastAutoGenerator.create(
"jdbc:mysql://localhost:3306/your_db?useSSL=false",
"root", "your_password")
.globalConfig(builder -> builder
.author("老张")
.outputDir(System.getProperty("user.dir") + "/src/main/java")
.commentDate("yyyy-MM-dd")
)
.packageConfig(builder -> builder
.parent("com.example")
.entity("model")
.mapper("mapper")
.service("service")
.serviceImpl("service.impl")
.controller("controller")
.pathInfo(Collections.singletonMap(
OutputFile.xml, System.getProperty("user.dir") + "/src/main/resources/mapper"
))
)
.strategyConfig(builder -> builder
.addInclude("t_order", "t_user") // 要生成的表名
.addTablePrefix("t_") // 去掉表前缀
.entityBuilder()
.enableLombok()
.enableTableFieldAnnotation()
.logicDeleteColumnName("deleted")
.versionColumnName("version")
.addTableFills(
new com.baomidou.mybatisplus.generator.fill.Column("create_time",
com.baomidou.mybatisplus.annotation.FieldFill.INSERT),
new com.baomidou.mybatisplus.generator.fill.Column("update_time",
com.baomidou.mybatisplus.annotation.FieldFill.INSERT_UPDATE)
)
.mapperBuilder()
.enableMapperAnnotation()
.controllerBuilder()
.enableRestStyle()
)
.templateEngine(new FreemarkerTemplateEngine())
.execute();
}
}