文章目录
-
- [1. 课前导读](#1. 课前导读)
- [2. 知识目标](#2. 知识目标)
- [3. 理论讲解](#3. 理论讲解)
-
- [3.1 MyBatis-Plus的定位与核心价值](#3.1 MyBatis-Plus的定位与核心价值)
- [3.2 Spring Boot 4.x的版本适配要点](#3.2 Spring Boot 4.x的版本适配要点)
- [3.3 BaseMapper的零SQL CRUD](#3.3 BaseMapper的零SQL CRUD)
- [3.4 分页查询的插件机制](#3.4 分页查询的插件机制)
- [3.5 条件构造器的设计哲学](#3.5 条件构造器的设计哲学)
- [3.6 逻辑删除的实现原理](#3.6 逻辑删除的实现原理)
- [3.7 乐观锁与自动填充](#3.7 乐观锁与自动填充)
- [4. 环境搭建](#4. 环境搭建)
-
- [4.1 项目创建](#4.1 项目创建)
- [4.2 pom.xml](#4.2 pom.xml)
- [4.3 数据库准备](#4.3 数据库准备)
- [4.4 application.yml](#4.4 application.yml)
- [5. 代码实战](#5. 代码实战)
-
- [5.1 实体类](#5.1 实体类)
- [5.2 Mapper接口](#5.2 Mapper接口)
- [5.3 Service层](#5.3 Service层)
- [5.4 插件配置](#5.4 插件配置)
- [5.5 DTO与Controller](#5.5 DTO与Controller)
- [5.6 启动类](#5.6 启动类)
- [5.7 运行与验证](#5.7 运行与验证)
- [6. 底层源码剖析](#6. 底层源码剖析)
-
- [6.1 BaseMapper方法SQL的生成机制](#6.1 BaseMapper方法SQL的生成机制)
- [6.2 分页插件的SQL改写机制](#6.2 分页插件的SQL改写机制)
- [6.3 逻辑删除的SQL注入机制](#6.3 逻辑删除的SQL注入机制)
- [6.4 乐观锁的版本号比较机制](#6.4 乐观锁的版本号比较机制)
- [7. 常见坑点与生产问题排查](#7. 常见坑点与生产问题排查)
-
- 坑点1:使用旧的mybatis-plus-boot-starter坐标
- 坑点2:mybatis-spring版本冲突导致DataSourceAutoConfiguration找不到
- 坑点3:分页插件未配置导致分页失效
- 坑点4:分页插件未设置dbType导致SQL错误
- 坑点5:逻辑删除配置了但未生效
- 坑点6:自动填充不生效
- 坑点7:乐观锁更新返回false但数据库数据未变
- [坑点8:分页插件放在插件链中间导致COUNT SQL错误](#坑点8:分页插件放在插件链中间导致COUNT SQL错误)
- [8. 本章总结](#8. 本章总结)
- [9. 课后作业](#9. 课后作业)
- [🔗 SpringBoot 4.x 从入门到企业级实战系列课程](#🔗 SpringBoot 4.x 从入门到企业级实战系列课程)

本文是《SpringBoot 4.x 从入门到精通》系列第13课,全文约13000字,建议阅读时间50分钟。
环境要求:Spring Boot 4.x(基于4.0.x GA),Spring Framework 7.0.x,Java 17+(推荐Java 21/25),MyBatis-Plus 3.5.15+
1. 课前导读
上一课我们完成了MyBatis的原生整合------手写Mapper接口、手写SQL、手写XML映射文件。你已经能够用MyBatis完成单表CRUD了。但如果你实际写过企业项目,一定会有这样的感受:单表CRUD的代码,80%都是重复的。
SELECT * FROM users WHERE id = ?、INSERT INTO users (...) VALUES (...)、UPDATE users SET ... WHERE id = ?、DELETE FROM users WHERE id = ?------这些SQL的模式完全相同,唯一的区别只是表名和字段名。每个实体都要写一遍,每个Mapper都要复制一遍,代码量巨大且容易出错。
MyBatis-Plus(简称MP)正是为解决这个问题而生的。 它是MyBatis的增强工具,在MyBatis的基础上"只做增强不做改变"。你只需要让Mapper接口继承BaseMapper<T>,就能立即拥有17个常用的CRUD方法------不需要写一行SQL,不需要写一个XML文件。这就是所谓的**"零SQL CRUD"**。
但MyBatis-Plus的价值远不止于此。分页查询、条件构造器、逻辑删除、乐观锁、自动填充------这些在生产项目中高频使用的功能,MyBatis-Plus都以插件或注解的形式提供了开箱即用的支持。
Spring Boot 4.x的适配是本节课最核心的前提。 MyBatis-Plus从3.5.15版本 开始全面支持Spring Boot 4.0和Jackson 3.0。但依赖坐标发生了变化------Spring Boot 4.x必须使用mybatis-plus-spring-boot4-starter,而非传统的mybatis-plus-boot-starter。此外,MyBatis-Plus 3.5.16版本升级了mybatis-spring到4.0.0,如果你遇到DataSourceAutoConfiguration类找不到的报错,通常是因为mybatis-spring版本没有对齐。
本节课将系统讲解MyBatis-Plus在Spring Boot 4.x中的完整用法,从依赖配置、BaseMapper零SQL CRUD,到分页查询、条件构造器、逻辑删除三大核心实战,最后深入插件机制和自动配置的底层源码,帮你把MyBatis-Plus从"会用"提升到"用好"的层次。
前置知识:第12课的MyBatis原生整合、SQL基础、Java泛型和注解。
学习难点 :理解BaseMapper的泛型机制和方法签名;掌握LambdaQueryWrapper的链式调用和条件组合;区分Page对象与IPage接口的使用场景;理解逻辑删除的全局配置与注解配置的优先级。
【配图描述:MyBatis-Plus核心能力全景图------BaseMapper零SQL CRUD、分页插件、条件构造器、逻辑删除的完整体系】
2. 知识目标
学完本节课,你必须掌握以下核心知识点:
- Spring Boot 4.x的版本适配 :
mybatis-plus-spring-boot4-starter的依赖坐标、3.5.15+的版本要求、mybatis-spring4.0.0的版本对齐、mybatis-plus-jsqlparser的必要性。 - BaseMapper零SQL CRUD :17个内置方法的用法,
insert、selectById、updateById、deleteById等核心方法。 - 分页查询实战 :
MybatisPlusInterceptor的配置、PaginationInnerInterceptor的用法、Page对象与IPage接口的区别、分页参数的传递与结果的获取。 - 条件构造器 :
LambdaQueryWrapper的链式调用、条件组合(eq、like、between、in、orderBy)、动态条件拼接(condition参数)。 - 逻辑删除实战 :全局配置方式(
application.yml)与注解方式(@TableLogic)的区别、逻辑删除的SQL转换规则、与唯一索引的配合使用。 - 生产常用特性 :乐观锁(
@Version+OptimisticLockerInnerInterceptor)、自动填充(@TableField(fill = ...)+MetaObjectHandler)、防全表更新(BlockAttackInnerInterceptor)。 - 升级踩坑指南 :
mybatis-spring版本冲突的解决、spring-boot4-starter与旧坐标的差异、Jackson 3.0的适配。
3. 理论讲解
3.1 MyBatis-Plus的定位与核心价值
MyBatis-Plus的官方定位是:"只做增强,不做改变 "。这句话的含义是:MyBatis-Plus完全兼容MyBatis,你可以在同一个项目中同时使用MyBatis-Plus的BaseMapper和MyBatis的原生@Select注解,两者不会冲突。
MyBatis-Plus的核心价值体现在三个层面:
第一层:零SQL CRUD。 Mapper接口继承BaseMapper<T>后,立即获得17个内置方法。这些方法的SQL由MyBatis-Plus在运行时动态生成------根据实体类的@TableName、@TableId、@TableField注解,自动拼装INSERT、SELECT、UPDATE、DELETE语句。
第二层:插件生态。 MyBatis-Plus通过InnerInterceptor插件机制提供了分页、乐观锁、防全表更新、多租户、数据权限等能力。这些插件插入MyBatis的执行链路中,在SQL执行前自动改写SQL。
第三层:Service层封装。 除了Mapper层,MyBatis-Plus还提供了IService<T>和ServiceImpl<M, T>,封装了saveBatch批量插入、saveOrUpdate存在则更新否则插入、getOne查询单条等业务级方法。
3.2 Spring Boot 4.x的版本适配要点
这是本节课最重要、也最容易踩坑的部分。MyBatis-Plus在Spring Boot 4.x中的适配经历了三个版本的关键演进:
| 版本 | 发布日期 | 关键变化 |
|---|---|---|
| 3.5.13 | 2025.08.29 | 首次新增Spring Boot 4支持,增加BOM管理 |
| 3.5.14 | 2025.08.29 | BOM增加对mybatis-plus-spring-boot4-starter的管理 |
| 3.5.15 | 2025.11.30 | 全面支持Spring Boot 4.0.0和Jackson 3.0 |
| 3.5.16 | 2026.01.11 | 升级mybatis-spring至4.0.0,修复Jackson 3 TypeHandler问题 |
依赖坐标的变化 是第一个关键点。在Spring Boot 3.x中,MyBatis-Plus的Starter坐标是mybatis-plus-boot-starter。在Spring Boot 4.x中,坐标变为mybatis-plus-spring-boot4-starter。如果沿用旧坐标,自动配置不会生效。
mybatis-spring版本对齐 是第二个关键点。MyBatis-Plus 3.5.15依赖的mybatis-spring版本是3.0.x,这个版本使用spring.factories声明自动配置。而Spring Boot 4.x使用AutoConfiguration.imports文件,两者不兼容。MyBatis-Plus 3.5.16升级了mybatis-spring到4.0.0,解决了这个问题。因此,强烈建议使用3.5.16或更高版本。 如果你使用3.5.15并遇到了DataSourceAutoConfiguration类找不到的报错,可以通过在pom.xml中排除mybatis-spring并手动引入4.0.0版本来解决。
mybatis-plus-jsqlparser的必要性 是第三个关键点。分页插件、乐观锁等插件需要解析和改写SQL,这依赖于JSQLParser库。MyBatis-Plus将这部分能力独立为mybatis-plus-jsqlparser模块,需要单独引入。如果你不使用插件,可以不引入;如果使用分页或乐观锁,必须引入。
3.3 BaseMapper的零SQL CRUD
BaseMapper<T>是MyBatis-Plus最核心的接口。它的17个方法可以分为四类:
插入类 :insert(T entity)------插入一条记录,支持自增主键回填。
查询类 :selectById(Serializable id)、selectBatchIds(Collection<? extends Serializable> idList)、selectByMap(Map<String, Object> columnMap)、selectOne(Wrapper<T> queryWrapper)、selectCount(Wrapper<T> queryWrapper)、selectList(Wrapper<T> queryWrapper)、selectMaps(Wrapper<T> queryWrapper)、selectObjs(Wrapper<T> queryWrapper)、selectPage(IPage<T> page, Wrapper<T> queryWrapper)。
更新类 :updateById(T entity)、update(T entity, Wrapper<T> updateWrapper)。
删除类 :deleteById(Serializable id)、deleteByMap(Map<String, Object> columnMap)、delete(Wrapper<T> queryWrapper)、deleteBatchIds(Collection<? extends Serializable> idList)。
SQL生成逻辑 :MyBatis-Plus在启动时扫描Mapper接口,为每个BaseMapper方法生成对应的MappedStatement。实体类通过@TableName指定表名,通过@TableId指定主键,通过@TableField指定列名。如果没有注解,MyBatis-Plus会按照"类名首字母小写""驼峰转下划线"的默认规则映射。
3.4 分页查询的插件机制
MyBatis-Plus的分页不是"在SQL中拼接LIMIT",而是通过插件拦截SQL改写实现的。
工作流程:
- 调用
mapper.selectPage(page, wrapper)方法 MybatisPlusInterceptor拦截执行,将PaginationInnerInterceptor插入执行链PaginationInnerInterceptor在SQL执行前拦截,将原始SQL改写为带LIMIT和OFFSET的分页SQL- 同时执行一次
COUNT查询,获取总记录数 - 将查询结果填充到
Page对象的records和total字段中
PaginationInnerInterceptor的关键属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
overflow |
boolean | false | 页码超出总页数时是否回到首页 |
maxLimit |
Long | 无 | 单页最大条数限制 |
dbType |
DbType | 自动检测 | 数据库类型,建议显式设置 |
建议 :显式设置dbType,如DbType.MYSQL,避免自动检测出错。同时,在多插件场景下,分页插件应放在插件链的最后 ,避免COUNT SQL被其他插件错误改写。
3.5 条件构造器的设计哲学
条件构造器(Wrapper)是MyBatis-Plus最优雅的设计之一。它用Java代码代替XML中的<if>标签,构建动态查询条件。
MyBatis-Plus提供了多种Wrapper:
| Wrapper类型 | 用途 | 字段引用方式 |
|---|---|---|
QueryWrapper<T> |
查询 | 字符串列名 |
LambdaQueryWrapper<T> |
查询 | Lambda方法引用(推荐) |
UpdateWrapper<T> |
更新 | 字符串列名 |
LambdaUpdateWrapper<T> |
更新 | Lambda方法引用(推荐) |
为什么推荐LambdaQueryWrapper? 使用QueryWrapper时,字段名是字符串,如eq("username", "alice")。如果字段名拼写错误,编译期不会报错,运行时才会抛出SQLSyntaxErrorException。而LambdaQueryWrapper使用User::getUsername引用字段,编译期就能检查出错误。
链式调用的核心方法:
java
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
.eq(User::getStatus, 1)
.like(User::getUsername, "ali")
.between(User::getAge, 18, 30)
.orderByDesc(User::getCreatedAt);
动态条件拼接 :每个方法都有一个带condition参数的重载。当condition为false时,该条件不参与SQL构建。这是动态查询的利器:
java
wrapper.eq(StringUtils.isNotBlank(username), User::getUsername, username)
.ge(minAge != null, User::getAge, minAge)
.le(maxAge != null, User::getAge, maxAge);
3.6 逻辑删除的实现原理
逻辑删除是生产系统中最常用的数据管理策略------不物理删除数据,而是通过一个deleted字段标记"已删除"。
MyBatis-Plus的逻辑删除工作方式:
- 插入:逻辑删除字段的值不受限制(建议数据库设置默认值0)
- 查找 :自动追加
WHERE deleted = 0条件,过滤掉已删除记录 - 更新 :自动追加
WHERE deleted = 0条件,防止更新到已删除数据 - 删除 :将
DELETE操作转换为UPDATE ... SET deleted = 1操作
两种配置方式:
方式一:全局配置 (在application.yml中):
yaml
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 全局逻辑删除字段名
logic-delete-value: 1 # 已删除值
logic-not-delete-value: 0 # 未删除值
方式二:注解配置(在实体类字段上):
java
@TableLogic
private Integer deleted;
优先级 :注解配置优先于全局配置。如果某个实体类需要不同的删除值,可以在@TableLogic(value = "0", delval = "1")中单独指定。
重要提醒 :如果使用全局配置,实体类中必须有对应的字段,且字段名与logic-delete-field配置一致。否则逻辑删除不会生效。
3.7 乐观锁与自动填充
乐观锁 用于解决并发更新冲突。MyBatis-Plus通过@Version注解和OptimisticLockerInnerInterceptor实现。
配置步骤:
- 在实体类中添加版本号字段,标注
@Version - 在插件配置中添加
OptimisticLockerInnerInterceptor - 更新时,
UPDATE ... SET ... version = version + 1 WHERE id = ? AND version = ?
自动填充 用于在插入和更新时自动维护createTime、updateTime、createBy、updateBy等审计字段。
配置步骤:
- 在实体类字段上标注
@TableField(fill = FieldFill.INSERT)或FieldFill.INSERT_UPDATE - 实现
MetaObjectHandler接口,在insertFill和updateFill方法中设置字段值 - 将
MetaObjectHandler实现注册为Spring Bean
4. 环境搭建
4.1 项目创建
项目结构
mybatis-plus-demo/
├── pom.xml
└── src/main/
├── java/com/example/mpdemo/
│ ├── MpDemoApplication.java
│ ├── entity/
│ │ └── Product.java
│ ├── mapper/
│ │ └── ProductMapper.java
│ ├── service/
│ │ ├── ProductService.java
│ │ └── impl/ProductServiceImpl.java
│ ├── config/
│ │ ├── MybatisPlusConfig.java
│ │ └── MetaObjectHandlerConfig.java
│ ├── controller/
│ │ └── ProductController.java
│ └── dto/
│ └── ProductQuery.java
└── resources/
├── application.yml
└── mapper/
└── ProductMapper.xml
4.2 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.8</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>mybatis-plus-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>mybatis-plus-demo</name>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!--
Spring Boot 4.x 专用 Starter
注意:不是 mybatis-plus-boot-starter(那是 3.x 的坐标)
-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!--
分页插件和 SQL 解析依赖
3.5.9+ 版本将 JSQLParser 独立,使用分页/乐观锁必须引入
-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
关键说明 :mybatis-plus-jsqlparser是分页和乐观锁插件的依赖。MyBatis-Plus 3.5.9版本之后,JSQLParser被拆分为独立模块。如果不引入这个依赖,分页插件在运行时可能报ClassNotFoundException。
4.3 数据库准备
sql
CREATE DATABASE IF NOT EXISTS mp_demo
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE mp_demo;
CREATE TABLE product (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
name VARCHAR(100) NOT NULL COMMENT '商品名称',
category VARCHAR(50) NOT NULL COMMENT '商品分类',
price DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '价格',
stock INT NOT NULL DEFAULT 0 COMMENT '库存',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-上架,0-下架',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_category (category),
INDEX idx_deleted (deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
4.4 application.yml
yaml
spring:
application:
name: mybatis-plus-demo
datasource:
url: jdbc:mysql://localhost:3306/mp_demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
mybatis-plus:
# XML 映射文件位置
mapper-locations: classpath*:/mapper/**/*.xml
# 实体类包路径
type-aliases-package: com.example.mpdemo.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
banner: false
db-config:
id-type: auto
# 逻辑删除全局配置
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
server:
port: 8080
logging:
level:
com.example.mpdemo: DEBUG
关键配置说明:
logic-delete-field: deleted:全局指定逻辑删除字段名。实体类中必须有同名字段。map-underscore-to-camel-case: true:自动将数据库的created_at映射到Java的createdAt。log-impl: StdOutImpl:打印SQL日志,便于调试。
5. 代码实战
5.1 实体类
Product.java:
java
package com.example.mpdemo.entity;
import com.baomidou.mybatisplus.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品实体类。
*
* MyBatis-Plus 注解说明:
* - @TableName:指定表名,默认为类名首字母小写
* - @TableId:指定主键策略,IdType.AUTO 表示数据库自增
* - @TableField:指定字段映射和填充策略
* - @TableLogic:逻辑删除标记(全局配置了 logic-delete-field 后可省略)
* - @Version:乐观锁版本号
*/
@TableName("product")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String category;
private BigDecimal price;
private Integer stock;
/** 状态:1-上架,0-下架 */
private Integer status;
/**
* 逻辑删除标记。
* 由于全局配置了 logic-delete-field=deleted,
* 这里的 @TableLogic 可以省略,但显式标注更清晰。
*/
@TableLogic
private Integer deleted;
/** 乐观锁版本号 */
@Version
private Integer version;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
// ===== Getter / Setter =====
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public Integer getStock() { return stock; }
public void setStock(Integer stock) { this.stock = stock; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
public Integer getDeleted() { return deleted; }
public void setDeleted(Integer deleted) { this.deleted = deleted; }
public Integer getVersion() { return version; }
public void setVersion(Integer version) { this.version = version; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
5.2 Mapper接口
ProductMapper.java:
java
package com.example.mpdemo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mpdemo.entity.Product;
import org.apache.ibatis.annotations.Mapper;
/**
* 商品 Mapper。
*
* 继承 BaseMapper<Product> 后,自动获得 17 个 CRUD 方法,
* 无需编写任何 SQL 或 XML。
*/
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
// 无需定义任何方法------BaseMapper 已提供全部基础 CRUD
}
5.3 Service层
ProductService.java:
java
package com.example.mpdemo.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.mpdemo.dto.ProductQuery;
import com.example.mpdemo.entity.Product;
public interface ProductService extends IService<Product> {
/** 分页 + 条件查询 */
IPage<Product> pageQuery(ProductQuery query);
/** 逻辑删除商品 */
boolean logicDelete(Long id);
/** 批量上架/下架 */
boolean updateStatus(Long id, Integer status);
}
ProductServiceImpl.java:
java
package com.example.mpdemo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.mpdemo.dto.ProductQuery;
import com.example.mpdemo.entity.Product;
import com.example.mpdemo.mapper.ProductMapper;
import com.example.mpdemo.service.ProductService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* 商品 Service 实现。
*
* 继承 ServiceImpl<ProductMapper, Product> 后,
* 自动获得 saveBatch、saveOrUpdate、getOne 等业务级方法。
*/
@Service
public class ProductServiceImpl
extends ServiceImpl<ProductMapper, Product>
implements ProductService {
/**
* 分页 + 动态条件查询。
*
* 使用 LambdaQueryWrapper 构建条件,利用 condition 参数实现动态拼接。
*/
@Override
public IPage<Product> pageQuery(ProductQuery query) {
// 1. 构建分页对象
Page<Product> page = new Page<>(query.getPage(), query.getSize());
// 2. 构建查询条件(LambdaQueryWrapper 编译期检查字段名)
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
// 动态条件:仅当参数不为空时拼接
wrapper.like(StringUtils.hasText(query.getName()),
Product::getName, query.getName())
.eq(StringUtils.hasText(query.getCategory()),
Product::getCategory, query.getCategory())
.ge(query.getMinPrice() != null,
Product::getPrice, query.getMinPrice())
.le(query.getMaxPrice() != null,
Product::getPrice, query.getMaxPrice())
.eq(query.getStatus() != null,
Product::getStatus, query.getStatus())
.orderByDesc(Product::getCreatedAt);
// 3. 执行分页查询
return this.page(page, wrapper);
}
/**
* 逻辑删除商品。
*
* 调用 removeById 后,MyBatis-Plus 自动将
* DELETE 转换为 UPDATE product SET deleted=1 WHERE id=? AND deleted=0
*/
@Override
@Transactional
public boolean logicDelete(Long id) {
return this.removeById(id);
}
@Override
@Transactional
public boolean updateStatus(Long id, Integer status) {
Product product = new Product();
product.setId(id);
product.setStatus(status);
return this.updateById(product);
}
}
5.4 插件配置
MybatisPlusConfig.java:
java
package com.example.mpdemo.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 插件配置。
*
* 注意插件顺序:
* 1. 乐观锁插件
* 2. 防全表更新插件
* 3. 分页插件(必须放在最后)
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 2. 防止全表更新与删除
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
// 3. 分页插件(必须放在最后)
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor();
pagination.setDbType(DbType.MYSQL); // 显式设置数据库类型
pagination.setMaxLimit(100L); // 单页最大100条
pagination.setOverflow(false); // 页码超出不回到首页
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
分页插件必须放在插件链的最后 。因为分页插件会改写SQL,在SQL末尾追加LIMIT。如果其他插件在分页插件之后执行,可能会破坏分页SQL的结构。
MetaObjectHandlerConfig.java(自动填充):
java
package com.example.mpdemo.config;
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)
* 或 FieldFill.INSERT_UPDATE 时,MyBatis-Plus 会在
* 插入或更新时调用此处理器填充字段值。
*/
@Component
public class MetaObjectHandlerConfig implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createdAt",
LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updatedAt",
LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updatedAt",
LocalDateTime.class, LocalDateTime.now());
}
}
5.5 DTO与Controller
ProductQuery.java:
java
package com.example.mpdemo.dto;
/**
* 商品查询条件 DTO。
*/
public class ProductQuery {
private Integer page = 1;
private Integer size = 10;
private String name;
private String category;
private java.math.BigDecimal minPrice;
private java.math.BigDecimal maxPrice;
private Integer status;
// Getter / Setter
public Integer getPage() { return page; }
public void setPage(Integer page) { this.page = page; }
public Integer getSize() { return size; }
public void setSize(Integer size) { this.size = size; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public java.math.BigDecimal getMinPrice() { return minPrice; }
public void setMinPrice(java.math.BigDecimal minPrice) { this.minPrice = minPrice; }
public java.math.BigDecimal getMaxPrice() { return maxPrice; }
public void setMaxPrice(java.math.BigDecimal maxPrice) { this.maxPrice = maxPrice; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
}
ProductController.java:
java
package com.example.mpdemo.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.example.mpdemo.dto.ProductQuery;
import com.example.mpdemo.entity.Product;
import com.example.mpdemo.service.ProductService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 商品管理接口。
*/
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
/**
* 分页 + 条件查询。
* GET /api/products/page?page=1&size=10&category=electronics&minPrice=100
*/
@GetMapping("/page")
public IPage<Product> page(ProductQuery query) {
return productService.pageQuery(query);
}
/**
* 新增商品(测试自动填充)。
* POST /api/products
*/
@PostMapping
public Product create(@RequestBody Product product) {
productService.save(product);
return productService.getById(product.getId());
}
/**
* 更新商品(测试乐观锁)。
* PUT /api/products
*/
@PutMapping
public Map<String, Object> update(@RequestBody Product product) {
boolean success = productService.updateById(product);
return Map.of(
"success", success,
"message", success ? "更新成功" : "更新失败(版本号冲突,请重试)"
);
}
/**
* 逻辑删除。
* DELETE /api/products/{id}
*/
@DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable Long id) {
boolean success = productService.logicDelete(id);
return Map.of("success", success);
}
/**
* 上架/下架。
* PATCH /api/products/{id}/status?status=0
*/
@PatchMapping("/{id}/status")
public Map<String, Object> updateStatus(
@PathVariable Long id,
@RequestParam Integer status) {
boolean success = productService.updateStatus(id, status);
return Map.of("success", success, "status", status);
}
}
5.6 启动类
java
package com.example.mpdemo;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.example.mpdemo.mapper")
public class MpDemoApplication {
public static void main(String[] args) {
SpringApplication.run(MpDemoApplication.class, args);
}
}
5.7 运行与验证
编译并启动
bash
cd mybatis-plus-demo
mvn clean install
mvn spring-boot:run
测试零SQL CRUD
bash
# 新增商品(观察自动填充的 created_at 和 updated_at)
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"iPhone 17","category":"electronics","price":8999.00,"stock":100,"status":1}'
# 逻辑删除(观察 SQL 日志:DELETE 被转换为 UPDATE)
curl -X DELETE http://localhost:8080/api/products/1
# 分页查询(观察 SQL 日志中的 LIMIT)
curl "http://localhost:8080/api/products/page?page=1&size=5&category=electronics&minPrice=1000"
# 输出:{"records":[...],"total":1,"size":5,"current":1,"pages":1}
验证逻辑删除 。删除一条记录后,再次查询该记录,会发现selectById返回null------因为MyBatis-Plus自动追加了WHERE deleted = 0条件。但数据库中该记录仍然存在,只是deleted字段变为了1。
验证乐观锁。使用两个并发请求同时更新同一条记录,第二个请求会失败,因为版本号已经变化:
bash
# 请求1:更新版本号=0的记录
curl -X PUT http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"id":1,"name":"iPhone 17 Pro","price":9999,"version":0}'
# 输出:{"success":true}
# 请求2:同样更新版本号=0的记录(版本号已被请求1改为1)
curl -X PUT http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"id":1,"name":"iPhone 17 Pro Max","price":10999,"version":0}'
# 输出:{"success":false,"message":"更新失败(版本号冲突,请重试)"}
6. 底层源码剖析
6.1 BaseMapper方法SQL的生成机制
当Spring容器启动时,MybatisPlusAutoConfiguration扫描所有BaseMapper的子接口,为每个接口创建JDK动态代理。代理对象的InvocationHandler是MybatisMapperProxy,它根据方法名查找对应的MappedStatement。
MappedStatement的SQL在MybatisConfiguration初始化时生成。MyBatis-Plus的AbstractMethod子类(如Insert、SelectById、UpdateById、DeleteById)负责构建SQL模板:
java
// 源码路径:com.baomidou.mybatisplus.core.injector.methods.Insert
public class Insert extends AbstractMethod {
@Override
public MappedStatement injectMappedStatement(
Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
// 构建 INSERT SQL
String sql = String.format(
"<script>INSERT INTO %s %s VALUES %s</script>",
tableInfo.getTableName(),
sqlInsertColumns(),
sqlInsertValues()
);
// 创建 MappedStatement
return addInsertMappedStatement(mapperClass, modelClass, sql, ...);
}
}
关键点 :SQL模板中的表名和列名来自TableInfo对象。TableInfo在应用启动时通过扫描实体类的@TableName、@TableId、@TableField注解构建。
6.2 分页插件的SQL改写机制
PaginationInnerInterceptor实现了InnerInterceptor接口,在SQL执行前拦截并改写:
java
// 源码路径:com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor
@Override
public boolean willDoQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) {
// 1. 判断是否需要分页(parameter 中是否包含 IPage)
IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
if (page == null) return true;
// 2. 执行 COUNT 查询获取总记录数
long total = countQuery(executor, ms, parameter, boundSql);
page.setTotal(total);
// 3. 如果当前页超出总页数,直接返回空结果
if (page.getCurrent() > page.getPages()) {
page.setRecords(Collections.emptyList());
return false;
}
return true;
}
@Override
public boolean willDoUpdate(Executor executor, MappedStatement ms,
Object parameter) {
// 4. 改写 SQL,追加 LIMIT
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
String paginatedSql = dialect.buildPaginationSql(
originalSql, page.offset(), page.getSize());
// 5. 替换 SQL
PluginUtils.mpBoundSql(boundSql).sql(paginatedSql);
return true;
}
关键点 :分页插件先执行COUNT查询获取总数,然后改写原始SQL追加LIMIT,再将结果填充到Page对象中。
6.3 逻辑删除的SQL注入机制
逻辑删除的SQL改写由LogicDeleteInterceptor(或LogicSqlInjector)完成。它在MyBatis的SQL解析阶段介入:
java
// 概念性源码结构
public class LogicDeleteInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) {
// 1. 获取实体的 TableInfo
TableInfo tableInfo = TableInfoHelper.getTableInfo(ms.getParameterType());
if (tableInfo != null && tableInfo.isWithLogicDelete()) {
// 2. 在 WHERE 条件后追加 deleted = 0
String sql = boundSql.getSql();
String newSql = sql + " AND " + tableInfo.getLogicDeleteSql(true, false);
PluginUtils.mpBoundSql(boundSql).sql(newSql);
}
}
}
关键点 :逻辑删除的DELETE操作在SQL解析阶段被转换为UPDATE。MyBatis-Plus的DeleteById方法在生成SQL时,如果检测到实体配置了逻辑删除,会生成UPDATE ... SET deleted=1 WHERE id=? AND deleted=0而非DELETE FROM ...。
6.4 乐观锁的版本号比较机制
OptimisticLockerInnerInterceptor在更新前检查版本号:
java
// 源码路径:com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor
@Override
public boolean willDoUpdate(Executor executor, MappedStatement ms,
Object parameter) {
// 1. 获取参数中的实体对象
Object param = ParameterUtils.findEntity(parameter);
if (param == null) return true;
// 2. 获取版本号字段
TableInfo tableInfo = TableInfoHelper.getTableInfo(param.getClass());
String versionColumn = tableInfo.getVersionColumn();
Object versionValue = tableInfo.getVersionField().get(param);
// 3. 在 WHERE 条件中追加 version = ?
// 4. 在 SET 中追加 version = version + 1
return true;
}
关键点 :乐观锁插件在UPDATE语句的WHERE条件中追加version = 当前版本号,在SET中追加version = version + 1。如果并发更新,第二个请求的version值不匹配,UPDATE影响行数为0,MyBatis-Plus返回false。
7. 常见坑点与生产问题排查
坑点1:使用旧的mybatis-plus-boot-starter坐标
报错现象:Spring Boot 4.x项目中MyBatis-Plus自动配置不生效,Mapper无法注入。
原因 :mybatis-plus-boot-starter是Spring Boot 3.x的坐标,在4.x中需要使用mybatis-plus-spring-boot4-starter。
解决方案 :替换依赖坐标为com.baomidou:mybatis-plus-spring-boot4-starter:3.5.16。
坑点2:mybatis-spring版本冲突导致DataSourceAutoConfiguration找不到
报错现象 :启动时报ClassNotFoundException: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration。
原因 :MyBatis-Plus 3.5.15依赖的mybatis-spring 3.0.x与Spring Boot 4.x不兼容。
解决方案 :升级到MyBatis-Plus 3.5.16+(已升级mybatis-spring到4.0.0)。如果无法升级,在pom.xml中排除旧版mybatis-spring并手动引入4.0.0。
坑点3:分页插件未配置导致分页失效
报错现象 :调用selectPage()返回全部数据,没有分页效果。
原因 :没有注册MybatisPlusInterceptor Bean,或没有添加PaginationInnerInterceptor。
解决方案 :创建MybatisPlusConfig配置类,注册MybatisPlusInterceptor并添加分页插件。
坑点4:分页插件未设置dbType导致SQL错误
报错现象:分页SQL报语法错误,或COUNT查询结果不准确。
原因 :PaginationInnerInterceptor未设置dbType,自动检测在某些场景下可能出错。
解决方案 :显式设置pagination.setDbType(DbType.MYSQL)。
坑点5:逻辑删除配置了但未生效
报错现象 :调用removeById后数据被物理删除,deleted字段没有变化。
原因 :全局配置的logic-delete-field与实体类中的字段名不一致,或实体类中没有对应的字段。
解决方案 :确保全局配置的字段名与实体类字段名完全一致。如果使用@TableLogic注解,检查注解是否加在了正确的字段上。
坑点6:自动填充不生效
报错现象 :插入数据后createdAt和updatedAt为null。
原因 :MetaObjectHandler实现类没有被注册为Spring Bean,或实体类字段没有标注@TableField(fill = ...)。
解决方案 :确保MetaObjectHandler实现类标注了@Component,实体类字段标注了@TableField(fill = FieldFill.INSERT)或FieldFill.INSERT_UPDATE。
坑点7:乐观锁更新返回false但数据库数据未变
报错现象 :updateById返回false,但查询数据库发现数据已经更新。
原因 :版本号字段在更新前被其他操作修改了,或者实体对象的version值没有从数据库查询获取。
解决方案 :更新前先selectById查询当前记录,获取最新的version值,再执行更新。避免手动构造实体对象时遗漏version字段。
坑点8:分页插件放在插件链中间导致COUNT SQL错误
报错现象:分页查询的COUNT结果不准确,或SQL报语法错误。
原因:分页插件不是插件链中的最后一个,其他插件可能在分页SQL的基础上再次改写。
解决方案 :将PaginationInnerInterceptor放在MybatisPlusInterceptor的最后添加。
8. 本章总结
本节课围绕MyBatis-Plus在Spring Boot 4.x中的整合与实战,从版本适配、BaseMapper零SQL CRUD、分页查询、条件构造器到逻辑删除和插件机制,构建了完整的MyBatis-Plus知识体系。
版本适配是前提 :Spring Boot 4.x必须使用mybatis-plus-spring-boot4-starter,版本要求3.5.15+,推荐3.5.16+。mybatis-spring必须对齐到4.0.0。分页和乐观锁插件需要额外引入mybatis-plus-jsqlparser。
BaseMapper零SQL CRUD :Mapper接口继承BaseMapper<T>后自动获得17个CRUD方法,SQL由MyBatis-Plus根据实体类的@TableName、@TableId、@TableField注解动态生成。
分页查询 :通过MybatisPlusInterceptor + PaginationInnerInterceptor实现。插件先执行COUNT查询,再改写SQL追加LIMIT。分页插件必须放在插件链最后,并显式设置dbType。
条件构造器 :推荐使用LambdaQueryWrapper,编译期检查字段名。利用condition参数实现动态条件拼接。
逻辑删除 :通过全局配置或@TableLogic注解启用。删除操作被转换为UPDATE,查询和更新自动追加WHERE deleted = 0条件。
生产常用特性 :乐观锁(@Version + OptimisticLockerInnerInterceptor)解决并发更新冲突;自动填充(@TableField(fill = ...) + MetaObjectHandler)维护审计字段;BlockAttackInnerInterceptor防止全表更新。
升级踩坑 :mybatis-spring版本冲突是最高频的问题,升级到3.5.16+或手动对齐版本即可解决。
9. 课后作业
基础题1
在本节课的mybatis-plus-demo项目中,完成以下任务:
- 在
ProductMapper中添加一个自定义方法selectByCategory(@Param("category") String category),使用@Select注解编写SQL - 在
ProductService中添加方法getByCategory(String category),调用自定义Mapper方法 - 在
ProductController中添加GET /api/products/category/{category}接口 - 测试:插入几条不同分类的商品,分别查询验证
提示 :MyBatis-Plus的BaseMapper和原生MyBatis的@Select注解可以共存,不会冲突。
基础题2
使用LambdaQueryWrapper实现以下查询,并在ProductServiceImpl中添加对应方法:
findByPriceRange(BigDecimal min, BigDecimal max):查询价格在指定范围内的商品,按价格升序排列findLowStock(int threshold):查询库存低于指定阈值的商品countByCategory(String category):统计指定分类的商品数量
在Controller中添加对应的接口,使用curl测试并观察控制台输出的SQL日志。
综合实战题
构建一个完整的MyBatis-Plus实战项目,要求:
-
数据库设计 :创建
orders表,包含字段:id、order_no(唯一)、user_id、total_amount、status(0-待支付,1-已支付,2-已发货,3-已完成,4-已取消)、deleted、version、created_at、updated_at -
实体类:使用MyBatis-Plus注解完整标注,包括逻辑删除、乐观锁、自动填充
-
Mapper :
OrderMapper extends BaseMapper<Order> -
Service:
pageQuery(OrderQuery query):分页 + 动态条件查询(支持orderNo模糊、status精确、金额区间、时间范围)cancelOrder(Long id):取消订单(先查询状态是否为待支付,是则更新为已取消)payOrder(Long id):支付订单(使用乐观锁防止重复支付)
-
Controller:提供完整的REST API
-
验证要求:
- 使用curl测试所有接口
- 观察逻辑删除的SQL转换
- 测试乐观锁的并发更新场景
- 验证自动填充的
createdAt和updatedAt - 使用分页查询测试多个条件的组合
扩展挑战 :实现一个自定义的InnerInterceptor,在SQL执行前记录慢SQL(执行时间超过500ms的语句),将SQL和耗时打印到日志中。解释InnerInterceptor的beforeQuery和willDoQuery方法的区别,以及它们分别在SQL执行的哪个阶段被调用。