专栏《Java后端工程师进阶之路》从CRUD到AI工程师的完整跃迁路径Day 22/ 90MyBatis-Plus 的Wrapper链式 API就是为这种场景而生的。它不能替代所有 XML但能把 80% 的 CRUD 从“拼字符串”变成“写 Java 代码”让你的 SQL 在编译期就能被检查到一部分。一、环境准备Spring Boot 3 集成 MyBatis-Plusspring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted # 全局逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:/mapper/**/*.xml二、动态 SQL从 XML 中解放出来MyBatis-Plus 的动态 SQL 核心就是Wrapper。它分为两类基于字符串的QueryWrapper/UpdateWrapper基于 Lambda 的LambdaQueryWrapper/LambdaUpdateWrapper我强烈建议用 Lambda 版本。原因就一个字段名一改编译器立刻报错不会把user_name拼成userName留到运行时才暴露。实体类与 Mapper复杂条件查询Service public class UserService extends ServiceImplUserMapper, User { public PageUser searchUser(UserSearchDTO dto, int current, int size) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // 1. 模糊匹配用户名仅当参数不为空 wrapper.like(StringUtils.isNotBlank(dto.getUserName()), User::getUserName, dto.getUserName()); // 2. 精确匹配手机号 wrapper.eq(StringUtils.isNotBlank(dto.getMobile()), User::getMobile, dto.getMobile()); // 3. 状态必须明确传值才过滤不传就不过滤 wrapper.eq(dto.getStatus() ! null, User::getStatus, dto.getStatus()); // 4. VIP 等级范围 wrapper.ge(dto.getMinVipLevel() ! null, User::getVipLevel, dto.getMinVipLevel()) .le(dto.getMaxVipLevel() ! null, User::getVipLevel, dto.getMaxVipLevel()); // 5. 注册时间范围 wrapper.between(dto.getStartTime() ! null dto.getEndTime() ! null, User::getRegisterTime, dto.getStartTime(), dto.getEndTime()); // 6. 排序最后登录时间倒序再按注册时间倒序 wrapper.orderByDesc(User::getLastLoginTime) .orderByDesc(User::getRegisterTime); return baseMapper.selectPage(new Page(current, size), wrapper); } }这段代码比 XML 直观在哪里每个条件前面第一个参数就是“是否生效”的开关null/空字符串自动跳过字段是User::getUserName这种 Lambda 引用重构时 IDE 能追踪不会忘记11前缀也不会因为漏写AND导致 SQL 语法错误默认已经帮你处理好参数注入不存在字符串拼接的 SQL 注入风险。生成的 SQL 类似这样SELECT id, user_name, mobile, status, vip_level, register_time, last_login_time FROM t_user WHERE user_name LIKE %张% AND status 1 AND vip_level 1 AND vip_level 5 AND register_time BETWEEN 2025-01-01 AND 2025-12-31 ORDER BY last_login_time DESC, register_time DESC LIMIT 0, 20三、分页别再手写LIMIT和COUNTMyBatis-Plus 的分页是我使用频率第二高的功能。它自动帮你处理两件事先执行COUNT(*)查总数再执行带LIMIT的查询把结果封装成PageT返回。配置分页插件高版本已经不需要PaginationInterceptor了统一用MybatisPlusInterceptorConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 添加乐观锁插件后面讲 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }分页查询实战返回的Page对象包含records当前页数据total总记录数pages总页数current/size当前页和页大小optimizeJoinOfCountSql如果你联表查询分页可以调优 count SQL分页前一定要记得加ORDER BY否则 MySQL 返回的顺序是不可预期的分页结果会“乱跳”。四、乐观锁解决并发更新覆盖问题并发下最常见的坑A 和 B 同时读取同一条记录A 修改后保存B 也修改后保存结果 A 的修改被 B 覆盖。乐观锁的思路是给表加一个版本号字段version每次更新时version 1并且WHERE version 旧值。如果已经被别人更新过这次更新就返回 0 行影响业务层可以重试或提示。启用乐观锁实体类加VersionData TableName(t_account) public class Account { TableId(type IdType.AUTO) private Long id; private String userName; private BigDecimal balance; Version private Integer version; }配置插件前面已加new OptimisticLockerInnerInterceptor()。乐观锁使用示例Service public class AccountService extends ServiceImplAccountMapper, Account { public boolean deductBalance(Long accountId, BigDecimal amount) { // 1. 先读取当前记录和版本号 Account account baseMapper.selectById(accountId); if (account null || account.getBalance().compareTo(amount) 0) { throw new RuntimeException(账户不存在或余额不足); } // 2. 业务上扣减余额 account.setBalance(account.getBalance().subtract(amount)); // 3. MyBatis-Plus 会自动 WHERE version 旧值 int affected baseMapper.updateById(account); if (affected 0) { // 版本冲突可重试或抛异常 throw new RuntimeException(并发冲突请稍后重试); } return true; } }执行时生成的 SQLUPDATE t_account SET balance ?, version version 1 WHERE id ? AND version ?关键提醒只有updateById和update(wrapper, entity)这种标准更新会被插件拦截手写updateBatchSomeColumn或自定义 XML 不会自动加版本控制version字段不要手动 set 新值让插件自动加 1。五、逻辑删除别让数据真的“消失”生产环境真正的删除很少见。MyBatis-Plus 的逻辑删除机制是给表加一个deleted字段删除时只更新该字段查询时自动过滤deleted 0。配置与实体全局配置已经在 yaml 中写了logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0实体上也可以单独标注Data TableName(t_user) public class User { // ... 其他字段 TableLogic private Integer deleted; }使用方式// 调用删除时MyBatis-Plus 会自动改成 UPDATE userService.removeById(1001L); // 对应 SQL // UPDATE t_user SET deleted 1 WHERE id 1001 AND deleted 0 // 查询时自动过滤 User user userService.getById(1001L); // 对应 SQL // SELECT ... FROM t_user WHERE id 1001 AND deleted 0两个容易踩的坑1. 唯一索引冲突如果user_name有唯一索引删除后再注册同名用户会报错。解决思路联合唯一索引改成(user_name, deleted)允许同名用户一个被删除、一个未删除或者删除时把用户名后面拼接逻辑删除标识例如user_name _deleted_ id。2. 级联/外键场景逻辑删除只是更新一个字段关联表的数据不会自动被处理。如果子表有外键你需要在业务层自行维护级联逻辑删除或者干脆不用外键、用应用层约束。六、自动填充create_time / update_time 不需要手动 set几乎每个表都有create_time和update_timeMyBatis-Plus 提供MetaObjectHandler自动填充Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, deleted, Integer.class, 0); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体字段TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;这样你写 Service 的时候就不用再手动 set 创建时间代码干净很多。七、三条能直接落地的经验1. Wrapper 用 Lambda 版不要用字符串版字符串版QueryWrapper在字段重命名时不会报错极容易留下运行时才暴露的隐患。团队规模越大越要坚持 Lambda 版本。如果有人非要用字符串写复杂条件建议写到自定义 XML 里至少 SQL 一目了然。2. 分页查询一定要做“慢查询兜底”分页再方便也不代表查询本身不会慢。LIKE %xxx%不走索引、ORDER BY用错字段、连表分页都会产生大偏移量问题。生产环境建议单表分页超过 10 万行时考虑用延迟关联或上次查询的最大 ID 游标给分页接口加超时控制避免前端一直转圈用Page的setSearchCount(false)跳过 count当只展示“下一页”按钮时特别有用。3. 乐观锁和逻辑删除不是银弹要看业务场景乐观锁适合“读多写少、冲突概率低”的场景比如账户余额、库存扣减逻辑删除适合审计要求高的业务但会让统计报表查询变复杂需要同时看deleted 0和deleted 1如果业务对实时性要求极高悲观锁SELECT FOR UPDATE或 Redis 分布式锁反而更合适。MyBatis-Plus 是我最近几年用得最顺手的持久层工具之一。它不是要取代 MyBatis而是把 80% 的重复劳动接管过来让你把注意力放在那 20% 真正复杂的 SQL 上。用好Wrapper、分页、乐观锁、逻辑删除、自动填充这几张牌你的持久层代码会少一半维护起来也会轻松很多。工具选得好CRUD 写得少基础打得牢AI 才接得稳。下一篇 Day 23我们进入持久层选型的深水区《JPA vs MyBatis从架构哲学到性能数据的全面对比》。我会用同一套业务场景分别用 JPA 和 MyBatis 写一遍给你看看到底该怎么选。参考资料MyBatis-Plus 官方文档Spring Boot 3.2 官方文档MySQL 8.0 性能优化官方指南