主题
界面
主题
《MyBatis 数据持久化》 用 MyBatis 写了 8 个方法,每个都配一段 SQL。回头看,其中有 5 个是单表的标准动作:
findAll / findById / insert / updatePrice / deleteById这五件事,每个实体都要写一遍——一个 20 张表的系统,就是 100 个方法 + 100 段几乎雷同的 SQL ✗
本节的主角 MyBatis-Plus(MP) 就是来消掉这部分重复的:继承一个接口,单表 CRUD 一行 SQL 都不用写。
完成本节后,你应该能够:
BaseMapper 提供了哪些通用能力;@TableName / @TableId / @TableField / @TableLogic 做实体与表的映射;LambdaQueryWrapper 写类型安全的条件查询;@TableLogic 帮你省掉了什么(与 《MyBatis 数据持久化》 手写 deleted = 0 对比);#{} 与 ${} 的区别吗?(《MyBatis 数据持久化》 的 5.3 节)先看规模。假设一个系统有 20 张表,每张表都要:
| 动作 | 手写 MyBatis 的工作量 |
|---|---|
| 按主键查 | 1 个方法 + 1 段 SQL |
| 列表查询 | 1 + 1 |
| 新增 | 1 + 1 |
| 按主键更新 | 1 + 1 |
| 按主键删除 | 1 + 1 |
| 小计 | 5 个方法 + 5 段 SQL × 20 张表 = 100 + 100 |
而这些 SQL 的本质是"同一段模板里换表名/列名"——这正是可以自动化的信号 ✓
MyBatis-Plus 的做法:
@Mapper
public interface ProductPlusMapper extends BaseMapper<Product> {
// 就这一行
}然后:
plusMapper.selectById(1L);
plusMapper.selectList(null);
plusMapper.insert(product);
plusMapper.updateById(product);
plusMapper.deleteById(3L);实测输出(本节代码,注意 MyBatis-Plus 生成的 SQL):
==> 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 | 逻辑删除——不是真删 |
BaseMapper 的通用能力 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);
}记住三类:
| 类别 | 方法前缀 | 数量 |
|---|---|---|
| 增 | insert | 1 |
| 删 | delete* | 3 |
| 改 | update* | 2 |
| 查 | select* | 约 10 |
⚠️ 第一个要警惕的方法:selectList(null) —— 参数为 null 表示"无条件"=全表 ✓
| 场景 | 后果 |
|---|---|
| 表里有 10 万行 | 一次查 10 万行 → 内存暴涨、接口超时 ✗ |
| 生产环境 | 一次误调用可能拖垮服务 |
实践规则:
selectList一定要带条件或last("LIMIT ...");确实要全表时,用分页接口或专门的导出任务。
@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 就是这么做的)
LambdaQueryWrapper:类型安全的条件构造 为什么需要它——先看不类型安全的版本:
QueryWrapper<Product> wrapper = new QueryWrapper<>();
wrapper.ge("price_in_cents", 10_000); // ✗ 列名是字符串,写错编译期发现不了
wrapper.orderByDesc("price_in_cents");列名写错(如 price_incents)→ 运行时报 SQL 错误 ✗
LambdaQueryWrapper 的写法:
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:
==> 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 / likeRight | LIKE '%?%' / LIKE '?%' | 前缀匹配能走索引(《表设计与约束》) |
in / notIn | IN (...) | .in(Product::getId, List.of(1L, 3L)) |
isNull / isNotNull | IS NULL / IS NOT NULL | .isNull(Product::getDeleted) |
between | BETWEEN ? AND ? | .between(Product::getPriceInCents, 1_000, 9_999) |
orderByAsc / orderByDesc | ORDER BY ... | .orderByDesc(Product::getPriceInCents) |
groupBy / having | 分组 | .groupBy(Product::getStock) |
or / and | 连接条件 | .eq(...).or().eq(...) |
last | 直接拼 SQL 片段 | .last("LIMIT 2") ⚠️ |
⚠️ 关于 last():它把字符串直接拼到 SQL 尾部——和 《MyBatis 数据持久化》 的 ${} 一样有注入风险 ✗
// ✗ 绝不能把用户输入放进 last()
wrapper.last("ORDER BY " + userInput);
// ✓ 固定字符串可以用(本项目的用法)
wrapper.orderByAsc(Product::getId).last("LIMIT 2");一条判断规则:last() 里只能出现"代码里写死的字符串",绝不能拼接用户输入 ✓
条件为 null 时的处理(动态条件的日常需求):
// 写法一:手动判断(啰嗦)
if (keyword != null) wrapper.like(Product::getName, keyword);
// 写法二:用 condition 参数(推荐)
wrapper.like(keyword != null && !keyword.isBlank(), Product::getName, keyword);condition 参数的意义:第一个参数为 false 时,这个条件被忽略 ✓ —— 这是 MP 版的"动态 SQL" ✓
很多人踩的第一个坑:写了 selectPage 却发现"没分页" ✗
Page<Product> page = plusMapper.selectPage(Page.of(1, 2), wrapper);为什么:分页需要改写 SQL(加 LIMIT)——而改写 SQL 需要一个拦截器 ✓
注册方式:
@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**(实测):
==> 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 查询 ✓
回顾 《MyBatis 数据持久化》 的痛苦:每个查询都要手写 WHERE deleted = 0——漏一个就出错,而且不报错 ✗
MP 的做法:实体上标一个 @TableLogic ✓
@TableLogic
private Integer deleted;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 |
实测输出:
--- 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 或专门的清理任务 |
这是本节最需要建立的认知——两者是"并存"关系:
MyBatis-Plus = MyBatis + 通用 CRUD + 条件构造器 + 插件(分页/乐观锁/防全表更新…)什么时候用 MP,什么时候写 XML:
| 场景 | 用哪个 | 理由 |
|---|---|---|
| 单表按主键 CRUD | MP | 零 SQL,一行搞定 |
| 单表简单条件查询 | MP Wrapper | 类型安全、免写 SQL |
| 多表联查 | XML | Wrapper 表达不了 JOIN |
| 复杂聚合报表 | XML | GROUP BY + HAVING + 窗口函数 |
| 需要精细控制的 SQL | XML | 强制索引、FOR UPDATE、批量 upsert |
| 性能敏感的查询 | XML | 需要逐字调优时,SQL 在眼前更可控 |
实测:两者在同一个项目里并存 ✓
--- 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 ✓
BaseMapper 没有 insertBatch ✗(insert 只能一次插一条)
三种方案:
| 方案 | 做法 | 适用 |
|---|---|---|
Service 层的 saveBatch | 继承 ServiceImpl 后可用(默认每批 1000 条) | 中小批量 |
XML 里 <foreach> 拼多值 INSERT | 一条 SQL 插多行 | 大批量(最快) ✓ |
JDBC 的 addBatch | 底层批处理(《JDBC 与数据库连接》) | 极端批量、需要精细控制 |
saveBatch 的实现细节(很多人不知道):它内部就是 addBatch,所以性能取决于 JDBC 驱动的批处理支持 ✓ 并且要在 URL 上加参数才最快:
MySQL: jdbc:mysql://...?rewriteBatchedStatements=true ← 不开的话批量插入退化成逐条 ✗这是一条非常实用的经验:"用了批处理接口但没开驱动参数"是典型的"以为优化了其实没有" ✓
@Mapper
public interface ProductPlusMapper extends BaseMapper<Product> { }productPlusMapper.selectById(1L);
productPlusMapper.selectList(null);
productPlusMapper.insert(product);
productPlusMapper.updateById(product);
productPlusMapper.deleteById(3L);见第 7 节。
// ✗ 想查"商品 + 分类名",硬用单表 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 查完 ✓
<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 嵌套映射的用途 ✓
文件:
labs/data-access/src/main/java/cn/kb/learn/data/
├── mapper/ProductPlusMapper.java ← 只继承 BaseMapper
├── config/MyBatisPlusConfig.java ← 注册分页拦截器
└── MyBatisPlusLessonRunner.java ← 本节演示Mapper 与配置:
@Mapper
public interface ProductPlusMapper extends BaseMapper<Product> { }@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
return interceptor;
}
}运行命令:
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预期输出(节选真实结果):
===== 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 节仍走 XML | MP 与手写 MyBatis 并存,各管各的场景 |
MyBatisPlusConfig.mybatisPlusInterceptor 处打断点:观察拦截器注册(不注册就没有真分页);PaginationInnerInterceptor 那行注释掉,重跑第 4 节——观察 SQL 里没有 LIMIT,且 total 变成当前页条数 ✗(这是"内存分页"的现场);BaseMapper.selectById 的实现处断点(或看 MybatisMapperProxy):观察 MP 如何把方法名翻译成 SQL 模板;@TableLogic 注释掉,重跑第 5 节——观察 SQL 变成 DELETE FROM product WHERE id=?(真删了)✗;@TableId(type = IdType.AUTO) 改成默认值,重跑第 2 节——观察插入的 id 变成一个很长的雪花 ID。见 5.4 节。症状是"没报错但分页不对"——最危险的一类 ✗
自查:看 SQL 日志里有没有 LIMIT ✓
private Long id; // ✗ 用默认的 ASSIGN_ID(雪花算法)症状:数据库的自增列没生效,id 是个 19 位的大数 ✗
修复:@TableId(type = IdType.AUTO) 或全局配 id-type: auto ✓
selectList(null) 查出全表 见 5.1 节。在测试环境没问题,生产环境可能直接打爆内存 ✗
修复:加条件,或用分页接口 ✓
last() 里拼用户输入 wrapper.last("ORDER BY " + orderBy); // ✗ 与 S3-008 的 ${} 同罪修复:last() 只放写死的字符串;动态排序用白名单 + orderByAsc/Desc ✓
@TableField(exist = false) 漏写 private String categoryName; // ✗ 不是数据库字段,但 MP 会把它当列去查症状:Unknown column 'category_name' in 'field list' ✗
修复:@TableField(exist = false) ✓
这个坑在"实体兼作 VO"时特别常见——更稳的做法是 VO 与实体分开(S3-REST 的 8.5 节)✓
saveBatch 一定快 见 5.7 节。MySQL 不加 rewriteBatchedStatements=true 时,批量插入会退化成逐条 ✗
验证方式:看 SQL 日志里是多少条 INSERT ✓
PaginationInnerInterceptor 本项目实际踩到的坑(《MyBatis 数据持久化》 的 9.7 节也记了):
[ERROR] 找不到符号
符号: 类 PaginationInnerInterceptor修复:加 mybatis-plus-jsqlparser 依赖 ✓
目标:亲手验证"拦截器、逻辑删除、条件构造器"三件事。
步骤:
total 与 SQL 的变化(从"两条 SQL + 真总数"变成"一条 SQL + 内存截取");@TableLogic,重跑第 5 节——观察 SQL 从 UPDATE ... SET deleted=1 变成 DELETE FROM ...;condition 参数写动态条件:给第 3 节加一个可选的 maxStock 条件(用 .le(maxStock != null, Product::getStock, maxStock)),验证传 null 时 SQL 里没有这个条件;Page.of(1, 10) 查询,对比"注册拦截器前后"的 SQL 日志(一个带 LIMIT、一个不带);name 建唯一索引,插入两条同名记录 → 删一条 → 再插同名——观察是否能插进去(提示:唯一索引要不要带 deleted)。验收方式:能说出第 2 步 total 为什么会错、第 3 步两条 SQL 的差别。
扩展任务:给 Product 加 createdAt / updatedAt 两个字段,用 MetaObjectHandler 实现自动填充(插入时填 createdAt,更新时填 updatedAt)——观察插入与更新时的 SQL 参数。
基础题
BaseMapper.selectList(null) 会报错,因为不允许无条件查询。(对 / 错)LambdaQueryWrapper 与 QueryWrapper 的核心差别是什么?代码阅读题
// 实体
@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);sortColumn 来自前端参数,会发生什么?编程题
Article(id, title, content, authorId, status, viewCount, createdAt, deleted);BaseMapper 实现:按主键查、插入(回填主键)、自增阅读数、逻辑删除;LambdaQueryWrapper 实现:按标题关键词 + 状态 + 作者三个可选条件查询(用 condition 参数写,不写 if);COUNT 与 LIMIT 两条 SQL);综合任务
selectList(null) 查全表,你如何从工程上防住它"(提示:MP 有 BlockAttackInnerInterceptor 之类的插件;还有代码评审与压测)。提示 1(方向):第 1 题的方向反了——null 是合法的,但意味着"无条件"。
提示 2(关键点):第 4 题里出现了两个 last()——想想它们会怎么拼接。
提示 3(接近答案):第 7 题的"用 MP 更容易出错",典型是"内存分页(忘注册拦截器)""全表查询""主键策略不匹配"。
null 是合法的,它的语义是"没有任何条件"=全表查询 ✓问题不在语法,在后果:
| 表规模 | 后果 |
|---|---|
| 1 千行 | 没什么感觉 |
| 10 万行 | 响应几百毫秒到几秒,内存占用上升 |
| 1000 万行 | OOM / 接口超时 / 数据库被打满 ✗ |
为什么这个错误很危险:
它"在开发和测试环境完全正常"——因为测试库的数据量小 ✓ 问题只在生产的数据规模下暴露 ✗
防御手段:
| 手段 | 说明 |
|---|---|
| 代码评审 | 搜 selectList(null) 与 selectList(new QueryWrapper<>()) |
| 分页接口 | 需要全量时走分页或游标 |
| 插件 | MP 的 BlockAttackInnerInterceptor 能拦住"全表更新/删除",但全表查询要自己拦 |
| 压测 | 用生产量级的数据压一遍(T2-003) |
PaginationInnerInterceptor(分页内部拦截器);内存分页(把全表查回来再截取);@TableLogic。第三条补充:也可以用全局配置 mybatis-plus.global-config.db-config.logic-delete-field 指定字段名,不必每个实体都标注解 ✓
QueryWrapper | LambdaQueryWrapper | |
|---|---|---|
| 列名写法 | 字符串:"price_in_cents" | 方法引用:Product::getPriceInCents |
| 写错列名 | 运行时 SQL 错误 ✗ | 编译不过 ✓ |
| IDE 重构 | ❌ 改字段名要手动搜字符串 | ✅ 自动跟着改 |
| 可读性 | 一般 | 更好(看得出是哪个字段) |
唯一的例外:需要写原生 SQL 片段时(last()、apply()),两者差别不大 ✓
结论:默认用 LambdaQueryWrapper ✓ 只有在"必须用字符串列名"时才退回 QueryWrapper ✓
第 4 题:四个问题。
问题一:last() 被调用了两次,只有最后一次生效 ✗
.last("ORDER BY " + sortColumn)
.last("LIMIT " + pageSize)MP 的 last() 的行为:多次调用会覆盖(它内部是"设置 SQL 末尾片段",不是追加)——所以 ORDER BY 被丢掉了 ✗
证据:生成的 SQL 里只有 LIMIT,没有 ORDER BY → 排序失效,分页结果还是乱的 ✗
更糟的是:分页结果"看起来正常"(确实返回了一页数据),只是顺序不确定 ✗ 这类 bug 极难发现。
问题二:sortColumn 直接拼进 SQL ✗
.last("ORDER BY " + sortColumn) // 若 sortColumn = "id; DROP TABLE orders" 呢?与 《MyBatis 数据持久化》 的 ${} 完全同类 ✗ 必须过白名单。
问题三:LIMIT 是多余的 ✗
selectPage 已经会自动加 LIMIT(注册了拦截器的话)✓ 手动 last("LIMIT ...") 会造成:
SELECT ... LIMIT 10 LIMIT 10 -- ✗ 语法错误(或行为不可预期)或者与拦截器的改写冲突 ✗
问题四:Page.of(pageNo, pageSize) 的分页与 LIMIT 参数可能不一致 ✗(一个来自 pageSize,一个来自方法参数,容易改一处漏一处)
修复后的写法:
// 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" | 配合其他写法可能泄露数据 |
更隐蔽的一点:这种注入往往不会报错——它只是"排序不对",日志和监控都看不出来 ✗
正确的处理:
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 控制,不是字符串 ✓ |
最强的防注入设计是"让危险输入根本无法表达"——把"字符串排序字段"换成"方法引用",注入面直接归零 ✓
这比"拼串 + 校验"更彻底——因为没有拼串这个动作了。
实体:
@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:
@Mapper
public interface ArticleMapper extends BaseMapper<Article> {
/** 联表查询仍然写 XML */
List<Article> findWithAuthor(@Param("keyword") String keyword,
@Param("limit") int limit);
}<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 部分):
@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 片段) ✓
更稳的写法(完全避免拼串):
// 用 updateById 前先读出来会丢更新;用 setSql 是常见做法,但要确保拼进去的是"值"不是"字符串"
// 若要绝对安全,可以写 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 |
|---|---|---|
findById | 1 方法 + 3 行 SQL | 0 行(selectById) |
insert | 1 方法 + 3 行 SQL | 0 行(insert) |
logicDelete | 1 方法 + 3 行 SQL | 0 行(deleteById) |
search(三条件) | 1 方法 + 12 行动态 SQL | 0 行(4 行 Wrapper 代码) |
findByIds | 1 方法 + 7 行 SQL | 0 行(selectBatchIds) |
| 合计 SQL 行数 | 约 30 行 | 0 行 |
仍然需要手写 XML 的部分:
| 场景 | 为什么 |
|---|---|
findWithAuthor(联表查作者名) | Wrapper 只能表达单表条件,JOIN 表达不了 |
| 复杂聚合(按作者统计文章数) | GROUP BY + HAVING 用 Wrapper 写很别扭 |
| 字段自增等精细更新 | 写 XML 更清楚(虽然 setSql 能做) |
| 需要强制索引 / 优化提示 | SQL 要逐字控制 |
结论:
MP 消掉的是"单表模板化 SQL",不是"所有 SQL"。
一个务实的分工:80% 的单表操作交给 MP,20% 的复杂查询手写 XML ✓ —— 这也正是本节 5.6 节的那张表。
技术选型:逐查询决定
| 查询 | 选型 | 理由 |
|---|---|---|
| 单表 CRUD(订单/商品/用户) | Wrapper / BaseMapper | 模板化,零 SQL |
| 订单列表多条件筛选 + 分页 | Wrapper | 条件是"可选组合",condition 参数天然支持;动态 SQL 写起来反而啰嗦 |
| 订单详情(订单 + 明细 + 商品名 + 用户名) | XML | 多表联查,Wrapper 表达不了;且需要嵌套映射(《MyBatis 数据持久化》 的 resultMap) |
| 销售报表(按日/周分组统计) | XML | GROUP 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)),不要单独建索引 |
⚠️ 唯一索引那个坑值得展开:
如果唯一索引是 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):
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) |
| 2 | selectList(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) |
| 接口层的分页强制 | ✅ 强 | 列表接口的参数对象里分页字段设为必填 |
自定义拦截器的示意(思路):
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 的"质量门禁"、《参数校验与全局异常处理》 的"统一异常处理"是同一种思路:把正确性从"自觉"转移到"机制"上。
BaseMapper 完成单表 CRUD(零 SQL);selectList(null) 的语义与风险;@TableName / @TableId / @TableField / @TableLogic;LambdaQueryWrapper,并知道 condition 参数能替代 if 判断;PaginationInnerInterceptor,并知道不注册会静默退化成内存分页;@TableLogic 自动加 deleted = 0,且 deleteById 发的是 UPDATE;last() 的注入风险,只放写死的字符串;deleted 存时间戳的解法;saveBatch 要配驱动参数才真的快。数据访问主线到此完成(《JDBC 与数据库连接》 → 《MyBatis 数据持久化》 → 《MyBatis-Plus 通用 CRUD 与条件构造器》)。接下来可以:
暂无