1. 项目概述为什么Mybatis-Plus值得深挖如果你正在用Java做后端开发尤其是基于Spring Boot的Web项目那么Mybatis-Plus简称MP这个名字你肯定不陌生。它几乎成了Mybatis的“官方增强包”从简单的CRUD代码生成到复杂的条件构造器、分页插件MP用极低的侵入性把我们从大量重复的样板代码中解放了出来。我最早接触MP是在一个用户量级不小的电商后台项目里当时团队为了赶进度几乎是无脑引入了MP的代码生成器一行配置Controller、Service、Mapper、Entity全都有了开发效率肉眼可见地提升。但很快我们就遇到了问题。一次大促前的压测某个核心列表接口的响应时间从几十毫秒飙升到了几秒。排查下来发现罪魁祸首是MP自动生成的selectPage方法在构造复杂查询时生成的COUNT语句性能极差。这让我意识到MP提供的“便利”背后藏着不少需要开发者主动去理解和规避的“坑”。它就像一把锋利的瑞士军刀功能齐全上手简单但如果你不了解每个工具的正确用法和限制很容易在关键时刻“伤到自己”。所以这篇内容不是一份简单的MP使用手册而是基于我多年在多个生产项目中深度使用MP的经验总结出的实战技巧与隐患分析。我会带你从“会用”走向“精通”不仅告诉你MP怎么用更会重点剖析那些官方文档可能一笔带过但在生产环境中至关重要的问题比如为什么你的分页突然变慢为什么乐观锁在高并发下失效了自动填充字段在特定场景下为何会“捣乱”以及如何优雅地处理多租户、逻辑删除这些“标配”功能带来的连锁反应。无论你是刚接触MP的新手想避开我踩过的那些坑还是已经用过一段时间希望进一步优化项目中的MP使用让代码更健壮、性能更优我相信接下来的内容都会对你有所启发。我们直接进入正题。2. 核心功能使用技巧与深度解析2.1 条件构造器告别手写SQL的“智能”与“陷阱”MP最吸引人的特性之一就是QueryWrapper和LambdaQueryWrapper。它们允许你用Java链式调用的方式构建查询条件看起来既优雅又类型安全。但这里面的门道远不止eq、like那么简单。技巧一优先使用LambdaQueryWrapper很多团队为了省事直接用QueryWrapper通过字符串指定字段名。这带来了两个隐患1. 容易拼写错误且编译期无法发现2. 重构困难字段名改了字符串条件全得手动找。LambdaQueryWrapper通过方法引用来获取字段名完美解决了这两个问题。// 不推荐 - 硬编码字段名易错难重构 QueryWrapperUser qw new QueryWrapper(); qw.eq(“user_name”, “张三”); // 推荐 - 类型安全支持重构 LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.eq(User::getUserName, “张三”);技巧二警惕apply方法的SQL注入风险apply方法非常强大允许你嵌入自定义的SQL片段用于处理数据库函数、复杂表达式等。但这也是一个高危操作。// 危险直接拼接用户输入 wrapper.apply(“date_format(create_time,‘%Y-%m-%d’) ‘” dateStr “‘”); // 如果dateStr是“2024-01-01‘ OR ‘1’‘1”就构成了SQL注入。 // 安全做法使用占位符 {0}, {1}... MP会对其进行预处理防止注入。 wrapper.apply(“date_format(create_time,‘%Y-%m-%d’) {0}”, dateStr);注意apply中的占位符语法是MP特有的它最终会使用PreparedStatement进行参数替换从而避免注入。务必养成使用占位符的习惯。技巧三in语句的“空集合”处理这是一个非常常见的运行时错误。当你动态构建一个in条件而传入的集合List为空时MP生成的SQL会是WHERE id IN ()这在大多数数据库如MySQL中会直接报语法错误。ListLong idList getIdsFromSomewhere(); // 可能返回空列表 LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.in(Order::getId, idList); // 如果idList为空执行时会报错。解决方案在构建条件前进行判空。if (CollectionUtils.isNotEmpty(idList)) { wrapper.in(Order::getId, idList); } else { // 根据业务逻辑处理返回空结果或者添加一个永假条件如 10 wrapper.apply(“1 0”); }更优雅的做法是封装一个工具方法统一处理这类边界情况。2.2 分页插件性能瓶颈的重灾区与优化方案MP的分页插件PaginationInterceptor3.x版本或MybatisPlusInterceptor3.4.x之后配置简单但也是最容易出性能问题的地方。隐患分析COUNT查询优化MP的分页逻辑是先执行一条COUNT(*)语句获取总数再执行分页LIMIT语句获取数据。问题就出在这个COUNT语句上。默认行为MP会原样复用你的查询WHERE条件来生成COUNT语句。如果你的查询包含多表关联JOIN、复杂的子查询或者GROUP BY这个COUNT语句会非常慢因为它需要扫描和计算所有符合条件的数据行。索引失效复杂的WHERE条件可能导致COUNT查询无法有效利用索引。优化技巧一自定义COUNT查询对于极其复杂的查询最彻底的优化是绕过MP自动生成的COUNT语句自己提供一个优化的COUNT查询。这可以通过XML映射文件或Select注解实现。在Mapper接口中定义两个方法public interface OrderMapper extends BaseMapperOrder { // 分页查询数据使用MP的IPage接收 IPageOrderComplexVO selectOrderPage(IPageOrderComplexVO page, Param(“query”) OrderQuery query); // 自定义的、优化过的COUNT查询 Long selectOrderPageCount(Param(“query”) OrderQuery query); }在Service层手动调用public IPageOrderComplexVO getOrderPage(PageOrderComplexVO page, OrderQuery query) { // 先查总数 Long total orderMapper.selectOrderPageCount(query); page.setTotal(total); if (total 0) { // 总数大于0才查数据 ListOrderComplexVO records orderMapper.selectOrderPage(page, query); page.setRecords(records); } return page; }这种方式将COUNT查询的掌控权完全交还给了开发者你可以为它单独设计索引和简化查询逻辑。优化技巧二使用page.setSearchCount(false)如果你的业务场景是“无限滚动”或“仅看前N页”不需要知道精确的总数可以完全关闭COUNT查询。PageUser page new Page(current, size); page.setSearchCount(false); // 关键设置不会执行COUNT查询 IPageUser result userMapper.selectPage(page, queryWrapper); // 此时 result.getTotal() 为0但 result.getRecords() 有数据这能极大提升性能尤其在海量数据分页时。优化技巧三评估是否真的需要分页这是架构层面的思考。很多前端“分页表格”的需求其实是为了展示和管理数据。对于后台管理系统数据量通常可控可以考虑使用“游标”方式基于id或create_time的范围查询来替代传统的LIMIT offset, size分页后者在offset很大时性能会急剧下降。MP的条件构造器同样能很好地支持这种模式。2.3 自动填充与乐观锁并发场景下的“沉默守护者”这两个功能都是为了简化特定场景的开发但理解其原理和局限至关重要。自动填充MetaObjectHandler的隐患TableField(fill FieldFill.INSERT)和FieldFill.UPDATE非常方便可以自动设置create_time,update_time,create_by等字段。隐患1局部更新导致的填充失效。这是最易踩的坑。当你使用updateById或update方法但传入的Entity对象中某些字段为null时MP默认只会更新非null字段。如果你的update_time字段在Entity里是null那么自动填充的update处理器不会生效因为MP认为你没有想更新这个字段。解决方案确保在更新时要么传入完整的实体所有字段都有值要么使用UpdateWrapper进行更新。UpdateWrapper的set操作会触发自动填充。// 方法一使用UpdateWrapper推荐 LambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(User::getId, userId).set(User::getEmail, newEmail); userMapper.update(null, updateWrapper); // 第一个参数传null自动填充会生效 // 方法二确保实体中要自动填充的字段有值可以是任意值会被覆盖 User user new User(); user.setId(userId); user.setEmail(newEmail); user.setUpdateTime(null); // 即使设为null只要字段在对象里有些版本的MP也会触发填充不这恰恰是问题所在。最稳妥的还是用方法一。隐患2自定义SQL不触发填充。如果你在Mapper的XML文件中手写了INSERT或UPDATE语句MP的自动填充机制是不会生效的。你需要手动在SQL中处理这些字段的值。乐观锁Version的原理与注意事项乐观锁通过一个版本号字段来解决并发更新丢失问题。MP的实现很简单更新时WHERE条件中带上版本号并将版本号1。UPDATE user SET name‘新名字‘, versionversion1 WHERE id1 AND version1;核心技巧更新失败时的重试机制。如果上述SQL更新行数为0说明被别人抢先更新了MP会抛出OptimisticLockException。你不能简单地吞掉这个异常必须由业务层决定如何处理——通常是告知用户“数据已变更请刷新后重试”或者对于非用户直接操作的后台任务实现自动重试。Transactional(rollbackFor Exception.class) public boolean updateWithRetry(User user) { int maxRetries 3; for (int i 0; i maxRetries; i) { try { int count userMapper.updateById(user); return count 0; } catch (OptimisticLockException e) { if (i maxRetries - 1) { throw new BusinessException(“更新冲突请稍后重试”); } // 重试前重新查询最新数据 User latestUser userMapper.selectById(user.getId()); // 将用户本次的修改合并到最新数据上这里需要根据业务逻辑实现 mergeUserChanges(latestUser, user); user.setVersion(latestUser.getVersion()); // 使用最新的版本号 } } return false; }重要限制乐观锁仅对updateById和update(实体, updateWrapper)方法生效。对于update(wrapper)这种根据条件批量更新的方法乐观锁是不生效的因为MP无法为批量更新中的每一条数据单独维护版本号条件。在高并发批量更新场景需要考虑其他方案如使用数据库悲观锁SELECT ... FOR UPDATE或在业务逻辑上做串行化处理。3. 高级特性与生产级配置3.1 多租户插件数据隔离的优雅实现与坑SaaS系统或后台管理平台常需要数据隔离。MP的多租户插件TenantLineInnerInterceptor通过在执行的SQL上自动追加租户ID条件来实现。// 配置示例 public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantInterceptor new TenantLineInnerInterceptor(); tenantInterceptor.setTenantLineHandler(new TenantLineHandler() { Override public Expression getTenantId() { // 从当前请求上下文如ThreadLocal获取租户ID String tenantId UserContext.getCurrentTenantId(); return new StringValue(tenantId); } Override public String getTenantIdColumn() { return “tenant_id”; // 数据库中的租户字段名 } Override public boolean ignoreTable(String tableName) { // 忽略不需要加租户条件的表如全局配置表 return “sys_config”.equalsIgnoreCase(tableName); } }); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor; }核心隐患与技巧JOIN查询的别名问题如果你的SQL涉及多表JOIN并且这些表都有tenant_id字段插件需要知道为每个表的tenant_id字段添加条件。这要求你的SQL语句中每个表都必须有明确的别名Alias。MP插件会尝试解析SQL为每个表自动追加AND alias.tenant_id ‘xxx‘。如果SQL写得很复杂或没有别名解析可能会失败导致数据隔离漏洞。务必在编写多表SQL时使用别名并充分测试。全表更新/删除的防护多租户插件能有效防止SELECT查询越权但对于UPDATE和DELETE操作它同样通过追加租户条件来限制范围。这意味着即使你误操作写了一个没有WHERE条件的更新语句最终执行时也会被加上AND tenant_id ‘xxx‘从而只影响当前租户的数据。这是一个非常重要的安全保护。租户ID上下文管理getTenantId()的实现是关键。必须确保在每次数据库操作时都能从正确的上下文如Spring Security的SecurityContext、或自定义的ThreadLocal中获取到当前租户ID。在异步任务如Async、消息队列消费者、定时任务中需要手动传递或设置租户上下文否则会导致租户ID为空插件行为不可预期。3.2 逻辑删除不仅仅是加个deleted字段逻辑删除通过TableLogic注解实现将删除操作变为更新deleted字段。TableLogic(value “0”, delval “1”) // 未删除为0删除为1 private Integer deleted;你需要知道的细节自动过滤启用逻辑删除后MP会自动在所有SELECT语句的WHERE条件中加上AND deleted 0。deleteById方法会变为执行UPDATE SET deleted1。联表查询的“坑”这是逻辑删除最大的隐患。当你手写多表JOIN的SQL时MP不会自动为关联表加上deleted条件例如SELECT u.*, o.order_no FROM user u LEFT JOIN order o ON u.id o.user_id WHERE u.status1;即使user和order表都配置了逻辑删除这条SQL也只会自动过滤u.deleted0而o.deleted条件需要你手动添加ON u.id o.user_id AND o.deleted 0。忘记这一点会导致查询出已被逻辑删除的关联数据引发业务逻辑错误。如何“真正”删除有时你需要物理删除如清理测试数据。可以使用deleteById的重载方法传入一个DeleteWrapper并在Wrapper中不指定逻辑删除字段的条件但这需要非常小心。更推荐的做法是直接使用SqlInjector或编写原生SQL执行物理删除并确保该操作有严格的权限控制。唯一索引冲突如果表上对username字段设置了唯一索引逻辑删除一个用户deleted1后就无法再创建一个同名用户了因为唯一索引约束仍然存在。常见的解决方案是将唯一索引改为包含deleted字段的复合唯一索引如UNIQUE KEY uk_username (username, deleted)但需要确保deleted为非删除状态的值如0才参与唯一性约束。或者使用删除时间戳delete_time代替0/1标志未删除时为NULL删除时为时间戳并将(username, delete_time)设为唯一索引因为NULL值在唯一索引中通常被视为不相等。3.3 自定义全局注入器与SQL注入器当MP默认提供的方法不能满足你时比如你想实现一个通用的“根据ID列表批量查询并返回Map”的方法就需要用到自定义SQL注入器。场景我们经常需要根据一批ID获取实体列表并转换为MapID, Entity方便查找。MP没有直接提供。定义自定义Mapper接口public interface ExpandBaseMapperT extends BaseMapperT { /** * 根据ID集合批量查询并返回 MapID, Entity * param idList ID集合 * return Map */ MapLong, T selectMapByIds(Param(“idList”) ListLong idList); }实现自定义方法在XML中select id“selectMapByIds” resultType“your.entity” SELECT * FROM your_table WHERE id IN foreach collection“idList” item“id” open“(” separator“,” close“)” #{id} /foreach /select注意这里返回的是ListT我们需要在Service层手动转成Map。如果想在Mapper层直接返回Map需要更复杂的ResultHandler处理。创建自定义SQL注入器关键步骤Component public class MySqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass) { ListAbstractMethod methodList super.getMethodList(mapperClass); // 添加你自己的通用方法 methodList.add(new SelectMapByIds()); // SelectMapByIds需要你继承AbstractMethod实现 return methodList; } }让你的Mapper继承自定义接口public interface UserMapper extends ExpandBaseMapperUser { // ... 其他自定义方法 }这样所有继承了ExpandBaseMapper的Mapper就都拥有了selectMapByIds这个通用方法。这个例子展示了如何扩展MP将通用的业务查询模式沉淀下来减少重复代码。4. 性能调优与生产环境最佳实践4.1 连接池与Mybatis配置MP基于Mybatis因此Mybatis本身的配置对性能影响巨大。二级缓存生产环境慎用。Mybatis的二级缓存是跨Session的在分布式环境下除非你使用Redis等集中式缓存并正确实现了序列化和失效策略否则很容易导致脏数据。对于绝大多数OLTP联机事务处理场景建议直接关闭二级缓存mybatis.configuration.cache-enabledfalse依靠数据库自身缓存和应用层缓存如Redis。一级缓存Mybatis默认开启一级缓存Session级别。在同一个SqlSession内相同的查询会直接返回缓存对象。这有时会导致问题比如你在一个事务里先查了一条数据然后另一个方法修改了数据库甚至是通过JDBC直接修改再查一次拿到的还是缓存里的旧数据。如果你希望每次都查询数据库可以在方法上使用Options(flushCache Options.FlushCachePolicy.TRUE)或者在查询的select标签里设置flushCache“true”。但通常在一个事务内保持一致性是更合理的设计。连接池配置使用HikariCP等高性能连接池。关键参数如maximumPoolSize最大连接数需要根据数据库性能和业务并发量调整不是越大越好。connectionTimeout获取连接超时时间和idleTimeout连接空闲超时也需要合理设置。4.2 监控与慢SQL排查当系统出现性能问题时快速定位到有问题的SQL是第一步。开启Mybatis SQL日志在开发或测试环境可以配置mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl将SQL打印到控制台。但在生产环境这会产生海量日志通常只会在特定情况下临时开启。使用P6Spy等SQL拦截工具P6Spy可以作为JDBC驱动代理记录所有SQL语句及其执行时间并输出到日志文件。它能帮你直观地看到哪些SQL执行慢。# application.yml spring: datasource: driver-class-name: com.p6spy.engine.spy.P6SpyDriver # 替换原驱动 url: jdbc:p6spy:mysql://localhost:3306/db?useSSLfalse # 在url前加p6spy在spy.properties中配置日志输出格式和过滤条件。结合APM工具对于分布式系统使用SkyWalking、Pinpoint等APM工具是更好的选择。它们可以自动追踪整个调用链并标识出其中耗时的数据库操作直接定位到慢SQL和对应的代码行。4.3 代码生成器的正确使用姿势MP的代码生成器AutoGenerator能极大提升初期开发效率但切忌“生成即结束”。定制化模板默认生成的Controller、Service、Entity等可能不符合你的项目规范。MP支持自定义模板文件.ftl或.vm。你应该根据团队规范定制一套自己的模板比如Entity类加上Swagger注解、Service接口和实现类分离、Controller统一继承某个基类并添加全局注解等。只生成一次后续手动维护代码生成器最适合在项目初期创建表结构对应的基础代码。一旦生成完毕这些代码就成为了你的业务代码的一部分后续的表结构变更或业务逻辑添加都应该手动修改这些文件而不是重新生成覆盖。重新生成会覆盖你之前写的所有业务逻辑。生成“领域模型”而非“数据库映射”在DDD领域驱动设计或一些分层架构中我们倾向于区分Entity与数据库表结构强对应和Domain Model业务领域模型。MP生成的Entity更适合作为前者。如果项目有更复杂的领域模型可以考虑以生成的Entity作为数据持久化对象PO再通过手动编码或MapStruct等工具将其转换为领域对象DO或视图对象VO/DTO。5. 常见问题排查与实战避坑指南在实际开发中总会遇到一些“诡异”的问题。这里记录几个我印象深刻的案例。问题一更新操作成功但字段值没变现象使用updateById更新一个实体返回affected rows 1但查询数据库发现某个字段的值没更新。排查检查实体对象中该字段是否为null。MP默认的更新策略是只更新非null字段。如果你从数据库查出一个对象修改了A字段但B字段保持null那么B字段不会被更新。检查是否有拦截器或AOP切面修改了数据例如审计拦截器可能在更新前将字段重置了。检查数据库触发器虽然不常见但数据库层面的触发器可能会在更新后修改你的值。解决确保要更新的字段在实体对象里不为null或者使用UpdateWrapper的set方法进行更新。问题二分页查询结果总数total不对现象调用selectPage方法返回的IPage对象中records当前页数据正确但total总记录数明显不对有时为0有时是一个很大的数。排查自定义COUNT语句问题如果你在XML中为分页查询自定义了COUNT语句通过id为selectPage_COUNT请检查这个COUNT语句的逻辑是否正确。它应该是一个只返回单个总数、去除了GROUP BY和ORDER BY的简化查询。多租户/逻辑删除插件干扰检查你的查询条件是否与多租户插件或逻辑删除插件添加的条件冲突。例如你的QueryWrapper里手动加了一个deleted 1而逻辑删除插件会自动加deleted 0导致WHERE条件矛盾COUNT结果为0。SQL语法兼容性某些极端复杂的SQL特别是使用了数据库特定函数或语法MP在将其转换为COUNT语句时可能会生成错误的SQL导致执行失败或结果错误。开启SQL日志对比数据查询语句和COUNT查询语句。解决对于复杂分页最稳妥的方式是采用前文提到的“自定义COUNT查询手动设置总数”方案完全掌控COUNT逻辑。问题三insert后主键回填失败现象执行insert后实体对象的自增主键id仍然是null。排查检查数据库表主键是否确实是自增AUTO_INCREMENT。检查MP的全局配置mybatis-plus.global-config.db-config.id-type是否设置正确。对于自增主键通常设置为IdType.AUTO。检查你的实体类TableId注解的type属性。如果数据库是自增这里也应该用IdType.AUTO。检查你是否在insert之后又对实体对象进行了其他可能导致属性被覆盖的操作。解决确保配置一致。一个常见的“坑”是为了使用MP的雪花算法分布式ID将id-type设为ASSIGN_ID但数据库表设计又是自增主键导致不匹配。需要根据你的ID生成策略统一配置。问题四事务方法内调用MP的select方法数据不一致现象在一个Transactional方法内先更新了数据然后立即用MP查询发现查到的不是最新数据。排查这通常不是MP的问题而是数据库事务隔离级别和Mybatis一级缓存共同作用的结果。在默认的“可重复读”REPEATABLE READ隔离级别下MySQL默认同一个事务内多次读取同一行数据会读到事务开始时的快照看不到本事务内后续更新除非是更新操作本身。Mybatis一级缓存SqlSession级别加剧了这个问题。即使你发出了新的SELECT语句如果参数完全相同Mybatis可能会直接从缓存返回对象。解决如果业务允许可以在查询方法上使用Transactional(propagation Propagation.REQUIRES_NEW)开启一个新事务但这会带来复杂度。更常见的做法是在同一个事务内如果需要读取刚更新的最新数据直接使用更新后的实体对象或者通过SqlSession强制清空本地缓存sqlSession.clearCache()但后者侵入性较强。更好的设计是避免在同一个事务方法中频繁依赖“立即查询刚更新的数据”这种模式。最后关于MP的使用我个人最深的体会是它极大地提升了开发效率但绝不能替代你对Mybatis和SQL本身的理解。当你遇到复杂查询、高性能要求、或者诡异的问题时最终往往需要你深入到底层的SQL和Mybatis原理去寻找答案。把MP当作一个得力的“助手”而不是一个“黑盒”才能真正用好它。在项目初期可以大胆使用它的便捷功能快速搭建框架在项目中后期尤其是性能敏感和业务复杂的模块要有能力、有意识地去审视和优化它生成的SQL必要时回归手写XML这才是成熟开发者应有的态度。
MyBatis-Plus实战:从高效开发到生产级优化的深度解析
发布时间:2026/5/19 14:04:16
1. 项目概述为什么Mybatis-Plus值得深挖如果你正在用Java做后端开发尤其是基于Spring Boot的Web项目那么Mybatis-Plus简称MP这个名字你肯定不陌生。它几乎成了Mybatis的“官方增强包”从简单的CRUD代码生成到复杂的条件构造器、分页插件MP用极低的侵入性把我们从大量重复的样板代码中解放了出来。我最早接触MP是在一个用户量级不小的电商后台项目里当时团队为了赶进度几乎是无脑引入了MP的代码生成器一行配置Controller、Service、Mapper、Entity全都有了开发效率肉眼可见地提升。但很快我们就遇到了问题。一次大促前的压测某个核心列表接口的响应时间从几十毫秒飙升到了几秒。排查下来发现罪魁祸首是MP自动生成的selectPage方法在构造复杂查询时生成的COUNT语句性能极差。这让我意识到MP提供的“便利”背后藏着不少需要开发者主动去理解和规避的“坑”。它就像一把锋利的瑞士军刀功能齐全上手简单但如果你不了解每个工具的正确用法和限制很容易在关键时刻“伤到自己”。所以这篇内容不是一份简单的MP使用手册而是基于我多年在多个生产项目中深度使用MP的经验总结出的实战技巧与隐患分析。我会带你从“会用”走向“精通”不仅告诉你MP怎么用更会重点剖析那些官方文档可能一笔带过但在生产环境中至关重要的问题比如为什么你的分页突然变慢为什么乐观锁在高并发下失效了自动填充字段在特定场景下为何会“捣乱”以及如何优雅地处理多租户、逻辑删除这些“标配”功能带来的连锁反应。无论你是刚接触MP的新手想避开我踩过的那些坑还是已经用过一段时间希望进一步优化项目中的MP使用让代码更健壮、性能更优我相信接下来的内容都会对你有所启发。我们直接进入正题。2. 核心功能使用技巧与深度解析2.1 条件构造器告别手写SQL的“智能”与“陷阱”MP最吸引人的特性之一就是QueryWrapper和LambdaQueryWrapper。它们允许你用Java链式调用的方式构建查询条件看起来既优雅又类型安全。但这里面的门道远不止eq、like那么简单。技巧一优先使用LambdaQueryWrapper很多团队为了省事直接用QueryWrapper通过字符串指定字段名。这带来了两个隐患1. 容易拼写错误且编译期无法发现2. 重构困难字段名改了字符串条件全得手动找。LambdaQueryWrapper通过方法引用来获取字段名完美解决了这两个问题。// 不推荐 - 硬编码字段名易错难重构 QueryWrapperUser qw new QueryWrapper(); qw.eq(“user_name”, “张三”); // 推荐 - 类型安全支持重构 LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.eq(User::getUserName, “张三”);技巧二警惕apply方法的SQL注入风险apply方法非常强大允许你嵌入自定义的SQL片段用于处理数据库函数、复杂表达式等。但这也是一个高危操作。// 危险直接拼接用户输入 wrapper.apply(“date_format(create_time,‘%Y-%m-%d’) ‘” dateStr “‘”); // 如果dateStr是“2024-01-01‘ OR ‘1’‘1”就构成了SQL注入。 // 安全做法使用占位符 {0}, {1}... MP会对其进行预处理防止注入。 wrapper.apply(“date_format(create_time,‘%Y-%m-%d’) {0}”, dateStr);注意apply中的占位符语法是MP特有的它最终会使用PreparedStatement进行参数替换从而避免注入。务必养成使用占位符的习惯。技巧三in语句的“空集合”处理这是一个非常常见的运行时错误。当你动态构建一个in条件而传入的集合List为空时MP生成的SQL会是WHERE id IN ()这在大多数数据库如MySQL中会直接报语法错误。ListLong idList getIdsFromSomewhere(); // 可能返回空列表 LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.in(Order::getId, idList); // 如果idList为空执行时会报错。解决方案在构建条件前进行判空。if (CollectionUtils.isNotEmpty(idList)) { wrapper.in(Order::getId, idList); } else { // 根据业务逻辑处理返回空结果或者添加一个永假条件如 10 wrapper.apply(“1 0”); }更优雅的做法是封装一个工具方法统一处理这类边界情况。2.2 分页插件性能瓶颈的重灾区与优化方案MP的分页插件PaginationInterceptor3.x版本或MybatisPlusInterceptor3.4.x之后配置简单但也是最容易出性能问题的地方。隐患分析COUNT查询优化MP的分页逻辑是先执行一条COUNT(*)语句获取总数再执行分页LIMIT语句获取数据。问题就出在这个COUNT语句上。默认行为MP会原样复用你的查询WHERE条件来生成COUNT语句。如果你的查询包含多表关联JOIN、复杂的子查询或者GROUP BY这个COUNT语句会非常慢因为它需要扫描和计算所有符合条件的数据行。索引失效复杂的WHERE条件可能导致COUNT查询无法有效利用索引。优化技巧一自定义COUNT查询对于极其复杂的查询最彻底的优化是绕过MP自动生成的COUNT语句自己提供一个优化的COUNT查询。这可以通过XML映射文件或Select注解实现。在Mapper接口中定义两个方法public interface OrderMapper extends BaseMapperOrder { // 分页查询数据使用MP的IPage接收 IPageOrderComplexVO selectOrderPage(IPageOrderComplexVO page, Param(“query”) OrderQuery query); // 自定义的、优化过的COUNT查询 Long selectOrderPageCount(Param(“query”) OrderQuery query); }在Service层手动调用public IPageOrderComplexVO getOrderPage(PageOrderComplexVO page, OrderQuery query) { // 先查总数 Long total orderMapper.selectOrderPageCount(query); page.setTotal(total); if (total 0) { // 总数大于0才查数据 ListOrderComplexVO records orderMapper.selectOrderPage(page, query); page.setRecords(records); } return page; }这种方式将COUNT查询的掌控权完全交还给了开发者你可以为它单独设计索引和简化查询逻辑。优化技巧二使用page.setSearchCount(false)如果你的业务场景是“无限滚动”或“仅看前N页”不需要知道精确的总数可以完全关闭COUNT查询。PageUser page new Page(current, size); page.setSearchCount(false); // 关键设置不会执行COUNT查询 IPageUser result userMapper.selectPage(page, queryWrapper); // 此时 result.getTotal() 为0但 result.getRecords() 有数据这能极大提升性能尤其在海量数据分页时。优化技巧三评估是否真的需要分页这是架构层面的思考。很多前端“分页表格”的需求其实是为了展示和管理数据。对于后台管理系统数据量通常可控可以考虑使用“游标”方式基于id或create_time的范围查询来替代传统的LIMIT offset, size分页后者在offset很大时性能会急剧下降。MP的条件构造器同样能很好地支持这种模式。2.3 自动填充与乐观锁并发场景下的“沉默守护者”这两个功能都是为了简化特定场景的开发但理解其原理和局限至关重要。自动填充MetaObjectHandler的隐患TableField(fill FieldFill.INSERT)和FieldFill.UPDATE非常方便可以自动设置create_time,update_time,create_by等字段。隐患1局部更新导致的填充失效。这是最易踩的坑。当你使用updateById或update方法但传入的Entity对象中某些字段为null时MP默认只会更新非null字段。如果你的update_time字段在Entity里是null那么自动填充的update处理器不会生效因为MP认为你没有想更新这个字段。解决方案确保在更新时要么传入完整的实体所有字段都有值要么使用UpdateWrapper进行更新。UpdateWrapper的set操作会触发自动填充。// 方法一使用UpdateWrapper推荐 LambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(User::getId, userId).set(User::getEmail, newEmail); userMapper.update(null, updateWrapper); // 第一个参数传null自动填充会生效 // 方法二确保实体中要自动填充的字段有值可以是任意值会被覆盖 User user new User(); user.setId(userId); user.setEmail(newEmail); user.setUpdateTime(null); // 即使设为null只要字段在对象里有些版本的MP也会触发填充不这恰恰是问题所在。最稳妥的还是用方法一。隐患2自定义SQL不触发填充。如果你在Mapper的XML文件中手写了INSERT或UPDATE语句MP的自动填充机制是不会生效的。你需要手动在SQL中处理这些字段的值。乐观锁Version的原理与注意事项乐观锁通过一个版本号字段来解决并发更新丢失问题。MP的实现很简单更新时WHERE条件中带上版本号并将版本号1。UPDATE user SET name‘新名字‘, versionversion1 WHERE id1 AND version1;核心技巧更新失败时的重试机制。如果上述SQL更新行数为0说明被别人抢先更新了MP会抛出OptimisticLockException。你不能简单地吞掉这个异常必须由业务层决定如何处理——通常是告知用户“数据已变更请刷新后重试”或者对于非用户直接操作的后台任务实现自动重试。Transactional(rollbackFor Exception.class) public boolean updateWithRetry(User user) { int maxRetries 3; for (int i 0; i maxRetries; i) { try { int count userMapper.updateById(user); return count 0; } catch (OptimisticLockException e) { if (i maxRetries - 1) { throw new BusinessException(“更新冲突请稍后重试”); } // 重试前重新查询最新数据 User latestUser userMapper.selectById(user.getId()); // 将用户本次的修改合并到最新数据上这里需要根据业务逻辑实现 mergeUserChanges(latestUser, user); user.setVersion(latestUser.getVersion()); // 使用最新的版本号 } } return false; }重要限制乐观锁仅对updateById和update(实体, updateWrapper)方法生效。对于update(wrapper)这种根据条件批量更新的方法乐观锁是不生效的因为MP无法为批量更新中的每一条数据单独维护版本号条件。在高并发批量更新场景需要考虑其他方案如使用数据库悲观锁SELECT ... FOR UPDATE或在业务逻辑上做串行化处理。3. 高级特性与生产级配置3.1 多租户插件数据隔离的优雅实现与坑SaaS系统或后台管理平台常需要数据隔离。MP的多租户插件TenantLineInnerInterceptor通过在执行的SQL上自动追加租户ID条件来实现。// 配置示例 public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantInterceptor new TenantLineInnerInterceptor(); tenantInterceptor.setTenantLineHandler(new TenantLineHandler() { Override public Expression getTenantId() { // 从当前请求上下文如ThreadLocal获取租户ID String tenantId UserContext.getCurrentTenantId(); return new StringValue(tenantId); } Override public String getTenantIdColumn() { return “tenant_id”; // 数据库中的租户字段名 } Override public boolean ignoreTable(String tableName) { // 忽略不需要加租户条件的表如全局配置表 return “sys_config”.equalsIgnoreCase(tableName); } }); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor; }核心隐患与技巧JOIN查询的别名问题如果你的SQL涉及多表JOIN并且这些表都有tenant_id字段插件需要知道为每个表的tenant_id字段添加条件。这要求你的SQL语句中每个表都必须有明确的别名Alias。MP插件会尝试解析SQL为每个表自动追加AND alias.tenant_id ‘xxx‘。如果SQL写得很复杂或没有别名解析可能会失败导致数据隔离漏洞。务必在编写多表SQL时使用别名并充分测试。全表更新/删除的防护多租户插件能有效防止SELECT查询越权但对于UPDATE和DELETE操作它同样通过追加租户条件来限制范围。这意味着即使你误操作写了一个没有WHERE条件的更新语句最终执行时也会被加上AND tenant_id ‘xxx‘从而只影响当前租户的数据。这是一个非常重要的安全保护。租户ID上下文管理getTenantId()的实现是关键。必须确保在每次数据库操作时都能从正确的上下文如Spring Security的SecurityContext、或自定义的ThreadLocal中获取到当前租户ID。在异步任务如Async、消息队列消费者、定时任务中需要手动传递或设置租户上下文否则会导致租户ID为空插件行为不可预期。3.2 逻辑删除不仅仅是加个deleted字段逻辑删除通过TableLogic注解实现将删除操作变为更新deleted字段。TableLogic(value “0”, delval “1”) // 未删除为0删除为1 private Integer deleted;你需要知道的细节自动过滤启用逻辑删除后MP会自动在所有SELECT语句的WHERE条件中加上AND deleted 0。deleteById方法会变为执行UPDATE SET deleted1。联表查询的“坑”这是逻辑删除最大的隐患。当你手写多表JOIN的SQL时MP不会自动为关联表加上deleted条件例如SELECT u.*, o.order_no FROM user u LEFT JOIN order o ON u.id o.user_id WHERE u.status1;即使user和order表都配置了逻辑删除这条SQL也只会自动过滤u.deleted0而o.deleted条件需要你手动添加ON u.id o.user_id AND o.deleted 0。忘记这一点会导致查询出已被逻辑删除的关联数据引发业务逻辑错误。如何“真正”删除有时你需要物理删除如清理测试数据。可以使用deleteById的重载方法传入一个DeleteWrapper并在Wrapper中不指定逻辑删除字段的条件但这需要非常小心。更推荐的做法是直接使用SqlInjector或编写原生SQL执行物理删除并确保该操作有严格的权限控制。唯一索引冲突如果表上对username字段设置了唯一索引逻辑删除一个用户deleted1后就无法再创建一个同名用户了因为唯一索引约束仍然存在。常见的解决方案是将唯一索引改为包含deleted字段的复合唯一索引如UNIQUE KEY uk_username (username, deleted)但需要确保deleted为非删除状态的值如0才参与唯一性约束。或者使用删除时间戳delete_time代替0/1标志未删除时为NULL删除时为时间戳并将(username, delete_time)设为唯一索引因为NULL值在唯一索引中通常被视为不相等。3.3 自定义全局注入器与SQL注入器当MP默认提供的方法不能满足你时比如你想实现一个通用的“根据ID列表批量查询并返回Map”的方法就需要用到自定义SQL注入器。场景我们经常需要根据一批ID获取实体列表并转换为MapID, Entity方便查找。MP没有直接提供。定义自定义Mapper接口public interface ExpandBaseMapperT extends BaseMapperT { /** * 根据ID集合批量查询并返回 MapID, Entity * param idList ID集合 * return Map */ MapLong, T selectMapByIds(Param(“idList”) ListLong idList); }实现自定义方法在XML中select id“selectMapByIds” resultType“your.entity” SELECT * FROM your_table WHERE id IN foreach collection“idList” item“id” open“(” separator“,” close“)” #{id} /foreach /select注意这里返回的是ListT我们需要在Service层手动转成Map。如果想在Mapper层直接返回Map需要更复杂的ResultHandler处理。创建自定义SQL注入器关键步骤Component public class MySqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass) { ListAbstractMethod methodList super.getMethodList(mapperClass); // 添加你自己的通用方法 methodList.add(new SelectMapByIds()); // SelectMapByIds需要你继承AbstractMethod实现 return methodList; } }让你的Mapper继承自定义接口public interface UserMapper extends ExpandBaseMapperUser { // ... 其他自定义方法 }这样所有继承了ExpandBaseMapper的Mapper就都拥有了selectMapByIds这个通用方法。这个例子展示了如何扩展MP将通用的业务查询模式沉淀下来减少重复代码。4. 性能调优与生产环境最佳实践4.1 连接池与Mybatis配置MP基于Mybatis因此Mybatis本身的配置对性能影响巨大。二级缓存生产环境慎用。Mybatis的二级缓存是跨Session的在分布式环境下除非你使用Redis等集中式缓存并正确实现了序列化和失效策略否则很容易导致脏数据。对于绝大多数OLTP联机事务处理场景建议直接关闭二级缓存mybatis.configuration.cache-enabledfalse依靠数据库自身缓存和应用层缓存如Redis。一级缓存Mybatis默认开启一级缓存Session级别。在同一个SqlSession内相同的查询会直接返回缓存对象。这有时会导致问题比如你在一个事务里先查了一条数据然后另一个方法修改了数据库甚至是通过JDBC直接修改再查一次拿到的还是缓存里的旧数据。如果你希望每次都查询数据库可以在方法上使用Options(flushCache Options.FlushCachePolicy.TRUE)或者在查询的select标签里设置flushCache“true”。但通常在一个事务内保持一致性是更合理的设计。连接池配置使用HikariCP等高性能连接池。关键参数如maximumPoolSize最大连接数需要根据数据库性能和业务并发量调整不是越大越好。connectionTimeout获取连接超时时间和idleTimeout连接空闲超时也需要合理设置。4.2 监控与慢SQL排查当系统出现性能问题时快速定位到有问题的SQL是第一步。开启Mybatis SQL日志在开发或测试环境可以配置mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl将SQL打印到控制台。但在生产环境这会产生海量日志通常只会在特定情况下临时开启。使用P6Spy等SQL拦截工具P6Spy可以作为JDBC驱动代理记录所有SQL语句及其执行时间并输出到日志文件。它能帮你直观地看到哪些SQL执行慢。# application.yml spring: datasource: driver-class-name: com.p6spy.engine.spy.P6SpyDriver # 替换原驱动 url: jdbc:p6spy:mysql://localhost:3306/db?useSSLfalse # 在url前加p6spy在spy.properties中配置日志输出格式和过滤条件。结合APM工具对于分布式系统使用SkyWalking、Pinpoint等APM工具是更好的选择。它们可以自动追踪整个调用链并标识出其中耗时的数据库操作直接定位到慢SQL和对应的代码行。4.3 代码生成器的正确使用姿势MP的代码生成器AutoGenerator能极大提升初期开发效率但切忌“生成即结束”。定制化模板默认生成的Controller、Service、Entity等可能不符合你的项目规范。MP支持自定义模板文件.ftl或.vm。你应该根据团队规范定制一套自己的模板比如Entity类加上Swagger注解、Service接口和实现类分离、Controller统一继承某个基类并添加全局注解等。只生成一次后续手动维护代码生成器最适合在项目初期创建表结构对应的基础代码。一旦生成完毕这些代码就成为了你的业务代码的一部分后续的表结构变更或业务逻辑添加都应该手动修改这些文件而不是重新生成覆盖。重新生成会覆盖你之前写的所有业务逻辑。生成“领域模型”而非“数据库映射”在DDD领域驱动设计或一些分层架构中我们倾向于区分Entity与数据库表结构强对应和Domain Model业务领域模型。MP生成的Entity更适合作为前者。如果项目有更复杂的领域模型可以考虑以生成的Entity作为数据持久化对象PO再通过手动编码或MapStruct等工具将其转换为领域对象DO或视图对象VO/DTO。5. 常见问题排查与实战避坑指南在实际开发中总会遇到一些“诡异”的问题。这里记录几个我印象深刻的案例。问题一更新操作成功但字段值没变现象使用updateById更新一个实体返回affected rows 1但查询数据库发现某个字段的值没更新。排查检查实体对象中该字段是否为null。MP默认的更新策略是只更新非null字段。如果你从数据库查出一个对象修改了A字段但B字段保持null那么B字段不会被更新。检查是否有拦截器或AOP切面修改了数据例如审计拦截器可能在更新前将字段重置了。检查数据库触发器虽然不常见但数据库层面的触发器可能会在更新后修改你的值。解决确保要更新的字段在实体对象里不为null或者使用UpdateWrapper的set方法进行更新。问题二分页查询结果总数total不对现象调用selectPage方法返回的IPage对象中records当前页数据正确但total总记录数明显不对有时为0有时是一个很大的数。排查自定义COUNT语句问题如果你在XML中为分页查询自定义了COUNT语句通过id为selectPage_COUNT请检查这个COUNT语句的逻辑是否正确。它应该是一个只返回单个总数、去除了GROUP BY和ORDER BY的简化查询。多租户/逻辑删除插件干扰检查你的查询条件是否与多租户插件或逻辑删除插件添加的条件冲突。例如你的QueryWrapper里手动加了一个deleted 1而逻辑删除插件会自动加deleted 0导致WHERE条件矛盾COUNT结果为0。SQL语法兼容性某些极端复杂的SQL特别是使用了数据库特定函数或语法MP在将其转换为COUNT语句时可能会生成错误的SQL导致执行失败或结果错误。开启SQL日志对比数据查询语句和COUNT查询语句。解决对于复杂分页最稳妥的方式是采用前文提到的“自定义COUNT查询手动设置总数”方案完全掌控COUNT逻辑。问题三insert后主键回填失败现象执行insert后实体对象的自增主键id仍然是null。排查检查数据库表主键是否确实是自增AUTO_INCREMENT。检查MP的全局配置mybatis-plus.global-config.db-config.id-type是否设置正确。对于自增主键通常设置为IdType.AUTO。检查你的实体类TableId注解的type属性。如果数据库是自增这里也应该用IdType.AUTO。检查你是否在insert之后又对实体对象进行了其他可能导致属性被覆盖的操作。解决确保配置一致。一个常见的“坑”是为了使用MP的雪花算法分布式ID将id-type设为ASSIGN_ID但数据库表设计又是自增主键导致不匹配。需要根据你的ID生成策略统一配置。问题四事务方法内调用MP的select方法数据不一致现象在一个Transactional方法内先更新了数据然后立即用MP查询发现查到的不是最新数据。排查这通常不是MP的问题而是数据库事务隔离级别和Mybatis一级缓存共同作用的结果。在默认的“可重复读”REPEATABLE READ隔离级别下MySQL默认同一个事务内多次读取同一行数据会读到事务开始时的快照看不到本事务内后续更新除非是更新操作本身。Mybatis一级缓存SqlSession级别加剧了这个问题。即使你发出了新的SELECT语句如果参数完全相同Mybatis可能会直接从缓存返回对象。解决如果业务允许可以在查询方法上使用Transactional(propagation Propagation.REQUIRES_NEW)开启一个新事务但这会带来复杂度。更常见的做法是在同一个事务内如果需要读取刚更新的最新数据直接使用更新后的实体对象或者通过SqlSession强制清空本地缓存sqlSession.clearCache()但后者侵入性较强。更好的设计是避免在同一个事务方法中频繁依赖“立即查询刚更新的数据”这种模式。最后关于MP的使用我个人最深的体会是它极大地提升了开发效率但绝不能替代你对Mybatis和SQL本身的理解。当你遇到复杂查询、高性能要求、或者诡异的问题时最终往往需要你深入到底层的SQL和Mybatis原理去寻找答案。把MP当作一个得力的“助手”而不是一个“黑盒”才能真正用好它。在项目初期可以大胆使用它的便捷功能快速搭建框架在项目中后期尤其是性能敏感和业务复杂的模块要有能力、有意识地去审视和优化它生成的SQL必要时回归手写XML这才是成熟开发者应有的态度。