Skip to content

MyBatis-Plus 通用 CRUD 与条件构造器 ​

S3-024建议学进阶约 60 分钟JDK 21企业开发基础 · 数据持久化

1. 本节定位 ​

《MyBatis 数据持久化》 用 MyBatis 写了 8 个方法,每个都配一段 SQL。回头看,其中有 5 个是单表的标准动作:

text
findAll / findById / insert / updatePrice / deleteById

这五件事,每个实体都要写一遍——一个 20 张表的系统,就是 100 个方法 + 100 段几乎雷同的 SQL ✗

本节的主角 MyBatis-Plus(MP) 就是来消掉这部分重复的:继承一个接口,单表 CRUD 一行 SQL 都不用写。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 BaseMapper 提供了哪些通用能力;
  • 用 @TableName / @TableId / @TableField / @TableLogic 做实体与表的映射;
  • 用 LambdaQueryWrapper 写类型安全的条件查询;
  • 说出"分页为什么必须注册拦截器",并说出没注册会发生什么;
  • 说出 @TableLogic 帮你省掉了什么(与 《MyBatis 数据持久化》 手写 deleted = 0 对比);
  • 说出 MyBatis-Plus 的边界——哪些场景仍应该写 XML。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看规模。假设一个系统有 20 张表,每张表都要:

动作手写 MyBatis 的工作量
按主键查1 个方法 + 1 段 SQL
列表查询1 + 1
新增1 + 1
按主键更新1 + 1
按主键删除1 + 1
小计5 个方法 + 5 段 SQL × 20 张表 = 100 + 100

而这些 SQL 的本质是"同一段模板里换表名/列名"——这正是可以自动化的信号 ✓

MyBatis-Plus 的做法:

java
@Mapper
public interface ProductPlusMapper extends BaseMapper<Product> {
    // 就这一行
}

然后:

java
plusMapper.selectById(1L);
plusMapper.selectList(null);
plusMapper.insert(product);
plusMapper.updateById(product);
plusMapper.deleteById(3L);

实测输出(本节代码,注意 MyBatis-Plus 生成的 SQL):

text
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE id=? AND deleted=0
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE deleted=0
==>  Preparing: INSERT INTO product ( name, price_in_cents, stock ) VALUES ( ?, ?, ? )
==>  Preparing: UPDATE product SET name=?, price_in_cents=?, stock=? WHERE id=? AND deleted=0
==>  Preparing: UPDATE product SET deleted=1 WHERE id=? AND deleted=0

注意三件事:

观察含义
SQL 是"生成"的只写了接口,SQL 由框架按实体注解拼出来
每个查询都自动带 deleted=0@TableLogic 生效了
deleteById 发的是 UPDATE逻辑删除——不是真删

5. 核心概念 ​

5.1 BaseMapper 的通用能力 ​

java
public interface BaseMapper<T> {
    // 增
    int insert(T entity);

    // 删
    int deleteById(Serializable id);
    int delete(Wrapper<T> wrapper);
    int deleteBatchIds(Collection<?> idList);

    // 改
    int updateById(T entity);
    int update(T entity, Wrapper<T> updateWrapper);

    // 查(单个)
    T selectById(Serializable id);
    T selectOne(Wrapper<T> queryWrapper);
    List<T> selectBatchIds(Collection<? extends Serializable> idList);

    // 查(列表与统计)
    List<T> selectList(Wrapper<T> queryWrapper);
    List<Map<String, Object>> selectMaps(Wrapper<T> queryWrapper);
    List<Object> selectObjs(Wrapper<T> queryWrapper);
    Long selectCount(Wrapper<T> queryWrapper);

    // 分页
    <P extends IPage<T>> P selectPage(P page, Wrapper<T> queryWrapper);
    <P extends IPage<Map<String, Object>>> P selectMapsPage(P page, Wrapper<T> queryWrapper);
}

记住三类:

类别方法前缀数量
增insert1
删delete*3
改update*2
查select*约 10

⚠️ 第一个要警惕的方法:selectList(null) —— 参数为 null 表示"无条件"=全表 ✓

场景后果
表里有 10 万行一次查 10 万行 → 内存暴涨、接口超时 ✗
生产环境一次误调用可能拖垮服务

实践规则:

selectList 一定要带条件或 last("LIMIT ...");确实要全表时,用分页接口或专门的导出任务。

5.2 实体注解 ​

java
@TableName("product")                                    // 表名(不写则按类名推断)
public class Product {
    @TableId(type = IdType.AUTO)                         // 主键策略
    private Long id;

    @TableField("price_in_cents")                        // 列名(不写则按驼峰转下划线)
    private Integer priceInCents;

    @TableField(exist = false)                           // 不是数据库字段(VO 字段常见)
    private String categoryName;

    @TableLogic                                          // 逻辑删除标记
    private Integer deleted;

    @TableField(fill = FieldFill.INSERT)                 // 插入时自动填充
    private LocalDateTime createdAt;
}
注解作用不写会怎样
@TableName指定表名按类名推断(Product → product,某些命名规则下会错)
@TableId主键列与策略按 id 字段推断
@TableField列名 / 是否存在的字段按驼峰转下划线推断
@TableLogic逻辑删除标记删除会变成真删 ✗

IdType 的常用值:

值策略适用
AUTO数据库自增MySQL 的 AUTO_INCREMENT ✓
ASSIGN_ID雪花算法生成 Long分布式场景(默认值)
INPUT自己填手动指定 id
NONE跟随全局配置—

⚠️ 一个常见坑:实体用 Long id 却用了默认的 ASSIGN_ID——插入时 MP 会生成一个雪花 ID 填进去,与你数据库的自增列冲突(或让自增失效)✗

修复:自增表要显式写 @TableId(type = IdType.AUTO) ✓(或在全局配置里设 id-type: auto,本项目的 application.yml 就是这么做的)

