多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MyBatis动态SQL核心标签实战:if、where、set、foreach、trim详解

MyBatis动态SQL核心标签实战:if、where、set、foreach、trim详解 1. 为什么要学 MyBatis 动态 SQL做 Java 后端开发的几乎没有不认识 MyBatis 的。它轻量、灵活、SQL 掌控力强是很多团队持久层方案的默认选项。但是真正用好的分水岭往往就出现在“动态 SQL”这一关——也就是根据条件拼接 SQL 的能力。如果你写过这样的代码你一定能体会那种痛苦在 Java 里用 StringBuffer 拼 SQL条件一多就是一场噩梦。判空、加空格、处理 AND 前缀、处理 WHERE 关键字、处理 IN 列表……稍微漏掉一个空格或者多了一个 AND程序就报错。更别说需求一变条件一加整个拼接逻辑就要推翻重来。MyBatis 的动态 SQL就是为了把这种拼接逻辑从 Java 代码中抽离出来让 SQL 的组装更直观、更安全、更可维护。简单说动态 SQL 解决的就是**“同一个查询参数不同SQL 不同”**的问题。比如前端搜索页面用户可能只填了用户名也可能同时填了用户名和创建时间区间还可能什么都填了直接点搜索。如果你为每一种组合写一个 SQL那这个接口得写八个甚至十六个方法。而用动态 SQL一个方法就能覆盖全部查询场景代码量直接降一个级别。这篇文章我会从零到一拆解 MyBatis 动态 SQL 的核心用法围绕 if、choose、where、set、foreach、trim、bind 这几个核心标签展开再用实际案例讲清楚什么场景用哪个标签最合理最后分享一些我在开发中踩过的坑和总结出来的排查套路。无论你是刚接触 MyBatis 的新手还是用了多年但只停留在写固定 SQL 的老手这篇文章都能帮你把动态 SQL 这块补完整。2. 动态 SQL 的核心标签逐个击破2.1 if 标签最简单的条件判断if 是动态 SQL 里最基础的标签作用跟 Java 里的 if 差不多满足条件才拼接内部的 SQL 片段。最常见的使用场景就是条件查询比如select idselectUserByCondition resultTypeUser SELECT * FROM user WHERE 1 1 if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testage ! null AND age #{age} /if /select先写一个WHERE 1 1后面的每个 if 块都能直接用AND开头。这是最简单的写法也很好理解。但老实说WHERE 1 1这种方式在 SQL 解析层面多了一步判断虽然对 MySQL 来说性能影响几乎可以忽略但代码洁癖严重的人看着会不太舒服。更优雅的做法是用 where 标签来替代等会儿讲到 where 的时候再展开。if 标签里那个test属性需要注意的是它使用的是 OGNL 表达式。很多人在这里踩坑拼字符串用单引号判断空串用name ! 看官方文档是这么写的但你直接写双引号会报错。因为 test 属性本身在 XML 里已经被双引号包了一层内部不能再无脑用双引号所以字符串比较记得用单引号。还有一点if testname ! null and name ! 这种写法里and是 OGNL 的关键词支持直接用不需要写成。当然你写也可以但在 XML 文件里需要转义成amp;amp;太麻烦直接用and省事。2.2 where 标签比起 11我更推荐用它where 标签解决的就是“动态 WHERE 子句”的拼装问题。它内部会自动处理如果标签内有内容就生成WHERE关键字同时会去掉第一个满足条件的子句前面的AND或OR。上面的例子用 where 重写一下select idselectUserByCondition resultTypeUser SELECT * FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testage ! null AND age #{age} /if /where /select如果两个条件都不满足where 标签不会生成 WHERE 关键字查询就会变成SELECT * FROM user是全表查。如果只有 age 满足它会自动把AND age #{age}开头的 AND 去掉最终拼出WHERE age #{age}不会出语法错误。当然 where 标签不是万能的它只会处理第一个子句前面的 AND 或 OR而且只处理它认识的情况。它内部本质上是用 trim 标签实现的对应的等价写法是trim prefixWHERE prefixOverridesAND |OR ... /trim注意prefixOverrides里的AND和OR后面是有空格的这个空格很关键它表示匹配时会连同后面的空格一起去掉。如果不加这个空格遇到AND age 20这种情况AND 后面有空格它只去掉 AND就会残留一个空格SQL 变成WHERE age 20虽然不报错但还是有点诡异。2.3 set 标签动态更新字段利器更新场景同样需要动态。比如一个编辑用户信息的接口前端传了哪些字段就更新哪些字段没传的字段保持原值不动。如果你用一个写死的 UPDATE 语句没传的字段会被更新成 NULL这在业务上往往是不可接受的。用 set 标签可以解决这个问题update idupdateUserSelective UPDATE user set if testname ! null name #{name}, /if if testage ! null age #{age}, /if if testemail ! null email #{email}, /if /set WHERE id #{id} /updateset 标签的作用是生成SET关键字同时去掉最后一个条件后面的逗号。这个非常实用因为你没法保证最后一个 if 是哪个字段写固定逗号一定会出错。等价的 trim 写法是trim prefixSET suffixOverrides, ... /trim这里的suffixOverrides是去掉末尾的逗号。如果你后面有多个 if最后一个满足条件的 if 恰好不是 SQL 里最后一段内容它依然会把尾逗号去掉安全得很。有一个容易忽略的地方UPDATE user SET ... WHERE id #{id}如果所有 if 条件都不满足set 标签内部为空生成的 SQL 会变成UPDATE user WHERE id ?直接语法错误。所以使用 set 标签时务必保证至少有一个字段会被更新。通常我会在前面额外加一个if testid ! null的判断做兜底或者在业务层校验必须存在更新字段避免执行无效 SQL。2.4 choose、when、otherwise相当于 Java 的 switchif 标签本质上是“多个条件可以同时生效”的逻辑。但现实中有一种场景几个条件互斥命中了一个就不再判断其他条件这就是 java 中if / else if / else的逻辑。MyBatis 对应提供的是 choose / when / otherwise 组合。典型场景是排序规则前端传了 sortType按 sortType 走对应的排序逻辑不传的话给一个默认排序。select idselectUserList resultTypeUser SELECT * FROM user where choose when testsortType time ORDER BY create_time DESC /when when testsortType age ORDER BY age DESC /when otherwise ORDER BY id DESC /otherwise /choose /where /select注意这里的执行逻辑按顺序从上到下匹配 when一旦某个 when 的 test 条件成立就拼接对应的内容后面的 when 和 otherwise 全部跳过。如果所有 when 都不成立才会走 otherwise。和 if 的区别要分清楚if 是独立的每个 if 都可以单独成立、可以多个同时成立choose 是互斥的只能落在其中一个分支上。如果在同一个查询里既有必选条件又有可选排序记得把 choose 放在查询逻辑之后而不是 where 内部的时间逻辑里不然排序子句会被嵌套进 where 的拼接里出现奇怪的 SQL。2.5 foreach 标签循环拼接 IN 列表foreach 大概是最常用的动态 SQL 标签之一了。它的场景很明确批量查询、批量插入、批量删除。比如根据 ID 列表批量查询select idselectUserByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /selectforeach 的几个属性要弄清楚collection传入的集合参数名在接口方法参数上用Param(ids)指定了就是这个名字。item循环变量名循环体内通过#{item}引用当前元素。open拼接的前缀这里用了左括号。close拼接的后缀右括号。separator每个元素之间的分隔符。index可选属性循环下标循环 Map 集合时代表 key。批量插入是 foreach 的另一个高频场景insert idbatchInsert INSERT INTO user (name, age, email) VALUES foreach collectionuserList itemuser separator, (#{user.name}, #{user.age}, #{user.email}) /foreach /insertforeach 有一个性能上的隐患值得注意如果ids集合特别大拼出来的 SQL 文本会很长超过数据库的max_allowed_packet限制就会直接报错。常见的解决思路是分批插入比如每批 500 或 1000 条循环调用批量插入方法。我在实际项目里一般会封装一个公共方法按固定批次切分集合避免一次拼接过多参数。还有一个很多人问过的问题foreach 里能不能直接写#{ids}不行。foreach 的 item 才是当前元素循环体内只能引用 item你不能在循环体访问整个集合对象。同理如果你想在循环内部判断某个值可以用if testuser.name ! null这个 test 里是可以直接用 item 的属性的。2.6 trim 标签最灵活的万能标签如果说 where 和 set 是特定场景的语法糖那么 trim 就是从底层解决前后缀问题的全能选手。它能替代 where 和 set还能处理它们处理不了的场景。trim 的四个属性prefix拼接内容前添加的前缀。suffix拼接内容后添加的后缀。prefixOverrides去除内容中开头匹配的指定字符串。suffixOverrides去除内容中结尾匹配的指定字符串。举个例子实现 where 的等价逻辑trim prefixWHERE prefixOverridesAND |OR if testname ! null and name ! AND name #{name} /if if testage ! null AND age #{age} /if /trim如果只有 age 条件拼接的内容是AND age ?prefixOverrides 把开头的AND带空格去掉再加上 prefix 的WHERE结果就是WHERE age ?。set 的等价逻辑前面已经给过trim prefixSET suffixOverrides, ... /trimtrim 还有一个更精细的用途是动态拼接 SQL 片段的前后缀。比如你需要根据条件给查询添加一个锁子句trim prefixFOR UPDATE suffixOverrides, ... /trim不过说实话大多数情况下 where、set、foreach 已经完全够用了trim 更像是当你遇到一个 where 和 set 覆盖不了的拼接需求时拿来做自定义拼接规则的兜底方案。但既然是通用标签前面那四个属性值理解透还是很有必要的它能让你对动态 SQL 原理的理解提升一个层次。3. 再进阶一点bind、sql 片段与内置参数3.1 bind 标签预计算并绑定参数值bind 标签允许你在 SQL 中定义一个变量后面直接引用。最常见的使用场景是处理模糊查询select idselectUserByKeyword resultTypeUser bind namekeyword value% keyword % / SELECT * FROM user WHERE name LIKE #{keyword} /select很多人在 MyBatis 里写模糊查询喜欢直接在 Java 代码里拼好%xxx%再传进来这样做也能用。但 bind 的优势在于把拼接逻辑放在 SQL 层Java 端只需要传原始关键字尤其当同一个关键字被多处引用时bind 只需要定义一次后面处处可用。bind 的 value 属性也是 OGNL 表达式。字符串拼接用这个和 Java 语法一样。注意%是字面量字符串内部用单引号不能写成双引号。3.2 sql 片段与 include复用公共 SQL 内容当多个查询语句都有相同的字段列表、相同的 JOIN 条件时可以用sql定义片段然后通过include引入。比如sql iduserBaseColumns id, name, age, email, create_time /sql select idselectUserById resultTypeUser SELECT include refiduserBaseColumns / FROM user WHERE id #{id} /selectsql 片段里的内容可以很复杂包括 WHERE 条件、JOIN 语句都能放进去。但使用时要克制别把太多东西塞进一个片段。如果两个查询虽然能共用一段 SQL但后面的扩展方向不一样硬抽出来反而增加阅读难度。我自己习惯只抽取字段列表和经常重复的过滤条件保持片段小而清晰。另外一个注意点include支持通过property向 sql 片段传参比如sql idcondition if test${alias}.status ! null ${alias}.status #{aliasStatus} /if /sql include refidcondition property namealias valueu / /include这里alias在 sql 片段内以${alias}的形式引用。这里面存在 SQL 注入的风险所以 property 的值绝对不能来自外部输入应该是你写死的别名这也是很多团队约定俗成的红线。3.3 两个内置参数_parameter 与 _databaseId动态 SQL 的条件判断中有两个内置参数值得知道。_parameter代表当前传入的完整参数对象。比如方法只有一个参数且没加Param注解那_parameter就是那个参数本身。如果传的是单个实体对象可以用_parameter.name访问属性。_databaseId只有在配置了databaseIdProvider的时候才有值。它可以根据不同的数据库厂商执行不同的 SQL 片段。某些公司内部系统需要同时兼容 MySQL 和 Oracle 语法时可以用它来做差异处理select idselectUserList resultTypeUser SELECT * FROM user where choose when test_databaseId mysql LIMIT #{limit} /when when test_databaseId oracle AND ROWNUM lt; #{limit} /when /choose /where /select内置参数在平时可能用不太上但遇到多数据库适配的场景这就是不用写两套 XML 的救命稻草。4. 完整实操从零搭建一个带动态条件的用户查询接口前面的知识点比较分散我们通过一个完整的案例把它们串起来。假设要做一个人力资源管理系统的用户查询功能需求如下支持按用户名模糊搜索。支持按年龄区间过滤。支持按创建时间区间过滤。支持按状态筛选。支持传入 ID 列表做过滤。排序方式可选按创建时间倒序 / 按年龄升序默认按 ID 倒序。支持分页。最终写的 Mapper 接口方法ListUser selectUserList(Param(query) UserQuery query, Param(ids) ListLong ids);UserQuery 内部包含private String name; private Integer ageStart; private Integer ageEnd; private String createStart; private String createEnd; private Integer status; private String sortType;Mapper XMLsql iduserBaseColumns id, name, age, email, status, create_time /sql select idselectUserList resultTypeUser SELECT include refiduserBaseColumns / FROM user where if testquery.name ! null and query.name ! bind namenamePattern value% query.name % / AND name LIKE #{namePattern} /if if testquery.ageStart ! null AND age gt; #{query.ageStart} /if if testquery.ageEnd ! null AND age lt; #{query.ageEnd} /if if testquery.createStart ! null and query.createStart ! AND create_time gt; #{query.createStart} /if if testquery.createEnd ! null and query.createEnd ! AND create_time lt; #{query.createEnd} /if if testquery.status ! null AND status #{query.status} /if if testids ! null and ids.size() 0 AND id IN foreach collectionids itemid open( separator, close) #{id} /foreach /if /where choose when testquery.sortType createTime ORDER BY create_time DESC /when when testquery.sortType age ORDER BY age ASC /when otherwise ORDER BY id DESC /otherwise /choose LIMIT #{query.offset}, #{query.pageSize} /select这里有几个细节值得单独说。第一个是ids.size()这个写法在 OGNL 中调用集合的size()方法是可以的但要在 test 表达式里注意别漏掉括号。有些老版本 MyBatis 对ids.size() 0支持良好但更稳妥的写法是ids ! null and ids.size() 0两步都判断避免空指针。第二个是和在 XML 中必须转义。其实可以不用转义但里的后面紧跟有些 XML 解析器在特定环境下会解析异常所以gt;和lt;都用转义写法最稳妥。同理必须写成lt;否则 XML 解析器会把if后面的标签结构搞乱。第三个是bind放在if内部。如果你把 bind 放在if外面那每次查询都会执行绑定即使 name 为空也会绑定一个%%的模糊模式虽然没有语法问题但多了一次无意义的字符串拼接。放在 if 里能保证只有确实需要模糊查询时才计算。第四个是排序逻辑单独用 choose 而不是直接写在 where 内部。因为 ORDER BY 不属于 WHERE 子句的过滤条件把choose放在 where 标签外生成的 SQL 结构更清晰。再提供一个验证思路你可以先打开 MyBatis 的日志输出把实际执行的 SQL 打印出来。观察不同传参情况下 SQL 的变化确认每个分支都符合预期。这一步在新手阶段极有帮助因为代码生成的 SQL 和你想的 SQL 如果不一致越早发现越容易改。5. 动态 SQL 常见问题与排查技巧实录5.1 常见报错与解决方案速查表报错/问题出现原因解决方案There is no getter for property named xxx in class ...方法参数没有加Param注解OGNL 无法找到对应属性在接口方法参数上补ParamBadSqlGrammarExceptionSQL 以WHERE结尾where 内所有条件都不满足生成了空 WHERE检查调用方传参或额外加兜底判断SQL 以SET结尾set 内无更新字段在业务层校验必须存在至少一个更新字段SQLSyntaxErrorException: ... near )foreach 集合为空或 open/close 配置错误给集合判空对空集合做提前返回test中字符串比较报错用了双引号包裹字符串改为单引号abcXML 解析报错元素类型必须由匹配的结束标记终止lt;没转义把改为lt;Parameter index out of range动态 SQL 生成的占位符和实际传参不匹配开启日志打印 SQL 排查占位符数量ORA-00933Oracle 环境的 LIMIT 报错MySQL 的 SQL 语法用在了 Oracle使用_databaseId做多库适配5.2 那些年我踩过的动态 SQL 的坑第一个坑是if teststatus ! null把数字 0 给漏了。有些业务里status 0是一个有效状态但 Java 代码判断时可能只在 status 非 null 时传入这个没问题。坑在于有些同事喜欢在 Java 层先做一层if (status ! null status ! 0)的这种前置过滤直接把 0 状态的数据给过滤掉了。正确的做法是状态字段的判断只判 null不要用数值做“非空”的判断条件0 有业务含义就让它进查询。第二个坑是 foreach 的 collection 名字写错。接口方法写了Param(ids) ListLong idsXML 里写了collectionidList运行时报错说找不到属性。排查这类问题最有效率的方式是直接看日志里解析后生成的 SQL 和异常信息而不是盲目在代码里猜。第三个坑是拼接的 LIKE 条件在大数据量下性能急剧下降。LIKE CONCAT(%, #{name}, %)这种前置通配符写法是没法走索引的。如果业务上允许考虑使用LIKE CONCAT(#{name}, %)右匹配来利用索引或者引入全文检索、搜索引擎等方案不要让动态 SQL 承担它不该承担的性能压力。第四个坑是![CDATA[]]的使用。当你在动态 SQL 中遇到这类符号需要写比较表达式又不想经过转义时CDATA 是救星。但要注意 CDATA 只能包普通文本不能包${}以外的 MyBatis 标签也就是说 CDATA 里面不能写if这种标签否则解析会出问题。我一般只在 SQL 原生片段里用 CDATA 包住比较符号标签本身放在外面。第五个坑是 mapper XML 中注释的使用。动态 SQL 拼接时如果写了--注释并且注释内包含换行可能导致 SQL 拼接后结构混乱。比如一个-- 按创建时间过滤注释位于 if 块内恰好这个 if 不满足条件但下一行条件前的 AND 连接符就可能出问题。最佳实践是动态 SQL 里尽量少写 SQL 注释要写写在 if 外面。5.3 排查动态 SQL 问题的三条硬核经验第一日志必须开。在配置文件里把 Mapper 接口的日志级别设为 DEBUG就可以看到 MyBatis 解析后的完整 SQL 以及参数列表。很多人把日志看成小事但遇到问题不能还原现场得多难受。打印出来的 SQL 才是真正执行的 SQL而不是你以为的 SQL这一条能节省大量排查时间。第二小步验证。写一段复杂的动态 SQL 之前先测最简单的传参情况比如只传主键、只传一两个条件确认基础路径没问题再逐步增加条件组合。一次测一个变量符合二分法的思想能快速定位到问题条件。第三对生成的 SQL 做静态分析。有时候你说的 SQL 报错是数据库版本差异导致的。比如 MySQL 5.7 和 MySQL 8.0 在某些语法上有不同表现。把日志中的 SQL 复制到数据库客户端执行直接看数据库报错信息能快速区分是 MyBatis 拼接问题还是 SQL 本身写的就有问题。6. 性能与维护动态 SQL 使用的边界思考动态 SQL 便利但它不是万能钥匙用过头也会带来维护和性能上的负担。先说说维护问题。一个 Mapper XML 里如果塞了二三十个if阅读体验是灾难性的。后面接手的人根本分不清哪些条件会同时出现、哪些互斥。我的习惯是单一查询的动态条件数量控制在 6 到 8 个以内再多就考虑拆成多个查询方法或者用 Entity/Criteria 模式管理条件对象。条件太多往往是需求本身太复杂不是动态 SQL 能解决的。再看性能问题。动态 SQL 最容易被忽略的性能细节是权限过滤条件必须写在 SQL 里而不是在 Java 代码里过滤结果集。如果你在业务层查询全量数据再内存过滤用户多了直接内存爆炸。所有过滤条件无论动态与否都应该进 SQL。另外如果查询条件多且固定组合有限比如用户在搜索页只会选择“有名有条件”、“有年龄有条件”、“既无名又无年龄”这几种可以考虑在代码里预编译多个固定 SQL 语句避免每次动态拼接带来的解析开销。MyBatis 的动态 SQL 解析是发生在首次执行时后续可以走缓存但在条件组合极端复杂、SQL 文本极长的情况下预编译的收益依然存在。动态 SQL 还有一类隐形开销是如果同一个sql片段被多个查询include每个查询都会单独解析展开XML 不会帮你合并。这本身没问题但如果你把特别大的结果集片段四处 include整体 Mapper 解析时间会变长。所以公共片段抽取得好是减少重复抽得不好就是隐形负担。在我看来动态 SQL 的使用边界是能在 SQL 层解决的拼接问题就在 SQL 层解决能让代码更容易理解的拼接逻辑就在代码层拆清楚条件组合之间的依赖关系必须在设计阶段就理清别让动态 SQL 变成一坨谁也看不懂的条件堆积。7. 最后分享一点个人的使用经验动态 SQL 我用了很多年踩过的坑、看到的烂代码都不少最后沉淀下来几条很朴素的经验。第一条永远让 XML 保持可读性。如果一个 Mapper 文件里的动态 SQL 让你自己都看得头大那就先想想是不是拆分不够、条件太碎。代码的质量不是靠标注高级实现的而是靠逻辑清晰、别人能接手实现的。第二条条件判断不要过度设计。见过有人在 test 里写正则表达式判断、写字符串长度判断、写 double 判断看起来花哨但业务上完全用不上。测试条件是让 SQL 正常工作不是炫技。第三条如果动态 SQL 和数据库原生能力冲突优先调整 SQL 设计而不是硬凑动态拼接。比如你需要对某列做动态排序排序字段来自外部输入就不要在拼 SQL 时用${sortColumn}直接替代字段名这是 SQL 注入的高危写法。宁可做一个字段白名单映射通过 if/choose 把外部值映射到固定的内部字段上也不要用${}拼接不可控内容。第四条任何动态 SQL 改动必须场景全覆盖验证。动态 SQL 的分支组合是指数级增长的你改一个条件可能影响八个分支。改完之后把每个分支的传参组合都跑一遍确认没有遗漏的语法问题。我在实际项目里会专门为复杂查询写一个测试类模拟不同条件组合断言生成的 SQL 符合预期格式。这一步投入成本不高但带来的安全感极强。我自己动手折腾动态 SQL 比较频繁的项目是一个用户中心的后台管理模块里面查询条件有十来个XML 片段抽了六七个靠的也是上面提到的这些方法。从一个纯小白到现在基本可以闭着眼写出不会出错的动态 SQL中间无非就是多看日志、多测边界、多整理报错场景。希望这篇文章能帮你把这条路走得更顺畅一点遇到问题也能快速定位、平稳落地。
返回列表