ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis-Plus核心原理与企业级CRUD实践:告别重复SQL

MyBatis-Plus核心原理与企业级CRUD实践:告别重复SQL 从进公司的第二周开始我就被Service层那几百行反复式的 CRUD 代码折磨得够呛。每来一张新表就要照着旧代码复制粘贴改个表名、改几个字段insertUser、updateUser、selectById、deleteById一写就是一整天全是体力活。后来项目组引入 MyBatis-Plus两个星期内数据访问层代码砍掉将近六成那是我第一次意识到单表增删改查这件事真的不该让程序员反复造轮子。这篇文章我会从框架的核心原理开始讲帮你理解它为什么能自动生成 SQL然后落地到一套能直接抄进生产项目的企业级实践包括通用 CRUD 服务、条件构造器、批量插入、分页优化和常见的坑。适合正在用 MyBatis 但想摆脱重复样板代码的 Java 后端也适合那些准备把 MyBatis-Plus 推进到生产级项目但还没摸清边界的人。1. 项目概述MyBatis-Plus到底解决了什么问题1.1 从反复粘贴到“无状态”的CRUD时代先明确一点MyBatis-Plus 不是要替代 MyBatis它是在 MyBatis 之上做增强官方原话是“只做增强不做改变”。引入它之后你现有的 XML 映射、自定义 Mapper 方法、SqlSession 机制都照常工作改变的只是基础的单表操作方式。很多团队之所以选它核心原因是省事。以前写一个用户表的增删改查你要写 Mapper 接口、写 XML、写 Service、写 ServiceImpl四层文件缺一不可改一个字段要四层联动。引入 MyBatis-Plus 之后Mapper 接口继承一个 BaseMapperService 继承一个 IService方法直接就有不需要 XML不需要自己拼 SQL。近几个版本还提供了 Db 工具类也就是无状态的通用 CRUD 服务。你甚至不用定义 Service 和 ServiceImpl直接 Db.save(user)、Db.lambdaQuery(User.class).eq(User::getStatus, 1).list()就能完成常规操作。这种风格非常适合模板方法、批量任务和无状态的工具类场景代码写起来非常干净这也是企业里越来越多人愿意把它当成数据访问层默认底座的原因。1.2 核心能力全景一张表究竟能省多少代码为了让你直观感受差异我们拿一张 user 表举例假设字段有 id、name、status、age、create_time。用原生 MyBatis 写一套单表 CRUD大概要创建 5 个文件写 20 条左右的 SQL如果用 MyBatis-Plus你只需要定义实体和继承 BaseMapper 的接口剩下的方法全是现成的。操作类型原生 MyBatis 需要做的事MyBatis-Plus 的做法插入单条写 insert 语句、动态判断 nullBaseMapper.insert(entity)按 ID 查询写 selectByIdBaseMapper.selectById(id)条件列表查询写 selectList where 动态拼接LambdaQueryWrapper 一行解决按条件更新写 update 语句逐字段判断LambdaUpdateWrapper.set(...).eq(...)分页查询手写 limit、count 两条 SQLPage 分页插件自动解析逻辑删除每次 SQL 都写 deleted0TableLogic 自动追加条件乐观锁手动用 version 字段拼接 updateVersion 乐观锁插件批量保存手写 foreach 插入saveBatch、Db.saveBatch自动填充创建时间、更新时间两个字段每次都要手动 setMetaObjectHandler 全局处理这只是最基本的 BaseMapper 能力。再加上条件构造器 Wrapper、SQL 注入器、多租户插件、防全表更新插件生产环境里绝大多数单表场景都可以覆盖。1.3 适用场景与真实边界也得说说它的边界。MyBatis-Plus 的舒适区是单表 CRUD、单表条件查询、轻量级分页一旦遇到多表 join、复杂子查询、报表汇总这种场景你还是得写 XML。这个不是缺陷而是设计哲学它把简单的事做到极致把复杂的事交还给 SQL所以团队里如果有人指望一个框架解决所有查询最好趁早打消这个念头。实际落地时我建议把它当“单表 CRUD 的终结者”而非整个数据访问层的替代品。复杂的统计报表、跨表业务模型老老实实写在 XML 里用 Select、ResultMap 明确控制结果映射。框架和原生 SQL 混用才是生产项目里最常见的姿态。2. 核心原理拆解从Mapper接口到SQL执行的这条链路2.1 表结构元数据TableInfo是怎么建立起来的先说清楚一个关键点MyBatis-Plus 之所以能自动生成 SQL是因为它在启动阶段就把实体类解析成了自己的一套表结构元数据这套元数据叫 TableInfo。每个实体类启动时都会被扫描扫描的内容包括表名、主键、字段列表、字段属性、逻辑删除字段、乐观锁字段、自动填充字段等。比如你用 TableName(t_user) 指定了表名用 TableId(type IdType.ASSIGN_ID) 指定了主键策略用 TableField(nick_name) 指定了字段映射这些信息最终都会进入 TableInfo 的缓存结构后续所有自动 SQL 都从这里取数据。这个地方很容易踩坑的是 ID 生成策略它直接影响插入行为。IdType.AUTO 是数据库自增插入后会用 Jdbc3KeyGenerator 回填主键IdType.ASSIGN_ID 是分布式雪花 ID插入前由框架生成雪花值再 set 到实体里IdType.ASSIGN_UUID 则是生成去掉横线的 32 位字符串IdType.INPUT 就是完全交给业务层自己设置主键。生产环境多实例部署我一般建议用 ASSIGN_ID避免依赖数据库自增导致的性能瓶颈与主键冲突风险。字段解析也不是只有驼峰转下划线那么简单。实体里用 TableField 显式指定列名时以注解优先没有注解时才走 map-underscore-to-camel-case 的驼峰映射再往下还有字段类型处理器 field-handler 的注册。这些规则全部拼起来最终形成一个完整的 TableInfo。2.2 BaseMapper方法为什么不需要写SQL如果你追过源码会看到 Mapper 接口上的 BaseMapper 只是一个接口定义真正的实现逻辑是通过 SQL 注入器把一系列预置方法绑定到 MapperProxy 上。MyBatis 本身的 Mapper 是通过 JDK 动态代理生成的代理类执行时通过 MappedStatement 找到对应的 SQL。MyBatis-Plus 做了一件巧妙的增强启动时扫描所有自定义的 Mapper 接口发现有继承 BaseMapper 的就把那些预置的 CRUD 方法构建成 MappedStatement 并注册进去每个方法背后都有一条自动拼接好的 SQL 模板。以 insert 为例遍历 TableInfo 里的字段列表过滤掉主键和自动填充字段按 INSERT INTO 表名 (列名...) VALUES (#{属性名...}) 的形式拼出 SQL。以 selectById 为例拼出 SELECT 列 FROM 表 WHERE 主键 ?。这些 SQL 模板并不会缓存死因为它们要根据 Wrapper 动态变化所以大多数查询走的都是 DynamicSqlSource每次执行时再根据参数最终绑定 SQL。理解了这套机制你就明白为什么 BaseMapper 里那些方法号称“无需 SQL”表面看是魔法底层仍然是 MyBatis 的 MappedStatement 体系只是把原本需要人肉完成的 SQL 模板改成了框架按元数据自动生成。2.3 条件构造器Lambda表达式是怎么变成WHERE条件的条件构造器 Wrapper 是 MyBatis-Plus 最有特色的部分LambdaQueryWrapper 允许你写 userMapper.selectList(new LambdaQueryWrapper ().eq(User::getStatus, 1).like(User::getName, 张))完全不用接触列名列名跟着实体走。它的原理是 Lambda 表达式被编译成了 SerializedLambda框架可以从 SerializedLambda 的 implMethodName 中反解出方法名比如 getName 对应 Getter 方法再按照 Java Bean 规范去掉 get、首字母小写得到属性名然后通过 TableInfo 查到这个属性对应的数据库列名。为什么要用 Lambda 而不是字符串好处是编译期就检查了属性是否存在重构字段时不会出现 SQL 里列名忘了改的情况IDE 跳转和搜索也都方便所以我在团队规范里直接要求能用 Lambda 写就别用字符串写。掌握 Wrapper 的几个核心方法就够用了eq、ne、gt、ge、lt、le、like、in、isNull、orderByAsc、orderByDesc、last、groupBy还有 and 与 or 的嵌套组合。嵌套条件是最容易写错的地方比如“状态为1且(名称包含张或备注包含技术)”这种括号表达式。很多人第一反应是 eq(status,1).and(w - w.like(name, 张).or().like(remark, 技术))这样写就是对的关键是 or 要包在 and 子句里让括号结构符合业务意图。条件构造器本质上会把这些条件解析成一段动态 SQL 片段最后拼接到主 SQL 之后理解成“它会帮你拼 WHERE 后的部分”就够了。3. 企业级实操从CRUD到通用服务的完整落地3.1 环境准备依赖、配置与分页插件注册实操第一步是引入依赖。生产项目基于 Spring Boot 的场景我一般用 mybatis-plus-boot-starter版本就看分支3.5.7 之后仍然是这个坐标配合 Spring Boot 3 时要注意引入的是 mybatis-plus-spring-boot3-starter这是很多人升级时会翻车的地方。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.12/version /dependency然后是 application.yml 的基础配置逐个解释关键项mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0mapper-locations 是给自定义 XML 用的map-underscore-to-camel-case 让数据库列 sid 自动映射到实体 sidid-type 设置全局主键策略实体里被 TableId 覆盖时以实体注解优先logic-delete-field 指定全局逻辑删除字段可以让所有实体自动拥有逻辑删除能力。log-impl 在本地开发时设为 StdOutImpl 可以打印 SQL生产环境请一定关掉否则日志量会非常可怕。分页是 MyBatis-Plus 最常见的插件能力但它不是默认生效的必须在配置类里注册 MybatisPlusInterceptor 并添加 PaginationInnerInterceptorConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }dbType 尽量显式指定不要靠框架自动推断因为生产环境连接池里的连接可能包含不同数据库方言写死更稳。setMaxLimit(500) 是保护机制正常业务明细查询很难超过 500 条超过时直接报错防止有人写漏条件把全表拉出来。setOverflow(false) 表示页码超出总页数时不再回退到第一页避免分页跳页带来的数据重复和业务事故。3.2 实体与Mapper的规范写法实体是 MyBatis-Plus 的核心映射对象我给出一个生产环境可用的模板Data TableName(t_user) public class User { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private Integer status; private Integer age; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; Version private Integer version; }几个设计点主键用 ASSIGN_ID避免数据库自增createTime 和 updateTime 交给自动填充不靠数据库时间因为数据库时间和应用时间可能有偏差deleted 和 version 也很典型一个逻辑删除、一个乐观锁字段本身仍然存在数据库里查询时框架会自动处理。Mapper 接口则非常薄public interface UserMapper extends BaseMapperUser { }如果后续需要自定义查询方法直接在接口里加方法然后在 mapper 目录写 XML或者直接加 Select 注解都不影响现有能力。这个组合的好处是开箱即用也保留扩展点。3.3 用Db工具类实现真正的无状态增删改查接下来是重头戏通用 CRUD 服务。如果你已经注册好了实体和 MapperMyBatis-Plus 3.5.x 提供的 Db 工具类可以直接开工它是对 Mapper 的进一步封装免掉了定义 Service、ServiceImpl 的环节真正做到了无状态增删改查。// 插入单条 User user new User(); user.setName(张三); user.setStatus(1); Db.save(user); // 批量插入 ListUser list buildUserList(); Db.saveBatch(list); // 按主键删除 Db.removeById(User.class, 1L); // 按条件删除 Db.lambdaUpdate(User.class) .eq(User::getStatus, 0) .remove(); // 查询单条 User one Db.lambdaQuery(User.class) .eq(User::getId, 1L) .one(); // 条件列表查询 ListUser users Db.lambdaQuery(User.class) .eq(User::getStatus, 1) .like(User::getName, 张) .orderByDesc(User::getCreateTime) .page(new Page(1, 10)) .getRecords();代码读起来几乎就是人话“查一下 User 表条件是这个、这个再按时间倒序分页”。这一点对可维护性提升很大新来的同事看代码成本极低。但我得提醒一句Db 工具类适合 Controller、Job、定时任务和临时数据处理脚本这些场景不需要 Service 层的缓存和事务编排如果业务本身有复杂的别名校验、状态机流转、多表操作事务边界那就老老实实定义 Service 和 ServiceImpl把事务注解放在 Service 方法上让 Db 工具类只做简单的数据访问。3.4 Wrapper条件构造器实战多个常见场景一次看懂条件构造器的最大价值是“动态”也就是当查询条件不确定时你不需要手动拼 SQL 和判断空值。比如一个用户筛选接口入参可能只有名字、可能只有状态、也可能全都有LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(name), User::getName, name) .eq(status ! null, User::getStatus, status) .orderByDesc(User::getCreateTime); page(userMapper.selectPage(new Page(pageNum, pageSize), wrapper));注意 eq 第一个参数是 boolean 条件为 false 时该条件自动忽略这个机制比手动 if 判断干净得多。更新场景同样适用Db.lambdaUpdate(User.class) .eq(User::getId, 1001L) .set(User::getStatus, 2) .set(User::getName, 李四) .update();这样生成的 SQL 是 UPDATE t_user SET status?, name? WHERE id?只更新你 set 过的字段不会动其它字段。这是它比先查出来再 update 整个实体更安全的地方也避免了并发场景下的“查改不一致”。嵌套条件再举一个查所有有效用户条件里 (name like 张 或 age 30) 并且 status1Db.lambdaQuery(User.class) .eq(User::getStatus, 1) .and(w - w.like(User::getName, 张) .or() .gt(User::getAge, 30)) .list();生成的 SQL 是 WHERE status1 AND (name LIKE ? OR age ?)。这种写法建议在团队里统一起来括号逻辑可读性比手动拼接字符串强太多。4. 生产级优化性能、安全与维护性的平衡4.1 批量插入从“百条”到“万条”三个可落地的方案生产环境里最常见的一个性能问题是批量插入。BaseMapper 的 insert 一次只能插入一条数据系统初始化数据、批量导入、上报明细这种场景几千条数据如果循环调用 insert会产生几千次数据库往返速度感人。实测数据可以给个参考本地数据库 500 条数据循环 insert 大约耗时 3 到 5 秒换成批量插入方案直接降到 500 毫秒以内差距非常明显。第一个方案是使用 MyBatis-Plus 内置的 saveBatch。ServiceImpl.saveBatch 或 Db.saveBatch 会把集合拆成每 1000 条一批通过 SqlSessionTemplate 在同一个 SqlSession 里连续执行多条 insert减少了会话切换开销。但注意它本质上仍然是一条一条 insert只是省了会话和批次处理成本。第二个方案是自定义 SQL 注入器使用 INSERT INTO ... VALUES (...), (...), (...) 这种多值批量插入。实现思路是先定义一个自定义方法然后继承 DefaultSqlInjector 注册并注入到 Mapper 里。操作稍微繁琐但收益直接。如果不想引入太复杂的注入器直接在 XML 里写 foreach 也能达到目标insert idbatchInsert INSERT INTO t_user (id, name, status, age, create_time, update_time) VALUES foreach collectionlist itemitem separator, (#{item.id}, #{item.name}, #{item.status}, #{item.age}, #{item.createTime}, #{item.updateTime}) /foreach /insert第三种最容易忽略MySQL 的连接 URL 加上 rewriteBatchedStatementstrue。这个参数会告诉 JDBC 驱动把多条 INSERT 批量重写成多值 VALUES 下发实测批量性能提升超过 8 倍。很多团队的批量还是慢不是 SQL 的问题而是驱动默认没开这个参数。配置类似 jdbc:mysql://localhost:3306/app?rewriteBatchedStatementstrue。我实际测试时加了它以后同样一批 5000 条数据的插入耗时从约 1600ms 降到了约 450ms。4.2 分页深翻页与count慢的治理分页插件用起来方便但生产环境深翻页是大坑。LIMIT 100000, 20 这种写法MySQL 要先把前十万条数据扫描出来再丢掉越往后翻越慢。很多时候接口慢得离谱不是索引没用而是 MySQL 驻留在回表和排序上的代价太高。方案一限制深度分页。超过某个页数直接报错或要求用户走条件筛选比如 maxLimit 已经能挡住单页过大但没挡住 offset 很大所以业务上最好限制最大页码。方案二使用游标分页或 keyset 分页也就是“上一次查询最后一条记录的主键/时间戳下一次查询从它之后开始取”。MyBatis-Plus 没有原生游标分页但是支持利用 Wrapper 条件自己拼WHERE id ? ORDER BY id ASC LIMIT 20。这个方案适合信息流类产品稳定性和性能都好。另一个容易忽略的是 count 查询性能。PaginationInnerInterceptor 会自动生成 count SQL 来统计总条数但它的自动生成基于 SQL 解析处理 order by 有时不够智能ORDER BY 在页面上其实没必要参与 count尤其排序字段没有索引时count 子句里带着 ORDER BY 会拖慢速度。可以看生成的 count SQL如果还带着 order by直接自定义 count 覆盖。我在项目里会全局配置禁止 count 时出现不必要的排序用覆盖 SQL 或精简字段来优化。还有一条经验是列表查询尽量避免 SELECT 全字段用 Wrapper.select(User::getId, User::getName) 裁剪列减少回表数据量。尤其列表页只需要展示用到的列时这种裁剪效果立竿见影。4.3 逻辑删除、乐观锁、多租户的企业级正确姿势逻辑删除在单表查询上确实优雅框架会自动把所有查询加上 deleted 0 条件删除变成 update deleted1。但难点在于被关联表查询、唯一索引和统计汇总时。一个经典场景是唯一索引。手机号在 user 表上建了唯一索引用户删了一次再注册同手机号第二次插入会因为 deleted 是 0 和唯一索引冲突而插不进去。常规解法是让唯一索引带上 deleted 字段比如 UNIQUE KEY uk_mobile (mobile, deleted)然后把删除时的 deleted 改成主键 ID这样每个删除记录都有唯一的一个 deleted 值不会和正常记录冲突。逻辑删除字段就不能只用 0/1 了MyBatis-Plus 的逻辑删除配置支持把逻辑删除值配成 DELETED通过 setLogicDeleteValue 等配置实现。乐观锁是解决并发更新的标准姿势。实体加了 Version 字段后注册 OptimisticLockerInnerInterceptor更新时自动拼接 WHERE version ?更新成功后 version 自增。如果两条线程同时读同一条数据后更新的线程会因为 version 不匹配而更新失败业务层需要收到影响行数 0 后做重试或提示。注意乐观锁字段不能用数据库触发器或手写 SQL 去改它必须跟着业务更新走否则框架判断版本号会失效。还有一个常见误用version 字段是 Integer 类型时初始值如果是 null更新时拼进 SQL 可能出现 null 值导致条件永远不成立这种情况要确保插入时 version 有默认值 0。多租户插件是 SaaS 系统的刚需。TenantLineInnerInterceptor 可以让你所有 SQL 自动追加 tenant_id 当前租户条件不需要在每个查询里手动写。使用时要特别注意多租户插件要注册在分页插件之前MybatisPlusInterceptor 内部是按添加顺序执行的顺序错了可能分页查不到 tenant 条件。4.4 慢SQL日志与运行时安全拦截生产环境的 SQL 日志一定要关掉 StdOutImpl改成运行时只记录慢 SQL。MyBatis-Plus 自带的插件机制支持自定义 InnerInterceptor你可以编写一个统计 SQL 执行耗时超过阈值的拦截器超过 500ms 就写 warn 日志并带上完整 SQL。这样既不影响大量正常语句又能在高峰期快速定位慢查询接口。Component public class SlowSqlInterceptor implements InnerInterceptor { Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { // 可以在这里记录开始时间到 ThreadLocal } Override public void afterQuery(...) { // 计算耗时并判断阈值 } }还有一个生产必备的安全插件防全表更新与删除。注册 BlockAttackInnerInterceptor 后如果出现没有 WHERE 条件的 update 或 delete框架直接抛异常从根本上挡住了手滑把全表数据清空的惨案。这个插件我强烈建议每个生产项目都加上成本极低但能拦住一次事故就值回票价。5. 避坑指南开发中绕不开的“深水区”5.1 字段映射与空值更新的坑第一个高频坑是驼峰映射失效。实体里写了 private Long userNo数据库列是 user_no框架默认能映射上但如果列名是 userNo 这种非标准命名或者用了奇怪的缩写就会查询结果集映射不上字段一直是 null。排查时优先看生成的 SQL 和查询结果再看全局配置 map-underscore-to-camel-case 是否打开最后用 TableField(user_no) 显式指定列名兜底。我个人的习惯是数据库设计一旦出现不合规列名直接显式写 TableField别赌默认映射。第二个坑是 updateById 传了 null 字段。很多人写 userMapper.updateById(user)发现实体的某个字段明明是 null结果数据库里对应列没有被清空。原因是 MyBatis-Plus 默认的字段策略是 NOT_NULL也就是值为 null 的字段不参与更新。想更新 null 字段有几种办法局部用 LambdaUpdateWrapper 的 set 方法强制更新或者实体字段上加 TableField(updateStrategy FieldStrategy.ALWAYS)或者在 updateById 前手动 set 字段。建议按业务场景选择不要全局放开因为全局放开会导致一些“只想改个别字段”的更新把别列为 null。5.2 逻辑删除与唯一索引冲突前面提过唯一索引加 deleted 的做法我再展开具体操作。假设表里有一个唯一索引是 phone逻辑删除字段是 deleted那么建表索引要改成联合索引 UNIQUE KEY uk_phone_deleted (phone, deleted)。删除时不能只把 deleted 设成 1要设成主键值保证每条删除记录的唯一性。这样新插入的记录 deleted 为 0不会历史删除记录冲突。框架侧如果开了逻辑删除普通查询会自动带 deleted0但联合查询或者自定义 SQL 里不会自动带你需要在 XML 里手动加条件否则可能出现删掉的用户还被关联表查到的情况。5.3 乐观锁失效的三种现场乐观锁失效是我排查过最多的并发问题。第一种是拦截器没注册只加了 Version 注解却没在 MybatisPlusInterceptor 里加 OptimisticLockerInnerInterceptor更新语句根本不会带 version 条件。第二种是更新走向了自定义 SQL 或 XML 方法乐观锁插件只对注入的通用方法生效自己写的 update SQL 要手动把 version 条件拼上。第三种是实体中 version 字段没有参与更新因为字段策略 NOT_NULL 导致 version 传入时被过滤结果更新语句里既没有 version 条件也没有 version 自增。测试乐观锁时最直接的办法是看日志里 update 语句是否带了 WHERE version ? 片段没带就是配置有问题。5.4 LambdaWrapper的泛型擦除与继承陷阱条件构造器在使用 Lambda 时有个隐藏陷阱如果在父类泛型里写 LambdaQueryWrapper 框架反解析 SerializedLambda 时需要知道当前方法的 OwnerClass。如果泛型被子类继承但泛型实参没有在方法签名里体现可能出现反序列化失败报类似 “cant find lambda cache” 的错。解决办法是在继承场景下给 wrapper 直接显式指定泛型不要依赖父类泛型推导。另外实体里如果有 boolean 类型的 getter命名不规范时也会导致反解析出错的列名异常所以实体字段名、getter 方法名务必规范。5.5 分页插件与其他插件顺序干扰MybatisPlusInterceptor 内部通过 List 保存多个 InnerInterceptor执行顺序就是添加顺序。多租户插件必须放在分页插件前面先追加 tenant_id 条件再分页否则 count SQL 解析时可能漏掉租户条件防全表更新 BlockAttackInterceptor 通常放在最后。插件的顺序问题在日志里很隐蔽现象是某条 SQL 没有租户条件或 count 与列表数据不一致排查时要回到顺序配置上先把顺序调整成多租户 - 分页 - 乐观锁 - 防全表更新再测一遍。这条链路系统跑稳之后我最大的体会是把 MyBatis-Plus 当成数据访问层的高效工具而不是银弹。单表 CRUD 用框架自动完成复杂报表和跨表查询老老实实写 XML两类场景各就各位才能让团队在业务需求里腾出手来优化真正的瓶颈。最后分享一个我在新项目里养成的实用小习惯每新建一张表先用 Db.lambdaQuery(XX.class).list() 跑一条空条件验证看实体映射和 SQL 输出是否正常这套一分钟的验证能帮你在上线前拦掉大部分字段映射和配置错误。
返回列表