5.3 LambdaQueryWrapper:类型安全的条件构造 ​

为什么需要它——先看不类型安全的版本:

java
QueryWrapper<Product> wrapper = new QueryWrapper<>();
wrapper.ge("price_in_cents", 10_000);        // ✗ 列名是字符串,写错编译期发现不了
wrapper.orderByDesc("price_in_cents");

列名写错(如 price_incents)→ 运行时报 SQL 错误 ✗

LambdaQueryWrapper 的写法:

java
LambdaQueryWrapper<Product> wrapper = Wrappers.<Product>lambdaQuery()
        .ge(Product::getPriceInCents, 10_000)
        .lt(Product::getPriceInCents, 100_000)
        .like(Product::getName, "架")
        .orderByDesc(Product::getPriceInCents);

收益:

收益说明
编译期检查Product::getPriceInCents 方法引用写错就编译不过 ✓
IDE 支持能跳转、能重构(改字段名会自动跟着改)
列名由框架推导不用手写字符串

实测生成的 SQL:

text
==>  Preparing: SELECT ... FROM product WHERE deleted=0
    AND (price_in_cents >= ? AND price_in_cents < ? AND name LIKE ?)
    ORDER BY price_in_cents DESC

注意三件事:

观察说明
条件被包在括号里AND (...)——多个条件自动成组,不会与外层条件混淆 ✓
驼峰 → 下划线自动转换priceInCents 映射成 price_in_cents
deleted=0 恒在最前逻辑删除条件自动加上 ✓

常用方法速查:

方法SQL 片段例子
eq / ne= ? / <> ?.eq(Product::getStock, 0)
gt / ge / lt / le> ? / >= ? / < ? / <= ?.ge(Product::getPriceInCents, 10_000)
like / likeRightLIKE '%?%' / LIKE '?%'前缀匹配能走索引(《表设计与约束》)
in / notInIN (...).in(Product::getId, List.of(1L, 3L))
isNull / isNotNullIS NULL / IS NOT NULL.isNull(Product::getDeleted)
betweenBETWEEN ? AND ?.between(Product::getPriceInCents, 1_000, 9_999)
orderByAsc / orderByDescORDER BY ....orderByDesc(Product::getPriceInCents)
groupBy / having分组.groupBy(Product::getStock)
or / and连接条件.eq(...).or().eq(...)
last直接拼 SQL 片段.last("LIMIT 2") ⚠️

⚠️ 关于 last():它把字符串直接拼到 SQL 尾部——和 《MyBatis 数据持久化》 的 ${} 一样有注入风险 ✗

java
// ✗ 绝不能把用户输入放进 last()
wrapper.last("ORDER BY " + userInput);

// ✓ 固定字符串可以用(本项目的用法)
wrapper.orderByAsc(Product::getId).last("LIMIT 2");

一条判断规则:last() 里只能出现"代码里写死的字符串",绝不能拼接用户输入 ✓

条件为 null 时的处理(动态条件的日常需求):

java
// 写法一:手动判断(啰嗦)
if (keyword != null) wrapper.like(Product::getName, keyword);

// 写法二:用 condition 参数(推荐)
wrapper.like(keyword != null && !keyword.isBlank(), Product::getName, keyword);

condition 参数的意义:第一个参数为 false 时,这个条件被忽略 ✓ —— 这是 MP 版的"动态 SQL" ✓

5.4 分页:必须注册拦截器 ​

很多人踩的第一个坑:写了 selectPage 却发现"没分页" ✗

java
Page<Product> page = plusMapper.selectPage(Page.of(1, 2), wrapper);

为什么:分页需要改写 SQL(加 LIMIT)——而改写 SQL 需要一个拦截器 ✓

注册方式:

java
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // DbType 要与实际数据库一致;换到 MySQL 改成 DbType.MYSQL
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
        return interceptor;
    }
}

不注册会怎样:selectPage 把全表查回来,再在内存里截取 ✗

后果说明
性能灾难查 10 万行只为返回 10 行
total 不准内存分页的 total 是"当前页的条数",不是总数
静默不报错——所以这个问题往往上线后才被发现 ✗

注册之后,MP 发了两条** SQL**(实测):

text
==>  Preparing: SELECT COUNT(*) AS total FROM product WHERE deleted = 0
<==      Total: 1
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE deleted=0 ORDER BY id ASC LIMIT ?
<==      Total: 2
  current=1, size=2, total=5, pages=3

两条 SQL 的分工:

SQL作用
COUNT(*)算总条数(用于 total / pages)
带 LIMIT 的查询取当前页的数据

这才是真分页 ✓

Page 对象的常用属性:

属性含义
getRecords()当前页数据
getTotal()总条数
getCurrent() / getSize()当前页 / 每页大小
getPages()总页数

⚠️ COUNT(*) 的代价:大表上 COUNT(*) 本身可能很慢(《执行计划与慢查询定位》 会讲优化方式)✓ 如果前端不需要总数(如"滚动加载"),可以用 Page.of(current, size, false) 关掉 count 查询 ✓

5.5 逻辑删除的自动化 ​

回顾 《MyBatis 数据持久化》 的痛苦:每个查询都要手写 WHERE deleted = 0——漏一个就出错,而且不报错 ✗

MP 的做法:实体上标一个 @TableLogic ✓

java
@TableLogic
private Integer deleted;
yaml
mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted      # 全局指定逻辑删除字段
      logic-delete-value: 1            # 删除后的值
      logic-not-delete-value: 0        # 未删除的值

然后它会自动做四件事:

操作你写的代码实际发出的 SQL(实测)
查询selectById(3L)SELECT ... WHERE id=? AND deleted=0
列表selectList(null)SELECT ... WHERE deleted=0
删除deleteById(3L)UPDATE product SET deleted=1 WHERE id=? AND deleted=0
统计selectCount(null)SELECT COUNT( * ) AS total FROM product WHERE deleted=0

