在Java后端这边待得久一点,你基本绕不开一个东西:MyBatis动态SQL。只要你的项目里出现“这个列表查询条件不固定,A情况要按姓名筛,B情况要按部门筛,C情况两个都不筛”这类需求,你就得打开XML映射文件,开始写动态SQL。这功能确实顺手,但我也见过不少写崩的:一个查询方法里if标签堆出上百行,where标签没兜住导致UPDATE把整张表都改了,foreach不知道collection里该写啥直接报BindingException。这篇文章就把MyBatis动态SQL这几个核心语法全部过一遍,从if、where、set、trim到choose、foreach、bind,再到sql片段复用,每个标签的职责边界、适用场景和容易踩的坑,我一个一个跟你讲清楚。适合刚接触MyBatis的新人通读建立整体认知,也适合写了好几年还在靠复制粘贴拼SQL的老手拿来查漏补缺。
1. 先从JDBC拼SQL的噩梦说起
1.1 动态SQL到底解决什么问题
把场景拉回最早的JDBC时代。那时候想要按条件查用户,代码大概是这个鬼样子:
String sql = "SELECT * FROM user WHERE 1 = 1"; if (StringUtils.hasText(name)) { sql += " AND name LIKE '%" + name + "%'"; } if (age != null) { sql += " AND age >= " + age; } sql += " ORDER BY id DESC";看着还行对吧?但几个星期之后需求变了,加了“部门多选”,又加了“按创建时间排序”,这个字符串就开始失控了。引号、空格、AND、OR、逗号,每一个都是潜伏的雷。更可怕的是字符串拼接直接把用户输入塞进SQL,一旦有人输入一个' OR '1'='1,整张表就裸奔了。那个年代的SQL注入漏洞满天飞,不是没有原因的。
MyBatis动态SQL解决的就是这个问题。它让你在XML里写“半成品SQL”,用if、where、foreach这些标签控制片段要不要拼进去,MyBatis运行时会根据参数计算后生成最终的SQL文本。这个过程是在框架层面完成的,你可以用#{}走预编译,把SQL注入的风险按下去。所谓动态SQL,本质就是“SQL的模板引擎”,只是它专门为数据库访问场景设计,语法更贴合SQL习惯。
1.2 理解MyBatis动态SQL的工作方式
用久了你会觉得动态SQL是“理所当然”的,但其实它的底层机制很有意思。每个Mapper的XML在启动时会被解析成一颗SqlNode树,比如<if>对应一个IfSqlNode,<foreach>对应一个ForeachSqlNode。真正执行SQL时,MyBatis把这棵树扔进DynamicContext,逐个节点计算、拼接,最后得到一个完整的SQL字符串和参数映射,再交给JDBC执行。
这就解释了为什么动态SQL的“动态”只发生在执行阶段,标签结构的解析在启动时就完成了。有人担心动态SQL性能差,其实单个if判断的开销微乎其微,真正影响性能的是后面要讲的SQL文本不稳定导致数据库执行计划频繁更换,而不是标签本身。
2. 六个核心标签逐个拆解
2.1 if:所有动态判断的基础
if标签是动态SQL的基石,几乎所有动态SQL都是从一个<if>开始的。核心就是test属性,写OGNL表达式,表达式的值为真就拼接标签内部的内容。
<select id="search" resultType="User"> SELECT * FROM user WHERE 1 = 1 <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="age != null"> AND age >= #{age} </if> </select>先说一个新手最容易踩的坑:OGNL里空字符串、null、数字0都会被当作false。所以判断某个字符串参数是否传了,要写成name != null and name != '',缺一个都可能让你排查半天。数字类型则只需要判断null,因为如果字段是Integer,写age != 0会把“年龄等于0”这个合法条件挡在门外,逻辑上就是错的。
另一个藏得比较深的问题:如果你的DTO里用的是int基本类型,而不是Integer,OGNL表达式age != null会直接报错或者永远判断为true,因为基本类型没有null的概念。所以凡是可能作为动态SQL条件的字段,一律用包装类型。
还有个细节很容易忽视:XML里写age >= #{age},这个>是>的转义。因为XML解析器会把<当作标签开始,直接写age >= #{age}不会报错,因为这里没有<,但如果你写的是age < 30,就一定要写成age < 30。刚转XML的新人经常在这一步看半天语法错误。
if标签本身不处理任何拼接整洁度的问题,它只负责“这段SQL要不要出现”。多个if之间是独立关系,可以同时生效,也可以一个都不生效。如果想让多个条件之间互斥,那就得用后面的choose标签。
2.2 where、set与trim:处理SQL拼接脏数据的净化器
很多初学者写完上面的if,发现SQL可能变成这样:
SELECT * FROM user WHERE AND name LIKE '%张%'原因很简单:第一个条件没传,第二个条件传了,if直接拼了一个AND name...在WHERE后面。于是MyBatis给了你<where>标签:
<select id="search" resultType="User"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="age != null"> AND age >= #{age} </if> </where> </select>where标签会智能地把子元素生成的SQL里开头的AND或者OR去掉,只在有内容时保留WHERE关键字。也就是说,上面这个SQL最终变成SELECT * FROM user WHERE name LIKE ...,干净利落。
但你得明白它的局限:where只处理开头位置的多余AND/OR。如果你表达式里写了AND age >= #{age} AND,最后一个AND它是不会管的,照样会把语法错误留给你。另外,where标签不能帮你去掉WHERE后面的括号,如果你动态片段里有AND (a = 1 OR b = 2)这种复杂结构,要注意括号位置的生成。
set标签和where是亲兄弟,专门服务UPDATE语句。它的作用是自动把子元素生成的SQL末尾多余的逗号去掉,同时生成SET关键字:
<update id="updateById"> UPDATE user <set> <if test="name != null and name != ''"> name = #{name}, </if> <if test="age != null"> age = #{age}, </if> </set> WHERE id = #{id} </update>如果name和age都有值,最终SQL是UPDATE user SET name = ?, age = ? WHERE id = ?,末尾多出来的逗号被set吃掉了。如果两个if都没命中,SQL会变成UPDATE user WHERE id = ?,这在MySQL里直接语法错误。所以用set标签时,外层一定要包一层if判断确保至少一个字段有值,否则这个UPDATE在大部分数据库里都执行不了,某些数据库还可能出现全表更新的灾难。
trim是where和set的底层通用实现,它给你四个属性,想怎么裁就怎么裁:
| 属性 | 作用 |
|---|---|
| prefix | 子元素拼接完成后,在结果前添加指定前缀 |
| suffix | 在结果后添加指定后缀 |
| prefixOverrides | 如果结果开头匹配指定值,忽略或移除 |
| suffixOverrides | 如果结果末尾匹配指定值,忽略或移除 |
所以你可以用trim模拟where:
<trim prefix="WHERE" prefixOverrides="AND |OR "> <if test="name != null">AND name = #{name}</if> </trim>也能模拟set:
<trim prefix="SET" suffixOverrides=","> <if test="name != null">name = #{name},</if> </trim>我个人建议优先用where和set,代码可读性更高;当遇到需要复杂前缀后缀的边界场景时再用trim。比如你想动态拼接VALUES (...)的括号和逗号,trim会比foreach硬拼舒服很多。
2.3 foreach:搞定批量操作
foreach可能是动态SQL里“用得爽也坑得多”的标签。它解决的是批量插入、批量删除、IN查询这一类问题。
最基础的用法是IN查询:
<select id="selectByIds" resultType="User"> SELECT * FROM user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>collection指定遍历哪个参数,item是当前元素的变量名,open和close是拼接的前后缀,separator是元素之间的分隔符。上面这条最终生成WHERE id IN (?, ?, ?)。
批量插入则是另一种经典姿势:
<insert id="batchInsert"> INSERT INTO user(name, age, email) VALUES <foreach collection="users" item="u" separator=","> (#{u.name}, #{u.age}, #{u.email}) </foreach> </insert>很多人在这遇到第一个问题:collection到底写什么名字。规则我先给你理清楚:
- 方法参数是单个List且没有@Param注解:写
list或collection都行。 - 方法参数是单个数组且没有@Param注解:写
array。 - 方法参数是单个List但加了@Param注解:必须写注解里指定的名字,比如
@Param("ids") List<Long> ids,XML里就写ids。 - 多个参数时,必须用@Param给每个参数命名,否则你只能靠
param1、param2这种反人类的名字。 - Map参数场景,直接写Map的key。
// 推荐写法 List<User> selectByIds(@Param("ids") List<Long> ids); // 避免这种,collection=“list”可以跑但可读性差,而且没法同时传多个集合 List<User> selectByIds(List<Long> ids);foreach里还有一个item引用的陷阱。比如批量插入时你写item="u",内部必须用#{u.name}取字段,写成#{name}就会报NoSuchPropertyException。遍历的是List<String>时,item的名字随便取,内部用#{item}这个同名变量即可。另外,如果遍历的是一个对象的某个集合属性,比如query.deptIds,collection可以直接写query.deptIds,item名取deptId,非常直观。
批量操作还要注意一次拼太多参数的问题。数据库对SQL占位符数量有限制,MySQL默认的max_allowed_packet也会约束SQL文本大小。我实测下来,单条INSERT多行拼到一两百行,占位符上千个,很多环境就开始报错或者性能急剧下降。所以批量插入一定要分批,常见做法是每200条刷一批。
批量更新多条记录是另一个高频需求,写法通常是拼接多条UPDATE用分号隔开:
<update id="batchUpdateById"> <foreach collection="list" item="u" separator=";"> UPDATE user SET name = #{u.name} WHERE id = #{u.id} </foreach> </update>这里有一个最坑的细节:MySQL JDBC默认不允许一条请求里执行多条分号分隔的SQL,必须在数据库连接URL上加上allowMultiQueries=true。不少人在测试环境跑得欢、上线后发现批量更新失效,排查半天发现是连接池的URL少配了这个参数。遇到这种批量更新需求,我更推荐的做法是改为循环调用单条UPDATE,虽然多几次网络往返,但逻辑清晰、没有隐藏限制,事务控制也更容易。
2.4 choose:分支互斥,switch的替身
多个if之间是独立判断,而业务里经常需要“优先级互斥”逻辑——命中了A条件就忽略B条件。这时候该用<choose>。
<select id="searchByType" resultType="User"> SELECT * FROM user <where> <choose> <when test="type == 1"> AND name = #{keyword} </when> <when test="type == 2"> AND email = #{keyword} </when> <when test="type == 3"> AND phone = #{keyword} </when> <otherwise> AND id = #{defaultId} </otherwise> </choose> </where> </select>choose的结构很像Java里的switch:when相当于case,从上往下匹配,第一个成立的when生效,后面的都不会再看;otherwise相当于default,所有when都不成立时兜底。
它和多个if的核心区别就是互斥性。比如搜索场景里用户选择了“按姓名”就不能同时“按邮箱”,这种需求用多个if就会同时拼出两个条件,结果必然为空。choose设计出来就是为了解决这类分支选择。
写when的时候,test表达式里同样要注意OGNL的false规则。还有一点:如果所有when都没命中,又没写otherwise,choose就不会给SQL添加任何片段,你得保证主SQL本身能独立运行。
2.5 bind:锁死变量,统一表达式
bind标签可以让你在XML里定义一个变量,后续直接引用。最常见的场景是模糊查询:
<select id="search" resultType="User"> <bind name="keywordLike" value="'%' + keyword + '%'" /> SELECT * FROM user WHERE name LIKE #{keywordLike} </select>面试常问一个问题:为什么不用LIKE CONCAT('%', #{keyword}, '%')?其实两种写法都能用,bind的优势在于:把拼接逻辑从数据库方言中抽离出来。MySQL支持CONCAT函数,Oracle用||拼接,如果每个地方的模糊查询都写数据库函数,将来换数据库就得全文替换。用bind变量,就能把这层拼接逻辑统一在XML一处管理,换库时改动集中很多。
bind的另一个价值是复用OGNL表达式。如果你的同一个条件在SQL里反复出现,比如两个字段都要按同一个关键词模糊过滤,可以绑定一个变量用两次,避免重复写那一段复杂的OGNL表达式。
<bind name="keywordLike" value="'%' + keyword + '%'" /> SELECT * FROM user WHERE name LIKE #{keywordLike} OR email LIKE #{keywordLike}一个小提醒:bind的value里单引号表示字符串字面量,加号是OGNL的字符串拼接,不要把它当成SQL的加号。也别在bind里写#{},那是预编译参数的写法,bind的value是OGNL表达式,直接用参数名。
2.6 sql与include组合:SQL片段复用
一个项目里不同查询的返回列往往高度重合,比如id, name, age, email, create_time每个select都要写一遍。sql片段就是干这个的:
<sql id="Base_Column_List"> id, name, age, email, dept_id, create_time </sql> <select id="search" resultType="User"> SELECT <include refid="Base_Column_List"/> FROM user <where>...</where> </select>include除了引用片段,还能传参进去。比如你有一组动态生成的表名:
<sql id="TablePrefix"> ${tableName} </sql> <select id="dynamicTableQuery"> SELECT * FROM <include refid="TablePrefix"> <property name="tableName" value="user"/> </include> </select>这个功能在分表场景里非常实用。注意${}在include传参中是必须的,不能用#{},因为表名、列名这类数据库对象标识符没法预编译。所以动态表名一定要在调用方做好白名单限制,别直接接受用户输入。
sql片段最大的价值是消除重复,让多个SQL的列定义保持一致。比如你加了一个新字段,只需要改片段定义,所有引用它的查询列都会一起变更,比全文搜索替换靠谱得多。
3. 实战:一套顺手的多条件查询接口
3.1 第一版:条件拼接的血泪现场
场景是一个管理系统后台的用户列表查询,需求大概是这样:
- 按姓名模糊搜索,可选
- 按年龄范围过滤,可选
- 按部门ID多选过滤,可选
- 排序字段和方向可指定,但有白名单
- 分页
我见过不少人第一版是这么写的:
public List<User> queryUser(String name, Integer minAge, Integer maxAge, List<Long> deptIds, String orderBy, String orderDir) { SqlSession session = sqlSessionFactory.openSession(); return session.selectList("com.example.UserMapper.searchUser", new HashMap<>() {{ put("name", name); put("minAge", minAge); put("deptIds", deptIds); // ... }}); }XML转成动态SQL之后,看起来能跑,但其实处处踩雷:
<select id="searchUser" resultType="User"> SELECT * FROM user WHERE 1 = 1 <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="minAge != null"> AND age >= #{minAge} </if> <if test="maxAge != null"> AND age <= #{maxAge} </if> <if test="deptIds != null"> AND dept_id IN <foreach collection="deptIds" item="deptId" open="(" separator="," close=")"> #{deptId} </foreach> </if> ORDER BY id DESC </select>这版代码大概能应付最简单的场景,但问题不少:deptIds这个集合如果传成空list,deptIds != null成立,结果生成AND dept_id IN (),MySQL直接语法错误;排序字段写死id DESC,需求根本满足不了。
3.2 第二版:where标签和choose把SQL救回来
改进版我习惯把查询条件封装成一个Query对象,Mapper方法用@Param显式命名,XML里逻辑完整得多:
public class UserQuery { private String keyword; private Integer minAge; private Integer maxAge; private List<Long> deptIds; private String orderBy; private String orderDir; private Integer pageNum; private Integer pageSize; }List<User> searchUsers(@Param("query") UserQuery query);再看XML这版:
<select id="searchUsers" resultType="User"> SELECT <include refid="Base_Column_List"/> FROM user <where> <if test="query.keyword != null and query.keyword != ''"> AND name LIKE CONCAT('%', #{query.keyword}, '%') </if> <if test="query.minAge != null"> AND age >= #{query.minAge} </if> <if test="query.maxAge != null"> AND age <= #{query.maxAge} </if> <if test="query.deptIds != null and query.deptIds.size() > 0"> AND dept_id IN <foreach collection="query.deptIds" item="deptId" open="(" separator="," close=")"> #{deptId} </foreach> </if> </where> ORDER BY <choose> <when test="query.orderBy == 'createTime'">create_time</when> <when test="query.orderBy == 'age'">age</when> <otherwise>id</otherwise> </choose> <choose> <when test="query.orderDir == 'desc'">DESC</when> <otherwise>ASC</otherwise> </choose> LIMIT #{query.pageSize} OFFSET #{query.pageSize * (query.pageNum - 1)} </select>这版的改动有几个关键点:
- 集合判空用了
query.deptIds.size() > 0,空list不会生成IN ()。 - ORDER BY部分没有用
${}直接接受用户传值,而是通过choose把允许的值映射到固定的排序字段白名单里。这是处理排序字段的标准姿势。 - 分页计算直接写在OGNL表达式里,MyBatis会在执行前帮你算出一个数值。不过注意,LIMIT不能直接传表达式,要确保它被计算成整数。
实际开发中分页更推荐用PageHelper这类物理分页插件,能自动处理LIMIT部分的方言差异,但理解底层SQL生成逻辑仍然重要。
3.3 再加一个动态更新与批量操作
同样一张user表,编辑接口要求“只更新提交的非空字段”。如果你每次都写死SET name=?, age=?,用户只改了名字没传age,age就被覆盖成了空值。动态更新用set标签就能干净解决,前面已经展示过。
批量删除这里是组合拳:
<delete id="deleteByIds"> DELETE FROM user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </delete>配合Java方法参数:
int deleteByIds(@Param("ids") List<Long> ids);这一套下来,一个后端工程师最常碰到的CRUD场景基本都覆盖了。后面被分配“老系统改造”任务时,这些动态SQL的实际组合方式会比单纯背语法有用得多。
4. 动态SQL的性能与安全红线
4.1 为什么动态SQL会带来索引失效
动态SQL本身不会让索引失效,但开发者在动态拼接里写出的SQL模式,很容易在无意识中摧毁索引。最常见的是对索引列做函数计算:
<if test="minAge != null"> AND DATE_FORMAT(create_time, '%Y-%m-%d') >= #{minAge} </if>只要把create_time包进函数,MySQL就放弃走这个列上的索引了。正确做法是用范围查询:create_time >= #{startTime}这种原始列比较,索引才能生效。
LIKE前置通配符是另一个高频爆点。LIKE '%' + keyword + '%'这种写法,因为开头是%,索引也无法利用。如果只做后缀匹配,keyword%,是有机会走索引的,但那段历史代码往往就是整个模式都搜,这是业务取舍问题,没有太多好办法,至少要知道为什么慢。
OR连接条件也值得警惕:当OR两边是不同的索引列时,MySQL优化器可能算不出来合并方案,直接退化为全表扫描。查询条件复杂时需要多用EXPLAIN看执行计划,动态SQL拼出的语句尤其要验证。
4.2 SQL注入:$和#之间不只有拼写差异
#{}会被MyBatis编译成JDBC占位符?,参数值由数据库驱动通过PreparedStatement传入,这是防SQL注入的正道。${}则是纯字符串替换,直接把变量值嵌入SQL文本,注入风险完全暴露。
比如ORDER BY ${orderBy},用户传了id; DROP TABLE user; --,SQL就变成了灾难。所以在动态SQL里,${}只能用在数据库对象标识符位置,也就是表名、列名这种不能被参数化的地方。而且必须配合白名单处理:
private static final Map<String, String> ORDER_MAP = Map.of( "createTime", "create_time", "age", "age", "id", "id" ); public String resolveOrderBy(String key) { return ORDER_MAP.getOrDefault(key, "id"); }MyBatis的choose白名单写法本质也是这个思路。我必须把这条写出来:凡是你能用 #{} 解决的位置,永远不要用 ${}。用了${}的数据值,比如LIKE '%${keyword}%',等于主动给攻击者开了一扇门。
4.3 动态SQL的性能损耗到底可不可怕
理解前面说的SqlNode树之后,可以定量看待性能问题。每次执行动态SQL,MyBatis都要走一遍OGNL表达式计算、SQL片段拼接,肯定比直接执行静态SQL多耗CPU。但这种开销通常在微秒级别,对比数据库网络往返动辄几毫秒到几十毫秒,可以忽略不计。
动态SQL真正可怕的性能隐患是它生成SQL文本的不稳定性。数据库对第一次碰到的SQL会做硬解析并缓存执行计划,如果同一条Mapper SQL由于动态条件的不同产生了十几种不同的文本,执行计划缓存就被冲垮了,数据库反复硬解析,CPU飙升,查询延迟忽高忽低。解决思路有两个方向:一是尽量保证动态SQL的主干不变,动态部分只影响参数,比如通过COALESCE(#{param}, 字段)这类写法让SQL结构稳定;二是条件组合确实太多时,拆成多个静态SQL分支,别让一个动态方法扛所有场景。
还有一类性能问题是N+1查询。动态SQL经常和嵌套结果映射配合,如果一次列表查询的每行都触发一次关联查询,性能就会断崖式下跌。优化的标准套路是主动使用JOIN一次性查出,或者分两次查询在内存里组装,别指望动态SQL替你解决一切查询问题。
5. 高频报错与排查实录
5.1 参数命名引发的BindingException
这是动态SQL的Top 1报错,你大概率见过这个:
Caused by: org.apache.ibatis.binding.BindingException: Parameter 'ids' not found. Available parameters are [list, param1]翻译一下:MyBatis告诉你,它只找到了list和param1两个可用参数名,你却写了ids。一般是Mapper方法忘了加@Param:
// 错误写法 List<User> selectByIds(List<Long> ids); // XML里写 collection="ids",于是找不到解决办法统一:凡是XML里要走的集合参数,都加上@Param。这样既解决命名问题,也让代码自文档化,读方法签名就明白XML里怎么引用。
另一个参数命名疑惑是单个对象传参:不加@Param时,XML里可以直接写属性名(比如keyword),加了@Param("query")就必须写query.keyword。团队里最好统一一种风格。个人经验是:单个简单参数不注解、多个参数全部注解、对象参数加上注解让引用路径明确。这个规则清晰之后,长期维护省心很多。
5.2 类型比较的隐藏陷阱
OGNL的false规则和类型判断,是动态SQL的第二大坑源。
<if test="sex == '1'">这段代码如果sex是String类型,结果可能让你傻眼——永远不成立。原因是在OGNL里,单引号'1'是char类型,不是String类型,char和String比较,类型都不匹配,等式自然为假。正确写法是:
<if test='sex == "1"'>这里要用双引号包住字符串。单引号和双引号在OGNL里的语义完全不同,和Java的习惯刚好相反。
字符串和数字的比较也有迷惑性。test="type == 1"中type如果是String类型,OGNL在某些版本里会尝试转换,但不同环境表现不一致。最稳妥的做法是显式写类型判断,比如test='type.toString() == "1"',或者干脆在Java侧把参数类型处理好,别让动态SQL去做隐式转换。
5.3 空集合遍历与OGNL表达式的坑
foreach最常见的空集合问题在前面已经提过了。collection != null只能保证不是null,保证不了不是空list,必须补上size() > 0判断。
还有一个场景是遍历一个可能为null的对象属性:
<foreach collection="query.deptIds" ...>如果query.deptIds本身是null,这个foreach会直接抛异常,而不是“什么都不做”。所以必须在foreach外层包一层if判空,否则就要保证调用方的这个集合永远不为null。我自己写动态SQL时的习惯是:凡是进入XML的数据,先想清楚null和空集两种情况,这个思维能挡掉后面大半的线上问题。
OGNL表达式的最后一个隐蔽坑:嵌套取属性时,中间某一层为null会直接抛source is null。比如query.user.name,query为null或者user为null,都会在取name时报错,而不是返回null。所以多层属性访问要提前想好null防护,要么在前面的if里对中间层做判空,要么避免在OGNL里写多级点号访问,改成在Java代码里提前拆成多个一级参数再传给Mapper。
5.4 动态SQL的可读性与排版建议
最后聊一个很少被当做“问题”的问题:可读性。动态SQL写多了以后,XML里的SQL碎片拼在一起,很难一眼看出完整SQL长什么样。我的几个习惯:
- 每个if标签内部保持一个完整SQL片段,开头大写AND或OR,不要省空格。
- 动态片段和静态片段之间用缩进区分,where子句的子条件统一缩进一格。
- 长SQL在Java侧封装为多个Mapper方法,一个方法只做一件事。如果一个select里的if超过五六个,说明这个方法承载了太多场景,拆开更清晰。
- XML里多写注释,每个if说明对应哪个业务场景,方便三个月后的自己看。
这套习惯让动态SQL的维护难度大幅下降。尤其多人协作时,别人接手你的XML,能看懂你的条件分支逻辑,比什么都重要。
我个人在实际项目里有一条线:能用静态SQL表达清楚的就别上动态SQL;动态SQL一旦超过三四个if,就考虑拆方法;写XML时永远先想想这条SQL会被多少人、多少场景复用。动态不是炫技,是为了解决真实变化的需求。最后再分享一个小技巧:每写完一段动态SQL,拿一组“全条件命中”“全条件未命中”“部分命中”三份测试数据去跑一遍,能把绝大多数隐患消灭在联调之前。动态SQL这东西,用对了是提效利器,用错了就是事故源泉。