1. 微服务落地时为什么我首选 Mybatis-Plus 做数据层做了几年 SpringCloud 微服务之后我越来越发现一个问题真正拖慢开发进度的往往不是服务治理、网关、熔断这些高大上的组件反而是最底层、最琐碎的数据库访问代码。微服务拆分的本质是业务隔离 独立演进落到数据层就是每个服务各自管好自己那一亩三分地。这时候你面对的是十多个甚至几十个微服务工程如果每个服务里都堆着大量手写的 XML、重复的 CRUD 方法光是维护这些样板代码就能消耗掉三分之一的人力。我选择 Mybatis-Plus 的原因很简单它能让我把精力从怎么写 SQL挪回到怎么设计业务。Mybatis-Plus 是在 MyBatis 基础上做的增强工具核心价值可以用三句话概括单表 CRUD 不用写 SQL、条件构造器替你拼 SQL、IService 接口帮你把 Service 层的基础方法全部封装好。配合 SpringCloud 的 OpenFeign 调用链和分布式事务场景数据层只需要关注当前服务自己的数据Mybatis-Plus 恰恰把单表操作做到极致和微服务的拆分思想天然契合。这篇教程是 SpringCloud 系列的第二篇重点拆解 Mybatis-Plus 的三个高频使用面条件构造器 Wrapper、自定义 SQL、Service 接口基本用法。不管你是刚从 SSM 转过来的老手还是 SpringBoot 入门不久的新人这三个点都是你写微服务业务时绕不开的日常操作。在进入具体用法之前我要先说清楚一个选型逻辑Mybatis-Plus 并不是要取代 MyBatis它是在 MyBatis 基础上做了增强但底层执行 SQL、结果集映射、事务管理这些机制还是 MyBatis 那一套。所以你在用 MP 的过程中遇到任何诡异问题第一反应应该是去查 MyBatis 的机制而不是怀疑 MP 本身。2. 条件构造器把拼接 SQL 这件事彻底交给框架2.1 从 QueryWrapper 看条件构造器的设计思路先看一段最传统的 MyBatis 写法在 XML 里写动态 SQLselect idselectUserList resultTypeUser SELECT * FROM user where if testname ! null and name ! AND name #{name} /if if testage ! null AND age gt; #{age} /if if testdeptId ! null AND dept_id #{deptId} /if /where /select这种写法本身没错但问题在于每张表、每个查询条件组合都要写一段类似的 XML微服务里表多了以后XML 文件的体量会变得非常恐怖。而且if标签一旦拼错一个空格整个 SQL 就出问题排查起来很费劲。条件构造器 Wrapper 解决的就是动态条件这个痛点。简单说你用 Java 代码把条件描述出来Mybatis-Plus 负责解析成 SQL 片段并拼接。比如刚才那段 XML 等价的条件构造器写法QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(StringUtils.hasText(name), name, name) .gt(age ! null, age, age) .eq(deptId ! null, dept_id, deptId); ListUser userList userMapper.selectList(wrapper);注意我这里的写法第一个参数是是否生效的判断条件这是 QueryWrapper 非常实用的能力。当这个判断条件为 false 时对应的查询条件会自动跳过等于帮你省掉了手写if的过程。这在写查询接口时特别有用——前端传什么参数你就拼接什么条件不传就查全部。2.2 Lambda 写法为什么更安全直接用字符串写字段名比如dept_id有一个隐患Java 编译期不会校验这个字符串如果你把字段名拼错成deptid编译能通过但运行时报 SQL 异常。为了根治这个问题Mybatis-Plus 提供了 Lambda 写法。LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(name), User::getName, name) .gt(age ! null, User::getAge, age) .eq(deptId ! null, User::getDeptId, deptId);User::getName是 Lambda 表达式引用的方法MP 能解析这个方法名反推出name字段名。这样你的字段名和实体类强绑定实体类改了字段编译期立刻报错不会拖到运行期才暴露。我在实际项目里基本只用 LambdaQueryWrapper原因有三个字段名合法性能在编译期保证杜绝手打字符串的低级错误。代码可读性更好条件拼接一眼能看出对应实体类的哪个字段。配合 IDEA 的重构功能实体类字段改名时 Lambda 引用会自动同步。LambdaQueryWrapper 还支持链式调用里继续叠加条件比如and、or、nested这种组合条件后面单独讲。2.3 高频查询条件速查与组合我在微服务项目里最常见的查询条件就是下面这几组建议你直接收藏方法作用示例eq等于eq(User::getStatus, 1)ne不等于ne(User::getStatus, 0)gt/ge大于 / 大于等于gt(User::getAge, 18)lt/le小于 / 小于等于le(User::getScore, 100)between区间between(User::getCreateTime, start, end)like模糊匹配like(User::getName, 张)likeRight右匹配前缀匹配走索引likeRight(User::getPhone, 138)in集合匹配in(User::getDeptId, deptIdList)isNull判断为空isNull(User::getEmail)orderByDesc排序orderByDesc(User::getCreateTime)groupBy分组groupBy(User::getDeptId)last追加原生 SQL 片段last(limit 10)这批方法里我特别标注一下likeRight。做搜索场景时很多人习惯无脑like但like %关键词%是无法走索引的数据量一大就慢。如果你只需要以什么开头的匹配用likeRight会快很多。组合条件的玩法在于and和or。看这个需求查状态为 1 且年龄大于 18 或姓名包含张的用户LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1) .and(w - w.gt(User::getAge, 18).or().like(User::getName, 张));留意这里.and(w - ...)的用法w是一个嵌套的 QueryWrapperMP 会自动把这个嵌套条件用括号包起来生成的效果是WHERE status 1 AND (age 18 OR name LIKE %张%)如果不加.and()而直接写.or()生成的 SQL 很可能不是你想要的逻辑比如变成WHERE status 1 OR age 18 AND name LIKE %张%这就是 SQL 优先级导致的坑。我见过太多同事在这里栽跟头条件多了以后括号用错查出来的数据莫名其妙地多或者少。记住一个原则凡是 or 和其它条件混用一定用and()或nested()把 or 包成子组。2.4 select 语句的字段控制条件构造器不仅能拼 WHERE还能控制查询返回哪些字段。默认selectList会查所有字段但有时候一张表有三四十个字段你只需要其中几个全查出来虽然能跑但数据传输量大尤其在微服务内部调动频繁时影响性能。LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.select(User::getId, User::getName, User::getAge) .eq(User::getStatus, 1); ListUser list userMapper.selectList(wrapper);这里的select相当于 SQL 里的SELECT id, name, age。对于大字段比如文本内容、JSON 配置特别有用能显著减少网络传输和内存占用。还有一种用法排除字段比如不查密码字段。Mybatis-Plus 3.5.x 之后提供了select的重载你也可以在实体类的TableField(select false)注解里定义默认不查询的字段然后在需要时用select方法显式带出来。2.5 UpdateWrapper 更新场景条件构造器不只是查询能用更新也能用。用 UpdateWrapper 可以实现在不查出实体的情况下直接按条件更新字段LambdaUpdateWrapperUser updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(User::getId, userId) .set(User::getStatus, 2) .set(User::getUpdateTime, LocalDateTime.now()); userMapper.update(null, updateWrapper);第一参数传null表示不需要实体参与更新完全靠 UpdateWrapper 里的set方法指定要修改的字段。这在做状态流转、标记删除这类操作时特别优雅而且不会把实体里的 null 字段误更新到数据库——如果你传了实体Mybatis-Plus 默认策略是只更新非 null 字段但具体策略取决于全局配置这个后面讲坑的时候再展开。我习惯在批量更新状态时用 UpdateWrapper比如订单超时批量关闭LambdaUpdateWrapperOrder wrapper Wrappers.lambdaUpdate(); wrapper.in(Order::getId, orderIdList) .eq(Order::getStatus, OrderStatus.WAIT_PAY) .set(Order::getStatus, OrderStatus.CLOSED) .set(Order::getCloseTime, LocalDateTime.now()); orderMapper.update(null, wrapper);这里的eq条件带上旧状态相当于一种乐观校验防止并发下把已关单的订单重复关闭或覆盖其他状态。3. 自定义 SQL条件构造器和 XML 结合的正确姿势3.1 为什么有了条件构造器还要自定义 SQL条件构造器擅长的是单表的动态条件查询。但微服务里有很多业务是跨表查询的比如订单要关联用户表查用户名、关联商品表查商品名这时候单靠 MP 的 BaseMapper 就不够了。我的建议是能用条件构造器解决的单表操作绝不自定义 SQL多表查询和复杂统计才走自定义 SQL。这个边界一旦划清楚代码维护成本会低很多。自定义 SQL 的姿势有两类注解方式和 XML 方式。3.2 注解方式适合轻量级定制如果你只需要一两条多表查询不想建 XML 文件可以直接在 Mapper 接口方法上用SelectSelect(SELECT u.id, u.name, o.order_no FROM user u INNER JOIN orders o ON u.id o.user_id WHERE u.status #{status}) ListUserOrderVO selectUserOrders(Param(status) Integer status);注解方式简单粗暴但一旦 SQL 变长字符串拼接维护起来很费劲尤其涉及动态条件时就难受了。所以我建议注解方式只用于固定 SQL 的场景动态条件还是交给 XML。3.3 XML 方式动态 SQL 的正确归宿XML 方式可以把自定义 SQL 和 MP 的条件构造器结合起来这是我最推荐的方式。核心玩法是先定义 Mapper 接口方法方法的参数里放一个Wrapper然后在 XML 里用${ew.customSqlSegment}引用条件构造器拼出来的条件// Mapper 接口 ListUserOrderVO selectUserOrders(Param(ew) WrapperUser queryWrapper);对应的 XMLselect idselectUserOrders resultTypecom.example.vo.UserOrderVO SELECT u.id, u.name, o.order_no FROM user u INNER JOIN orders o ON u.id o.user_id ${ew.customSqlSegment} /select调用时照常构建条件构造器LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1) .like(User::getName, 张) .orderByDesc(User::getCreateTime); ListUserOrderVO list userMapper.selectUserOrders(wrapper);MP 会把wrapper解析成WHERE status 1 AND name LIKE %张% ORDER BY create_time DESC替换到${ew.customSqlSegment}的位置。这里有一个关键细节容易踩坑使用${ew.customSqlSegment}时如果 wrapper 没传任何条件customSqlSegment 会是空字符串那么 SQL 就会变成... FROM user u INNER JOIN orders o ON ...而没有任何 WHERE查询全部数据。所以业务上如果你必须要有条件记得在代码里先判断 wrapper 是否为空或者是否携带条件。3.4 自定义分页配合 Mybatis-Plus 分页插件Mybatis-Plus 自带分页插件但如果你走的是自定义 SQL只要 Mapper 方法传入Page参数插件也能自动做物理分页。参考写法// Mapper 接口 IPageUserOrderVO selectUserOrderPage(Page? page, Param(ew) WrapperUser queryWrapper);XML 不用写 LIMITselect idselectUserOrderPage resultTypecom.example.vo.UserOrderVO SELECT u.id, u.name, o.order_no, o.amount FROM user u INNER JOIN orders o ON u.id o.user_id ${ew.customSqlSegment} /selectService 层调用PageUser page new Page(current, size); LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1); IPageUserOrderVO result userMapper.selectUserOrderPage(page, wrapper);这里要注意一个细节分页插件只有在数据库方言明确配置后才会拼接对应的分页语句。在 application.yml 里这样配置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted id-type: assign_iddb-config里虽然没直接写方言但 MP 会根据你数据源的 driver 自动判断是 MySQL 还是 PostgreSQL 还是其他数据库所以一般不用手动指定。如果你用国产数据库比如达梦、人大金仓则需要手动告诉 MP 方言否则分页会异常。3.5 ${} 和 #{} 的取舍以及 SQL 注入风险这是自定义 SQL 里最核心的安全点。#{}是预编译占位符对应 JDBC 的PreparedStatement传进去的参数会被当作文本值加上引号能有效防止 SQL 注入。${}是字符串拼接直接把值拼进 SQL 里有注入风险。在 Mybatis-Plus 的 Wrapper 配合自定义 SQL 时${ew.customSqlSegment}是必须用的因为条件构造器生成的 SQL 片段本身包含列名和排序方向不能被预编译。但在你的业务里凡是用户输入的内容一律走#{}禁止拼${}。比如排序字段如果有人把前端传的orderBy字段名直接拼到 SQL 里黑客就能通过在字段名位置构造11; DROP TABLE user之类的参数搞破坏。一个安全兜底方案是排序字段做一个白名单映射。前端传createTime后端映射成create_time传id映射成id其它全拒绝。我自己项目里就是封装了个简单的字段白名单工具类避免在排序场景暴露 SQL 拼接。4. IService 与 ServiceImpl业务层快速落地的骨架4.1 为什么不再手写 Service 里那些垃圾桶方法每次新建一个微服务你会写多少这种代码public interface UserService { User getById(Long id); ListUser listAll(); boolean save(User user); boolean update(User user); boolean delete(Long id); }这些方法没有任何业务可言每个实体都要重复实现一遍。Mybatis-Plus 提供的IServiceT接口把这些通用方法全部定义好了你只需要让自己的 Service 接口继承它然后让实现类继承ServiceImplMapper, Tpublic interface UserService extends IServiceUser { // 这里只写你自己扩展的业务方法 ListUser getActiveUsers(); } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { // 通用 CRUD 方法由 Mybatis-Plus 提供不需要自己写 }这样你的UserService天然就有getById、listByIds、saveBatch、removeByIds等等方法Spring 容器注入后直接调用。我刚开始转型用的时候是有点不放心的——担心继承来的方法不够灵活。用了一段后发现日常业务 70% 的操作靠这些内置方法就够了剩下 30% 才是自己写。4.2 saveBatch、saveOrUpdate 与批量场景的效率对比微服务里最常见的批量场景有两个批量导入、批量同步。MP 的saveBatch解决的就是这个需求。ListUser userList new ArrayList(); // 前端上传几百条数据转成 User 实体 userService.saveBatch(userList, 500);第二个参数 500 表示每批提交 500 条这是对数据库连接的合理压力控制。如果不传默认是 1000我实测下来 500 到 1000 之间对 MySQL 比较友好太大反而可能超时或者锁竞争激烈。还要特别注意saveOrUpdate。这个方法的逻辑是先根据主键判断记录是否存在存在就更新不存在就插入。它有两个变体userService.saveOrUpdate(user); // 按主键判断 userService.saveOrUpdateBatch(userList, 500); // 批量批量导入场景下如果数据里可能包含已存在的主键用saveOrUpdateBatch能省去你先查一遍再决定插入还是更新的代码。但这里有个性能警告saveOrUpdateBatch内部是逐条判断再逐条执行它比真正纯新增的saveBatch慢不少。如果你的业务确定全是新增就别用它如果有部分更新那它确实是省代码的方案。4.3 链式查询和链式更新代码可读性的天花板IService也内置了链式查询实际用法长这样// 条件查询查状态正常的用户列表 ListUser userList userService.lambdaQuery() .eq(User::getStatus, 1) .like(StringUtils.hasText(name), User::getName, name) .orderByDesc(User::getCreateTime) .list();如果只要一条User user userService.lambdaQuery() .eq(User::getId, userId) .one();这里的one()很有意思如果查询结果有两条以上MP 会直接报异常。所以用在唯一索引保证唯一的业务里是安全的。链式更新也很优雅userService.lambdaUpdate() .eq(User::getId, userId) .set(User::getStatus, 0) .update();这类链式 API 的逻辑是前一段是条件最后一段是list()/one()/update()这些终结方法。刚开始用的时候你觉得它像语法糖用多了会发现团队里手写循环、手写for更新的大量代码能直接删掉而且可读性强得多。query()和lambdaQuery()的区别在于query()返回的是QueryChainWrapper需要自己写字符串字段名我建议直接无脑用lambdaQuery()避免字符串字段名隐患。4.4 Service 层如何配合 SpringCloud 的调用链微服务里 Service 层往往会被Transactional修饰配合 OpenFeign 跨服务调用时事务边界要特别注意。我总结了几条我在微服务项目里贯彻的规矩第一事务只放在本地 Service 方法上。跨服务调用不要放在同一个事务里因为 OpenFeign 调用是走网络的一旦远程服务响应超时本地事务会被长时间挂起数据库连接也释放不了。正确做法是本地事务只管本地数据库操作跨服务调用放在事务提交之后用事务同步器或发布事件来触发。Transactional(rollbackFor Exception.class) public void createUser(User user) { userService.save(user); // 本地事务提交后再通知其它服务做后续任务 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { // 调用远程服务或发送 MQ 消息 } }); }第二批量操作的数据量要控制。一次saveBatch更新几千条数据在单库里没问题但如果这个服务同时被多个上游服务高频调用就容易拖垮数据库连接池。我习惯再用一个信号量或线程池隔离批量操作和常规操作防止批量导入把接口的响应时间全部拉高。第三Service 层的命名和职责要清晰。IService接管了基础 CRUD 之后你的 Service 实现类里只保留真正的业务方法。我一般这样分层Controller 层只做参数接收和结果封装Service 层做业务编排和事务控制Mapper 层只做数据访问。不要因为 MP 方便就把 SQL 条件直接写在 Controller 里那样后续改起来会非常痛苦。4.5 getOne、getById、removeById 的细节用法getOne还有个重载方法加第二个参数boolean throwExUser user userService.getOne( Wrappers.lambdaQueryUser().eq(User::getPhone, phone), true // 查到多条直接抛异常 );如果你确定业务上不可能有重复就传 true让异常及早暴露别等到数据对不上账才排查。removeById在微服务场景里要注意如果你开启了逻辑删除removeById实际执行的是UPDATE ... SET deleted 1而不是物理DELETE。逻辑删除字段在实体类里用TableLogic标记TableLogic private Integer deleted;这样配置后所有 MP 内置的查询、更新、删除方法都会自动带上deleted 0条件逻辑删除和查询都不会误操作到已删除数据。但这对自定义 SQL 不生效——你在 XML 里写的 SQL 必须自己手动加deleted 0条件否则会把已删除的数据也查出来。这是我一直强调的坑很多团队上线后出了数据错乱才排查出原因。5. 微服务实践中踩过的坑与排查技巧5.1 分页插件失效Page 参数没有正确传参MP 的分页不是 SQL 里天然带LIMIT的它靠拦截器帮你在执行前改写 SQL。如果你发现自定义 SQL 里Page没有生效大概率是两种情况Mapper 方法的第一参数不是Page类型或者顺序不对。MP 只识别第一个Page参数放第二个位置就失效。分页插件没有加载。SpringBoot 场景下需要配置MybatisPlusInterceptor并添加分页拦截器Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }同时检查PaginationInnerInterceptor的DbType是否和你的数据库一致。我见过有人在 MySQL 项目里配了 SQLServer 方言结果分页 SQL 完全不对。5.2 逻辑删除字段导致的正常数据丢失TableLogic默认是 0 未删除、1 已删除。有个常见坑如果数据库里某些历史数据的 deleted 字段是 NULLMP 查询时deleted 0是匹配不到 NULL 的那部分数据就消失了。你需要在建表时给逻辑删除字段加默认值或者在TableLogic配置里指定逻辑未删除值包括 NULL 的情况。我在项目里还会专门用一条 SQL 检查逻辑删除字段的空值数据SELECT COUNT(*) FROM user WHERE deleted IS NULL;如果查出有数据就要及时修复否则用户看到的数据总是差几条而且这种 Bug 很难快速定位。5.3 Lambda 条件不生效字段与实体 getter 不匹配Lambda 写法虽然编译安全但前提是实体类的TableField注解和属性一一对应。有次我在实体里加了TableField(dept_id)但属性名写成了deptIdMapper 映射没问题可是用User::getDeptId去查的时候MP 会把它解析成什么字段名它默认用驼峰转下划线也就是dept_id碰巧对上了但如果你的表字段命名不规范比如DEPTID、DeptID这种Lambda 解析出来的字段名是DEPTID和真实字段不一致SQL 就会报错。解决办法是让字段命名规范化数据库统一小写下划线Java 实体统一驼峰。这个规范一开始就要定死不然 Lambda 条件构造器反而成为负担。5.4 自定义 SQL 与条件构造器混用时排序字段报错用${ew.customSqlSegment}的坑除了条件为空还有排序字段。如果你在 wrapper 里用了orderByDesc(User::getCreateTime)customSqlSegment 生成的 SQL 片段是ORDER BY create_time DESC这个没问题。但如果排序字段来自前端参数你在构建 wrapper 时直接orderByDesc(前端传的值)就有两个风险注入和列不存在。我都用字段白名单映射来解决比如MapString, String orderFieldMap Map.of( createTime, create_time, updateTime, update_time, id, id ); String column orderFieldMap.getOrDefault(request.getOrderBy(), id); wrapper.orderByDesc(StringUtils.hasText(column), column);注意这里orderByDesc第一参数是 booleanfalse 时排序条件不生效不会抛异常是一种安全的兜底。5.5 批量插入内存溢出集合一次性装载过多saveBatch是分批执行的但如果你先一次性把几十万数据SELECT出来塞到ListUser里内存必然爆炸。我在做数据迁移或服务间数据同步时用的是 MP 的Page 循环拉取每批查 1000 条处理完再拉下一批long pageNo 1; long pageSize 1000; while (true) { PageUser page userService.page(new Page(pageNo, pageSize)); if (page.getRecords().isEmpty()) { break; } processUserList(page.getRecords()); if (page.getCurrent() page.getPages()) { break; } pageNo; }这种流式分批处理在微服务里是最稳妥的。高并发下如果一次性把整表数据读进内存再转发给下游很容易把服务自身的堆内存打满导致频繁 Full GC 甚至 OOM。5.6 乐观锁插件使用注意MP 提供了乐观锁插件OptimisticLockerInnerInterceptor实体里加Version字段。使用时的两个前提更新时实体必须带版本号字段且插件必须加载。乐观锁适用于更新频繁且允许失败重试的业务比如库存扣减、余额变更。我有一次没注意在update(null, wrapper)这种 UpdateWrapper 更新方式下也想用它实现乐观锁结果发现完全没生效——因为乐观锁插件的生效条件是实体字段里有Version且传入的实体非空。如果你用 UpdateWrapper 不带实体插件根本没有机会帮你拼version version 1和WHERE version ?。解决办法是要么走实体更新要么自己在 UpdateWrapper 里手工实现版本判断。6. 一个完整的微服务查询接口落地示例最后把一个完整的流程串起来。假设你在做订单服务需要提供一个分页查询接口按用户 ID、订单状态筛选返回订单号、用户名、商品名按下单时间倒序。Controller 层RestController RequestMapping(/order) public class OrderController { Resource private IOrderService orderService; GetMapping(/page) public RIPageOrderVO page(OrderQuery query) { return R.ok(orderService.queryOrderPage(query)); } }Service 接口与实现public interface IOrderService extends IServiceOrder { IPageOrderVO queryOrderPage(OrderQuery query); } Service public class OrderServiceImpl extends ServiceImplOrderMapper, Order implements IOrderService { Override public IPageOrderVO queryOrderPage(OrderQuery query) { PageOrder page new Page(query.getCurrent(), query.getSize()); LambdaQueryWrapperOrder wrapper Wrappers.lambdaQuery(); wrapper.eq(query.getUserId() ! null, Order::getUserId, query.getUserId()) .eq(query.getStatus() ! null, Order::getStatus, query.getStatus()) .orderByDesc(Order::getCreateTime); return this.baseMapper.selectOrderPage(page, wrapper); } }Mapper 接口与 XMLpublic interface OrderMapper extends BaseMapperOrder { IPageOrderVO selectOrderPage(Page? page, Param(ew) WrapperOrder wrapper); }select idselectOrderPage resultTypecom.example.vo.OrderVO SELECT o.id, o.order_no, u.name AS userName, p.name AS productName FROM orders o LEFT JOIN user u ON o.user_id u.id AND u.deleted 0 LEFT JOIN product p ON o.product_id p.id AND p.deleted 0 ${ew.customSqlSegment} /select这套流程覆盖了条件构造器、自定义 SQL、IService 三个核心点也是微服务订单查询里最标准的一种写法。注意我这里的自定义 SQL 里给关联表手动加了deleted 0因为逻辑删除对自定义 SQL 不自动生效。还有一个容易被忽略的性能点LEFT JOIN关联的表如果数据量大分页插件算 count 时也会执行 join 的 count 查询数据量大时 count 很慢。我遇到过一个 case订单 100 万、用户 50 万join 完 count 要两秒多。后来的优化方案是先分页查出订单 ID再用 ID 列表查用户和商品信息最后在内存里做组装。这也是分页插件optimizeJoin配置的初衷但如果你用的 MP 版本比较老注意确认分页插件是否自动把 count 的 join 优化掉。7. 我对 Mybatis-Plus 在 SpringCloud 体系中定位的实战体会用了这么多年的 MP我个人的体会是它不是一个炫技的框架而是解决微服务开发中 80% 重复数据访问工作的工程化工具。它的价值不在某个单点功能有多强而在于把单表 CRUD、条件查询、分页、批量操作、逻辑删除这些高频操作全部收敛到一致的 API 风格里让团队协作时几乎没有理解成本。实际项目中我见过两种极端一种是完全不用 MP任何查询都手写 XML结果微服务一多大量重复代码堆积另一种是无脑用 MP连多表复杂查询都硬要用条件构造器拼结果 SQL 逻辑混乱、性能查问题很困难。我的经验是把握好边界单表操作无脑交给 MP多表联查和复杂统计走 XML ${ew.customSqlSegment}业务层全部通过 IService 继承出基础方法再补充少量自定义业务方法这套组合在微服务场景下是最稳的。最后分享一个小技巧。在调试阶段把 MyBatis 的 SQL 日志打开mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl然后观察控制台里打印的 SQL你会发现条件构造器生成的 SQL 片段非常直白这也帮助我快速理解了 Wrapper 的解析规则。等对条件构造器有感觉之后再把这个日志关掉避免生产环境打印过多 SQL 影响性能。微服务的未来不只是注册中心、网关、链路追踪这些基础设施数据访问层的整洁和高效同样决定一个服务能否快速迭代。Mybatis-Plus 在这条路上给了我们一个很顺手的工具用好它你的日常开发生涯会轻松不少。