实测输出:

text
--- 5. 逻辑删除:deleteById 发的是 UPDATE ---
==>  Preparing: UPDATE product SET deleted=1 WHERE id=? AND deleted=0
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE id=? AND deleted=0
<==      Total: 0
  删除 id=3 后再查 = null(null)
  当前总数 = 4(比之前少 1)

"删除了但数据还在"——这就是逻辑删除,而 MP 让这件事从"每处都要记得"变成"配一次就够" ✓

逻辑删除的三个实践要点:

要点说明
唯一索引要带上删除标记否则"删掉的记录"会占着唯一索引,导致新记录插不进去 ✗(如唯一索引建成 (username, deleted))
查询要走索引deleted 字段通常选择性很低,别单独给它建索引;放进复合索引的尾部即可
物理删除要留出口数据清理(如"删除 3 年前的已删记录")需要绕过逻辑删除——用原生 SQL 或专门的清理任务

5.6 MyBatis-Plus 不是 MyBatis 的替代品 ​

这是本节最需要建立的认知——两者是"并存"关系:

text
MyBatis-Plus = MyBatis + 通用 CRUD + 条件构造器 + 插件(分页/乐观锁/防全表更新…)

什么时候用 MP,什么时候写 XML:

场景用哪个理由
单表按主键 CRUDMP零 SQL,一行搞定
单表简单条件查询MP Wrapper类型安全、免写 SQL
多表联查XMLWrapper 表达不了 JOIN
复杂聚合报表XMLGROUP BY + HAVING + 窗口函数
需要精细控制的 SQLXML强制索引、FOR UPDATE、批量 upsert
性能敏感的查询XML需要逐字调优时,SQL 在眼前更可控

实测:两者在同一个项目里并存 ✓

text
--- 6. 复杂查询仍走 XML:两者并存 ---
==>  Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product
                WHERE deleted = 0 AND name LIKE CONCAT('%', ?, '%') AND price_in_cents >= ? ORDER BY id
  ProductMapper.search("架", 20000, null) = [显示器支架]

注意:ProductMapper(手写 XML)与 ProductPlusMapper(继承 BaseMapper)可以同时存在,因为它们操作同一张表、各自解决不同的问题 ✓

"过犹不及"的两个反模式:

反模式问题
所有查询都硬套 Wrapper多表联查被拆成 N 次单表查询 → N+1 查询问题 ✗
在业务层到处用 selectList(null)无条件全表 ✗

判断标准:

"这条 SQL 用 Wrapper 表达得清楚吗?"

清楚 → 用 Wrapper(省事、类型安全);要绕弯、要拼 last() → 写 XML ✓

last() 用得越多,说明越该回头写 XML ✓

5.7 一个必须知道的边界:批量操作 ​

BaseMapper 没有 insertBatch ✗(insert 只能一次插一条)

三种方案:

方案做法适用
Service 层的 saveBatch继承 ServiceImpl 后可用(默认每批 1000 条)中小批量
XML 里 <foreach> 拼多值 INSERT一条 SQL 插多行大批量(最快) ✓
JDBC 的 addBatch底层批处理(《JDBC 与数据库连接》)极端批量、需要精细控制

saveBatch 的实现细节(很多人不知道):它内部就是 addBatch,所以性能取决于 JDBC 驱动的批处理支持 ✓ 并且要在 URL 上加参数才最快:

text
MySQL:  jdbc:mysql://...?rewriteBatchedStatements=true      ← 不开的话批量插入退化成逐条 ✗

这是一条非常实用的经验:"用了批处理接口但没开驱动参数"是典型的"以为优化了其实没有" ✓

6. 递进示例 ​

6.1 最小示例:零 SQL 的 CRUD ​

java
@Mapper
public interface ProductPlusMapper extends BaseMapper<Product> { }
java
productPlusMapper.selectById(1L);
productPlusMapper.selectList(null);
productPlusMapper.insert(product);
productPlusMapper.updateById(product);
productPlusMapper.deleteById(3L);

6.2 业务示例:条件构造器 + 分页 ​

见第 7 节。

6.3 反例:用 Wrapper 硬拼多表查询 ​

java
// ✗ 想查"商品 + 分类名",硬用单表 Wrapper 拆成两次查询
List<Product> products = plusMapper.selectList(wrapper);
for (Product product : products) {
    Category category = categoryMapper.selectById(product.getCategoryId());    // ← N+1!
    product.setCategoryName(category.getName());
}

问题:1 次查商品 + N 次查分类 = N+1 查询 ✗

100 个商品就是 101 次数据库往返——慢,而且这个性能问题在小数据量下完全发现不了 ✗

修复:写 XML 一次 JOIN 查完 ✓

xml
<select id="findWithCategory" resultMap="productWithCategory">
    SELECT p.id, p.name, p.price_in_cents, c.id AS category_id, c.name AS category_name
    FROM product p
    LEFT JOIN category c ON c.id = p.category_id
    WHERE p.deleted = 0
</select>

这就是 《MyBatis 数据持久化》 的 5.4 节讲 resultMap 嵌套映射的用途 ✓

7. 完整可运行代码 ​

文件:

text
labs/data-access/src/main/java/cn/kb/learn/data/
├── mapper/ProductPlusMapper.java        ← 只继承 BaseMapper
├── config/MyBatisPlusConfig.java        ← 注册分页拦截器
└── MyBatisPlusLessonRunner.java         ← 本节演示

Mapper 与配置:

java
@Mapper
public interface ProductPlusMapper extends BaseMapper<Product> { }
java
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
        return interceptor;
    }
}

运行命令:

