ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatis-Plus动态SQL实战:从Wrapper原理到性能排查

MyBatis-Plus动态SQL实战:从Wrapper原理到性能排查

做后端开发这些年,MyBatis-Plus的Wrapper动态SQL是我用得最多的功能之一。只要涉及筛选条件不固定的列表接口,动态SQL几乎都是绕不开的核心能力。简单说,它就是在运行时根据传入参数拼接SQL:条件存在就拼进WHERE,不存在就跳过。相比手工字符串拼接带来的脏乱和风险,MyBatis-Plus用一套条件构造器把这件事做得很干净,但用得不好又会埋下各种坑——比如索引失效、SQL注入风险、N+1查询、条件覆盖导致查询范围异常。这篇文章想把动态SQL从原理、写法到排查讲透,适合刚接触MyBatis-Plus的初学者,也适合已经写过很多Wrapper但想系统梳理一下的开发者。

先声明一下,我这里主要以Spring Boot + MySQL环境为例,因为这是目前中小团队最主流的组合。MyBatis-Plus的版本我推荐3.5.x,功能更稳定。文章内容不依赖某个特定业务,我会用一个订单列表筛选场景贯穿始终,尽量让每个结论都有代码可验证。你会看到同样一个需求,有四种写法:QueryWrapper、LambdaQueryWrapper、注解SQL和XML映射。它们各有适合的场景,关键不是背语法,而是搞清楚当前业务到底需要什么,然后在性能和可维护性之间做取舍。

1. 项目概述与整体设计

1.1 动态SQL解决的核心场景

做后端接口时,最难受的一个需求就是“条件筛选”。用户在前端勾选三个复选框、填两个输入框,每个字段只有在不为空时才会参与过滤。如果只用传统SQL,你往往要提前把“最复杂情况”的参数全部考虑进去,写成WHERE 1=1加上一堆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> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

如果用的是Spring Boot 3,需要换成mybatis-plus-spring-boot3-starter,包名不变,但版本必须配套。application.yml里除了数据源,还可以提前把SQL日志打开,方便后面调试:

spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8 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与LambdaQueryWrapper

Wrapper是MyBatis-Plus里最核心的条件构造器,平时常看到的是QueryWrapper和LambdaQueryWrapper。两者都能拼WHERE条件,区别在字段名的写法。

先看QueryWrapper:

QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1); wrapper.like("name", "张");

再看LambdaQueryWrapper:

LambdaQueryWrapper<User> 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,查出来的结果完全不对。比如:

LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, status); wrapper.like(User::getName, name);

如果status是null,最终SQL会变成WHERE status = null,查不出任何数据。正确做法是让Wrapper知道“什么时候拼,什么时候不拼”。MyBatis-Plus在每个方法上都提供了一个condition参数,放在最前面:

LambdaQueryWrapper<User> 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 collection='ids' item='id' open='(' separator=',' close=')'>" + "#{id}" + "</foreach>" + "</script>") List<User> selectByIdsCustom(@Param("ids") List<Long> 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>") List<User> selectByWrapper(@Param(Constants.WRAPPER) Wrapper<User> wrapper);

注意参数必须用@Param(Constants.WRAPPER),并且${ew.sqlSegment}是直接展开的,不是预编译参数,所以传入的Wrapper内容不能来自用户可控的原始字符串拼接,通常都使用Wrapper内置方法生成,安全性才有保障。

2.4 XML映射文件中的动态SQL标签

XML方式仍是复杂动态SQL的最佳归宿。日常使用频率最高的是<if>、<where>、<set>、<foreach>这四类。

先看最经典的<where>配合<if>:

<select id="selectUserList" resultType="com.example.entity.User"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> </where> </select>

<where>会自动去掉第一个条件前面的AND或OR,避免写出WHERE 1=1的丑陋姿势。注意<if>的判断条件里,字符串空串也要处理,否则用户传name=""时,SQL会变成AND name LIKE '%%',性能和安全都不好。

更新场景则用<set>:

<update id="updateUserSelective"> UPDATE user <set> <if test="name != null">name = #{name},</if> <if test="status != null">status = #{status},</if> </set> WHERE id = #{id} </update>

