前阵子一位后端同事把我拉进一个排查群,说后台的订单筛选功能出了个怪问题:状态下拉框里选了“待处理”,查出来的数据反而把“已处理”和其他状态全带出来了,只有选“已处理”时过滤才正常。乍一听很像脏数据问题,但当我看到筛选条件是status=0时,心里基本已经有答案了——这是 MyBatis 动态 SQL 里很经典的“Integer 字段传 0,被空字符串判断误杀”的坑。
这个坑在基于 MyBatis 做的管理后台里非常常见,凡是写过<if test="xxx != null and xxx != ''">这种条件的人都有可能踩中,只是很多人当时没意识到是 OGNL 表达式的类型比较问题。无论你写的是订单查询、用户检索还是报表筛选,只要筛选字段是 Integer/Long 这类数值类型,并且业务上允许传 0,这篇文章就值得你看完。我会从现象复现、根因拆解、完整排查链路到修复方案和防御习惯,一次性讲透。
1. 症状复现:Integer传0时查询条件神秘消失
1.1 一个后台筛选功能的三次复现
先把问题的现场还原一下。某后台系统的订单查询页面,状态筛选字段是 Integer 类型,0 表示“待处理”,1 表示“已处理”,页面下拉框选择后通过 GET 请求把 status 参数传给后端。
大概的操作流程是这样的:
- 打开订单管理列表页,状态筛选项选择“待处理”,此时前端传
status=0。 - 点击查询,发现列表返回的是全部状态的订单,“待处理”这个筛选条件完全没有生效。
- 把筛选项切换成“已处理”,传
status=1,查询结果正常,列表只返回已处理订单。
我在本地把同样的请求跑了一遍,打开 SQL 日志,看到的是这样的输出:
==> Preparing: SELECT id, order_no, status, amount, create_time FROM order_info WHERE status = ? AND create_time >= ? ==> Parameters: 1(Integer), 2024-01-01(String) <== Total: 42咦,第一次跑 1 的时候条件是正常的。接着改成传 0 再跑:
==> Preparing: SELECT id, order_no, status, amount, create_time FROM order_info WHERE create_time >= ? ==> Parameters: 2024-01-01(String) <== Total: 237注意看,参数列表里压根没有status了,SQL 里的WHERE status = ?也消失了。明明接口入参里传了0(Integer),但 MyBatis 生成的 SQL 就是少了一块条件,看起来就像这个条件被“吃”掉了。
对应到 Mapper XML 里的写法,当时是这样写的:
<select id="listOrders" resultType="com.xxx.model.OrderInfo"> SELECT id, order_no, status, amount, create_time FROM order_info <where> <if test="status != null and status != ''"> AND status = #{status} </if> <if test="createTime != null"> AND create_time >= #{createTime} </if> </where> </select>问题就出在<if test="status != null and status != ''">这一行。status是 Integer,值为 0,按理说status != null是 true,为什么整体条件还是 false?答案在后面的status != ''。
1.2 影响范围:不是个别字段,而是所有数值型空值判断
不要以为这只是某一个字段写错了,实际上这种写法遍地都是。很多人从老项目里复制 SQL 片段,!= null and != ''已经成为肌肉记忆,见到一个字段就贴一个。问题是:这个经验只对 String 类型成立,对数值类型是灾难。
我后来把团队里所有 Mapper XML 都扫了一遍,发现中招的远不止订单状态这一个字段:
| 字段类型 | 传入值 | <if test="x != null and x != ''">求值结果 | 实际表现 |
|---|---|---|---|
| Integer | 0 | false | 条件被丢弃 |
| Integer | 1 | true | 正常 |
| Long | 0L | false | 条件被丢弃 |
| Double | 0.0 | false | 条件被丢弃 |
| BigDecimal | 0 | false | 条件被丢弃 |
| String | "0" | true | 正常 |
| String | "" | false | 正常(不希望拼接) |
| String | " " | true | 可能误判为一个空格值 |
可以明显看到,String "0" 不会触发问题,但数值 0 会。也就是说这类问题跟数值零强相关,越是“0 是有效业务值”的场景,越容易踩中。
2. 根因初探:为什么0会被当成空字符串
2.1<if test>背后是OGNL求值,不是Java
很多人看到 XML 里的<if test>就下意识以为里面写的是 Java 表达式,这是一个很大的误区。MyBatis 3 的动态 SQL 中,test属性里的表达式并不是由 Java 直接求值,而是交给 OGNL(Object-Graph Navigation Language)这个表达式引擎来解析的。OGNL 长得像 Java,很多语法也兼容 Java,但它在类型转换、比较规则上有一套自己的逻辑。
OGNL 在 MyBatis 里的地位非常重要,不只是<if>,<foreach>的collection、<bind>的value、以及 resultMap 里的一些复杂取值都会用到它。但它毕竟是独立的表达式语言,不能完全拿 Java 的语义去套。
问题就出在这里:OGNL 在比较不同类型的数据时,会先做类型转换和数值化处理,而不是像 Java 那样要求两边类型一致或者显式调用 equals。于是当表达式里出现数字 0 和空字符串时,OGNL 会按它自己的规则去“拉齐”两边的类型。
2.2 OGNL对空字符串的“宽松数字转换”
我们直接看结论:在 OGNL 里,0 == ""这个表达式的求值结果是 true。这一点非常反直觉,因为在 Java 里 Integer 和 String 用==比较要么编译报错,要么根本不相等。但在 OGNL 的类型比较逻辑中,数字 0 和空字符串""会被当成同一类“非真值”处理,或者更准确地说,OGNL 在跨类型比较时会对空字符串做数值转换,转换结果恰好等于 0。
所以回到我们的表达式:
status != null and status != ''当status = 0时:
0 != null→ true0 != ''→ false(因为 OGNL 认为0 == '')
true and false的结果自然就是 false,于是整个<if>条件不成立,MyBatis 就跳过这个 SQL 片段。你说它是“识别成空字符串”吗?表面看确实是,但本质上是 OGNL 的类型松弛比较把数值 0 和空字符串划上了等号。
这里还要特别注意一个衍生坑:不只是0 == ''成立,0 == '0'在 OGNL 里同样成立。换句话说,如果你觉得把空字符串改成'0'就能解决,那就错了。比如<if test="status != null and status != '0'">,当 status 为 Integer 0 时,结果依然是 false,条件照样被丢弃。因为 OGNL 在比较数字和字符串时,会把字符串'0'转换成数字 0,两边一对比还是相等。
2.3 为什么只有0中招,其他数字不会
这一点也好理解。OGNL 对空字符串做数值转换时,空串没有可解析的数字内容,就被当成 0 处理。于是:
1 == ''→ false2 == ''→ false-1 == ''→ false0 == ''→ true
只有 0 恰好和空串转换后的数值相等。所以线上只会在“0 是有效业务值”的时候爆发,比如状态 0、类型 0、数量 0、价格为 0 这些场景。而如果业务值恰好从 1 开始,那么再写一百年!= ''也不会触发这个问题,这也就是为什么它能潜伏很久、让很多人毫无防备。
3. 定位全过程:从日志到源码的三层排查
3.1 第一层:确认参数确实传了0
排查这类问题,第一步不是去改代码,而是要确认参数到底有没有传进来。很多人一看到 SQL 少条件就怀疑 MyBatis 缓存、怀疑数据库、怀疑 MyBatis-Plus 的 Wrapper,结果绕了一大圈。
我当时直接在后端接口入口打了一条日志,确认收到的 request 对象里 status 确实是 0,而且是 Integer 类型。再看 MyBatis 打出来的==> Parameters:输出,也能看到0(Integer)这样的绑定参数,说明参数已经正确传到了 SQL 绑定这一层。
这里有一个很容易被忽略的细节:日志显示的Parameters: 0(Integer)虽然说明#{status}这个参数被绑定了,但“被绑定”和“条件被拼接”是两回事。如果<if>根本没进入,MyBatis 不会把status拼进 SQL,日志里出现的 Parameters 只表示这个参数出现在了 statement 的某个占位符附近。所以要判断条件是否生效,核心还是要看 Preparing 这行日志里的 SQL 文本本身。
3.2 第二层:XML表达式逐个拆分验证
确认参数没问题之后,我开始怀疑<if>表达式本身。为了快速验证,我没有直接删代码,而是把条件拆成两个独立的<if>分别测试:
<if test="status != null"> AND status = #{status} </if><if test="status != ''"> AND status = #{status} </if>第一个条件status != null单独测的时候,SQL 正常拼接;第二个条件status != ''单独测的时候,status 为 0,条件不生效。到这里基本可以断定问题就出在空字符串判断上。
你可能想问:为什么不直接出结果,还要继续往下查?因为如果我到这里就停手,只是侥幸删掉空串判断解决了,下次遇到0 == '0'这种变体还是会掉坑。搞明白 OGNL 为什么这么判断,才是一劳永逸的关键。
3.3 第三层:用OGNL表达式直接验证
为了把话说死,我写了一个独立的 OGNL 测试。Maven 项目里临时引一下 ognl 依赖:
<dependency> <groupId>ognl</groupId> <artifactId>ognl</artifactId> <version>3.3.4</version> </dependency>然后写一段最简验证代码:
import ognl.Ognl; import ognl.OgnlException; public class OgnlCheck { public static void main(String[] args) throws OgnlException { Object r1 = Ognl.getValue("0 == ''", null); Object r2 = Ognl.getValue("1 == ''", null); Object r3 = Ognl.getValue("0 == '0'", null); Object r4 = Ognl.getValue("'0' == ''", null); System.out.println("0 == '' -> " + r1); // true System.out.println("1 == '' -> " + r2); // false System.out.println("0 == '0' -> " + r3); // true System.out.println("'0' == '' -> " + r4); // false } }运行结果一目了然:
0 == '' -> true 1 == '' -> false 0 == '0' -> true '0' == '' -> false这一下就完全确认了:OGNL 不是把 Integer 0 当成 Java 里的 null,而是在自己的比较规则里认为数字 0 和空字符串、数字 0 和字符串 "0" 是相等的。整个过程没有玄学,就是表达式引擎的类型转换逻辑和我们的直觉不同。
提示:如果你不想依赖 OGNL 单独建测试工程,也可以在原有项目里写一个带 main 方法的类直接跑,或者用
jshell加载 ognl 的 jar 包后手敲表达式验证。关键是亲眼看到输出结果,排查结论才站得住。
4. 修复方案对比:最直接、最稳妥、最规范的三条路
4.1 最直接的改法:删除空字符串判断
对于绝大多数场景,最正确的做法就是把数值字段的那个and xxx != ''直接删掉,只保留 null 判断:
<if test="status != null"> AND status = #{status} </if>这样做的理由是:一个 Integer 类型的字段,在 Java 持久层里只有 null 和非 null 两种状态。既然 MyBatis 收到的是 Integer 类型,就不会出现“空字符串”这个值。只要你不在入口把""硬塞给 Integer 字段,null 判断已经足够覆盖所有“没传”的场景。
要注意的是:如果调用方真的很野,把空字符串传给了 Integer 参数,那么#{status}绑定的时候大概率会因为类型转换失败直接抛异常,而不是静默变成 0。所以更稳妥的做法是在 Service 入口对参数做一次类型洁净化处理,把非法的空字符串拦截掉,而不是指望 XML 里的 来做防守。
4.2 更严谨的改法:显式判断数值零
如果业务语义里“0”不是有效值,而是类似“全部”或“未设置”的意思,你也可以更显式地判断:
<if test="status != null and status != 0"> AND status = #{status} </if>这里直接让 Integer 和数字 0 比较,走的是 OGNL 数值比较的正常路径,不会再把它当空字符串。语义上也比!= ''明确得多:我就是要排除 0 这个特定值。
但这里有一个提醒:如果你业务里的 0 是合法筛选值(比如待处理状态就是 0),那你千万不能用status != 0这种写法,否则又绕回去了——0 被当成“排除值”,条件照样不生效。所以方案二一定要结合业务语义来判断,不能盲目套用。
4.3 最简单的统一方案:用工具方法收敛判断
如果项目里类似的<if>判断太多,一个一个改容易漏,可以写一个自定义的 OGNL 静态方法统一处理。MyBatis 支持在 test 表达式里调用静态方法,格式类似下面这样:
public final class MyOgnlUtil { public static boolean isSpecified(Object value) { if (value == null) { return false; } if (value instanceof String) { String str = (String) value; return !str.isEmpty(); } return true; } }然后在 XML 里这样用:
<if test="@com.xxx.common.MyOgnlUtil@isSpecified(status)"> AND status = #{status} </if>这样一个方法同时处理 String 和数值类型:String 会判断是否为空串,数值类型只要不是 null 就认为是有效值。从入口统一拦截,以后不会再有人写出Integer != ''这种不伦不类的表达式了。
三个方案各有适用场景,我整理成了一个简单对比表:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 删除空串判断 | 绝大多数查询条件 | 最简单,语义清晰 | 对入口类型洁净化有一定要求 |
| 显式判断 0 | 0 表示“全部”或“未设置” | 语义明确,可控性强 | 0 为有效值时不可用 |
| 自定义 OGNL 工具方法 | 项目里 判断很多 | 统一收口,防复发 | 需要额外加工具类和维护约束 |
5. 同类深坑与防御习惯:别只修这一个if
5.1 还有哪些写法会触发OGNL的类型松弛比较
修完这一个<if>不代表着这件事就翻篇了,我强烈建议把同类坑一次性扫干净。根据 OGNL 的数值转换机制,下面这些组合都可能产生“反直觉的相等”:
| 表达式 | 结果 | 解释 |
|---|---|---|
0 == '' | true | 空串转换后数值为 0 |
0 == '0' | true | 数字 0 和字符串 '0' 做数值比较相等 |
0L == '' | true | 长整型 0 同样受影响 |
0.0 == '' | true | 浮点 0 同理 |
null == '' | false | null 和空串并不相等 |
1 == '' | false | 只有 0 才会中招 |
尤其要注意0 == '0'这条,很多人把<if test="status != ''">改成<if test="status != '0'">之后发现还是不生效,就是因为数字 0 和字符串 "0" 在 OGNL 数值比较下也是相等的。所以正确做法不是换一个字符串常量继续比,而是彻底放弃“数值字段和字符串常量比较”这条路。
另外,如果项目里用了<bind>,也要留意同样的坑。比如有人写:
<bind name="statusBind" value="status != '' and status != null"/>status为 Integer 0 时,这个 bind 的值同样会是 false。OGNL 表达式的规则是一致的,不会因为换了个标签就改变判断逻辑。
5.2 推荐采用的防御习惯
这次排查之后,我给团队定了几条规矩,现在回头看,帮助很大:
第一,手写动态 SQL 时,凡是数值类型字段,test 里只允许写xxx != null,禁止写xxx != ''。String 类型可以保留空串判断,但要区分''和' '(一个空格不等于空串,有时会带来很隐蔽的查询差异)。
第二,前端传参时,数值筛选框的空值统一不传参数,或者在网关层把空字符串转成 null,保证后端拿到的永远是 Integer 或 null,而不是一个“空字符串形态”的假空值。
第三,开发环境把 MyBatis SQL 日志常开。我用的是log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,每次查询都能看到完整 SQL 和参数列表。筛选条件不生效这种问题,只要 SQL 文本里少了条件,一眼就能看出来,根本不用靠猜。
第四,新查询接口的单元测试必须覆盖边界值:null、0、1、空字符串。哪怕你觉得自己这次写对了,也要让测试来兜底,防止后来者接手时不小心又把!= ''加回去。
第五,Code Review 时重点扫一遍<if>里的!= ''和!= '0',凡是作用在数值字段上的,一律打回。这类问题不报错、不抛异常,线上数据一变就可能引发事故,属于“静默 Bug”,比编译错误危险多了。
最后提一句,如果你用的是 MyBatis-Plus 的 QueryWrapper / LambdaQueryWrapper,:eq("status", 0)这种直接构建条件的方式通常不会有这个坑,问题绝大多数集中在手写 XML 动态 SQL。但手写 XML 在复杂查询里又是绕不开的,所以理解 OGNL 的比较规则,比记住“去掉空串判断”这一条答案要重要得多。
这次排障后我自己的体会是,遇到“看起来像数据问题但数据没错”的怪事,先打印 SQL 日志,再顺着 SQL 反推是哪一段动态条件丢了,基本都能定位。不要一上来就质疑框架,MyBatis 大多数情况下是忠实执行了表达式,只是表达式没有按你的直觉去理解而已。把 OGNL 这套规则刻进脑子里之后,这类坑基本就和你无缘了。