powershell
cd labs
./mvnw -pl data-access package -DskipTests
java "-Dstdout.encoding=UTF-8" -jar data-access/target/data-access-1.0.0.jar --lesson=mybatis-plus

预期输出(节选真实结果):

text
===== S3-024 MyBatis-Plus:单表 CRUD 不用写 SQL =====
--- 1. 全表查询:一行代码,零 SQL ---
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE deleted=0
<==      Total: 4
  Product{id=1, name='机械键盘', priceInCents=19900, stock=12}
  Product{id=2, name='人体工学椅', priceInCents=89900, stock=5}
  Product{id=3, name='降噪耳机', priceInCents=15900, stock=30}
  Product{id=4, name='显示器支架', priceInCents=25900, stock=8}
==>  Preparing: SELECT COUNT( * ) AS total FROM product WHERE deleted=0
  selectCount(null) = 4

--- 2. 按主键查 / 插入回填 / 更新 ---
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE id=? AND deleted=0
  selectById(1) = Product{id=1, name='机械键盘', priceInCents=19900, stock=12}
==>  Preparing: INSERT INTO product ( name, price_in_cents, stock ) VALUES ( ?, ?, ? )
  insert 后回填主键 id = 5
==>  Preparing: UPDATE product SET name=?, price_in_cents=?, stock=? WHERE id=? AND deleted=0
  updateById 后 = Product{id=5, name='无线鼠标', priceInCents=8900, stock=50}

--- 3. LambdaQueryWrapper:类型安全的条件构造 ---
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE deleted=0
                AND (price_in_cents >= ? AND price_in_cents < ? AND name LIKE ?) ORDER BY price_in_cents DESC
<==      Total: 1
  价格 [10000, 100000) 且名称含「架」,按价格降序:
    Product{id=4, name='显示器支架', priceInCents=25900, stock=8}

--- 4. 分页:需要先注册 PaginationInnerInterceptor ---
==>  Preparing: SELECT COUNT(*) AS total FROM product WHERE deleted = 0
<==      Total: 1
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE deleted=0 ORDER BY id ASC LIMIT ?
<==      Total: 2
  current=1, size=2, total=5, pages=3
    Product{id=1, name='机械键盘', priceInCents=19900, stock=12}
    Product{id=2, name='人体工学椅', priceInCents=89900, stock=5}

--- 5. 逻辑删除:deleteById 发的是 UPDATE ---
==>  Preparing: UPDATE product SET deleted=1 WHERE id=? AND deleted=0
==>  Preparing: SELECT id,name,price_in_cents,stock,deleted FROM product WHERE id=? AND deleted=0
<==      Total: 0
  删除 id=3 后再查 = null(null)
  当前总数 = 4(比之前少 1)

--- 6. 复杂查询仍走 XML:两者并存 ---
==>  Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product
                WHERE deleted = 0 AND name LIKE CONCAT('%', ?, '%') AND price_in_cents >= ? ORDER BY id
  ProductMapper.search("架", 20000, null) = [显示器支架]

四处最值得盯住的输出:

输出说明了什么
所有查询都带 WHERE deleted=0@TableLogic 自动加条件——《MyBatis 数据持久化》 要手写的地方,这里配一次就够
deleteById 发的是 UPDATE逻辑删除:"删"其实是"改标记"
分页发了 COUNT(*) + LIMIT ? 两条 SQL这才是真分页(没注册拦截器时只会发一条,且在内存里截取)
第 6 节仍走 XMLMP 与手写 MyBatis 并存,各管各的场景

8. Debug 观察点 ​

  1. 在 MyBatisPlusConfig.mybatisPlusInterceptor 处打断点:观察拦截器注册(不注册就没有真分页);
  2. 把 PaginationInnerInterceptor 那行注释掉,重跑第 4 节——观察 SQL 里没有 LIMIT,且 total 变成当前页条数 ✗(这是"内存分页"的现场);
  3. 在 BaseMapper.selectById 的实现处断点(或看 MybatisMapperProxy):观察 MP 如何把方法名翻译成 SQL 模板;
  4. 把实体上的 @TableLogic 注释掉,重跑第 5 节——观察 SQL 变成 DELETE FROM product WHERE id=?(真删了)✗;
  5. 把 @TableId(type = IdType.AUTO) 改成默认值,重跑第 2 节——观察插入的 id 变成一个很长的雪花 ID。

9. 常见错误 ​

错误 1:忘记注册分页拦截器 ​

见 5.4 节。症状是"没报错但分页不对"——最危险的一类 ✗

自查:看 SQL 日志里有没有 LIMIT ✓

错误 2:主键策略与数据库不匹配 ​

java
private Long id;                      // ✗ 用默认的 ASSIGN_ID(雪花算法)

症状:数据库的自增列没生效,id 是个 19 位的大数 ✗

修复:@TableId(type = IdType.AUTO) 或全局配 id-type: auto ✓

错误 3:selectList(null) 查出全表 ​

见 5.1 节。在测试环境没问题,生产环境可能直接打爆内存 ✗

修复:加条件,或用分页接口 ✓

错误 4:last() 里拼用户输入 ​

java
wrapper.last("ORDER BY " + orderBy);          // ✗ 与 S3-008 的 ${} 同罪

修复:last() 只放写死的字符串;动态排序用白名单 + orderByAsc/Desc ✓

错误 5:@TableField(exist = false) 漏写 ​

java
private String categoryName;          // ✗ 不是数据库字段,但 MP 会把它当列去查

症状:Unknown column 'category_name' in 'field list' ✗

修复:@TableField(exist = false) ✓

这个坑在"实体兼作 VO"时特别常见——更稳的做法是 VO 与实体分开(S3-REST 的 8.5 节)✓

错误 6:以为 saveBatch 一定快 ​

见 5.7 节。MySQL 不加 rewriteBatchedStatements=true 时,批量插入会退化成逐条 ✗

