MyBatis-Plus 进阶:分页、乐观锁、逻辑删除、自动填充四件套
以「列表页分页、并发扣库存超卖、软删除、审计字段四大企业刚需」引入:MybatisPlusInterceptor 插件总线与官方添加顺序、PaginationInnerInterceptor 含自定义 XML 分页、@Version 乐观锁冲突与重试实验、@TableLogic 的唯一索引冲突三坑、MetaObjectHandler 审计填充,附 FastAutoGenerator 代码生成与四件套逐项验证实验。
前置知识
- MyBatis-Plus 单表 CRUD:会 Wrapper、BaseMapper 与实体三注解——本篇四件套全部长在它们之上;
- 事务管理:记得 @Transactional 的代理本质与回滚规则——没读过也能跟,把 @Transactional 当成「方法级 BEGIN/COMMIT」即可,第 4 节补一句对照。
学习目标
读完本文你将能够:
- 组装 MybatisPlusInterceptor,按官方建议给四件套排序并说清理由;
- 用 PaginationInnerInterceptor 做单表与自定义 XML 分页,说清 Page 四个核心字段的来源;
- 用 @Version 实现乐观锁,冲突时给出正确的业务响应(重试或放弃);
- 用 @TableLogic 实现逻辑删除,预判唯一索引冲突等三个工程坑并给出对策;
- 用 MetaObjectHandler 自动填充审计字段,说清它与数据库默认值的分工;
- 跑通 FastAutoGenerator 代码生成,知道生成后的三步验收。
预计 55 分钟。
1. 你现在要解决什么问题
企业系统对持久层有四个几乎必提的需求:列表页要分页,不能一口气捞十万行;库存、名额这类热点数据并发修改不能互相覆盖,超卖是事故;内容下架、用户注销要「软删除」——数据还在、界面上消失;还要能审计谁在什么时候改过什么。四件事 MP 都给了现成组件:分页插件、乐观锁插件、逻辑删除、自动填充。它们不是四个孤立功能,而是同一套机制(060 讲的拦截器)长出的四件套——分页与乐观锁住在 MybatisPlusInterceptor 里,逻辑删除与自动填充是实体层与注入器的配合。本篇逐个开启、逐个验证,重点不是 API 而是每个组件的「语义」:它替你改写了什么 SQL、哪些行为会被改变、哪个口子仍然敞着。
2. 准备现场:插件总线 MybatisPlusInterceptor 与添加顺序
四件套里两件是插件,先把总线装好。060 见过 MybatisPlusInterceptor 的组装,这里补全四件套:
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 分页
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); // 乐观锁
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 防全表更新
return interceptor;
}
}这个类是 MP 的插件总线:一个 MyBatis 插件、一列 InnerInterceptor,query 走查询线、update 走更新线,把内部组件在合适的时机挨个调用。顺序不是随意的——060 引过官方建议:会改写 SQL 的优先放入,只做检查的最后放入。本篇四件套里分页与乐观锁各改各的语句(分页改 SELECT,乐观锁改 UPDATE),互不干扰可同档;防全表更新是纯检查,收尾。必须记死的硬约束只有一条:分页要排在多租户、动态表名这类会改写 SELECT 的插件之后——排错了,count 与 LIMIT 就基于错误形态的 SQL 计算。
3. 分页:Page 对象与自定义 XML 分页
单表分页 060 演过机制,这里补全 Page 四个字段的语义:
Page<Product> page = productMapper.selectPage(
new Page<>(2, 10), // current=2, size=10
Wrappers.<Product>lambdaQuery().eq(Product::getStatus, 1));
page.getRecords(); // 本页数据(列表 SQL 的产出)
page.getTotal(); // 总条数(count SQL 的产出)
page.getPages(); // 总页数(total 与 size 推算)
page.getCurrent(); // 回读当前页;page.getSize() 回读页大小很多人不知道的特性:自定义 XML 方法也能分页。Mapper 方法第一个参数传 Page,其余参数照常,XML 里只写业务 SQL,分页插件照样拦截改写:
Page<OrderVo> selectOrderPage(Page<OrderVo> page, @Param("status") Integer status);<select id="selectOrderPage" resultType="com.example.shop.vo.OrderVo">
SELECT id, order_no, total_amount, created_at
FROM orders
WHERE status = #{status}
ORDER BY created_at DESC
</select>XML 里没有 LIMIT——它是插件在运行时改写进去的。翻页翻到几万页之后越来越慢的「深分页」是另一个世界的问题,090 病历本专治。
4. 乐观锁:更新丢失与 @Version
场景:两人同时编辑同一行库存,各自读到 stock=10,都改成 8 提交——后提交的把先提交的覆盖掉,一次减员凭空消失。这叫更新丢失,是「先读后写」模式的固有漏洞。数据库事务防不住它(两个事务都合规提交),要靠并发控制策略。乐观锁是其一:读时记下版本号,写时带条件比对。
三步启用:实体加 @Version 字段、注册 OptimisticLockerInnerInterceptor(第 2 节已装)、更新前先查出实体(旧版本号来自实体):
@Version
private Integer version;updateById 时 MP 自动把 SQL 改写成:
UPDATE products SET stock = ?, version = 2 WHERE id = ? AND version = 1影响行数 1 = 独占成功;0 = 你读到的版本已被别人改走。这就是 CAS(Compare-And-Swap)思想的数据库形态。亲手复现一次冲突:
@Test
@DisplayName("后提交者版本过期,影响行数为 0")
void optimisticLockConflict() {
Product first = productMapper.selectById(1L); // 两个「用户」同时读到 version=1
Product second = productMapper.selectById(1L);
first.setStock(first.getStock() - 1);
assertThat(productMapper.updateById(first)).isEqualTo(1); // 先提交成功,version 1 → 2
second.setStock(second.getStock() - 1);
assertThat(productMapper.updateById(second)).isEqualTo(0); // 后提交 0 行:版本过期
}影响行数为 0 不是异常,是被你吞掉的并发冲突——业务代码必须表态。标准响应是「重读重试、限次防活锁」:
public void deduct(Long id, int n) {
for (int i = 0; i < 3; i++) {
Product p = productMapper.selectById(id);
p.setStock(p.getStock() - n);
if (productMapper.updateById(p) > 0) {
return;
}
}
throw new ConcurrentUpdateException("操作过于拥挤,请稍后重试");
}两个边界。其一,乐观锁不替代事务——事务(spring-boot/100)保证一段 SQL 的原子提交与回滚,乐观锁防的是并发「更新丢失」,两者正交、工程上常常同时出现;它也不修改 MVCC 的快照规则(mysql/430),只是给 UPDATE 加了一个当前读的比对条件。其二,作用范围:乐观锁语义只对「带实体的更新」生效(updateById 与 update(entity, wrapper));update(null, wrapper) 这种纯 wrapper 路径不带版本条件——想绕过它很容易,团队要约好热点行只走实体路径。
5. 逻辑删除:@TableLogic 与三个工程坑
全局配置(在实体字段上标 @TableLogic 也行,二选一):
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0语义两条:deleteById 不再是 DELETE 而是 UPDATE … SET deleted = 1;MP 注入器的查询自动追加 AND deleted = 0。一跑便知:deleteById(1L) 后看控制台是 UPDATE,selectById(1L) 返回 null,但数据库里行还在。
便利背后三个工程坑,逐个对策。
坑一:唯一索引撞逻辑删除。username 唯一索引加逻辑删,张三注销(deleted=1)后行还占着 username,新张三注册直接 DuplicateKey。两个方案:
| 方案 | 做法 | 代价 |
|---|---|---|
| 联合唯一 (username, deleted) | 唯一索引带上删除标记列 | 同名注销两次即自撞:两行 deleted 都是 1 |
| 标记差异化 | 删除时把标记列写成 id 或时间戳,再联合唯一 | 脱离 @TableLogic 的固定值语义,标记维护自己管 |
选型:删除频率低、同名回收需求真实,上方案二;一般业务方案一够用。
坑二:数据只增不减。逻辑删行永远躺在表里,页越滚越厚,连 deleted = 0 的查询也跟着变慢。对策:定期把老的历史删除行归档进历史表,主表保持体态。
坑三:物理删的口子仍然敞着。@TableLogic 只改写 MP 注入器管辖的方法;你自己写的 XML DELETE 原样物理删。对策是纪律:软删表禁写 DELETE 语句,新增删除语句进评审。
6. 自动填充:MetaObjectHandler 管审计字段
审计字段的诉求是「不许忘、不许错」,靠人手 set 注定漏。实现两件套——实体声明填充时机:
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;处理器负责填值:
@Component
public class AuditFillHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
}strict 的含义:字段已有值不覆盖,调用方显式指定的优先。既然数据库有 DEFAULT CURRENT_TIMESTAMP,为什么还要应用层填?三个理由:DEFAULT 只在 INSERT 语句省略该列时生效,行为依赖「列有没有出现在列清单里」这类细节,换个框架换个插入策略就变;MySQL 的列要自动更新时间还得加 ON UPDATE CURRENT_TIMESTAMP 子句,跨库方言立刻不齐;真正兜不住的是业务审计字段——updatedBy(谁改的)数据库默认值给不出来。应用层统一规则,跨库一致、可测试。
7. 代码生成:FastAutoGenerator
表结构定了,entity、mapper、service、controller 的样板交给生成器(依赖 mybatis-plus-generator 加一个模板引擎,官方示例用 freemarker,以官方文档为准):
public class CodeGen {
public static void main(String[] args) {
FastAutoGenerator.create(
"jdbc:mysql://localhost:3306/shop", "root", "password")
.globalConfig(b -> b.author("fandex")
.outputDir("src/main/java")
.disableOpenDir())
.packageConfig(b -> b.parent("com.example.shop").xml("mapper/xml"))
.strategyConfig(b -> b.addInclude("products", "orders")
.entityBuilder().enableLombok()
.mapperBuilder().enableBaseResultMap()
.controllerBuilder().enableRestStyle())
.execute();
}
}生成不是结束,三步验收:打开实体核对字段类型与表一致(decimal 对 BigDecimal、datetime 对 LocalDateTime,映射错类型是最常见的生成事故);确认 XML 落在 mapper-locations 指向的目录、Mapper 接口在 @MapperScan 扫描范围内;写一条 CRUD 冒烟测试再合入——生成产物也要过测试这道门。
8. 实验:四件套逐个开启逐个验证
四件套别一口气全上,逐个加、逐个验证,出了问题才知道是谁。
- 分页:跑第 3 节 selectPage,断言 total 与 records.size();StdOutImpl 里数出 COUNT 与 LIMIT 两条 SQL;
- 乐观锁:第 4 节冲突测试跑绿;故意删掉 @Version 注解重跑,second 的 updateById 变成 1 行——两个用户都「成功」,更新丢失当场复现;
- 逻辑删:deleteById 后控制台应是 UPDATE;selectById 返回 null;再用一条手写 XML 的 DELETE 对比,体会坑三的口子;
- 自动填充:insert 一行查库,created_at 与 updated_at 非空;update 一行,updated_at 刷新而 created_at 不动。
动手实践
任务一:为 orders 表配齐四件套。 假设新表 orders(id、order_no、amount、status、version、deleted、created_at、updated_at),从零写四样东西:MybatisPlusInterceptor 配置 Bean(含分页与乐观锁,顺序按官方建议)、带 @Version 与 @TableLogic 的实体、MetaObjectHandler 审计填充、一条自定义 XML 分页方法(按状态查订单,方法签名带 IPage)。提示:这个任务是把第 2 到 6 节的零件组装到一张新表上,验收标准是第 8 节实验的四条全部跑绿。
任务二:乐观锁失败重试。 给「扣减库存」写一个带有限重试的乐观锁更新:冲突(updateById 返回 0)时重读最新 version 再试,最多 3 次,全失败抛业务异常。提示:重读要在重试循环体内,重试外读到的永远是旧 version;返回 0 的分支别忘了先打日志再重试,方便观察冲突次数。
先自己写,再对照参考实现:
任务一参考实现(骨架自检版)
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}@Data
@TableName("orders")
public class Order {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private BigDecimal amount;
private Integer status;
@Version
private Integer version;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}@Component
public class AuditMetaObjectHandler 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());
}
}// 自定义 XML 分页:签名带 IPage,XML 里不写 LIMIT
IPage<Order> selectByStatus(IPage<Order> page, @Param("status") Integer status);对照自检清单:分页在前乐观锁在后(顺序硬约束);version 字段类型 Integer 且实体有 @Version;逻辑删字段 deleted 默认值要与全局配置 logic-not-delete-value 一致;XML 分页方法的第一个参数是 IPage——四条各错一处,对应的实验现象分别是「分页不生效」「更新丢失复现」「查询带出已删数据」「COUNT 不出现」。
任务二参考实现(乐观锁重试)
public boolean deductStock(Long productId, int qty) {
for (int attempt = 1; attempt <= 3; attempt++) {
// 关键:每次重试都在循环体内重读,拿到最新 version
Product current = productMapper.selectById(productId);
if (current.getStock() < qty) {
return false; // 库存不足不是冲突,直接失败
}
current.setStock(current.getStock() - qty);
int rows = productMapper.updateById(current); // WHERE ... AND version = n
if (rows == 1) {
return true;
}
log.warn("乐观锁冲突,productId={},第 {} 次重试", productId, attempt);
}
throw new BusinessException("扣减库存失败:并发冲突超过重试上限");
}两个易漏点:其一,「重读在循环体内」是这段代码的正确性来源,提到循环外重试的就是拿旧 version 再撞一次墙;其二,区分「冲突重试」与「库存不足失败」——前者值得重试(别人刚改过,再看看),后者重试没有意义(数据本身不满足条件)。这也是面试里「乐观锁失败怎么办」的标准答案骨架:有限重试 + 冲突与业务失败分流。
自检
遮住上文与代码,凭记忆回答;答不出的回到对应小节重读。
- MP 官方对 InnerInterceptor 添加顺序的建议是哪一句?四件套怎么排,哪条顺序是硬约束?
- 自定义 XML 方法也能分页吗?方法签名要满足什么形态?XML 里要不要写 LIMIT?
- Page 的 records、total、pages 各来自哪条 SQL 的产出?
- 乐观锁影响行数为 0 说明什么?代码该怎么响应?@Version 靠什么 SQL 形态实现?
- 乐观锁与事务、与 MVCC 分别是什么关系?纯 wrapper 路径为什么绕过了乐观锁?
- 唯一索引撞逻辑删除的两个方案与各自代价是什么?
- @TableLogic 改写哪些方法?哪个口子仍会物理删,纪律是什么?
- strictInsertFill 的 strict 体现在哪?数据库 DEFAULT CURRENT_TIMESTAMP 兜不住哪些审计需求?