
做后端开发这些年MyBatis-Plus的Wrapper动态SQL是我用得最多的功能之一。只要涉及筛选条件不固定的列表接口动态SQL几乎都是绕不开的核心能力。简单说它就是在运行时根据传入参数拼接SQL条件存在就拼进WHERE不存在就跳过。相比手工字符串拼接带来的脏乱和风险MyBatis-Plus用一套条件构造器把这件事做得很干净但用得不好又会埋下各种坑——比如索引失效、SQL注入风险、N1查询、条件覆盖导致查询范围异常。这篇文章想把动态SQL从原理、写法到排查讲透适合刚接触MyBatis-Plus的初学者也适合已经写过很多Wrapper但想系统梳理一下的开发者。先声明一下我这里主要以Spring Boot MySQL环境为例因为这是目前中小团队最主流的组合。MyBatis-Plus的版本我推荐3.5.x功能更稳定。文章内容不依赖某个特定业务我会用一个订单列表筛选场景贯穿始终尽量让每个结论都有代码可验证。你会看到同样一个需求有四种写法QueryWrapper、LambdaQueryWrapper、注解SQL和XML映射。它们各有适合的场景关键不是背语法而是搞清楚当前业务到底需要什么然后在性能和可维护性之间做取舍。1. 项目概述与整体设计1.1 动态SQL解决的核心场景做后端接口时最难受的一个需求就是“条件筛选”。用户在前端勾选三个复选框、填两个输入框每个字段只有在不为空时才会参与过滤。如果只用传统SQL你往往要提前把“最复杂情况”的参数全部考虑进去写成WHERE 11加上一堆AND然后靠程序里对未传入参数做特殊处理。这样代码虽然能跑但至少有四个明显问题一是SQL语句无法预编译每次参数变化都会生成不同文本数据库缓存完全失效二是可读性极差三是容易发生注入四是代码里全是判断维护成本高。MyBatis-Plus的动态SQL正是针对这种场景设计的。它用条件构造器在运行时根据参数真值决定是否追加条件最后生成的仍是带?占位符的预编译SQL条件多样化只是参数不同SQL模板相对稳定。多条件列表查询、批量状态更新、按日期的统计报表都是它的典型应用场景。说白了动态SQL要解决的根本问题是让SQL语句的形状跟随业务入参自适应而不是让业务代码去迁就固定SQL。1.2 为什么选用MyBatis-Plus而不是原生MyBatis原生MyBatis也能通过XML的if、where、foreach做动态SQL并不差。但它不提供通用Mapper每个表的增删改查代码都要重复写很多团队就为了一个selectById都要写一大段XML。MyBatis-Plus在原生MyBatis之上扩展了通用CRUD和Wrapper能力把大量样板代码吃掉开发者只需要关心业务语义。我列一个简单对比维度原生MyBatisMyBatis-Plus通用CRUD无需为每张表重复写内置BaseMapper动态条件构造XML标签手动编写Wrapper条件构造器字段类型安全一般靠xml字段名误写不易发现LambdaWrapper编译期校验分页依赖第三方插件或手写Limit内置分页插件学习成本较高较低核心方法少选择MyBatis-Plus并不是否定原生而是大多数普通业务系统不需要那么多“完全手写”的SQL权限。尤其在多人协作时统一用Wrapper写动态条件代码风格稳定、不易出低级错误。对于复杂报表或联表查询再改用XML或注解自定义SQL反而更灵活。这就是“标配全自动、特例手写”的分层设计思路。1.3 基础环境与工程规划这篇文章配套的环境比较简单我用的是Spring Boot 2.7、MySQL 8、MyBatis-Plus 3.5.3。如果你是新工程直接在pom里加依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency如果用的是Spring Boot 3需要换成mybatis-plus-spring-boot3-starter包名不变但版本必须配套。application.yml里除了数据源还可以提前把SQL日志打开方便后面调试spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意log-impl只在开发环境用生产环境千万不要开会刷屏且泄露查询细节。2. 核心细节解析与实操要点2.1 Wrapper体系拆解QueryWrapper与LambdaQueryWrapperWrapper是MyBatis-Plus里最核心的条件构造器平时常看到的是QueryWrapper和LambdaQueryWrapper。两者都能拼WHERE条件区别在字段名的写法。先看QueryWrapperQueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(status, 1); wrapper.like(name, 张);再看LambdaQueryWrapperLambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1); wrapper.like(User::getName, 张);从形式上看Lambda版更简洁而且直接引用实体类方法编译期就能发现字段拼写错误。比如User::getName写成了User::getNam编译直接报错字符串版name写成了nmae运行期才报错还得看异常排查。所以我的建议是能不用字符串列名就不用新代码一律优先LambdaQueryWrapper。但QueryWrapper并不是一无是处。在多表联查里字段可能属于不同的表Lambda版只解析当前实体类的字段想表达a.name、b.name就有点绕。QueryWrapper可以直接写a.name、b.name反而更灵活。还有一种情况你的查询条件并不是实体字段而是一个计算列、别名字段用QueryWrapper的apply或last会更顺手。再补充一个细节BaseMapper本身提供的很多通用方法可以直接传Wrapper比如selectList(wrapper)、selectCount(wrapper)、selectOne(wrapper)分页方法selectPage(page, wrapper)也是这个套路。把这些方法记熟日常开发效率会高很多。2.2 动态条件拼接的关键写法与condition参数很多人第一次用Wrapper时会把所有条件一股脑写上去结果参数为空的字段也写进SQL查出来的结果完全不对。比如LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, status); wrapper.like(User::getName, name);如果status是null最终SQL会变成WHERE status null查不出任何数据。正确做法是让Wrapper知道“什么时候拼什么时候不拼”。MyBatis-Plus在每个方法上都提供了一个condition参数放在最前面LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.isNotBlank(status), User::getStatus, status); wrapper.like(StringUtils.isNotBlank(name), User::getName, name);这里StringUtils可以用org.apache.commons.lang3.StringUtils也可以用MyBatis-Plus自带的com.baomidou.mybatisplus.core.toolkit.StringUtils。condition为true才拼接这个条件为false就完全忽略效果等价于自己去写if (status ! null) { wrapper.eq(...); }但代码短很多。动态条件还有一个绕不开的难点and与or的优先级。SQL里AND的优先级高于OR因此一旦需求变成“状态等于A或者创建时间在某一范围”就要用括号把条件包起来。Wrapper的写法是wrapper.eq(User::getStatus, 1) .and(w - w.lt(User::getCreateTime, endTime) .or() .isNull(User::getAuditTime));生成的SQL是WHERE status 1 AND (create_time ? OR audit_time IS NULL)千万不要直接在and外层再拼.or().isNull(...)那样生成的SQL会变成WHERE status 1 AND create_time ? OR audit_time IS NULL查询结果瞬间偏移。除了条件Wrapper也支持select指定查询列、orderByAsc排序、groupBy分组、last拼接自定义片段。last能拼LIMIT或FOR UPDATE但注意它会把内容原样追加到SQL末尾存在SQL注入风险业务代码里要谨慎使用必要时对传入值做白名单校验。2.3 注解方式自定义SQL与安全边界Wrapper能覆盖大部分简单查询但团队里总会出现一些报表类、统计类的复杂SQL。如果不想为此写XML可以使用注解Select配合script标签拼动态SQL。比如批量查询Select(script SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /script) ListUser selectByIdsCustom(Param(ids) ListLong ids);这里面的foreach就是MyBatis原生的动态SQL标签MP完全支持。注解方式的优点是简单SQL直接放在Mapper接口里不用额外维护XML文件缺点是SQL一旦超过两三行字符串拼接可读性会很差而且Java里写XML注释也没法加。我的建议是固定且简短的SQL用注解没问题复杂动态SQL还是放到XML里至少能换行、能写注释、能排版。还有一个进阶用法在自定义SQL里继续复用Wrapper条件。Mapper方法这样写Select(script SELECT * FROM user where${ew.sqlSegment}/where /script) ListUser selectByWrapper(Param(Constants.WRAPPER) WrapperUser wrapper);注意参数必须用Param(Constants.WRAPPER)并且${ew.sqlSegment}是直接展开的不是预编译参数所以传入的Wrapper内容不能来自用户可控的原始字符串拼接通常都使用Wrapper内置方法生成安全性才有保障。2.4 XML映射文件中的动态SQL标签XML方式仍是复杂动态SQL的最佳归宿。日常使用频率最高的是if、where、set、foreach这四类。先看最经典的where配合ifselect idselectUserList resultTypecom.example.entity.User SELECT * FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if /where /selectwhere会自动去掉第一个条件前面的AND或OR避免写出WHERE 11的丑陋姿势。注意if的判断条件里字符串空串也要处理否则用户传name时SQL会变成AND name LIKE %%性能和安全都不好。更新场景则用setupdate idupdateUserSelective UPDATE user set if testname ! nullname #{name},/if if teststatus ! nullstatus #{status},/if /set WHERE id #{id} /updateset会智能删掉末尾的逗号动态更新时非常好用不会把不需要更新的字段覆盖成空。批量插入或批量查询的foreachinsert idinsertBatch INSERT INTO user(name, status) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.status}) /foreach /insertXML里的动态标签和Wrapper从本质上讲是同一套思路根据条件决定SQL片段是否出现。不同点在于XML是“模板控制”Wrapper是“对象操控”。业务方法里Wrapper明显更贴合面向对象思维而XML更适合写“精准的数据库方言”。2.5 防SQL注入与动态参数的预编译机制动态SQL不等于“字符串拼接SQL”。这是很多新手容易混淆的概念。MyBatis在处理#{}参数时会把参数值交给JDBC的预编译机制生成?占位符而${}是直接替换成文本再拼进SQL存在注入风险。以模糊查询为例正确写法是用MyBatis-Plus的like方法wrapper.like(User::getName, keyword);最终SQL是SELECT * FROM user WHERE name LIKE ?参数值则被设置为%关键字%由JDBC传入安全可控。如果手痒写成下面这样就危险了wrapper.last(AND name LIKE % keyword %);一旦keyword里出现; DROP TABLE user; --后果不堪设想。last、apply这类方法本质上是${}能力只适合拼写固定的SQL片段比如排序字段、表名白名单绝不能拼用户输入值。使用XML时也一样值一律用#{}只有表名、列名、排序方向这类无法参数化的地方才考虑${}同时必须做白名单校验。这条原则在任何场景都成立动态SQL的安全底线就是“参数永远预编译结构片段永远白名单”。3. 实操过程与关键环节实现3.1 搭建可验证的最小项目纸上谈兵没意思我们直接搭一个最小项目。实体类一个用户表、一个订单表就够了。先看用户实体package com.example.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(user) public class User { TableId(type IdType.AUTO) private Long id; private String name; private Integer status; private LocalDateTime createTime; }Mapper接口package com.example.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.entity.User; public interface UserMapper extends BaseMapperUser { }启动类package com.example; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication MapperScan(com.example.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }到这一步userMapper.selectList(wrapper)这些通用方法已经可以用了。这就是MyBatis-Plus最舒服的地方不用为单表CRUD写任何XML。3.2 配置分页插件与自定义分页列表查询几乎必分页。MP内置了分页插件但需要手动注册拦截器package com.example.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页查询的标准写法PageUser page new Page(current, size); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1); PageUser result userMapper.selectPage(page, wrapper); long total result.getTotal(); ListUser records result.getRecords();如果自定义SQL也想分页只需要让Mapper方法第一个参数接收IPage加上通用配置MP会自动拼接LIMIT还会自动生成COUNT语句。比如Select(SELECT * FROM order_info WHERE status #{status}) IPageOrderInfo selectOrderPage(PageOrderInfo page, Param(status) Integer status);注意一个坑当主SQL特别复杂、带多个join或group by时MP生成的count语句不一定准确既可能多查也可能少查。这种情况建议手动写一个select idxxxCount后缀的count方法或者直接在XML里用select同时控制主查询和count避免踩到统计不准的坑。3.3 完整实战订单列表筛选与批量状态更新现在做一个电商后台常见的“订单筛选列表”。入参可能有订单号、订单状态、起止时间、最低金额。直接用LambdaQueryWrapper实现public PageOrderInfo queryOrderPage(OrderQueryDTO dto) { PageOrderInfo page new Page(dto.getCurrent(), dto.getSize()); LambdaQueryWrapperOrderInfo wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.isNotBlank(dto.getOrderNo()), OrderInfo::getOrderNo, dto.getOrderNo()); wrapper.eq(dto.getStatus() ! null, OrderInfo::getStatus, dto.getStatus()); wrapper.ge(dto.getStartTime() ! null, OrderInfo::getCreateTime, dto.getStartTime()); wrapper.le(dto.getEndTime() ! null, OrderInfo::getCreateTime, dto.getEndTime()); wrapper.ge(dto.getMinAmount() ! null, OrderInfo::getAmount, dto.getMinAmount()); wrapper.orderByDesc(OrderInfo::getCreateTime); return orderInfoMapper.selectPage(page, wrapper); }这段代码的关键在condition参数。orderNo为空时eq的condition为false这个条件就被跳过status为null时也一样。最终SQL里的WHERE子句会随着入参变化而且始终只出现“真实传入”的条件。如果什么都不传就是单纯的ORDER BY create_time DESC符合预期。有查询自然有更新。批量更新订单状态也是动态SQL的常见场景比如把一批符合条件的订单标记为已处理public void batchUpdateStatus(ListLong ids, Integer targetStatus) { LambdaUpdateWrapperOrderInfo wrapper new LambdaUpdateWrapper(); wrapper.in(ids ! null !ids.isEmpty(), OrderInfo::getId, ids); wrapper.eq(OrderInfo::getStatus, 1); OrderInfo updateEntity new OrderInfo(); updateEntity.setStatus(targetStatus); orderInfoMapper.update(updateEntity, wrapper); }LambdaUpdateWrapper是LambdaQueryWrapper的一个实现类同样支持condition判断、in、eq等条件。它和UpdateWrapper的区别同样是Lambda字段引用优先使用。update(updateEntity, wrapper)只会用updateEntity里非null字段做SET配合wrapper完成条件定位非常适合批量修改状态类的需求。3.4 从日志到Explain的性能排查动态SQL很容易在外表上看起来“很智能”实际生成的SQL却异常危险。所以要学会看SQL日志和执行计划。第一步在开发环境打开SQL日志mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl运行上面的查询接口控制台会输出类似 Preparing: SELECT order_no,status,amount,create_time FROM order_info WHERE order_no LIKE ? AND create_time ? ORDER BY create_time DESC LIMIT ? Parameters: %20240101%(String), 2024-01-01 00:00:00(LocalDateTime), 10(Long)看到?占位符就说明参数走了预编译是安全的。如果SQL文本里直接出现了参数值那就是某个${}使用不当需要立即排查。第二步把生成的SQL拿到数据库客户端执行前面加EXPLAINEXPLAIN SELECT order_no,status,amount,create_time FROM order_info WHERE order_no LIKE %20240101% AND create_time 2024-01-01 00:00:00 ORDER BY create_time DESC LIMIT 10;重点看三列type、key、rows。type是ALL说明全表扫描key为空说明没用索引rows很大说明扫描行数太多。如果发现WHERE条件里的列明明建了索引但没走先想想是不是SQL写法问题。比如动态SQL里偶尔会有人写出WHERE DATE(create_time) 2024-01-01这个写法对create_time列做了函数运算索引会失效。正确思路是把它改成范围条件WHERE create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00在Wrapper里就是两个条件wrapper.ge(begin ! null, OrderInfo::getCreateTime, begin); wrapper.lt(end ! null, OrderInfo::getCreateTime, end);写动态SQL时始终保持“每个查询列独立参与比较”不要对索引列做任何计算。这比建一堆索引更实在。4. 常见问题与排查技巧实录4.1 动态条件失效与数据范围异常最常见的问题就是“我不传某个条件结果查出来的数据不对”。前面提过wrapper.eq(User::getStatus, status)在status为null时SQL会变成status null一个正常表里永远不会有行满足这个条件所以列表会莫名其妙的空。还有一种情况是0被当成空。很多Java代码判断status时用status ! null status ! 0但如果状态码本身可以是0就会漏掉。所以我更推荐统一的判断习惯数值类型只判null字符串类型判null和空串不要过度判断。condition的位置写清楚团队里规定好参数校验标准这类问题能少一半。排查这种问题时先看日志里最后的PreparingSQL。如果一个本应为空的字段出现在SQL里那就说明condition写错了或没写。动态SQL调试的核心就是“用日志反推条件”而不是直接怀疑数据库数据。4.2 LambdaWrapper字段解析失败的场景LambdaWrapper虽然类型安全但在多表联查或字段歧义时也会翻车。比如一个订单表关联用户表查询条件是用户姓名实体类字段解析后会自动拼接为表的列名如果两张表都有name列生成的SQL可能产生歧义。这种情况下有两个思路第一个思路如果业务场景比较简单直接用QueryWrapper写明字段前缀QueryWrapperOrderInfo wrapper new QueryWrapper(); wrapper.eq(u.name, keyword); wrapper.last(LEFT JOIN user u ON u.id order_info.user_id);虽然字符串不优雅但至少能精确控制列归属。第二个思路在XML里写死查询SQL配合固定的别名和动态标签不依赖Lambda解析。推荐这种方式多表联查本来就应该在XML里沉淀不要在Service层强行拼Wrapper。另外还要注意如果实体类的TableField有自定义映射比如TableField(order_no)LambdaWrapper会正常解析。但如果你在SQL里用ORDER BY传入外部排序字段那就不能用Lambda了因为字段名是运行时才知道的只能白名单校验后拼接。4.3 N1查询与批量操作优化动态SQL写顺手之后很容易在循环里不自觉地调Mapper。我见过一段真实代码对每个订单ID反复查用户信息for (Long userId : userIds) { User user userMapper.selectById(userId); }如果userIds有100个就会产生100条查询语句数据库连接压力直接拉满。这不是MyBatis-Plus的问题是使用方式的问题。正确做法是改成批量查ListUser users userMapper.selectBatchIds(userIds);MP的selectBatchIds会生成一条IN查询明显更高效。如果ids很大可以分包处理比如每500个一组ListListLong partitions Lists.partition(userIds, 500); for (ListLong partition : partitions) { ListUser users userMapper.selectBatchIds(partition); }批量插入也有一个容易被忽略的优化点。MP的saveBatch本质上还是分批执行单条插入并不是一条SQL插入多行。如果想让数据真正批量写入可以开启MySQL驱动的rewriteBatchedStatementsspring: datasource: url: jdbc:mysql://localhost:3306/demo?rewriteBatchedStatementstrue加上这个参数之后JDBC会把多批次插入自动合并为INSERT INTO ... VALUES (...),(...)形式批量导入性能能提升一个量级。项目里如果经常做数据迁移这个开关几乎必开。4.4 问题排查速查表我把平时最容易踩的坑整理成表格方便大家直接对照。场景现象原因建议动态条件不生效传了非空参数却查不到数据condition为false检查condition判断逻辑传null导致全空结果SQL里出现status null没用condition数值用! null模糊查询变成了%%数据全查出来性能变差空串未过滤字符串判isNotBlankor条件优先级错误查询结果偏差外层直接or用and(w - w.or())包裹like被写入last方法存在注入风险${}使用不当改用wrapper.like多表字段歧义SQL报错或结果错乱Lambda字段解析冲突XML手写别名分页count不准总条数异常复杂SQL自动count失效手动写count查询循环调Mapper请求变慢连接池告警N1查询selectBatchIds或in索引没生效explain显示ALL对索引列做函数运算改成范围比较这张表基本覆盖了动态SQL开发中90%的日常问题。遇到疑难杂症时第一步永远是打开SQL日志看真实的SQL长什么样再结合数据库执行计划去定位不要凭空猜。4.5 多年的实操心得与避坑清单最后分享几个我个人的操作习惯不算标准答案但很实用。第一默认不select所有字段。selectList(wrapper)查出来的是整行数据如果列表页只需要ID、标题、状态可以在Wrapper里用select指定列wrapper.select(User::getId, User::getName, User::getStatus);这能让查询结果集变小减少数据库到应用服务器的传输量。尤其表字段多的场景效果非常明显。第二写动态SQL前先画一张“条件矩阵”。把入参罗列出来标出哪些可能为空、哪些之间是互斥关系、哪些需要组合成区间。矩阵理清楚了Wrapper代码就是一次成型的而不是写完再反复修。第三设置团队规范不允许在循环里调用任何Mapper方法不允许对用户输入使用last拼接不允许使用QueryWrapper字符串列名除非多表联查。这类规范看起来很基础但防住的都是线上事故。我自己的体会是动态SQL的“高效”不只是代码写得短更重要的是每一步都预判了生成SQL的效果和数据库执行行为。把日志、执行计划、索引设计看成动态SQL的一部分你才能真正掌握它。