验证方式:看 SQL 日志里是多少条 INSERT ✓

错误 7:MyBatis-Plus 3.5.9 找不到 PaginationInnerInterceptor ​

本项目实际踩到的坑(《MyBatis 数据持久化》 的 9.7 节也记了):

text
[ERROR] 找不到符号
  符号: 类 PaginationInnerInterceptor

修复:加 mybatis-plus-jsqlparser 依赖 ✓

10. 动手实验 ​

目标:亲手验证"拦截器、逻辑删除、条件构造器"三件事。

步骤:

  1. 运行第 7 节的命令,核对四处关键输出;
  2. 注释掉分页拦截器,重跑——记录 total 与 SQL 的变化(从"两条 SQL + 真总数"变成"一条 SQL + 内存截取");
  3. 注释掉 @TableLogic,重跑第 5 节——观察 SQL 从 UPDATE ... SET deleted=1 变成 DELETE FROM ...;
  4. 用 condition 参数写动态条件:给第 3 节加一个可选的 maxStock 条件(用 .le(maxStock != null, Product::getStock, maxStock)),验证传 null 时 SQL 里没有这个条件;
  5. 测一个"内存分页"的极端情况:造 100 条数据,用 Page.of(1, 10) 查询,对比"注册拦截器前后"的 SQL 日志(一个带 LIMIT、一个不带);
  6. 验证逻辑删除与唯一索引的冲突:给 name 建唯一索引,插入两条同名记录 → 删一条 → 再插同名——观察是否能插进去(提示:唯一索引要不要带 deleted)。

验收方式:能说出第 2 步 total 为什么会错、第 3 步两条 SQL 的差别。

扩展任务:给 Product 加 createdAt / updatedAt 两个字段,用 MetaObjectHandler 实现自动填充(插入时填 createdAt,更新时填 updatedAt)——观察插入与更新时的 SQL 参数。

11. 练习题 ​

基础题

  1. 判断:BaseMapper.selectList(null) 会报错,因为不允许无条件查询。(对 / 错)
  2. 填空:分页需要注册 ______ 拦截器,否则会变成 ______;逻辑删除靠实体上的 ______ 注解。
  3. 简答:LambdaQueryWrapper 与 QueryWrapper 的核心差别是什么?

代码阅读题

java
// 实体
@TableName("orders")
public class Order {
    private Long id;
    private String orderNo;
    private Integer amountInCents;
    private Integer status;
}

// 查询
LambdaQueryWrapper<Order> wrapper = Wrappers.<Order>lambdaQuery()
        .eq(Order::getStatus, 1)
        .ge(Order::getAmountInCents, 10_000)
        .last("ORDER BY " + sortColumn)
        .last("LIMIT " + pageSize);
Page<Order> page = orderMapper.selectPage(Page.of(pageNo, pageSize), wrapper);
  1. 这段代码有几个问题?
  2. 如果 sortColumn 来自前端参数,会发生什么?

编程题

  1. 为"文章"表用 MyBatis-Plus 重写 《MyBatis 数据持久化》 第 6 题的功能,并做对比:
    • 实体:Article(id, title, content, authorId, status, viewCount, createdAt, deleted);
    • 要求:
      • 用 BaseMapper 实现:按主键查、插入(回填主键)、自增阅读数、逻辑删除;
      • 用 LambdaQueryWrapper 实现:按标题关键词 + 状态 + 作者三个可选条件查询(用 condition 参数写,不写 if);
      • 分页查询(含 COUNT 与 LIMIT 两条 SQL);
      • 复杂查询(如需联表查作者名)仍然写 XML;
    • 统计:对比 《MyBatis 数据持久化》 的版本,你少写了多少行 SQL?
    • 回答:哪一部分仍然需要手写 XML?为什么?

综合任务

  1. 为一个"订单系统"设计数据访问层的技术选型方案:
    • 系统包含:订单表、订单明细表、商品表、用户表;典型查询有——
      • 单表 CRUD(增删改查);
      • 按多条件筛选订单列表(状态、时间范围、金额区间,分页);
      • 订单详情(订单 + 明细 + 商品名 + 用户名,多表联查);
      • 销售报表(按日/周分组统计 GMV、订单数、客单价);
      • 用户订单数排行榜(分组 + 排序 + 限制);
    • 要求:
      • 为每个查询决定用 Wrapper 还是 XML,并说明理由;
      • 说明"逻辑删除"方案(含唯一索引怎么建、老数据怎么清理);
      • 说明"主键策略"的选择(自增 vs 雪花 ID)以及各表为什么不一样;
      • 列出至少 3 个"用 MP 反而更容易出错"的场景;
    • 额外要求:说明"如果团队里有人用 selectList(null) 查全表,你如何从工程上防住它"(提示:MP 有 BlockAttackInnerInterceptor 之类的插件;还有代码评审与压测)。
分级提示

提示 1(方向):第 1 题的方向反了——null 是合法的,但意味着"无条件"。

提示 2(关键点):第 4 题里出现了两个 last()——想想它们会怎么拼接。

提示 3(接近答案):第 7 题的"用 MP 更容易出错",典型是"内存分页(忘注册拦截器)""全表查询""主键策略不匹配"。

12. 参考答案 ​

基础题
  1. 错。null 是合法的,它的语义是"没有任何条件"=全表查询 ✓

问题不在语法,在后果:

表规模后果
1 千行没什么感觉
10 万行响应几百毫秒到几秒,内存占用上升
1000 万行OOM / 接口超时 / 数据库被打满 ✗

为什么这个错误很危险:

它"在开发和测试环境完全正常"——因为测试库的数据量小 ✓ 问题只在生产的数据规模下暴露 ✗

防御手段:

