MyBatis动态SQL核心用法:多条件查询、批量操作与安全实践
做后端几年动态 SQL 基本是每天都要打交道的东西。业务方今天要按名称筛明天要加时间范围后天又要排除某几个状态如果每换一种组合就写一条 SQL代码量会无限膨胀。更麻烦的是条件一变拼接出来的语句经常带着多余的 AND 和 WHERE甚至直接语法报错。MyBatis 的动态 SQL 核心用法就是专门来解决这个问题的把 SQL 当成一段可以按条件动态组装的模板用 if、where、foreach 这些标签让 SQL 自己适应传入参数。这篇文章是进阶向内容默认你已经能跑通基础的 Mapper 和 XML但对动态 SQL 还没有系统玩过。我会把每个标签为什么这样设计、实际项目里怎么组合、踩了哪些坑都讲透。如果你正在做多条件筛选、批量操作、动态更新这类后台需求照着这套思路走基本不会跑偏。为了不空谈我拿一个商品管理系统的多条件查询作为主线从问题出发再逐个拆标签最后给一份能直接复用的最佳实践。你会发现动态 SQL 学起来并不难难的是理解它对变量状态的处理边界以及看懂生成出来的 SQL 是否真的符合预期。1. 动态 SQL 到底解决了什么问题1.1 无休止的 if/else 与拼接灾难在没有动态 SQL 之前多条件查询最常见的写法是 Java 代码里手动拼字符串String sql select * from product where 11 ; if (name ! null !name.isEmpty()) { sql and name like % name %; } if (categoryId ! null) { sql and category_id categoryId; } Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql);这种写法的问题特别致命。第一字符串里直接塞变量等于把 SQL 注入的风险亲手送上门用户只要在输入框里传一个 or 11 --整张表的数据就能被拖走。第二参数多起来之后SQL 的格式完全不可控空格、引号、括号稍微不对排查半天都找不到原因。第三维护上更是灾难Java 方法里藏着一大段 SQL 逻辑改查询条件要同时看 Java 代码和数据库表结构项目稍微一老就乱成一锅粥。国内很多老系统的报表模块最后难维护绝大多数都是手拼 SQL 留下的债。另一种常见思路是写固定 SQL用 Java 循环调用多次或者一个组合条件写一个方法。假设条件组合有四种可能就写四条 select 方法六种可能就写十几条。这样虽然避开了注入但代码量和冗余度瞬间爆炸。一个后台列表今天加状态筛选明天加时间范围后天加关键字模糊搜索每次新增组合条件你都得新增或修改方法一年下来 Mapper 接口能堆出上百个方法。更痛苦的是这些方法的查询列还经常变动改一个字段名就要满项目替换维护成本高到让人怀疑人生。1.2 MyBatis 动态 SQL 的设计思路MyBatis 把这个问题换了个角度去解SQL 本身是一段模板模板里用标签指示哪些片段要根据条件决定是否保留。条件满足时标签把片段拼进最终 SQL条件不满足时标签自动跳过。拼接过程由框架在 XML 解析阶段完成不经过 Java 字符串加号也不存在手动拼接注入的问题因为配合#{}参数占位符最终值由 PreparedStatement 处理。这里有个关键点值得展开动态 SQL 并不是运行时才去“优化”SQL而是在 Mapper 调用时生成最终 SQL 文本再交给 JDBC 的 PreparedStatement 去预编译。也就是说日志里打印出来的那行 SQL 就是真正发往数据库的那条但又不需要你手动管理问号和参数顺序。MyBatis 会自动把#{}中的参数与占位符绑定类型转换也一起处理。这个“模板生成 预编译”的组合是它比手写字符串拼接、比某些底层 JDBC 封装更顺手的原因。可以把动态 SQL 想象成搭积木if 是“这一块要不要放”where 是“外面这个框什么时候出现”foreach 是“同一块积木循环放多少次”。积木块由 XML 标签表达展开逻辑由 OGNL 表达式控制。把这套逻辑想明白之后后面再看复杂的查询 SQL 都不会慌因为核心无非就是那几个标签的排列组合。2. 核心标签逐个拆解用法与避坑2.1 if 标签最基础的条件判断if 是动态 SQL 里出现频率最高的标签语法本身很简单if testname ! null and name ! and name like CONCAT(%, #{name}, %) /iftest 里用的是 OGNL 表达式不是 Java也不是简单的字符串比较。这里最容易翻车的是“空串判断”和“数字 0 判断”。先说空串。很多初学者只写if testname ! null结果前端传过来的是空字符串 这个条件其实仍然成立SQL 里就会多出一个and name like %%。虽然大多数数据库不会报错但它会让本应该走索引的条件失效数据量一上来就是慢查询。正确习惯是name ! null and name ! 集合类型再加and name.size() 0。再说数字 0 的判断这是我自己踩过次数最多的坑。假设查询参数 status前端传 status 0 表示查询“禁用”状态。如果你写成if teststatus ! null and status ! 在 OGNL 看来 0 会被当成 false 处理条件直接不成立过滤条件就悄悄丢了。这里的本质是 OGNL 的布尔型转换规则数值 0、空字符串会被转成 false。解决办法是只判断 null或者用比较表达式teststatus ! null and status 0。写 if 时还有个经验尽量让 test 条件收敛到 Mapper 方法参数的一个属性上不要在 test 里写比较吃力的复杂逻辑。比如可以放一个Boolean isFilterTime作为开关然后在 test 里只判断这个开关而不是直接比较两个时间参数可读性会好很多。尤其当参数对象属性变多后清晰的 test 表达式比什么都重要。2.2 where 标签解决“多一个 AND”直接拼 if 很容易出现这样的 SQLselect * from product where category_id #{categoryId} and status #{status}如果第一个条件不满足而第二个满足最终就变成where and status ?直接语法错误。老办法是在前面写where 11让所有条件都用 and 开头。这个写法能用但确实有点丑而且部分数据库在11条件下对索引的选择会变得不太理想。MyBatis 提供了 where 标签专门处理这个问题select idsearch resultTypeProduct select * from product where if testcategoryId ! null category_id #{categoryId} /if if teststatus ! null and status ! and status #{status} /if /where /select它的行为有两个第一如果内部没有任何满足条件的 if整个 where 关键字不会拼进 SQL第二如果内部以 and 或 or 开头它会自动把开头这个连接词去掉。这两点正好解决了“有没有条件”和“第一个条件是 and”的问题。但 where 也不是万能药它只能处理开头的一个 and/or。如果你在内部用嵌套子块或 trim 拼错顺序导致中间出现多个 and它不会帮你去重。复杂场景最好直接上 trim。2.3 trim 标签最可控的拼接开关trim 是 where 和 set 的底层实现逻辑。如果你想把拼接规则完全掌握在自己手里用 trim 是最稳的。trim prefixWHERE prefixOverridesAND |OR ... /trimprefix 表示如果有内容在最前面拼上指定前缀prefixOverrides 表示如果内容以这些前缀开头自动去掉。注意AND |OR里的空格是有意义的表示去前缀时同时去掉 AND 或 OR 以及后面的一个空格漏掉这个空格就会得到WHEREAND这种尴尬结果。suffix 和 suffixOverrides 对应尾部。比如 set 标签内部的赋值语句最后一项可能带逗号就可以用 suffixOverrides 把逗号去掉。我实际写代码的偏好是简单条件组合优先用 where遇到复杂嵌套条件、动态 group by 或动态 order by就用 trim 把 prefix、prefixOverrides、suffix、suffixOverrides 全部显式写清楚。这样逻辑最透明出了问题也好定位不会被 where 的自动行为误导。2.4 choose、when、otherwise互斥分支if 是“各自独立判断可以全都要”choose 是“多选一选第一个满足的否则走兜底”。这个场景在订单状态展示、支付方式切换、报表统计里很常见。choose when testqueryType today and create_date CURDATE() /when when testqueryType week and create_date DATE_SUB(CURDATE(), INTERVAL 7 DAY) /when otherwise and create_date DATE_SUB(CURDATE(), INTERVAL 30 DAY) /otherwise /choose逻辑很简单从上到下找第一个 test 成立的 when执行它后面的都不再判断如果所有 when 都不成立执行 otherwise。它的语义非常接近 Java 的 if-else if-else不要和多个 if 混用。如果你需要的是“多个条件可同时生效”用多个 if如果你只需要“其中一个生效”不要用多个 if否则会把互斥的条件全部拼进去查询结果就完全不对了。2.5 set 标签动态更新的最后一块拼图更新操作最容易翻车的是尾逗号。比如更新一个对象只有 name 和 price 需要更新SQL 应该是update product set name ?, price ? where id ?。如果手写 if 拼接最后一项后面很可能带着逗号。update idupdateProduct update product set if testname ! null and name ! name #{name}, /if if testprice ! null price #{price}, /if /set where id #{id} /updateset 标签做的事情和 where 类似内部有条件才输出 set 关键字同时自动把最后一个逗号去掉。这个“自动去尾逗号”的能力本质上就是通过 suffixOverrides 实现的你只需要记住它的行为即可。动态更新有一个设计建议最好配合乐观锁或者 update_time 字段一起使用。因为动态更新通常只更新部分字段如果并发操作同一条记录很容易相互覆盖。我一般在实体上带一个 version 字段在 set 里写version version 1在 where 里带and version #{version}这样能显著减少并发下“你改了别人没改到”的问题。这属于实战里很少写进文档但很重要的经验。2.6 foreach 标签批量插入与 IN 查询foreach 是动态 SQL 里能力最强也最容易出错的标签。先看批量插入insert idbatchInsert insert into product(name, price, category_id) values foreach collectionlist itemitem separator, (#{item.name}, #{item.price}, #{item.categoryId}) /foreach /insertcollection 支持三种传入方式参数是 List 时写 list是数组时写 array是 Map 时写 Map 里对应的 key。如果你用 Param(list) 指定了参数名那就写 Param 里的名称这个优先级最高。item 是循环变量名index 是当前下标常用于需要序号或按位置处理数据的场景。foreach 还能拼 IN 条件if testids ! null and ids.size() 0 and id in foreach collectionids itemid open( close) separator, #{id} /foreach /if很多人会在这里踩坑的是 open/close 和 separator 的关系。open 和 close 是整体前后的字符separator 是元素之间的分隔符它不会在末尾多输出一个分隔符。比如 ids 有三个值上面生成(?, ?, ?)不会是(?, ?, ?, )。再提一个和性能相关的细节。批量插入一次插入几百条问题不大但一次插入几万条生成的 SQL 可能超过数据库对 max_allowed_packet 的限制直接报错。实际处理大批量数据时我一般按 500 或 1000 条一组分批执行既能保证事务一致又不会因为一条 SQL 太大拖垮网络和数据库。2.7 bind 标签更好的模糊查询早期很多项目在 if 标签里直接写and name like %${name}%这是最危险的写法之一。${}是直接拼接字符串完全不经过参数占位SQL 注入风险极高。正确做法是用 CONCAT 函数拼接if testname ! null and name ! and name like CONCAT(%, #{name}, %) /if如果你不想在每一条 SQL 里都写 CONCAT可以在 XML 里用 bind 预定义一个变量bind namenameLike value% name % / if testname ! null and name ! and name like #{nameLike} /ifbind 最大的优势是让后面的#{nameLike}仍然属于参数占位不会出现注入问题而且这个变量可以在同一个 SQL 里复用多次。比如统计总数和查询列表时可以引用同一个 bind。要注意 bind 的 value 表达式里拼接字符串用的是加号不是 SQL 的 CONCAT这是 OGNL 层面的操作。理解了这一点写 bind 时就不容易出错。3. 实操过程多条件商品查询从零到一3.1 需求与参数设计假设我们有这样一个后台需求商品管理列表支持按商品名称模糊搜索、按分类 ID 过滤、按价格区间过滤、按状态过滤还可以选择是否只看有库存的商品列表需要排序排序字段和方向由前端传入。排序字段必须做白名单校验。这里的逻辑是动态 SQL 里的值都可以用#{}安全处理但列名和排序方向这种结构关键字用#{}是没有意义的只能用${}直接拼进去。而${}一旦放开就会带来注入风险所以必须在前端参数进入 Mapper 之前做一次严格校验。与其在 XML 里费劲防注入不如在源头只允许固定值进入这也是我特别想强调的安全习惯。设计查询参数对象public class ProductQuery { private String keyword; private Long categoryId; private BigDecimal minPrice; private BigDecimal maxPrice; private Integer status; private Boolean onlyInStock; private String orderBy; private String orderDir; // getter/setter }注意这里 keyword 和 orderBy 用 StringcategoryId 用 Long 而不是 long。原因很简单包装类型可以为 nullif 才能根据 null 判断是否需要拼条件。如果用基本类型 long默认值是 0OGNL 判断categoryId ! null时结果永远是 true条件就被无条件拼进去了这是实际项目里特别隐蔽的一个 bug。所以查询对象里的可空字段我一律用包装类型。3.2 Mapper 接口与 XML接口定义ListProduct search(ProductQuery query);XML 里的核心 SQL 我给一份完整版本select idsearch resultTypeProduct select id, name, price, category_id, status, stock from product where if testkeyword ! null and keyword ! bind namekeywordLike value% keyword % / and name like #{keywordLike} /if if testcategoryId ! null and category_id #{categoryId} /if if testminPrice ! null and price #{minPrice} /if if testmaxPrice ! null and price lt; #{maxPrice} /if if teststatus ! null and status ! 0 and status #{status} /if if testonlyInStock ! null and onlyInStock and stock 0 /if /where if testorderBy ! null and orderBy ! order by ${orderBy} if testorderDir ! null and orderDir ! ${orderDir} /if /if /select这里有三个细节必须讲清楚。第一price lt; #{maxPrice}里的lt;是 XML 转义后的。如果你直接写XML 解析器会认为是标签开始符号直接报错。我见过很多新手在这里卡住连报错信息都看不懂。其实把它当作 HTML 里的转义来看就好理解了。第二status ! null and status ! 0这个判断是想让 status 0禁用状态作为合法查询条件避开 OGNL 把 0 转成 false 的坑。如果不想要这种写法直接写teststatus ! null and status 0也可以。第三order by ${orderBy}确实存在注入风险所以必须在 Java 层做白名单public static final SetString ORDER_BY_WHITELIST Set.of( create_time, price, stock ); public static final SetString ORDER_DIR_WHITELIST Set.of(asc, desc);在调用 Mapper 之前先校验前端传入的排序参数一旦不在白名单里就直接拒绝或使用默认值。这样即使 XML 里用了${}值也是可控的。这也是“动态 SQL 虽方便安全边界不能松”的典型例子。3.3 测试与日志验证测试代码简版ProductQuery query new ProductQuery(); query.setKeyword(手机); query.setCategoryId(1001L); query.setMinPrice(new BigDecimal(1000)); query.setStatus(0); query.setOrderBy(price); query.setOrderDir(desc); ListProduct list productMapper.search(query);最终 MyBatis 生成并执行的 SQL 大致是select id, name, price, category_id, status, stock from product WHERE (name like ? AND category_id ? AND price ? AND status ?) ORDER BY price desc注意这里 where 被某些版本包装成带括号的形式这是正常的。核心语义和我前面讲的 where 行为一致第一个条件前面的 and 被去掉内部满足条件的条件才保留。用日志打印出这条 SQL 后能直观确认动态拼接逻辑是否符合预期。排查动态 SQL 最有效的工具就是日志。在配置里设置mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl logging.level.com.example.mapperdebug然后每次执行查询控制台会打印Preparing:和Parameters:两行。第一行是最终交给数据库的 SQL第二行是各占位符和实际值。绝大多数动态 SQL 问题都可以靠这两行日志定位哪个条件没生效、参数顺序对不对、值是不是没传进来一眼就能看出来。遇到动态 SQL bug第一步永远不要猜先开日志。开日志之后十有八九问题就自己暴露了。4. 常见问题与排查技巧实录4.1 条件总是不生效或总是生效这类问题高居动态 SQL 榜首。最常见的三个原因第一test 表达式里用了 Java 的语法而不是 OGNL 语法。比如name.equals()这种写法在 OGNL 眼里可能直接调用方法出错。正确写法是name 或.equals(name)。第二参数类型用了基本类型。刚才提过的 long、int初始值 0 会导致判断永远为 true 或 false建议所有可空参数都用包装类型。第三多个条件想做“多选一”却用了多个 if导致 SQL 里拼进互相矛盾的条件。这时候该选 choose 就选 choose不要硬用 if。排查技巧把 test 表达式尽量改成简单写法比如if teststatus 0然后分别传不同值看日志看条件是否按预期出现逐步缩小范围。4.2 尾逗号、多余 AND/OR我已经在 where 和 set 里讲过这类问题但它在复杂嵌套场景依然会出现。比如在 where 里再放一个包含 and 的 trim 子块并且这个子块以 and 开头where 标签只能去掉第一层的 and子块内部的 and 它会保留最终拼出where and ...。解决思路有两个一是所有子块都去掉自己的连接词把 and/or 统一放到同一层靠 where 来兜底二是复杂场景统一用 trim把四个属性全部显式写清。我个人的习惯是复杂 SQL 直接 trim逻辑透明出了问题好改省得被 where 和 set 的自动行为带偏。还有一个容易被忽略的坑如果 if 条件内部拼出来的是空行或纯空白where 在部分版本里会认为“没有内容”而整个去掉。为了保险我通常不在 if 里放多余空行保持每个 if 块内部是一句完整的条件。4.3 foreach 批量操作时报错批量插入最常见的报错有两个。第一个是 SQL 语法错误大概率是 collection 名称写错了。解决方法是统一用 Param 注解指定名称不要依赖默认的 list/array/collection 规则int batchInsert(Param(list) ListProduct products);XML 里写collectionlist清晰且不容易错。第二个是 max_allowed_packet 报错。解决方法是分批。我实测下来单批 1000 条是大多数 MySQL 配置下比较稳妥的数量具体要看单条字段长度。如果字段很长比如有大型备注或 JSON 文本500 条都可能超限。你可以在日志里打印生成的 SQL 长度自己调出一个合适阈值。还有一点批量插入一定要在事务里执行否则中途失败会留下半截数据。4.4 动态 SQL 与缓存的配合问题MyBatis 的一级缓存和二级缓存默认都是基于“SQL 语句 参数”的组合作为 key 来命中。动态 SQL 每次生成的 SQL 文本可能不同所以缓存命中率天然比较低。如果某个查询很频繁且条件组合固定可以把不变的部分抽出来作为 sql 片段复用或者按需配置二级缓存但不要指望动态 SQL 的缓存命中率能像固定 SQL 一样高。更实用的优化方向是把高频查询固化。比如一个后台报表如果绝大多数人查的都是最近几天数据可以在 Java 层把“最近几天”这个条件直接组装成固定 SQL而不是每次都依赖动态判断。这样缓存命中率和 SQL 可读性都能提升不少。4.5 动态 SQL 的安全边界动态 SQL 只是把拼接工作从 Java 代码转移到了框架层它本身并不是免死金牌。需要重点检查的地方所有值都应该用#{}而不是${}。只有表名、列名、排序字段这种结构关键字才可以用${}且必须做白名单校验。不要相信用户传进来的 orderBy前端传什么你拼什么就是事故。bind 的值虽然经过 OGNL 拼接但它作为#{}占位符使用整体是安全的。模糊查询禁止直接写%${keyword}%必须用 CONCAT 或 bind。我把相关对比整理成一张表方便收藏用法是否安全说明#{name}安全使用 PreparedStatement 占位符${name}用于值危险直接拼接可能注入${orderBy}用于列名有条件安全必须白名单校验CONCAT(%, #{name}, %)安全常用模糊查询写法%${name}%危险绝对不要用这张表我贴在自己的笔记里很多年了每次做代码评审都会拿出来过一遍。5. 进阶优化与个人心得5.1 用 sql 片段统一公共查询列当项目里有多个查询都需要返回相同字段时可以把结果列抽出来sql idproductColumns id, name, price, category_id, status, stock /sql select idsearch resultTypeProduct select include refidproductColumns/ from product ... /selectinclude 还可以带属性比如在 sql 里通过${propertyName}引用。注意${}在这种场景下也是直接拼字符串建议只用来传结构性的内容不要在 include 里传用户可输入的值。这个技巧在维护多张结构类似的表时特别舒服改字段名只需改一处不会漏。5.2 OGNL 表达式的几个实用写法动态 SQL 的 test 经常要判断集合、字符串和布尔值我整理几个高频写法集合非空判断list ! null and list.size() 0字符串非空判断name ! null and name ! 布尔 true 判断onlyInStock ! null and onlyInStock true枚举判断status ! null and status.name() ENABLED多条件组合用括号把优先级分清楚比如(a ! null and a ! ) or (b ! null and b ! )很多人对 OGNL 不熟会把 Java 的习惯带进来比如直接写list.size() 0。这在 OGNL 里也支持但 null 判断必须在前否则空集合调用 size 还是会报空指针。可以这么记先判 null再判内容顺序不能反。5.3 过度使用动态 SQL 的警告写到这里我也想把反方的一面讲透。动态 SQL 是利器但用多了SQL 会变得很难读。尤其是嵌套多层 trim、foreach 加上一堆 if 之后生成的 SQL 文本往往和肉眼看到的 XML 对不上维护者需要花更多时间在脑内模拟拼接过程。我的原则是少量固定条件直接用普通 SQL别为了“看起来动态”而套 if。条件超过三个且可能组合用动态 SQL 是合理的。嵌套超过两层考虑把查询拆成多个 Mapper 方法各自语义清晰再在 Service 层做组合而不是硬写一个巨型动态 SQL。动态 SQL 的 XML 里尽量保证每个 if 块内是一句完整语义不要为了省几行代码把条件写得太隐晦。说白了灵活高效的前提是可控。动态 SQL 如果写到只有自己才能看懂那它已经从工具变成了负担。写代码的时候想想三个月后的自己看到这段 XML还能不能一眼讲清楚它想干什么。5.4 个人实测下来最顺手的组合套路最后分享一个我最常用的套路多条件列表查询。这个套路在好几个后台系统里都实测很好用查询参数统一用包装类型对象方便 if 判断空值。Mapper 方法只收一个查询对象XML 里用 where 加多个 if 组合不用额外 trim。排序字段用${}但必须在 Java 层过白名单校验。列表查询和总数统计尽量复用同一个 where 片段用 sql 标签抽取公共条件。上线前先打印 SQL确认生成结果完全符合预期再提交代码。如果你还停留在手拼字符串或用固定 SQL 堆方法的阶段可以从这个套路开始迁移。它覆盖面广、容易复制尤其适合团队从传统 JDBC 拼接迁移到 MyBatis 的场景。流程清晰代码可控排查问题时也不会摸不着头脑。动态 SQL 的核心价值从来不是炫技而是让本来混乱的查询逻辑变得透明、可靠并且能随着业务条件的增加平滑演进。