MyBatis动态SQL实战指南:六个核心标签与安全红线

发布时间:2026/10/12 16:03:55
MyBatis动态SQL实战指南:六个核心标签与安全红线 做后台业务开发的人对下面这种需求应该都不陌生一个用户列表查询页上面排着几个筛选条件——姓名、邮箱、状态、注册时间范围产品经理还补了一句“这些都可选空了就不查”。先别急着吐槽我见过太多团队在这件事上的处理方式了写六七个固定 SQL用一个 switch 选来选去或者在 Java 里拿 StringBuilder 手工拼 WHERE再或者干脆在 XML 里写死 WHERE 11然后在代码里逐个 append 条件。这些做法都能跑但无一例外地让“动态 SQL”这个本该由框架解决的核心能力变得混乱又危险。MyBatis 的动态 SQL就是专门为这类“一段 SQL 里哪些片段该出现取决于运行时参数”的场景设计的if、where、set、choose、foreach、trim 六个标签配合 bind 和 OGNL 表达式把 SQL 组装逻辑从 Java 代码里解放出来放回 XML 这种更适合承载结构的位置。这篇文章写给谁已经能写 MyBatis 基础增删改查、但一遇到“条件可以多个组合”就发怵的人以及准备在团队里统一动态 SQL 规范的人。坦白讲动态 SQL 本身不难难在前人留下的坑和边界意识——比如 #{} 和 ${} 用错一个就是安全漏洞foreach 的 collection 名字写错就把人困在异常日志里大半天。下面我会把每个核心标签的原理、适用场景和易错点都拆开讲再附上我自己在真实项目里踩过的几个坑。1. 为什么需要动态 SQL条件不确定才是最真实的业务常态1.1 静态 SQL 的三个死穴组合查询、局部更新、批量操作先说最普遍的组合查询。五个筛选条件各自独立可选理论上你要准备 2 的 5 次方减 1也就是 31 条查询语句这是没人愿意干的蠢事。于是很多人选择了 WHERE 11 这种“万能模板”SQL 固定在那里Java 代码里根据条件逐个追加 AND xxx。有一说一条件少的时候确实能用但它的代价比表面看起来大得多。首先WHERE 11 在大多数数据库里会被优化器视为恒真条件这会干扰索引选择特别是本来可以通过“时间范围 状态”走复合索引的查询可能因为 11 加上一堆 OR 条件执行计划直接退化成全表扫描。其次可读性问题会在团队协作中持续发酵三个月后别人接手这条 SQL根本分不清哪些条件是核心业务约束哪些只是某个接口的空参数兜底。最隐蔽的问题是当追加的逻辑里出现空字符串、0 这类“不是 null 但也不该参与过滤”的值时判断条件会越写越乱最后变成一团谁都不敢动的浆糊。第二个死穴是局部更新。一个用户编辑接口产品说“只改了昵称就别动其他字段”。传统做法是把整行查出来把非空字段填进去再整体 UPDATE但这样做有并发覆盖风险两个人同时编辑同一个用户后提交的人会把先提交的人改过的字段又覆盖回去。动态 SQL 的 set 标签才是局部更新的标准解——只更新传入的非空字段没传的字段在 SQL 里根本不出现从源头避免了覆盖。第三个死穴是批量操作。批量删除若干个 id、按一组 id 查询、批量插入一批记录参数个数本身就是动态的静态 SQL 根本表达不了。这类需求在报表导出、数据迁移、定时任务里太常见了foreach 几乎是唯一的合理解法。看到这里你应该明白了动态 SQL 不是锦上添花的语法糖而是组合查询、局部更新、批量操作这三类高频业务场景的基础设施。1.2 动态 SQL 不是“运行时拼字符串”骨架固定分支取舍很多人担心动态 SQL 性能差觉得“每次都现场拼一串 SQL 肯定慢”。这里有个认知需要纠正MyBatis 在启动阶段加载 Mapper XML 时就会把每个 SQL 块解析成一颗 SqlNode 树。每个 if 对应一个 IfSqlNodewhere 对应 WhereSqlNodeforeach 对应 ForEachSqlNode树的结构在启动时就已经固定了。运行时拿到参数对象初始化一个 DynamicContext然后递归调用每个 SqlNode 的 apply 方法满足条件的 if 片段被拼进去不满足的直接跳过。也就是说动态 SQL 的骨架是预编译的运行期只是走了一遍“布尔判断 字符串拼接”的流程这部分开销极小跟你在 Java 里手动写 if 拼接的成本基本相当。理解了这一点你就能理解后面几个关键结论动态 SQL 本身不是性能瓶颈瓶颈往往在拼出来的 SQL 是否走上合适的索引动态 SQL 的调试困难根源是“运行时才决定哪些片段出现”所以测试一定要覆盖不同的参数组合而不是只测一条“全字段都传了”的路径。这也是为什么我在文章最后专门列了一套测试纪律。2. 核心标签逐个拆解if、where、set、choose、foreach、trim2.1 if where多条件查询的标准组合拳先看一段最常见的 mapper 接口ListUser selectUsersByCondition(Param(name) String name, Param(email) String email, Param(status) Integer status);对应的 XML 是这样select idselectUsersByCondition resultTypeUser SELECT id, name, email, status, create_time FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testemail ! null and email ! AND email #{email} /if if teststatus ! null AND status #{status} /if /where ORDER BY id DESC /selectwhere 标签做了一件关键的事如果它内部的子元素有任一条件成立它就会在 SQL 中输出 WHERE 关键字并且自动去掉第一个满足条件的 if 片段开头的 AND 或 OR。这意味着你在 if 里写条件时可以统一用 AND 开头不用操心“第一个条件前面多出一个 AND”这种低级语法错误。这是一套非常舒服的写法但有几个细节值得单独拎出来说。第一test 表达式里 name ! 这种写法对 null 安全的判断必须放在前面。OGNL 表达式按从左到右短路求值先判断 name ! null 再判断 name ! 顺序反了或者漏判一旦传入 null 就会在表达式层直接抛出异常。第二name ! 对空白字符串 “ ” 是拦不住的严谨一点的服务端应该在 Controller 层先做 trim或者用后面会讲到的 bind 加 trim 统一处理。第三LIKE 查询我建议用 CONCAT(%, #{name}, %) 而不是直接在 Java 层拼好 %name% 再传进来前者保留了参数预编译的语义也方便后续在 SQL 层面统一调整匹配规则。2.2 set动态更新时不要再手动管逗号局部更新场景的 XML 长这样update idupdateUser parameterTypeUser UPDATE user set if testname ! nullname #{name},/if if testemail ! nullemail #{email},/if if teststatus ! nullstatus #{status},/if if testpassword ! nullpassword #{password},/if /set WHERE id #{id} /updateset 标签的作用和 where 是镜像关系它会输出 SET 关键字并自动剔除内容末尾多余的逗号。使用它时最大的注意点是如果所有字段都传了 nullset 标签会什么都不输出最终 SQL 变成 UPDATE user WHERE id #{id}这在 MySQL 里是语法错误在 PostgreSQL 里也会直接报错。所以动态更新方法在 Service 层必须做校验至少要保证有一个非空字段否则直接返回友好错误而不是把请求打进数据库等一个晦涩的异常。另外一个业务层面的坑是乐观锁。如果表里有 version 字段动态 set 忘了把它加进去等于放弃了并发控制。建议把 version version 1 单独用一条恒真 if 条件放在 set 里并在 WHERE 条件带上 version #{version}最后用 Mapper 方法返回的影响行数判断是否更新成功。这样既走了动态更新的便利又保住了并发安全的底线。2.3 choose / when / otherwiseSQL 里的 switch 分支前面讲的 if where 是“多个条件可以并存”但业务里还有一种常见逻辑是“多个条件互斥按优先级取第一个命中的”。比如列表查询用户既传了关键词 keyword又传了精确的 id业务约定 keyword 优先。这种场景用 if where 会同时满足两个条件结果集反而被过度过滤。choose...when...otherwise 就是为这种互斥逻辑设计的select idselectUserByKeyword resultTypeUser SELECT id, name, email, status FROM user where choose when testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR email LIKE CONCAT(%, #{keyword}, %)) /when when testid ! null AND id #{id} /when otherwise AND status 1 /otherwise /choose /where /select它的执行逻辑和 Java 的 switch 一致从上到下第一个 test 为 true 的 when 生效后续 when 全部跳过如果所有 when 都不满足就走 otherwise。这个标签在“按不同来源查询同一实体”的场景里非常实用比如订单来源支持订单号、外部交易号、手机号三种方式互斥查询命中一个就够不用再单独写三个查询方法。2.4 foreach批量删除、批量插入、IN 查询的主力foreach 是六个标签里最容易踩参数命名坑也最容易写出性能问题的。先看典型用法delete iddeleteUsersByIds DELETE FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /delete对应的 Java 接口int deleteUsersByIds(Param(ids) ListLong ids);批量插入稍微复杂一点因为要遍历的是对象集合insert idbatchInsertUsers INSERT INTO user (name, email, status) VALUES foreach collectionusers itemuser separator, (#{user.name}, #{user.email}, #{user.status}) /foreach /insert这个标签最核心的是 collection 属性。很多人在这里翻车如果接口只有一个 List 参数且没有加 Paramcollection 必须写 list 或 collection如果只有一个数组参数必须写 array一旦加了 Param(ids)就必须写 Param 里的名字 ids。我见过太多把 collection 写成 list但接口里已经有一个 Param(xxx) 的案例报错信息一直指向 Mapper 方法映射失败排查半天才发现是名字对不上。后面踩坑实录里我会说这个问题的完整排查链路。批量插入还有一个数据库差异的坑MySQL 支持一条 INSERT 带多个 VALUES 元组而 Oracle 的批量插入要么走 INSERT ALL要么用存储过程。即便在 MySQL 里也有单条语句大小的边界——max_allowed_packet 默认值在不同版本下是 4MB 或 16MB一次性拼上万条记录很可能直接超限。我的做法是分片提交每 500 条切一次既能避免包体过大也能在出错时减小回滚范围。2.5 trimwhere 和 set 的真面目where 和 set 本质上都是 trim 的语法糖。trim 提供了四个属性prefix、suffix、prefixOverrides、suffixOverrides分别控制前缀、后缀、要去掉的前缀覆盖词、要去掉的后缀覆盖词。手动实现和 where/set 相同的效果是这样写trim prefixWHERE prefixOverridesAND |OR if testname ! null and name ! AND name #{name}/if /trim trim prefixSET suffixOverrides, if testname ! nullname #{name},/if /trim熟悉 trim 之后很多场景可以跳出 where/set 的限制。比如做动态的 GROUP BY或者想让某段内容始终固定在某些关键字之间trim 会比 where/set 更直接。但日常开发里不要为了炫技而用 trim——where/set 的可读性已经很好只有需要同时控制前后缀、或者管理多个覆盖词时才值得用。下面的表把这六个标签的核心作用总结了一下方便记忆标签核心作用最常见的错误if条件成立才输出片段test 表达式里 null 判断顺序错误where输出 WHERE 并去掉首个 AND/OR条件内部末尾多写 ANDset输出 SET 并去掉末尾逗号全字段为空时生成语法错误choose/when/otherwise多选一互斥分支误当成多条件组合使用foreach遍历集合拼 SQLcollection 参数名不匹配trim自定义前缀后缀和覆盖词滥用导致 SQL 可读性差3. bind 与 OGNL动态 SQL 里容易被低估的两个能力3.1 bind参数预处理的正确姿势模糊查询最常见的写法就是前面用到的 LIKE CONCAT(%, #{name}, %)。但当你需要在多个地方复用同一个匹配规则或者 SQL 片段已经长到不好看时bind 可以把拼接逻辑提前到 SQL 片段之前select idsearchUsers resultTypeUser bind namepattern value% name %/ SELECT id, name, email FROM user WHERE name LIKE #{pattern} /selectbind 的语义是在参数上下文中注册一个新变量 pattern后续 #{} 直接引用它即可。它比在 Java 层拼好 %xxx% 再传进来更内聚也避免了 Service 层散落各种字符串拼接逻辑。但要注意bind 里用的是 OGNL 表达式字符串拼接要用单引号包裹字面量% name % 中单引号里的 % 是字面量name 是原始参数。还有一点bind 的作用域是当前 SQL 片段不能被其他查询复用别指望把它当全局变量用。3.2 OGNL 表达式的常用写法与转义陷阱test 属性里能写的表达式比很多人以为的多部分常用写法整理如下比较判断name ! null、age 18XML 里的 必须转义成 逻辑组合name ! null and name ! 、status 1 or status 2集合判断list ! null and list.size() 0成员判断admin in roles、status in {1, 2, 3}特别提醒一下XML 中的大于号小于号必须转义。很多人在 test 里写 age 18IDE 不会在写 XML 时默认报错但运行期解析 XML 就会报“元素类型必须由匹配的结束标记终止”然后开始怀疑人生。正确做法是用 和 或者把比较方向反过来写成 18 age 这种也可以用但可读性差建议老老实实用转义符。另外一个 OGNL 的坑是方法调用。OGNL 支持调用 getter但不要在 test 里写带副作用的复杂方法比如 statusList.contains(user.getStatus()) 这种。XML 里不该有业务逻辑更合理的做法是在 Java 侧把判断结果算好传一个 isFilterStatus 之类的布尔值进去test 里只做简单判断这样既清晰又容易测试。3.3 内置参数与特殊字符处理MyBatis 内部会向参数上下文注册两个默认变量_parameter整个参数对象和 _databaseId多数据库支持时才有。_parameter 在多参数的场景下会退化成第一个参数所以规则很简单能用 Param 显式命名就别依赖 _parameter否则代码的可读性和稳定性都打折扣。XML 里的三个特殊字符是 、、分别要写成 、、。如果 SQL 里用了很多比较运算逐个转义会非常痛苦可以把纯 SQL 部分包在 块里。但一定要记住CDATA 块里不能再嵌套动态标签所以通常只包纯 SQL 片段if testcreateTime ! null ![CDATA[ AND create_time #{createTime} ]] /if实际做日期范围查询时我用 CDATA 包起来的频率很高可读性提升非常明显。这个写法唯一的代价是看起来不够“干净”但对后面维护的人来说少一堆 转义符的折磨值了。4. #{} 与 ${} 的边界安全红线必须画清楚4.1 预编译与字符串拼接的本质差异#{} 编译后对应 JDBC 的 PreparedStatement 占位符 ?参数通过 setObject 传入数据库端做预编译。这意味着不管参数里带什么 OR 11 -- 之类的内容它都只是字符串值的一部分不会影响 SQL 结构。而 ${} 是在 SQL 拼接阶段直接做文本替换等价于把你传的内容原样塞进 SQL。如果参数由用户控制这就是教科书级别的 SQL 注入入口。我在代码评审里给团队立过一条规矩任何新增 XML 中出现 ${}必须写清楚为什么不能用 #{}。如果写不出来一律改回 #{}。这条规矩看起来简单但能挡掉绝大多数“图省事”引发的安全问题。两条写法的差异可以看这张表对比项#{}${}底层机制PreparedStatement 占位符文本直接替换参数安全性安全参数不影响 SQL 结构有注入风险适用场景值、条件、IN 列表等列名、表名、排序字段SQL 缓存友好度高SQL 文本固定低每个值都生成新 SQL 文本4.2 ${} 的合理合法使用场景不是所有 ${} 都必须禁止有两个场景是非用不可且相对安全的。第一是排序字段。ORDER BY 后面跟列名用 #{} 的话数据库会把它当成字符串字面量结果就是排序不生效。这里只能用 ${}但必须在 Java 侧做白名单过滤String[] allowedColumns {create_time, update_time, status}; String orderBy allowedColumns.contains(input) ? input : create_time;第二是动态表名或列名。比如多租户按月份分表order_202401、order_202402 这种表名只能靠 ${} 拼接但同样必须由后端从可信来源生成绝不接受前端裸传的表名。在这两个场景里${} 的值域已经被压缩到一个固定集合注入面趋近于零。4.3 防护实践白名单 日志双重校验在 Controller 层对排序字段做枚举校验在 XML 里对表名做前缀校验这是双保险。还有一点容易被忽略即使是 ${}也要注意别把用户输入直接拼进 LIKE、IN 这些位置——这些地方用 #{} 完全可以用 ${} 只是懒没有任何正当理由。我自己的习惯是所有含 ${} 的 Mapper 方法都在集成测试里单独跑一遍恶意参数用例比如传 orderBycreate_time; DROP TABLE user; --确认被白名单拦截后才算通过。这个习惯帮我提前挡掉了不止一次生产事故。5. 踩坑实录动态 SQL 的真实翻车现场5.1 坑一foreach 的 collection 参数名导致“找不到参数”有一次在订单查询接口里我写的是 collectionlist但接口方法已经写了 Param(orderIds)。运行时报的异常一直指向 Mapper 方法映射失败我花了近一小时才发现在 XML 里参数名写错了。排查过程是这样的先看异常栈里面没有 SQL 片段只有 MyBatis 反射调用的报错再看 Mapper 接口和 XML 的 id确认方法名和返回值没问题甚至怀疑过 resultType 映射错误直到把 XML 里的 collection 改回来问题才消失。后来我把规则刻进脑子里加了 Param 就用 Param 的值没加且是集合就按类型名写 list 或 array数组就是 array。现在团队里的约定更简单——所有传集合或数组的方法一律加 Param名字用可读的业务名彻底消灭这类歧义。5.2 坑二where 标签内部手动拼 AND 位置引起的“时灵时不灵”where 标签能去头但不能去尾也不会去中间。有一次我在 if 内部写了if teststatus ! null status #{status} AND /if把 AND 写在了条件末尾。第一条条件是 status ? AND第二条条件紧跟其后最终生成 status ? AND name ?这条 SQL 本身能跑因为 AND 在中间合法。可一旦 status 为空、只有 name 条件时SQL 变成 name ?也能跑看起来“没报错”。真正的问题出在后面当第三个条件被加进来且某个中间条件的 AND 位置写错SQL 会静默地生成错误语义或者多出一个孤立 AND 直接报错。这类问题最坑的点就是“时灵时不灵”偶发性报错最难定位。我的经验是不要在 if 里手动拼 AND/OR 的前后位置统一用 where 前缀 AND 的写法并且养成在测试环境打印实际 SQL 的习惯让这类低级错误在第一轮自测就暴露出来。5.3 坑三动态 SQL 的缓存命中率被 ${} 拖垮MyBatis 的二级缓存以及某些业务自己做的 SQL 结果缓存都以 SQL 文本为 key。如果你大量使用 ${} 拼排序字段或表名同一个查询会因 ${} 值不同而生成无数条 SQL 文本缓存命中率会急剧下降。更严重的场景是代码里用 ${} 拼了一个列表查询用户每次翻页传的排序方向不同缓存里全是不同的 SQL内存被无效缓存塞满服务压力不降反升。解决思路分两层能用 #{} 的绝对不用 ${}必须用 ${} 的场景把变化维度压缩到极少数固定集合也就是白名单机制从源头限制 SQL 文本变体数量。动态 SQL 本身是很好的东西但“同一逻辑的 SQL 文本变体”越少系统越好维护这条原则在分库分表、读写分离的场景下同样成立。6. 动态 SQL 的性能观与可维护性策略6.1 动态 SQL 不是性能借口真正要盯的是索引和包体很多人担心动态 SQL 慢但真正慢的往往不是动态拼接本身而是拼接出来的 SQL 没有走上正确的索引。举个例子动态查询条件多时把所有可筛选字段都拼进 WHERE优化器选择索引的成本也会变高可能出现“条件变多反而更慢”的情况。我的建议是分优先级先保证核心查询条件比如主键、唯一索引、时间范围必定出现在 SQL 里对可选条件做组合评估必要时用 EXPLAIN 看实际执行计划别等上了生产才后悔。另一个真实的性能经验是批量操作的包体控制。foreach 拼 1000 条数据的批量插入在 MySQL 上通常比逐条插入快一个数量级但不能贪多。我实测过 5 万条数据一次插入包体已经接近 MySQL 默认的 max_allowed_packet 限制改成每 500 条一批后总耗时反而稳定且可控出错时的回滚范围也更小。批量删除同理几千个 id 拼一个 IN 没问题几万个就该分片了。6.2 让 XML 保持可读的几条纪律第一单个动态 SQL 的 if 数量控制在 6 个以内。超过这个数建议把查询拆成几个独立的 mapper 方法由 Service 层选择调用哪个而不是在一个方法里堆十几个 if 互相嵌套。第二复用公共片段用 。比如把一组公共查询列抽出来sql iduserColumns id, name, email, status, create_time /sql select idselectUsers resultTypeUser SELECT include refiduserColumns/ FROM user ... /select第三XML 里的动态 SQL 不要写超过三层嵌套。双层 if where 是常见且合理的三层以上基本意味着设计出了问题——需要重新审视查询接口的粒度是不是太粗了。第四所有涉及动态条件的 mapper 方法在测试里至少覆盖三种输入全空、全有、部分有。这三个用例能暴露绝大多数 AND/OR 位置和参数名的问题。我见过太多只测“全字段都传了”这条路径的项目上线第一天就被空参数组合打懵这真的不值得。最后再说一个我一直在用的排查技巧如果动态 SQL 生成的 SQL 不符合预期又不想反复翻日志可以在 XML 里临时加一个 的恒真分支里面放一段带特殊注释的 SQL 片段运行后搜注释的痕迹就能快速定位是哪一层 if 走了还是没走。这招排查嵌套动态 SQL 非常快比加断点调试 XML 舒服多了。我在实际项目里养成的最顺手习惯是每次写完带动态 SQL 的 Mapper 方法本地先把日志级别调成 DEBUG跑一次真实参数请求把打印出来的 final SQL 复制出来和最初设计的逻辑逐句比对一遍确认 where 的位置、set 的逗号、foreach 的括号都对。这套检查做多了很多坑根本不会出现在生产环境。动态 SQL 的核心价值就是让 SQL 保持灵活的同时不失可控把边界画清楚、把测试补扎实它就是你手里最趁手的工具之一。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询