手段说明
代码评审搜 selectList(null) 与 selectList(new QueryWrapper<>())
分页接口需要全量时走分页或游标
插件MP 的 BlockAttackInnerInterceptor 能拦住"全表更新/删除",但全表查询要自己拦
压测用生产量级的数据压一遍(T2-003)
  1. PaginationInnerInterceptor(分页内部拦截器);内存分页(把全表查回来再截取);@TableLogic。

第三条补充:也可以用全局配置 mybatis-plus.global-config.db-config.logic-delete-field 指定字段名,不必每个实体都标注解 ✓

  1. 核心差别是"列名怎么表达":
QueryWrapperLambdaQueryWrapper
列名写法字符串:"price_in_cents"方法引用:Product::getPriceInCents
写错列名运行时 SQL 错误 ✗编译不过 ✓
IDE 重构❌ 改字段名要手动搜字符串✅ 自动跟着改
可读性一般更好(看得出是哪个字段)

唯一的例外:需要写原生 SQL 片段时(last()、apply()),两者差别不大 ✓

结论:默认用 LambdaQueryWrapper ✓ 只有在"必须用字符串列名"时才退回 QueryWrapper ✓

代码阅读题(第 4、5 题)

第 4 题:四个问题。

问题一:last() 被调用了两次,只有最后一次生效 ✗

java
.last("ORDER BY " + sortColumn)
.last("LIMIT " + pageSize)

MP 的 last() 的行为:多次调用会覆盖(它内部是"设置 SQL 末尾片段",不是追加)——所以 ORDER BY 被丢掉了 ✗

证据:生成的 SQL 里只有 LIMIT,没有 ORDER BY → 排序失效,分页结果还是乱的 ✗

更糟的是:分页结果"看起来正常"(确实返回了一页数据),只是顺序不确定 ✗ 这类 bug 极难发现。

问题二:sortColumn 直接拼进 SQL ✗

java
.last("ORDER BY " + sortColumn)          // 若 sortColumn = "id; DROP TABLE orders" 呢?

与 《MyBatis 数据持久化》 的 ${} 完全同类 ✗ 必须过白名单。

问题三:LIMIT 是多余的 ✗

selectPage 已经会自动加 LIMIT(注册了拦截器的话)✓ 手动 last("LIMIT ...") 会造成:

sql
SELECT ... LIMIT 10 LIMIT 10      -- ✗ 语法错误(或行为不可预期)

或者与拦截器的改写冲突 ✗

问题四:Page.of(pageNo, pageSize) 的分页与 LIMIT 参数可能不一致 ✗(一个来自 pageSize,一个来自方法参数,容易改一处漏一处)

修复后的写法:

java
// 1) 排序走白名单 + 类型安全 API
private static final Set<String> ALLOWED_SORT = Set.of("amount_in_cents", "created_at");

String column = safeColumn(sortColumn);                 // 白名单校验
LambdaQueryWrapper<Order> wrapper = Wrappers.<Order>lambdaQuery()
        .eq(Order::getStatus, 1)
        .ge(Order::getAmountInCents, 10_000);

// 2) 用 orderByAsc/Desc,不用 last()
if ("created_at".equals(column)) {
    wrapper.orderByDesc(Order::getCreatedAt);
} else {
    wrapper.orderByDesc(Order::getAmountInCents);
}

// 3) LIMIT 交给分页拦截器,不要再写 last()
Page<Order> page = orderMapper.selectPage(Page.of(pageNo, pageSize), wrapper);

关键改进:

改进收益
排序用 orderByDesc与分页拦截器协作,SQL 拼接正确
白名单防注入
去掉 last("LIMIT")不与拦截器冲突
Page 对象统一管分页参数只有一处来源

第 5 题:如果 sortColumn 来自前端,就是注入漏洞。

攻击方式(取决于数据库与驱动是否允许多语句):

输入可能的后果
"id DESC"正常(合法输入)
"(CASE WHEN (SELECT ...) THEN id ELSE amount_in_cents END)"布尔盲注:通过排序结果推断数据 ✗
"id; DROP TABLE orders"多语句执行(部分驱动允许)✗
"1 UNION SELECT password FROM users"配合其他写法可能泄露数据

更隐蔽的一点:这种注入往往不会报错——它只是"排序不对",日志和监控都看不出来 ✗

正确的处理:

java
private static final Map<String, SFunction<Order, ?>> SORT_FIELDS = Map.of(
        "amount", Order::getAmountInCents,
        "created", Order::getCreatedAt,
        "id", Order::getId);

public Page<Order> list(int pageNo, int pageSize, String sortKey, boolean asc) {
    SFunction<Order, ?> column = SORT_FIELDS.get(sortKey);          // 白名单:查不到就是 null
    if (column == null) {
        column = Order::getId;                                      // 默认排序
    }
    LambdaQueryWrapper<Order> wrapper = Wrappers.<Order>lambdaQuery()
            .eq(Order::getStatus, 1)
            .orderBy(true, asc, column);                            // 类型安全,无拼串
    return orderMapper.selectPage(Page.of(pageNo, pageSize), wrapper);
}

这个方案的三层防护:

层作用
Map 白名单只允许预设的排序键
值类型是 SFunction即使白名单被绕过,也无法注入(因为传的是方法引用,不是字符串)✓
orderBy(true, asc, column)排序方向由 boolean 控制,不是字符串 ✓

最强的防注入设计是"让危险输入根本无法表达"——把"字符串排序字段"换成"方法引用",注入面直接归零 ✓

这比"拼串 + 校验"更彻底——因为没有拼串这个动作了。

编程题(第 6 题)

实体:

java
@TableName("article")
public class Article {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String title;
    private String content;
    private Long authorId;
    private String status;
    private Integer viewCount;
    private LocalDateTime createdAt;

    @TableLogic
    private Integer deleted;

    @TableField(exist = false)
    private String authorName;          // 联表查询时填充,不是数据库列

    // getter / setter 省略
}

