尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MyBatis-Plus企业级实战:从CRUD到插件体系的高频踩坑与性能优化

MyBatis-Plus企业级实战:从CRUD到插件体系的高频踩坑与性能优化 做 Java 后端这些年MyBatis-Plus以下简称 MP几乎是我接手过的每个项目的标配。从早期的单表 CRUD 工具到后来参与几个企业级系统的架构改造我对 MP 的感情很复杂它确实省掉了大量样板代码但省事不等于可以无脑用。我在生产环境里踩过的坑随便拎一个出来都能让线上出问题逻辑删除和唯一索引打架、雪花 ID 传到前端变了数字、updateById 清空字段失败、分页插件莫名其妙不生效……这篇文章不打算复读官方文档里的 Hello World而是把从 CRUD 到企业级实战这条路上踩过的坑、验证过的方案、最终沉淀进团队规范的东西一次讲清楚。如果你正在用 MP或者正准备把老项目迁到 MP建议耐心看完至少能帮你避开我走过的弯路。1. 先记住一个结论MP 省掉的是样板代码不是思考1.1 BaseMapper 和 IService到底该选哪个很多刚接触 MP 的人会在继承 BaseMapper 还是 IService 之间纠结我甚至在同一个团队里见过两种风格混用的情况代码看起来非常分裂。实际上两个接口的定位完全不同搞清楚了就不纠结。BaseMapper 是 MyBatis 层面的 Mapper 接口里面定义的方法对应的是单表的基础 SQL 操作比如 selectById、selectList、insert、updateById、deleteById 这一套。IService 则是 Service 层的封装它内置了 saveBatch、saveOrUpdate、lambdaQuery、lambdaUpdate 等方法底层封装了 BaseMapper目的是让你在业务层少写几行代码。我的建议很简单业务层统一继承 IService数据访问层需要自定义 SQL 时再单独在 Mapper 里定义方法。IService 提供的 lambdaQuery 和 lambdaUpdate 真的很顺手配合条件构造器写起来非常接近自然语言可读性比裸 SQL 好太多。至于为什么要把 BaseMapper 和 IService 拆开理解是因为后面排查问题时你需要清楚地知道一个方法的执行链路到底经过了哪一层。1.2 saveBatch 的批量不是你以为的批量IService 里有个 saveBatch 方法很多新手以为调用它就能获得批量插入的性能提升。真实情况是默认情况下saveBatch 只是循环对每条记录调用 insert然后用 JDBC 的批量提交机制统一发送但 MySQL 的 JDBC 驱动在没有特殊配置时并不会真的把多条 INSERT 合并成一个多 VALUES 语句。换句话说你调用了 saveBatch网络层面还是那条一条发性能几乎没有提升。要让批量插入真正提速需要在 JDBC 连接 URL 上追加一个参数jdbc:mysql://localhost:3306/your_db?rewriteBatchedStatementstrue这个参数的作用是把多条单行 INSERT 驱动的批量提交重写成一条多 VALUES 的 INSERT 语句网络往返次数大幅下降。我实测过导入 1 万条数据没开这个参数耗时接近 4 秒开启后不到 0.5 秒提升接近 10 倍。这一条在后面的性能章节还会展开这里先记住结论回头去检查你的连接配置。1.3 CRUD 之外插件体系才是企业级应用的真正价值MP 的价值绝不止是让你少写几个方法。字段自动填充、逻辑删除、乐观锁、多租户、动态表名、数据权限这些插件能力才是它在企业级项目中的核心竞争力。我见过不少团队用 MP 还停留在生成实体、写 Service、调 list的阶段插件配置一概没用等于买了一辆越野车只用来在市区代步。后面几个章节会逐个拆解这些插件的实际用法、配置方式和坑点。提前说一句插件的使用不是越多越好每个插件都有副作用理解它们的实现原理本质是对 MyBatis 的 Executor 和 StatementHandler 做拦截才能真正用好。2. 高频踩坑实录从怎么查不出来到数据被覆盖了2.1 逻辑删除 唯一索引上线前没发现的幽灵冲突这是我经历过的印象最深的一个坑。业务有个用户表phone 字段加了唯一索引用户注销走的是 MP 的逻辑删除deleted 字段置为 1。上线后发现一个诡异的问题用户注销后再次用同一个手机号注册数据库报 Duplicate entry。当时一度以为是注册逻辑没有判断已删除记录排查到最后才发现是逻辑删除与唯一索引的天然冲突。原因很简单逻辑删除只是把 deleted 从 0 改成 1那条记录还物理存在于表里唯一索引依然生效。用户注销后想再注册phone 已经撞上旧记录了。解决办法业内比较成熟有两种联合唯一索引把唯一索引从 phone 改成 (phone, deleted)利用 MySQL 对联合索引中 NULL 值的特殊处理。具体做法是删除记录时 deleted 字段不写 1 而是写 NULL正常记录保持 0。MySQL 联合索引中一个字段为 NULL 时该记录不参与唯一性约束所以同 phone 的多条已删除记录可以共存。删除时间戳把 deleted 字段改成 delete_timebigint/datetime正常记录为 NULL删除时写入当前时间戳。配合 (phone, delete_time) 联合唯一索引效果一样而且还能顺带记录删除时间。这里有个细节创建实体时deleted 的类型如果是 Integer逻辑删除插件默认把 1 当作已删除直接把数据库字段设计为deleted并允许 NULL 也可以但类型上我建议用Long或者直接改成时间戳字段语义更清晰也更好扩展。2.2 updateById 空值不更新字段为 NULL 时直接被忽略MP 默认的字段更新策略是 NOT_NULL也就是说调用 updateById 时实体对象里为 null 的字段不会出现在 SET 子句中。这个设计有好有坏好处是你更新一条记录时不容易误把其他字段置空坏处是当你确实想把某个字段清空时updateById 是做不到的。我真实碰到过一起事故用户中心有个备注字段产品需求是允许用户清空备注。前端把备注传成空字符串后端直接把 DTO 转成实体调用 updateById结果数据库里的备注纹丝不动用户以为功能坏了。排查半天才发现空字符串经过某个环节被转成了 null而 null 字段被 MP 吃掉了。如果业务里确实存在清空字段的需求有几个方案使用UpdateWrapper显式 setnew LambdaUpdateWrapperUser().set(User::getRemark, null).eq(User::getId, id)这样 SQL 中会出现 SET remarknull可以真正清空。在实体字段上标记TableField(updateStrategy FieldStrategy.IGNORED)告诉 MP 这个字段即使为 null 也要出现在 SET 子句里。全局配置update-strategy: ignored但我不建议代价太大。我后来在团队规范里明确了一条所有更新操作优先用 LambdaUpdateWrapper明确指定要更新的字段目的就是避免 updateById 的行为不可控。2.3 分页插件不生效新旧版本配置的典型差异分页插件不生效是群里问得最多的问题表现形式也很典型你传了 Page 对象结果 SQL 里没有自动拼接 LIMIT查出来的还是全量数据。最常见的根因是版本不匹配。MyBatis-Plus 从 3.4.0 开始对插件体系做了大调整老的PaginationInterceptor已经被移除现在统一用MybatisPlusInterceptor配合PaginationInnerInterceptor。如果你在网上搜到老教程照抄了旧的配置类在新版本里分页就是静默失效的——编译可能不报错但运行时没有任何分页效果。正确的配置是这样Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }注意setMaxLimit(500L)这一行它限制了单次查询最大条数防止有人恶意传一个 size100000 把数据库打爆。尤其你的分页接口是对外开放的这个限制必须加。还有一个容易被忽视的点MP 分页插件对 current 是 0 或负数的情况会自动修正为 1但 size 如果传了 0 或负数某些版本的插件行为不一致建议在 Controller 入口统一做参数校验别依赖插件兜底。2.4 雪花 ID 传到前端精度丢失MP 默认主键策略是 ASSIGN_ID也就是雪花算法生成的 Long 型主键。雪花 ID 是 19 位数字而前端 JavaScript 的 Number 类型能安全表示的整数上限是 2^53 - 1大约 16 位。也就是说Long 型 ID 通过 JSON 序列化返回给前端后末尾几位会被 JS 截断或四舍五入之后前端拿着这个丢失精度的 ID 再调接口查询结果自然查不到数据。这个问题在高并发分布式系统里特别明显因为只有多地部署、多应用实例时你才会改用雪花 ID。解决方案有两个层面第一层是序列化层在 Jackson 配置里把 Long 统一转成 String 再输出。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - builder .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } }注意要处理Long.class和long.class两种情况否则还是会漏。我见过有人只配了 Long.class结果基本类型 long 照样丢失精度。第二层是契约层和后端约定所有 ID 字段在传输层都按字符串处理。前端拿到的是字符串传给后端时框架也能正常反序列化成 Long不影响后续查询。这一步做了之后我所在团队再没出现过ID 变了的线上反馈。2.5 乐观锁 version 字段不生效的隐蔽场景MP 的乐观锁插件用起来很简单实体里加一个Version注解字段配置OptimisticLockerInnerInterceptor即可。但我发现很多人在使用中会遇到一个隐蔽问题updateById 时如果实体对象的 version 字段为 null乐观锁完全不生效。原因是 MP 生成更新 SQL 时会先判断实体的 version 字段如果为 null它不会尝试在 SET 子句里加 versionversion1也不会在 WHERE 条件里拼接 version旧值。也就是说你的乐观锁形同虚设——这在高并发业务里可能是致命的。比如一个库存扣减接口查出来库存是 10version 是 3但你在 Service 层构建更新实体时只 set 了 id 和库存数量version 没传那么 update 就不加乐观锁校验两个并发请求可能同时通过库存被写脏。正确做法是更新之前把查询结果中的 version 完整赋值到更新实体上或者用 LambdaUpdateWrapper 显式携带版本条件。更重要的一点是更新方法的返回值受影响行数要判断如果为 0说明版本冲突要给用户返回数据已过期请刷新后重试而不是假装成功。User user userMapper.selectById(id); user.setStock(user.getStock() - 1); // version 字段保留查询出来的旧值 int rows userMapper.updateById(user); if (rows 0) { throw new BusinessException(并发冲突请刷新后重试); }2.6 自定义 SQL 想复用 Wrapper 条件${ew.customSqlSegment} 的正确姿势MP 的 Wrapper 很方便但你有时候不得不在 Mapper 里写自定义 SQL比如多表关联查询。这种场景能不能复用 Wrapper 动态拼条件答案是可以用${ew.customSqlSegment}。Mapper public interface OrderMapper extends BaseMapperOrder { ListOrderVO selectOrderWithUser(Param(Constants.WRAPPER) WrapperOrder wrapper); }XML 里这样写select idselectOrderWithUser resultTypecom.example.vo.OrderVO SELECT o.*, u.name AS userName FROM t_order o LEFT JOIN t_user u ON o.user_id u.id ${ew.customSqlSegment} /select注意几个坑customSqlSegment生成的是WHERE ...的完整片段所以你在 SQL 里不能再写 WHERE直接写${ew.customSqlSegment}即可。Wrapper参数必须用Param(Constants.WRAPPER)注解Constants.WRAPPER 的值就是 ew这是 MP 的约定。传入的 Wrapper 只能用 QueryWrapper不能传 LambdaQueryWrapper 到这种场景吗其实都可以但 XML 里解析的是 ew 的 SQL 片段不区分 lambda 还是普通 wrapper。另外要警惕${}是字符串拼接虽然 MP 生成的 SQL 片段内部是预编译参数但如果你在 Wrapper 外面又手动拼接了用户输入风险就回来了。所以我的原则是自定义 SQL 里只允许通过 ew 传入条件不允许额外串任何用户输入。3. Wrapper 条件构造器的正确玩法从能用写到好用3.1 Lambda 优先为什么我禁止团队用 QueryWrapperQueryWrapper 用的是字符串列名比如.eq(name, 张三)这种写法在编译期完全没有检查实体类字段改了名或者拼错了运行期才会报错排除起来很痛苦。LambdaQueryWrapper 用方法引用比如.eq(User::getName, 张三)编译器直接兜底重命名字段时 IDE 也能自动感知。我在团队规范里直接写死一条代码中禁止使用 QueryWrapper 和 UpdateWrapper 的字符串列名形式一律用 LambdaQueryWrapper 或 LambdaUpdateWrapper。没有例外。这条规则执行之后因为字段名拼错导致的线上 bug 基本绝迹了。LambdaQueryWrapper 的使用也很简单ListUser users userService.lambdaQuery() .eq(User::getStatus, 1) .like(StringUtils.hasText(name), User::getName, name) .ge(User::getCreateTime, startTime) .orderByDesc(User::getCreateTime) .list();注意.like(boolean condition, column, value)这种重载当第一个参数为 false 时这个条件会被自动忽略非常适合做动态条件。3.2 多条件动态拼接和前端筛选器配合的完整示例最常见的业务场景是列表页的筛选条件前端可能会传 name、status、beginTime、endTime、deptId也可能都不传。用 MP 的动态条件可以写得很干净public IPageUserVO pageUsers(UserQuery query, PageUserVO page) { LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(query.getStatus()), User::getStatus, query.getStatus()) .like(StringUtils.hasText(query.getName()), User::getName, query.getName()) .ge(query.getBeginTime() ! null, User::getCreateTime, query.getBeginTime()) .le(query.getEndTime() ! null, User::getCreateTime, query.getEndTime()) .eq(query.getDeptId() ! null, User::getDeptId, query.getDeptId()) .orderByDesc(User::getCreateTime); return userMapper.selectPage(page, wrapper); }时间区间这里有个性能隐患如果 beginTime 和 endTime 都传了范围很宽create_time 上如果没有索引全表扫描跑不掉。所以记得给 create_time 建立索引这是另一个层面的问题。还有一点对于in条件要先判断集合是否为空再拼接。wrapper.in(CollUtil.isNotEmpty(ids), User::getId, ids)否则传入空集合时 MP 会生成IN ()这种非法 SQL。3.3 聚合查询和子查询MP 的边界在哪里Wrapper 确实支持 groupBy、having、apply、inSql 这些方法理论上你可以用 MP 写出聚合 SQL。但我强烈建议多表关联、复杂聚合、子查询这类逻辑直接写在 XML 里。为什么因为 Wrapper 一旦开始承载复杂的聚合逻辑代码可读性下降得非常快。你写完一个月的wrapper.select(...).groupBy(...).having(...)回头看基本等于天书。相反XML 里的 SQL 是显式的DBA 或者后来接手的人一眼能看懂还能直接复制到 Navicat 里调优。我把这个边界说清楚单表单条件、简单动态条件LambdaQueryWrapperCop 程度可控。单表多条件筛选 分页LambdaQueryWrapper Page这是 MP 的主场。多表 join、聚合、子查询、复杂统计XML 自定义 SQL配合 Wrapper 参数复用查询条件。4. 企业级组合拳逻辑删除 自动填充 多租户如何协同不打架4.1 字段规范先行先定标准再写代码在企业级项目里最忌讳的是每个开发自定义一套公共字段。我参与过的项目最终都沉淀出一套基础字段约定实体统一继承一个 BaseEntity字段类型说明维护方式idbigint主键雪花 IDMP 自动create_timedatetime创建时间MP 自动填充update_timedatetime更新时间MP 自动填充create_bybigint创建人 IDMP 自动填充update_bybigint更新人 IDMP 自动填充deletedtinyint逻辑删除标记MP 逻辑删除插件versionint乐观锁版本号MP 乐观锁插件tenant_idbigint租户 IDMP 多租户插件这套约定的核心思路是基础设施字段由框架维护业务代码不能直接碰。开发写代码时只需要关注业务字段create_time、tenant_id 这些都在框架层自动完成减少人为遗忘。实体继承写法Data public class BaseEntity implements Serializable { TableId(type IdType.ASSIGN_ID) private Long id; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableField(fill FieldFill.INSERT) private Long createBy; TableField(fill FieldFill.INSERT_UPDATE) private Long updateBy; TableLogic private Integer deleted; Version private Integer version; }4.2 MetaObjectHandler 自动填充的完整配置与失效场景自动填充要生效需要两步实体上标TableField(fill FieldFill.INSERT_UPDATE)同时实现 MetaObjectHandler。Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { LocalDateTime now LocalDateTime.now(); this.strictInsertFill(metaObject, createTime, LocalDateTime.class, now); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, now); Long userId getCurrentUserId(); this.strictInsertFill(metaObject, createBy, Long.class, userId); this.strictInsertFill(metaObject, updateBy, Long.class, userId); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateBy, Long.class, getCurrentUserId()); } }几个实战要点strictInsertFill和strictUpdateFill会检查实体中对应字段是否已经有值如果有值就不覆盖。这个特性很重要比如你需要手动指定 create_time 倒排历史数据时不要被自动填充覆盖。如果实体没有加TableField(fill ...)注解填充是无效的。我见过同事配置了 MetaObjectHandler 但实体上没标注解结果 create_time 全部为 null。insert 时如果某个填充分被主键生成晚于填充没有影响因为主键和这些公共字段没有依赖关系。自动填充的字段在 update 时如果实体里显式设了值strict 版本照样不会覆盖只有实体里为 null 才会触发填充。还有一点要特别注意update 时自动填充依赖的是实体上的TableField(fill FieldFill.INSERT_UPDATE)但如果你用 LambdaUpdateWrapper 做更新且没有传递完整实体自动填充也可能不触发。原因是 MP 的填充逻辑处理的是实体对象的元数据如果更新 SQL 是从 wrapper 构建的部分版本的 fill 不会走实体。这种场景我建议手动在 wrapper 的 set 子句里加上 update_time。4.3 多租户插件与手写 SQL 的兼容处理多租户插件TenantLineInnerInterceptor的原理是解析 SQL 的 AST自动在 WHERE 条件中加入 tenant_id 当前租户。听起来很智能实际使用中需要注意几个点第一插入数据时实体必须带 tenant_id。多租户插件只负责给查询和更新加条件但 insert 时它不会自动把 tenant_id 塞进实体你需要自己在 MetaObjectHandler 的 insertFill 里填充 tenant_id或者从当前登录上下文取。第二部分表不需要租户隔离。比如字典表、系统配置表可能是所有租户共享的。遇到这种情况在配置 TenantLineHandler 时通过 ignoreTable 方法排除TenantLineInnerInterceptor tenantLine new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { return new LongValue(TenantContext.getTenantId()); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { return sys_dict.equals(tableName) || sys_config.equals(tableName); } });第三XML 手写 SQL 的兼容。TenantLineInnerInterceptor 会解析最终执行的所有 SQL包括 XML 里的。如果你的 XML SQL 里涉及了多表 join其中某张表不带 tenant_id 字段插件会强制给它套上去可能报错。解决方式一是尽量让所有业务表都有 tenant_id二是对确实无法兼容的 SQL用InterceptorIgnore(tenantLine true)注解在 Mapper 方法上跳过。这个注解是个隐形福利很多文档没提到排查问题时能救你一命。4.4 乐观锁插件的组合配置顺序MybatisPlusInterceptor 可以加多个 InnerInterceptor但顺序很关键。官方推荐顺序是多租户、动态表名、分页、乐观锁、防全表更新与删除。为什么因为多租户插件要最早修改 SQL拼上租户条件分页插件要在 SQL 最外层拼接 LIMIT乐观锁插件要处理 version 的 SET 和 WHERE 条件。如果顺序反了SQL 可能被处理成错误结果。我见过把分页放在最前面导致多租户条件没拼进去的情况虽然不报错但数据直接串了租户这在生产环境是非常严重的事故。推荐的完整配置Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 1. 多租户 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler)); // 2. 乐观锁 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); // 3. 分页 PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); // 4. 防全表更新与删除 interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); return interceptor; }4.5 代码生成器工程化把团队规范固化到每一行代码说完了插件再讲工程化配套。MP 的代码生成器对于团队规范落地有奇效前提是你愿意花时间定制模板。MP 3.5.x 版本的生成器 API 变化比较大建议直接用官方推荐的 FastAutoGenerator。实际项目中我一般这样用实体统一继承 BaseEntity公共字段不重复生成Controller 统一返回统一响应体 R不直接返回裸对象Service 接口默认继承 IServiceServiceImpl 加 Service 和必要的事务注解字段注释读取数据库列注释保证实体上的 JavaDoc 可溯源模板中统一生成参数校验注解如 NotBlank、NotNull避免重复劳动。生成器不是一键生成完事而是把团队规范固化到自动化产物里。新成员加入后不需要手把手教他建类规范跑一遍生成器就拿到了统一风格的代码这个收益在团队扩张时非常明显。5. 性能实测分页慢、批量慢MP 场景下的优化路径5.1 大分页深翻页LIMIT 偏移越深越慢MP 分页插件生成的 SQL 本质上就是LIMIT offset, size。当 offset 很大的时候MySQL 需要先扫描并丢弃前 offset 行再返回 size 行这个成本随 offset 线性增长。比如LIMIT 100000, 20MySQL 实际扫描了 100020 行其中大部分被丢弃。优化方案按优先级排列限制最大翻页深度分页插件里 setMaxLimit 已经限制单页大小但限制不了 current 过大。建议服务端再校验当前页码超过 1000 直接拒绝提示用户使用条件筛选这是最省事的兜底。键集分页游标分页把LIMIT offset, size改成WHERE id ? ORDER BY id ASC LIMIT size。这种方式只适合按主键排序的场景但性能极好翻页越深优势越大。MP 里用 wrapper 的 gt 和 orderByAsc 就能实现。子查询延迟关联需要保留 offset 语义时先用子查询把主键查出来再关联回原表取数据。SELECT * FROM t_order WHERE id IN ( SELECT id FROM t_order ORDER BY id LIMIT 100000, 20 );这个写法虽然不直观但能避免大表上深翻页导致的回表浪费。我经历过一个订单查询接口深翻页到 1 万行后用这个改写耗时从 1.8 秒降到 60 毫秒。5.2 rewriteBatchedStatements批量插入性能提升 10 倍前面提过的 rewriteBatchedStatements这里展开验证一下。我用 Spring Boot MP 的 saveBatch 插入 5 万条数据做对比配置耗时备注未开启 rewriteBatchedStatements约 19 秒每条 INSERT 单独提交网络往返多开启 rewriteBatchedStatements约 1.8 秒多条 INSERT 合并为一条多 VALUES这个参数是加在 JDBC URL 上的对 MP 的所有批量操作都生效。如果你的项目已经在生产环境跑了改一下连接 URL 重启即可代码零改动却实打实带来 10 倍提升属于性价比最高的性能优化。另外有个配套参数可以关注allowMultiQueriestrue。它允许一条 SQL 里用分号分隔多条语句但这种能力有 SQL 注入扩展的风险不建议在未充分评估的情况下开启。rewriteBatchedStatements 已经能满足绝大多数批量插入需求。5.3 p6spy 与慢 SQL 定位看真实参数比看打印占位符有用MP 自带 SQL 日志会打印Preparing: SELECT ... WHERE name ?和Parameters: 张三(String)开发联调够用。但生产环境我不建议开 MP 的 SQL 日志原因很简单量大、噪杂、影响性能。更推荐的做法测试环境接入 p6spy它能把 SQL 的真实执行 SQL参数已填入和耗时打出来排查问题非常直观。生产环境则用 MySQL 的慢查询日志或者云数据库的 SQL 审计能力抓慢 SQL定位后再 explain 分析执行计划。一个真实案例某列表页原本 300ms加了status ! 1条件后暴涨到 3 秒。explain 一看条件让 MySQL 放弃了 create_time 索引走了全表扫描。解决方案是把! 1改写成status 0 OR status 2的区间查询优化器能正确走索引。这个问题的根源和 MP 无关但在 MP 生成的 SQL 上更容易出现因为 Wrapper 写起来太简单开发往往会忽略执行计划。5.4 二级缓存与热点数据我的取舍MyBatis 自带二级缓存namespace 级但默认不开启。MP 也继承了 MyBatis 的这一能力。我在实际项目中分布式环境下从来不开启 MP 二级缓存。原因很现实二级缓存是进程内的缓存一致性需要自己保证多个应用实例之间只能靠广播或短过期时间实现一旦有脏数据排查成本极高。我一般把热点数据缓存放在 Redis由 Service 层显式控制。比如商品信息查的时候先查 Redis没命中再查数据库更新时先更新数据库再删缓存。这样链路清晰缓存失效可控。如果你确实要用二级缓存记住一句话只用在数据几乎不变、单机部署的场景同时配置 flushCache 和 useCache 时要想清楚读写放大。绝大多数情况下这是我个人不推荐的路线。5.5 避免 N1 查询批量查询的正确姿势企业级项目中N1 查询是个高频问题。比如查订单列表拿到 20 条订单后循环查每条的用户信息这就是 21 条 SQL。MP 环境下这个习惯很容易被 wrapper 的便利性掩盖。我的实战做法是先批量查出订单的 user_id 集合再用userService.listByIds(userIds)一次性查出用户映射到 Map最后在内存中关联。这样 21 条 SQL 变成 2 条性能提升立竿见影。ListOrder orders orderService.list(...); SetLong userIds orders.stream().map(Order::getUserId).collect(Collectors.toSet()); MapLong, User userMap userService.listByIds(userIds).stream() .collect(Collectors.toMap(User::getId, Function.identity()));6. 一些没写进官方文档的个人习惯最后分享几条我在项目沉淀中形成的习惯不算标准答案但实战中确实帮我避了不少雷。第一Mapper 方法命名要有约定。团队里统一规定查询单条用 query列表用 list分页用 page统计用 count。和 MP 自带的 selectOne、selectList 错开避免混淆。命名一致的好处是 code review 时扫一眼方法名就知道大概逻辑。第二Wrapper 不允许跨层传递。Controller 层不允许出现 WrapperService 层也不允许把 Wrapper 对象传给其他 Service。Wrapper 本质是 SQL 片段的生产器跨层传递会让代码耦合度高到没法维护也不利于后续做数据权限校验。第三所有对外分页接口统一返回 IPage不直接返回 List。IPage 里带着 total、current、size 信息前端做分页组件很顺手。如果直接返回 List前端拿不到总数还得再调一个 count 接口等于多一次数据库查询。第四实体类字段尽量用包装类型不用基本类型。int 的默认值是 0但很多时候 0 是有业务含义的状态 0 代表正常自动填充和条件判断都容易出问题。用 Integer 让 null 和 0 区分开MP 的 NOT_NULL 策略才能正确工作逻辑也更清晰。第五也是最重要的一点升级 MP 版本前一定要看 release note。MP 3.x 各版本之间 API 差异不小插件配置方式、默认策略都可能变。我说的分页插件失效问题就是例子。这个项目现在到了 3.5.x 之后 API 才相对稳定如果你还在老版本建议规划一次小版本升级升级时重点回归分页、逻辑删除、自动填充这三条主链路。回到最初的问题MP 到底适合什么样的项目我认为绝大多数以关系型数据库为核心、以 CRUD 为基础的业务系统都很适合。它最大的价值是把重复劳动压缩到极致让你把精力放在真正的业务逻辑上。但工具始终只是工具理解它背后的执行链路、清楚每个插件的边界和副作用才是从会用 MP走向用好 MP的分水岭。希望这篇实战记录能帮你在自己的项目里少走几步弯路。
返回列表