<set>会智能删掉末尾的逗号,动态更新时非常好用,不会把不需要更新的字段覆盖成空。

批量插入或批量查询的<foreach>:

<insert id="insertBatch"> INSERT INTO user(name, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.status}) </foreach> </insert>

XML里的动态标签,和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 BaseMapper<User> { }

启动类:

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; } }

分页查询的标准写法:

Page<User> page = new Page<>(current, size); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1); Page<User> result = userMapper.selectPage(page, wrapper); long total = result.getTotal(); List<User> records = result.getRecords();

如果自定义SQL也想分页,只需要让Mapper方法第一个参数接收IPage,加上通用配置,MP会自动拼接LIMIT,还会自动生成COUNT语句。比如:

@Select("SELECT * FROM order_info WHERE status = #{status}") IPage<OrderInfo> selectOrderPage(Page<OrderInfo> page, @Param("status") Integer status);

注意一个坑:当主SQL特别复杂、带多个join或group by时,MP生成的count语句不一定准确,既可能多查,也可能少查。这种情况建议手动写一个<select id="xxxCount">后缀的count方法,或者直接在XML里用<select>同时控制主查询和count,避免踩到统计不准的坑。

3.3 完整实战:订单列表筛选与批量状态更新

现在做一个电商后台常见的“订单筛选列表”。入参可能有订单号、订单状态、起止时间、最低金额。直接用LambdaQueryWrapper实现:

public Page<OrderInfo> queryOrderPage(OrderQueryDTO dto) { Page<OrderInfo> page = new Page<>(dto.getCurrent(), dto.getSize()); LambdaQueryWrapper<OrderInfo> 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(List<Long> ids, Integer targetStatus) { LambdaUpdateWrapper<OrderInfo> 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拿到数据库客户端执行,前面加EXPLAIN:

EXPLAIN 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写明字段前缀:

QueryWrapper<OrderInfo> 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 N+1查询与批量操作优化

动态SQL写顺手之后,很容易在循环里不自觉地调Mapper。我见过一段真实代码,对每个订单ID反复查用户信息:

for (Long userId : userIds) { User user = userMapper.selectById(userId); }

如果userIds有100个,就会产生100条查询语句,数据库连接压力直接拉满。这不是MyBatis-Plus的问题,是使用方式的问题。正确做法是改成批量查:

List<User> users = userMapper.selectBatchIds(userIds);

MP的selectBatchIds会生成一条IN查询,明显更高效。如果ids很大,可以分包处理,比如每500个一组:

List<List<Long>> partitions = Lists.partition(userIds, 500); for (List<Long> partition : partitions) { List<User> users = userMapper.selectBatchIds(partition); }

批量插入也有一个容易被忽略的优化点。MP的saveBatch本质上还是分批执行单条插入,并不是一条SQL插入多行。如果想让数据真正批量写入,可以开启MySQL驱动的rewriteBatchedStatements:

spring: datasource: url: jdbc:mysql://localhost:3306/demo?rewriteBatchedStatements=true

加上这个参数之后,JDBC会把多批次插入自动合并为INSERT INTO ... VALUES (...),(...)形式,批量导入性能能提升一个量级。项目里如果经常做数据迁移,这个开关几乎必开。

4.4 问题排查速查表

我把平时最容易踩的坑整理成表格,方便大家直接对照。

场景现象原因建议
动态条件不生效传了非空参数却查不到数据condition为false检查condition判断逻辑
传null导致全空结果SQL里出现status = null没用condition数值用!= null
模糊查询变成了%%数据全查出来,性能变差空串未过滤字符串判isNotBlank
or条件优先级错误查询结果偏差外层直接or用and(w -> w.or())包裹
like被写入last方法存在注入风险${}使用不当改用wrapper.like
多表字段歧义SQL报错或结果错乱Lambda字段解析冲突XML手写别名
分页count不准总条数异常复杂SQL自动count失效手动写count查询
循环调Mapper请求变慢,连接池告警N+1查询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的一部分,你才能真正掌握它。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进