Mapper:

java
@Mapper
public interface ArticleMapper extends BaseMapper<Article> {

    /** 联表查询仍然写 XML */
    List<Article> findWithAuthor(@Param("keyword") String keyword,
                                @Param("limit") int limit);
}
xml
<select id="findWithAuthor" resultType="cn.kb.learn.data.entity.Article">
    SELECT a.id, a.title, a.author_id, a.status, a.view_count,
           u.name AS author_name
    FROM article a
    LEFT JOIN users u ON u.id = a.author_id
    WHERE a.deleted = 0
    <if test="keyword != null and keyword != ''">
        AND a.title LIKE CONCAT('%', #{keyword}, '%')
    </if>
    ORDER BY a.view_count DESC
    LIMIT #{limit}
</select>

服务层(MP 部分):

java
@Repository
public class ArticleRepository {
    private static final Set<String> ALLOWED_STATUS = Set.of("DRAFT", "PUBLISHED", "ARCHIVED");

    private final ArticleMapper articleMapper;

    public ArticleRepository(ArticleMapper articleMapper) {
        this.articleMapper = articleMapper;
    }

    public Article findById(Long id) {
        return articleMapper.selectById(id);                 // 自动带 deleted = 0
    }

    public Article create(Article article) {
        articleMapper.insert(article);                       // 回填自增主键
        return article;
    }

    public int increaseViewCount(Long id, int delta) {
        // 用 UpdateWrapper 做"自增",避免"读出来 + 1 再写回去"的并发覆盖问题
        return articleMapper.update(null, Wrappers.<Article>lambdaUpdate()
                .setSql("view_count = view_count + " + delta)     // ⚠️ delta 是 int,不是用户字符串
                .eq(Article::getId, id));
    }

    public int logicDelete(Long id) {
        return articleMapper.deleteById(id);                 // 自动变成 UPDATE ... SET deleted = 1
    }

    /** 三个可选条件 + 分页:用 condition 参数,不写 if */
    public Page<Article> search(String keyword, String status, Long authorId, int pageNo, int pageSize) {
        LambdaQueryWrapper<Article> wrapper = Wrappers.<Article>lambdaQuery()
                .like(keyword != null && !keyword.isBlank(), Article::getTitle, keyword)
                .eq(status != null && ALLOWED_STATUS.contains(status), Article::getStatus, status)
                .eq(authorId != null, Article::getAuthorId, authorId)
                .orderByDesc(Article::getCreatedAt);
        return articleMapper.selectPage(Page.of(pageNo, pageSize), wrapper);
    }

    /** 分页查总数时不想付出 COUNT(*) 代价,可用 Page.of(pageNo, pageSize, false) 关闭 */
    public Page<Article> searchWithoutCount(String keyword, int pageNo, int pageSize) {
        LambdaQueryWrapper<Article> wrapper = Wrappers.<Article>lambdaQuery()
                .like(keyword != null && !keyword.isBlank(), Article::getTitle, keyword)
                .orderByDesc(Article::getCreatedAt);
        return articleMapper.selectPage(Page.of(pageNo, pageSize, false), wrapper);
    }
}

⚠️ 关于 setSql 的提醒:上面 setSql("view_count = view_count + " + delta) 用到了拼串——这里安全的前提是 delta 是 int 类型(不可能是 SQL 片段) ✓

更稳的写法(完全避免拼串):

java
// 用 updateById 前先读出来会丢更新;用 setSql 是常见做法,但要确保拼进去的是"值"不是"字符串"
// 若要绝对安全,可以写 XML:
xml
<update id="increaseViewCount">
    UPDATE article SET view_count = view_count + #{delta}
    WHERE id = #{id} AND deleted = 0
</update>

建议:这种"字段自增"的更新,写 XML 最清晰 ✓(呼应 5.6 节的判断标准)

对比:少写了多少 SQL

功能《MyBatis 数据持久化》 的 MyBatis本节的 MP
findById1 方法 + 3 行 SQL0 行(selectById)
insert1 方法 + 3 行 SQL0 行(insert)
logicDelete1 方法 + 3 行 SQL0 行(deleteById)
search(三条件)1 方法 + 12 行动态 SQL0 行(4 行 Wrapper 代码)
findByIds1 方法 + 7 行 SQL0 行(selectBatchIds)
合计 SQL 行数约 30 行0 行

仍然需要手写 XML 的部分:

场景为什么
findWithAuthor(联表查作者名)Wrapper 只能表达单表条件,JOIN 表达不了
复杂聚合(按作者统计文章数)GROUP BY + HAVING 用 Wrapper 写很别扭
字段自增等精细更新写 XML 更清楚(虽然 setSql 能做)
需要强制索引 / 优化提示SQL 要逐字控制

结论:

MP 消掉的是"单表模板化 SQL",不是"所有 SQL"。

一个务实的分工:80% 的单表操作交给 MP,20% 的复杂查询手写 XML ✓ —— 这也正是本节 5.6 节的那张表。

综合任务(第 7 题)

技术选型:逐查询决定

查询选型理由
单表 CRUD(订单/商品/用户)Wrapper / BaseMapper模板化,零 SQL
订单列表多条件筛选 + 分页Wrapper条件是"可选组合",condition 参数天然支持;动态 SQL 写起来反而啰嗦
订单详情(订单 + 明细 + 商品名 + 用户名)XML多表联查,Wrapper 表达不了;且需要嵌套映射(《MyBatis 数据持久化》 的 resultMap)
销售报表(按日/周分组统计)XMLGROUP BY DATE(created_at) + 聚合函数 + HAVING
用户订单数排行榜XML分组 + 排序 + LIMIT,且可能要处理"并列名次"
按状态批量更新Wrapper(update + lambdaUpdate)单表批量更新,条件简单

一句话规律:

"单表 + 条件组合" → Wrapper;"多表 / 聚合 / 需要精细控制" → XML。

逻辑删除方案:

项方案
字段deleted TINYINT NOT NULL DEFAULT 0(0 未删、1 已删)
MP 配置全局 logic-delete-field: deleted,或在实体上 @TableLogic
唯一索引必须带上 deleted:UNIQUE KEY uk_order_no (order_no, deleted)
老数据清理定时任务物理删除 1 年前的已删记录(用原生 SQL 绕过逻辑删除)
删除时间建议加 deleted_at 字段——清理任务需要它,审计也需要
查询索引把 deleted 放在复合索引的尾部(如 (user_id, deleted)),不要单独建索引

⚠️ 唯一索引那个坑值得展开:

text
如果唯一索引是 UNIQUE(order_no):
    插入 ORD-001          → 成功
    逻辑删除 ORD-001      → 行还在,deleted=1
    再插入 ORD-001        → ✗ 唯一约束冲突!业务上"该订单号可以重新用了",但数据库不让

如果唯一索引是 UNIQUE(order_no, deleted):
    插入 ORD-001 (deleted=0)      → 成功
    逻辑删除 → (ORD-001, 1)
    再插入 ORD-001 (deleted=0)    → ✓ 成功(不同的组合)
    但再删一次 → (ORD-001, 1) 重复 → ✗ 冲突

更稳的方案:把 deleted 存成"删除时间戳"(未删除时为 0):

sql
UNIQUE KEY uk_order_no (order_no, deleted)
-- 未删除:deleted = 0
-- 已删除:deleted = <删除时间戳,唯一>

这样每一条被删除的记录 deleted 值都不同,永远不会冲突 ✓ 这是业界常见做法。

主键策略选择:

表策略理由
订单表雪花 ID(ASSIGN_ID)订单号要对外暴露且不可猜测;分库分表时自增会冲突
订单明细表自增(AUTO)内部表,不对外,自增对索引友好(顺序写入,《执行计划与慢查询定位》)
商品表雪花 ID要对外(URL 里出现),且不想让人从 id 推断商品数量
用户表雪花 ID同上;且用户量增长快,便于分片

自增 vs 雪花 ID 的权衡:

自增雪花 ID
索引性能✅ 顺序写入,页分裂少⚠️ 趋势递增(有序),基本可用
对外安全❌ 可推测(/orders/1001 就是隔壁那单)✅ 不可推测
分库分表❌ 需要额外方案✅ 天然支持
可读性✅ 短、好记❌ 19 位长数字
依赖数据库应用时钟(需防时钟回拨)

"用 MP 反而更容易出错的 3 个场景":

#场景为什么会错怎么防
1忘注册分页拦截器静默地退化成内存分页,测试看不出来用 SQL 日志验证有 LIMIT;写一个集成测试断言 total 与 records.size() 的关系(T1-017)
2selectList(null) 全表测试库数据少,生产炸代码评审 + 压测;把"列表接口"统一走分页
3用 Wrapper 硬拼多表查询N+1 查询,小数据量下发现不了关键路径写 XML;用 SQL 日志统计"一次请求发了多少条 SQL"
4主键策略与数据库不匹配雪花 ID 写进自增列(或反过来)全局配 id-type,并在建表脚本里统一

如何从工程上防住 selectList(null):

手段强度说明
代码评审⚠️ 弱靠人,容易漏
自定义插件(InnerInterceptor)✅ 强拦截 SQL,发现"没有 WHERE 的 SELECT"就抛异常(可配置白名单表)
MP 自带的 BlockAttackInnerInterceptor⚠️ 部分它拦的是全表 update / delete,不含全表 select ✗
压测✅ 强用生产量级数据跑(T2-015)
接口层的分页强制✅ 强列表接口的参数对象里分页字段设为必填

自定义拦截器的示意(思路):

java
public class FullTableSelectGuard implements InnerInterceptor {
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter,
                            RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
        String sql = boundSql.getSql().toUpperCase();
        boolean hasWhere = sql.contains("WHERE");
        boolean isCount = sql.contains("COUNT(");
        if (!hasWhere && !isCount && !ALLOWED_TABLES.contains(tableOf(ms))) {
            throw new IllegalStateException("检测到无条件全表查询,已阻止:" + ms.getId());
        }
    }
}

