资讯动态

动态 SQL 的 OGNL 求值陷阱:if、foreach 与 bind 在复杂参数下的正确写法与排障

发布时间:2026/9/25 18:35:17 来源:尧图企业网站定制
动态 SQL 的 OGNL 求值陷阱if、foreach 与 bind 在复杂参数下的正确写法与排障1. 从一次线上报错说起假设你写了一个按条件筛选订单的接口Service 里组装了一个查询对象Mapper 里用if teststatus ! null and status ! 判断状态是否传入。测试环境一切正常上线后某个入口调用却直接抛异常org.apache.ibatis.reflection.ReflectionException: There is no getter for property named status in class java.lang.String奇怪的地方在于你明明传了 status。更奇怪的是换个方法调用又好了。还有一种更隐蔽的现象SQL 能跑但结果不对——某个条件“看起来没生效”或者 foreach 只拼出了一个占位符。这些问题的根因几乎都落在同一件事上MyBatis 的动态 SQL 不是字符串模板而是一次对参数对象的表达式求值。标签里的test、#{}、${}里的名字都要经过一套固定的取值规则才能变成最终 SQL 里的问号或字面量。名字对不上、类型对不上、层级对不上就会出现上面两类症状要么直接报错要么悄悄拼错但能执行。本文的目标不是把 OGNL 讲成编译原理而是让你能回答三个问题参数传进来之后被包装成了什么标签里的表达式在哪一步、对谁求值写错时应该看哪里。2. 一句话模型与全局链路先记住一个最小模型动态 SQL 的本质是 MyBatis 拿着一个“参数容器”对每个标签里的表达式求值把命中的片段拼起来最后把#{}换成?、把值放进参数列表。把整体拆成四部分看角色职责出问题时你会看到什么Mapper 接口代理把方法调用转成 MappedStatement 的调用找不到方法、绑定异常参数容器ParamMap / 单对象决定表达式里能用哪些名字There is no getterOGNL 求值器对 test 表达式求值决定标签是否命中条件不生效、抛表达式异常SqlSource / BoundSql拼接 SQL 文本保存参数与映射占位符数量对不上、SQL 语法错误一次请求的流转可以画成这样调用 Mapper 方法 | v MapperProxy 拦截 - 找到 MappedStatement | v 参数对象包装为 ParamMap或保持单对象 | v DynamicSqlSource.getBoundSql(param) | -- 遍历 SqlNode 树IfSqlNode / ForEachSqlNode / VarDeclSqlNode ... | | | -- OgnlCache 对 test 表达式求值 | -- 命中则追加 SQL 片段并写入 additionalParameters | v BoundSql 的 sql 文本、parameterMappings、parameterObject | v PreparedStatement 执行 - 结果映射关键点是OGNL 求值发生在拼 SQL 阶段而不是执行阶段。所以表达式里的名字必须在那一刻就能从参数容器里取到且foreach展开出来的临时变量也要能被后续片段引用。理解了这条链路后面所有“陷阱”都能定位到具体环节。3. 参数到底被包装成了什么3.1 单参数、多参数与 Map 的三种包装MyBatis 在ParamNameResolver里决定参数怎么命名规则可以概括为只有一个参数且没有Param直接使用该对象本身作为parameterObject。有多个参数每个参数按param1、param2...加上Param指定的名字一起放进ParamMap。参数是单个集合或数组且没有Param会被包一层用collection、list、array之类的默认名字访问。这里最容易误解的是“只传一个 String为什么还报没有 getter”因为你写的是teststatus ! null如果方法签名是ListOrder selectByStatus(String status)那么参数容器就是 String 本身OGNL 会尝试从 String 上取status属性自然失败。正确写法是test_parameter ! null或直接加Param。3.2Param是在改“名字”不是在改“值”Param(status)的作用是给参数起个可在表达式里使用的别名并把该参数放进 ParamMap。它不是类型转换也不改变传值。很多“条件不生效”的案例其实是别名与方法签名里的名字不一致导致表达式取到 null。下面这张表可以当作日常对照方法签名表达式里可用的名字常见误写select(String status)_parameter或把 status 当整体status ! nullselect(Param(status) String s)status、param1s ! nullselect(String a, String b)param1、param2a ! nullselect(Param(ids) ListLong ids)ids、collection、list、param1ids.size() 0写成ids.length4. OGNL 在 test 里到底怎么取值4.1 表达式求值的目标是参数容器对if test...求值时OGNL 的根对象就是参数容器。它按属性名逐层取值user.name会先取user再取name遇到 null 会返回 null但遇到不存在的方法/字段就会抛异常。这一段最常见的三个坑and、or之外用了不存在的运算符。OGNL 支持、||、and、or、not但把and写成AND或混用会报错。对字符串判空不一致。status ! null and status ! 只处理了空串没处理空白串如果业务要求去空格得写成status ! null and status.trim() ! 。数字与字符串比较。type 1在 type 是 Integer 时可能不成立因为 OGNL 不会自动把字符串转成数字做值比较需要type 1或统一在 Java 侧转换。4.2 一个能直接跑的最小示例下面这个示例不依赖 Spring直接读取 MyBatis 配置并解析 Mapper XML用来观察 test 表达式命中后生成的 SQL。环境JDK 8、Maven 依赖org.mybatis:mybatis:3.5.13、mysql-connector-java仅用于驱动类存在本示例不真正连库。!-- src/main/resources/OrderMapper.xml --mappernamespacedemo.OrderMapperselectidselectByConditionresultTypejava.lang.Stringselect id from orderswhereifteststatus ! null and status ! and status #{status}/ififtestamount ! nulland amountgt;#{amount}/if/where/select/mapperpackagedemo;importorg.apache.ibatis.builder.xml.XMLMapperBuilder;importorg.apache.ibatis.io.Resources;importorg.apache.ibatis.mapping.BoundSql;importorg.apache.ibatis.mapping.MappedStatement;importorg.apache.ibatis.session.Configuration;importjava.io.InputStream;importjava.util.HashMap;importjava.util.Map;publicclassDynamicSqlDemo{publicstaticvoidmain(String[]args)throwsException{ConfigurationconfigurationnewConfiguration();try(InputStreaminResources.getResourceAsStream(OrderMapper.xml)){newXMLMapperBuilder(in,configuration,OrderMapper.xml,configuration.getSqlFragments()).parse();}MappedStatementmsconfiguration.getMappedStatement(demo.OrderMapper.selectByCondition);MapString,ObjectparamnewHashMap();param.put(status,PAID);BoundSqlbs1ms.getBoundSql(param);System.out.println(case1 sql bs1.getSql());MapString,Objectparam2newHashMap();param2.put(amount,100);BoundSqlbs2ms.getBoundSql(param2);System.out.println(case2 sql bs2.getSql());BoundSqlbs3ms.getBoundSql(newHashMapString,Object());System.out.println(case3 sql bs3.getSql());}}预期输出case1 sql select id from orders WHERE status ? case2 sql select id from orders WHERE amount ? case3 sql select id from orders关键步骤是getBoundSql它触发DynamicSqlSource的求值过程把命中片段拼成最终 SQL。可以看到where标签会自动去掉开头多余的and而且三个 case 的 SQL 各不相同——这正是动态 SQL 的价值。BoundSql里同时保存了参数映射所以它才是后续设置 PreparedStatement 参数的依据。容易改错的地方如果把参数写成单个 String 而不是 Mapstatus就取不到了会复现第 1 节那类报错。这个示例适合排查“表达式为什么没命中”不适合验证真实数据库执行结果。5. foreach最容易被低估的标签5.1 foreach 在什么时候展开展开成什么foreach的作用是把集合或数组展开成一段重复的 SQL 片段。它的核心属性是collection要遍历的集合在参数容器里的名字item每次迭代的元素别名index下标或 Map 的 keyopen、close、separator包裹符号和分隔符。它的关键副作用是会把item、index注册到additionalParameters里这样后面的#{item}才能解析到具体值。所以 foreach 不只是“循环拼接”还承担了变量声明。5.2 嵌套 foreach两层名字不能互相覆盖业务上常见“订单里有多个商品每个商品有多个属性”的批量更新。如果两层 foreach 都用了item内层会覆盖外层的item导致外层#{item.orderId}取到商品属性对象。正确做法是给两层用不同别名updateidbatchUpdateItemsforeachcollectionordersitemorderseparator;update order_item set qty foreachcollectionorder.itemsitemitmopencase idcloseendseparatorwhen #{itm.id} then #{itm.qty}/foreachwhere order_id #{order.id}/foreach/update这段 SQL 的语义是每个订单执行一条 update用 CASE WHEN 按 item 的 id 更新数量。它同时说明了两点外层集合通过order.items这种属性链访问内层别名itm不会污染外层如果写成item嵌套itemMyBatis 不会善意报错而是取到错误对象。另外要留意 MySQL 默认不允许一次执行多条语句。上面用分号分隔的批量 update 需要allowMultiQueriestrue才成立而且它属于多次更新不是原子操作。生产上更推荐用case when单条 update或用 MyBatis-Plus 的批量接口配合事务。5.3 一个可复现的 foreach 示例目标验证空集合、普通集合、嵌套集合三种情况下的 SQL 生成结果。!-- src/main/resources/UserMapper.xml --mappernamespacedemo.UserMapperselectidselectByIdsresultTypejava.lang.Stringselect id from users where id inforeachcollectionidsitemidopen(close)separator,#{id}/foreach/select/mapperpackagedemo;importorg.apache.ibatis.builder.xml.XMLMapperBuilder;importorg.apache.ibatis.io.Resources;importorg.apache.ibatis.mapping.BoundSql;importorg.apache.ibatis.mapping.MappedStatement;importorg.apache.ibatis.session.Configuration;importjava.io.InputStream;importjava.util.Arrays;importjava.util.HashMap;importjava.util.Map;publicclassForeachDemo{publicstaticvoidmain(String[]args)throwsException{ConfigurationconfigurationnewConfiguration();try(InputStreaminResources.getResourceAsStream(UserMapper.xml)){newXMLMapperBuilder(in,configuration,UserMapper.xml,configuration.getSqlFragments()).parse();}MappedStatementmsconfiguration.getMappedStatement(demo.UserMapper.selectByIds);MapString,Objectp1newHashMap();p1.put(ids,Arrays.asList(1L,2L,3L));System.out.println(normal ms.getBoundSql(p1).getSql());MapString,Objectp2newHashMap();p2.put(ids,Arrays.asList());System.out.println(empty ms.getBoundSql(p2).getSql());}}预期输出normal select id from users where id in ( ? , ? , ? ) empty select id from users where id in结果说明了一件很重要的事空集合时 foreach 不产生任何片段但in前面的文本仍然在于是生成where id in这种语法错误 SQL。生产写法要么在参数侧拦截空集合要么用where配合ids ! null and ids.size() 0包裹整段。6. bind给复杂表达式起一个可复用名字6.1 bind 解决的是什么问题bind允许你把一个 OGNL 表达式的结果赋给一个变量再在后续#{}或 test 里引用。它解决的是“同一个表达式要写很多遍”“需要在 SQL 里做简单字符串拼接”这类问题。例如模糊查询统一加通配符selectidsearchbindnamekwvalue% keyword %/select id, name from users where name like #{kw}/select这里bind把拼接后的字符串放进 additionalParameters#{kw}就能取到。它比在 Java 侧拼好再传更分散所以什么时候用要看团队约定。6.2 bind 与#{}、${}的关系必须区分清楚#{}生成?并走参数绑定能防注入${}是文本替换直接把值拼进 SQL有注入风险。bind本身是变量声明不改变这一点——如果 bind 出来的变量被放进${}风险依然存在。写法生成结果是否预编译注入风险#{name}?是无值安全${name}直接拼值否有需白名单校验bind#{}?是无bind${}直接拼值否有还有一个边界bind的值在拼 SQL 阶段就要算出来所以如果表达式依赖数据库里的数据例如某张表的最大值它做不到——那时应该用子查询。7. 参数命名Param、param1与 Map7.1 命名规则决定了表达式能写什么把命名规则背下来没有意义理解来源更重要ParamNameResolver在方法调用时构造参数对象随后这个对象就是所有表达式的根。记住两张对照关系即可多参数一定有param1...paramNParam会在paramN之外额外提供你指定的名字单参数无注解时保持原对象。7.2 一个贴近业务的示例条件分页查询目标方法同时接收查询条件、时间范围和分页参数动态拼接 where 与 order by。packagedemo;importorg.apache.ibatis.annotations.Param;importjava.time.LocalDate;importjava.util.List;publicinterfaceOrderQueryMapper{ListStringquery(Param(status)Stringstatus,Param(start)LocalDatestart,Param(end)LocalDateend,Param(sortField)StringsortField);}selectidqueryresultTypejava.lang.Stringselect id from orderswhereifteststatus ! null and status ! and status #{status}/ififteststart ! nulland created_atgt; #{start}/ififtestend ! nulland created_atlt;#{end}/if/whereiftestsortField amountorder by amount desc/if/select调用query(PAID, d1, d2, amount)时表达式根对象是含status/start/end/sortField的 ParamMap所有名字都能取到。这里sortField只用于判断分支不进入 SQL 文本所以没有注入面。如果确实要动态列名必须用白名单把值与真实列名做映射绝不能把用户输入直接写进${}。8. SQL 注入风险动态 SQL 最容易出事的地方8.1 三种危险模式order by 直接拼${}order by ${sortField}是经典注入点。like 里拼${}like %${kw}%把用户输入当 SQL 文本。in 里拼${}in (${ids})看起来方便实际上等价于字符串拼接。8.2 安全的替代方式对上面三种情况分别有稳定做法排序字段用白名单映射模糊查询用bind或 Java 侧拼好通配符后走#{}in一律用foreach #{}。下面给一个排序白名单的完整示例packagedemo;importorg.apache.ibatis.annotations.Param;importjava.util.List;publicinterfaceSafeSortMapper{ListStringlist(Param(sortField)StringsortField);staticStringnormalizeSort(Stringraw){if(amount.equals(raw))returnamount;if(created_at.equals(raw))returncreated_at;returnid;}defaultListStringsafeList(StringrawSort){returnlist(normalizeSort(rawSort));}}selectidlistresultTypejava.lang.Stringselect id from orders order bychoosewhentestsortField amountamount/whenwhentestsortField created_atcreated_at/whenotherwiseid/otherwise/chooseasc/select关键点在于进入${}或本示例中的静态列名分支的值必须来自有限的、代码可控的集合。这里用choose把值转换成固定文本等于在 XML 层做了一次白名单。注意when test里的sortField依然来自参数但只用于判断不直接拼进 SQL。9. 常见误区把 test 当成 Java 代码OGNL 的子集语义与 Java 不完全一致例如对数字与字符串的比较行为需要实际验证。以为Param必须有单参数时可以不写但为了可读性和避免歧义团队通常统一要求写。认为 foreach 会自动处理空集合不会它只会不产出片段前后文本仍会拼接。用${}图省事任何进入${}的值都要按“用户可控输入”对待。忽略additionalParametersbind 和 foreach 的变量都放在这里排障时这是重要观察点。10. 生产实践建议条件判空统一成一套模板减少与空白串的差异带来的偶发问题。集合参数在 Service 层做一次非空校验避免生成in ()。排序、表名、字段名一律走白名单禁止直接${}。复杂查询优先用 MyBatis-Plus 的 Wrapper 构造但要清楚 Wrapper 最终也是参数绑定orderBy等字符串入口同样要校验。打开 MyBatis 日志把最终 SQL 和参数打出来很多问题看一眼就清楚。对批量操作明确事务边界把多次update包在一个事务里。11. 排障清单现象优先检查常见原因There is no getter方法签名与参数容器单参数无 Param名字写错条件不生效test 表达式与参数名名字不一致、类型不一致in 语法错误foreach 与集合判空空集合生成in嵌套取值错两层 item 别名别名覆盖SQL 注入告警所有${}用户输入直拼12. 面试/复盘问题为什么单参数 String 会报没有 getter而多参数不会foreach 展开时item 变量存在哪里为什么后续#{}能取到#{}与${}在 PreparedStatement 层面的差别是什么bind 变量能参与 SQL 执行阶段的计算吗如何在动态 SQL 中安全地实现排序字段13. 总结把动态 SQL 看成“对参数容器的一次表达式求值”大部分困惑都会变得可定位参数名决定表达式能取到什么OGNL 决定条件是否命中foreach 负责展开并声明变量bind 负责复用表达式#{}与${}决定安全边界。遇到问题时先确认参数容器里有什么再看 test 表达式求值结果最后看 BoundSql 的 SQL 文本与参数映射通常三步内就能定位。14. 参考资料MyBatis 官方文档Dynamic SQL、Mapper XML、ParametersMyBatis 源码org.apache.ibatis.scripting.xmltags、org.apache.ibatis.reflection.ParamNameResolver、org.apache.ibatis.mapping.BoundSqlOGNL 官方项目文档《MyBatis 技术内幕》技术书籍MyBatis-Plus 官方文档Wrapper、分页插件、逻辑删除、乐观锁、多租户

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