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 里写动态 SQL:
<select id="selectUserList" resultType="User"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name = #{name} </if> <if test="age != null"> AND age > #{age} </if> <if test="deptId != null"> AND dept_id = #{deptId} </if> </where> </select>这种写法本身没错,但问题在于:每张表、每个查询条件组合都要写一段类似的 XML,微服务里表多了以后,XML 文件的体量会变得非常恐怖。而且<if>标签一旦拼错一个空格,整个 SQL 就出问题,排查起来很费劲。
条件构造器 Wrapper 解决的就是"动态条件"这个痛点。简单说,你用 Java 代码把条件描述出来,Mybatis-Plus 负责解析成 SQL 片段并拼接。比如刚才那段 XML 等价的条件构造器写法:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq(StringUtils.hasText(name), "name", name) .gt(age != null, "age", age) .eq(deptId != null, "dept_id", deptId); List<User> userList = userMapper.selectList(wrapper);注意我这里的写法:第一个参数是"是否生效"的判断条件,这是 QueryWrapper 非常实用的能力。当这个判断条件为 false 时,对应的查询条件会自动跳过,等于帮你省掉了手写if的过程。这在写查询接口时特别有用——前端传什么参数你就拼接什么条件,不传就查全部。
2.2 Lambda 写法为什么更安全
直接用字符串写字段名,比如"dept_id",有一个隐患:Java 编译期不会校验这个字符串,如果你把字段名拼错成"deptid",编译能通过,但运行时报 SQL 异常。为了根治这个问题,Mybatis-Plus 提供了 Lambda 写法。
LambdaQueryWrapper<User> 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 或姓名包含"张")的用户:
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1) .and(w -> w.gt(User::getAge, 18).or().like(User::getName, "张"));留意这里.and(w -> ...)的用法:w是一个嵌套的 QueryWrapper,MP 会自动把这个嵌套条件用括号包起来,生成的效果是:
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会查所有字段,但有时候一张表有三四十个字段,你只需要其中几个,全查出来虽然能跑,但数据传输量大,尤其在微服务内部调动频繁时影响性能。
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.select(User::getId, User::getName, User::getAge) .eq(User::getStatus, 1); List<User> 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 可以实现在不查出实体的情况下直接按条件更新字段:
LambdaUpdateWrapper<User> 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,比如订单超时批量关闭:
LambdaUpdateWrapper<Order> 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 接口方法上用@Select:
@Select("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}") List<UserOrderVO> selectUserOrders(@Param("status") Integer status);注解方式简单粗暴,但一旦 SQL 变长,字符串拼接维护起来很费劲,尤其涉及动态条件时就难受了。所以我建议注解方式只用于固定 SQL 的场景,动态条件还是交给 XML。
3.3 XML 方式:动态 SQL 的正确归宿
XML 方式可以把自定义 SQL 和 MP 的条件构造器结合起来,这是我最推荐的方式。核心玩法是先定义 Mapper 接口方法,方法的参数里放一个Wrapper,然后在 XML 里用${ew.customSqlSegment}引用条件构造器拼出来的条件:
// Mapper 接口 List<UserOrderVO> selectUserOrders(@Param("ew") Wrapper<User> queryWrapper);对应的 XML:
<select id="selectUserOrders" resultType="com.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>调用时照常构建条件构造器:
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1) .like(User::getName, "张") .orderByDesc(User::getCreateTime); List<UserOrderVO> 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 接口 IPage<UserOrderVO> selectUserOrderPage(Page<?> page, @Param("ew") Wrapper<User> queryWrapper);XML 不用写 LIMIT:
<select id="selectUserOrderPage" resultType="com.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} </select>Service 层调用:
Page<User> page = new Page<>(current, size); LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(); wrapper.eq(User::getStatus, 1); IPage<UserOrderVO> 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 里,黑客就能通过在字段名位置构造1=1; DROP TABLE user之类的参数搞破坏。
一个安全兜底方案是:排序字段做一个白名单映射。前端传createTime,后端映射成create_time,传id映射成id,其它全拒绝。我自己项目里就是封装了个简单的字段白名单工具类,避免在排序场景暴露 SQL 拼接。
4. IService 与 ServiceImpl:业务层快速落地的骨架
4.1 为什么不再手写 Service 里那些"垃圾桶方法"
每次新建一个微服务,你会写多少这种代码:
public interface UserService { User getById(Long id); List<User> listAll(); boolean save(User user); boolean update(User user); boolean delete(Long id); }这些方法没有任何业务可言,每个实体都要重复实现一遍。Mybatis-Plus 提供的IService<T>接口把这些通用方法全部定义好了,你只需要让自己的 Service 接口继承它,然后让实现类继承ServiceImpl<Mapper, T>:
public interface UserService extends IService<User> { // 这里只写你自己扩展的业务方法 List<User> getActiveUsers(); } @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { // 通用 CRUD 方法由 Mybatis-Plus 提供,不需要自己写 }这样你的UserService天然就有getById、listByIds、saveBatch、removeByIds等等方法,Spring 容器注入后直接调用。我刚开始转型用的时候是有点不放心的——担心继承来的方法不够灵活。用了一段后发现,日常业务 70% 的操作靠这些内置方法就够了,剩下 30% 才是自己写。
4.2 saveBatch、saveOrUpdate 与批量场景的效率对比
微服务里最常见的批量场景有两个:批量导入、批量同步。MP 的saveBatch解决的就是这个需求。
List<User> 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也内置了链式查询,实际用法长这样:
// 条件查询:查状态正常的用户列表 List<User> 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 throwEx:
User user = userService.getOne( Wrappers.lambdaQuery<User>().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 字段是 NULL,MP 查询时deleted = 0是匹配不到 NULL 的,那部分数据就"消失"了。你需要在建表时给逻辑删除字段加默认值,或者在@TableLogic配置里指定逻辑未删除值包括 NULL 的情况。
我在项目里还会专门用一条 SQL 检查逻辑删除字段的空值数据:
SELECT COUNT(*) FROM user WHERE deleted IS NULL;如果查出有数据,就要及时修复,否则用户看到的数据总是差几条,而且这种 Bug 很难快速定位。
5.3 Lambda 条件不生效:字段与实体 getter 不匹配
Lambda 写法虽然编译安全,但前提是实体类的@TableField注解和属性一一对应。有次我在实体里加了@TableField("dept_id"),但属性名写成了deptId,Mapper 映射没问题,可是用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(前端传的值),就有两个风险:注入和列不存在。我都用字段白名单映射来解决,比如:
Map<String, 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第一参数是 boolean,false 时排序条件不生效,不会抛异常,是一种安全的兜底。
5.5 批量插入内存溢出:集合一次性装载过多
saveBatch是分批执行的,但如果你先一次性把几十万数据SELECT出来塞到List<User>里,内存必然爆炸。我在做数据迁移或服务间数据同步时,用的是 MP 的Page+ 循环拉取,每批查 1000 条,处理完再拉下一批:
long pageNo = 1; long pageSize = 1000; while (true) { Page<User> 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 R<IPage<OrderVO>> page(OrderQuery query) { return R.ok(orderService.queryOrderPage(query)); } }Service 接口与实现:
public interface IOrderService extends IService<Order> { IPage<OrderVO> queryOrderPage(OrderQuery query); } @Service public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService { @Override public IPage<OrderVO> queryOrderPage(OrderQuery query) { Page<Order> page = new Page<>(query.getCurrent(), query.getSize()); LambdaQueryWrapper<Order> 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 接口与 XML:
public interface OrderMapper extends BaseMapper<Order> { IPage<OrderVO> selectOrderPage(Page<?> page, @Param("ew") Wrapper<Order> wrapper); }<select id="selectOrderPage" resultType="com.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 在这条路上给了我们一个很顺手的工具,用好它,你的日常开发生涯会轻松不少。