这类"守卫式插件"的价值:

把"团队约定"变成"跑不过的检查" ✓

人总会忘;能自动拦住的,就别指望人记得。 ✓

这与 T1-018 的"质量门禁"、《参数校验与全局异常处理》 的"统一异常处理"是同一种思路:把正确性从"自觉"转移到"机制"上。

13. 掌握标准 ​

  • [ ] 能用 BaseMapper 完成单表 CRUD(零 SQL);
  • [ ] 知道 selectList(null) 的语义与风险;
  • [ ] 会用 @TableName / @TableId / @TableField / @TableLogic;
  • [ ] 会用 LambdaQueryWrapper,并知道 condition 参数能替代 if 判断;
  • [ ] 知道分页必须注册 PaginationInnerInterceptor,并知道不注册会静默退化成内存分页;
  • [ ] 知道 @TableLogic 自动加 deleted = 0,且 deleteById 发的是 UPDATE;
  • [ ] 知道 last() 的注入风险,只放写死的字符串;
  • [ ] 能判断"某个查询该用 Wrapper 还是 XML";
  • [ ] 知道逻辑删除与唯一索引的冲突,以及 deleted 存时间戳的解法;
  • [ ] 知道 saveBatch 要配驱动参数才真的快。

14. 扩展资源 ​

15. 下一步学习 ​

数据访问主线到此完成(《JDBC 与数据库连接》 → 《MyBatis 数据持久化》 → 《MyBatis-Plus 通用 CRUD 与条件构造器》)。接下来可以:

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引