在用 MyBatis Plus 的QueryWrapper做“不等于”查询时,我踩过一个特别隐蔽的坑:ne判断遇到字段值为NULL的记录,这些记录会被整个漏掉。当时做用户标签筛选,需求是捞“等级不等于 VIP 的用户”,前台反馈少了几个人,我核对半天才发现,少的全是level字段还没填、在数据库里就是NULL的用户。
这个问题的根子不在 MyBatis Plus,而在 SQL 本身对NULL的特殊处理逻辑。今天这篇文章我会从复现、根因、修复到同类隐患一条龙讲透,适合正在用 MyBatis Plus 写查询条件、还不太熟悉 SQL 三值逻辑的开发者。看完你不仅知道怎么改,还能顺手排查掉一堆类似的问题。
1. 问题现场:用 ne 筛“不等于”却把无值数据一起弄丢了
1.1 我当时遇到的业务场景
事情是这样的:系统后台要做用户筛选,需要把所有“等级不是 VIP”的用户列出来给运营做活动。听起来特别简单对吧?我当时写的代码就是这个样子:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.ne("level", "VIP"); List<User> users = userMapper.selectList(wrapper);表面上看没有任何问题:ne就是“不等于”,把等于 VIP 的排掉,剩下的不就是非 VIP 吗?但运营反馈说名单里少了人。我把数据库里的数据翻出来一对比,少掉的那几个用户,level字段在数据库里存的全是NULL——也就是用户注册后还没设置过等级。
这类问题最气人的地方在于:它不会报错,不会警告,代码跑得干干净净,就是结果少了几行。如果没有业务方较真去对数据,你可能永远发现不了。
1.2 最小复现:表结构、样例数据和 QueryWrapper 代码
为了把问题讲清楚,我构造了一个最小复现场景。假设有一张用户表,结构很简单:
CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50), `level` VARCHAR(20) );插几条测试数据,其中故意让两条数据的level为NULL:
INSERT INTO `user` (`name`, `level`) VALUES ('张三', 'VIP'), ('李四', 'NORMAL'), ('王五', NULL), ('赵六', 'NORMAL'), ('孙七', NULL);按业务语义,我需要查出“所有等级不等于 VIP 的用户”,预期结果是李四、王五、赵六、孙七,一共 4 条。用 MyBatis Plus 查询:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.ne("level", "VIP");框架翻译出的 SQL 长这样:
SELECT id, name, level FROM user WHERE level != 'VIP';执行结果如下:
| id | name | level |
|---|---|---|
| 2 | 李四 | NORMAL |
| 4 | 赵六 | NORMAL |
预期 4 条,实际只回来 2 条,王五和孙七这两条level为NULL的记录集体失踪了。
1.3 结果对比:预期和实际的差异一目了然
我把每条数据代入level != 'VIP'这个判断条件,手动推演一下每条记录的去向:
| 用户 | level 字段值 | level != 'VIP'的判断结果 | 是否被返回 |
|---|---|---|---|
| 张三 | VIP | FALSE | 否(正确) |
| 李四 | NORMAL | TRUE | 是(正确) |
| 王五 | NULL | UNKNOWN(既非 TRUE 也非 FALSE) | 否(错误) |
| 赵六 | NORMAL | TRUE | 是(正确) |
| 孙七 | NULL | UNKNOWN(既非 TRUE 也非 FALSE) | 否(错误) |
问题就出在那两条NULL记录上:它们既不是“等于 VIP”,也不是“不等于 VIP”,而是处于一种“不知道是不是 VIP”的状态。数据库面对这种状态,选择不放行。
2. 根因拆解:SQL 三值逻辑与 NULL 的“未知”身份
2.1 NULL 不是值,而是一种“未定义”状态
这是理解整个问题的关键:NULL在 SQL 里不是空字符串,不是 0,也不是“没有值”,它表示的是“未知”或“未定义”。你可以把它理解为数据库在说“这个格子里暂时没有任何有效信息,至于以后会不会填,我也不知道”。
正因为它代表“未知”,所以我们对它做任何比较运算,结果都是无法确定的。比如你问数据库:“这个未知的等级,等于 VIP 吗?”数据库回答不了,只能给你一个“不知道”。你再问:“这个未知的等级,不等于 VIP 吗?”数据库同样答不上来,还是给你一个“不知道”。
你可以直接在 MySQL 客户端里验证这一点:
SELECT NULL != 'VIP';运行结果不是1(TRUE),而是NULL。这个结果看起来很奇怪,但正是 SQL 标准的设计逻辑:与未知量比较,结果永远是未知的。
2.2 WHERE 的筛选机制:只有 TRUE 才放行
SQL 标准定义了一种三值逻辑:判断结果除了TRUE和FALSE,还有一个UNKNOWN。WHERE 子句在筛选数据行时,只放行判断结果为TRUE的行,FALSE和UNKNOWN都会被丢弃。
这就意味着,WHERE level != 'VIP'本质上是在做“凡是判断结果为 TRUE 的行就留下”。NORMAL不等于VIP是确定的,所以TRUE,留下;NULL不等于VIP是未知的,所以在数据库眼里它并不满足过滤条件,直接丢掉。
从业务角度你想表达的是“这些用户没有分配 VIP 等级,应该算作非 VIP”,但从 SQL 语义角度看,“非 VIP”并不天然包含NULL。这就是两个世界之间的认知差,也是绝大多数ne查询漏数据的根源。
2.3 为什么 OR 能救场,AND 里的 NULL 却一票否决
知道了NULL比较会得到UNKNOWN,你可能会想:那我在条件里用OR把IS NULL补上不就行了吗?确实可以,但要理解背后的逻辑。
level IS NULL OR level != 'VIP'这条判断对每条数据的结果是:
NULL行:level IS NULL为TRUE,所以整体TRUE,放行;NORMAL行:第一个条件为FALSE,但第二个条件为TRUE,整体TRUE,放行;VIP行:两个条件都为FALSE,整体FALSE,丢弃。
OR 逻辑的关键在于:只要有一个条件为TRUE,整体就是TRUE。所以IS NULL这个确定性条件,可以帮UNKNOWN状态兜底。
反过来,AND 逻辑就是另一个画风了。假设你写的是a IS NOT NULL AND a != 'VIP',对于a为NULL的行,第一个条件a IS NOT NULL已经是FALSE,整体直接FALSE。即便第二个条件是UNKNOWN,结果依然FALSE,行照样被丢弃。
更隐蔽的情况是a != 'VIP' AND b != 'BLACK'这种并列条件:当a是NULL、b是正常值时,第一个条件是UNKNOWN,第二个是TRUE,AND 的规则是“只要存在UNKNOWN且没有FALSE,结果就是UNKNOWN”,最终整行还是不会被返回。这就是为什么很多人在多个否定条件叠加时,发现数据莫名其妙变少的原因。
3. 修复写法:三种方案让 ne 在 NULL 面前也听话
3.1 方案一:IS NULL 与 OR 组合的写法与括号陷阱
最直观的修法,是在ne条件之外补一个IS NULL:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.isNull("level").or().ne("level", "VIP");生成的 SQL:
WHERE level IS NULL OR level != 'VIP'这条 SQL 能把NULL行和非VIP行正确捞出,前面表格里的王五、孙七都能回来,预期 4 条数据一条不少。
但这里有个极其容易踩的连环坑:如果查询不止这一个条件,or()会把整个条件结构打乱。举个例子,需求变成“查状态为正常且等级不等于 VIP 的用户”,新手往往会这么写:
wrapper.eq("status", 1) .isNull("level") .or() .ne("level", "VIP");生成的 SQL 是:
WHERE status = 1 AND level IS NULL OR level != 'VIP'由于 SQL 里AND的优先级高于OR,这条语句实际表达的是:
WHERE (status = 1 AND level IS NULL) OR (level != 'VIP')这个语义完全变了:它会选出“所有非 VIP 用户”,不管状态是不是 1,结果必然包含一大批业务上不该出现的用户。这种错误比漏数据还可怕,因为它不是少数据,而是多出一堆无关数据,最容易被业务方一眼抓住。
3.2 方案二:用 and(wrapper) 包裹避免 or 串味
MyBatis Plus 提供了一个很实用的重载,可以把多个条件强制包进一组括号内,从根本上解决 OR 优先级问题:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1) .and(w -> w.isNull("level").or().ne("level", "VIP"));生成的 SQL:
WHERE status = 1 AND (level IS NULL OR level != 'VIP')这样status = 1和括号里的整体条件是 AND 关系,括号内的 OR 只影响level自己的判断,逻辑就完全正确了。
如果不想用 lambda,也可以直接用嵌套写法:
wrapper.eq("status", 1) .and(new QueryWrapper<User>() .isNull("level") .or() .ne("level", "VIP"));不过个人更推荐第一种and(w -> ...)的写法,代码读起来最接近 SQL 原本的括号结构,维护的人一眼就能看懂意图。
3.3 方案三:业务层分开查询再合并
还有一种思路是绕开复杂 SQL,在业务层做两次查询、然后合并结果:
QueryWrapper<User> nullLevel = new QueryWrapper<>(); nullLevel.isNull("level"); List<User> usersWithNullLevel = userMapper.selectList(nullLevel); QueryWrapper<User> nonVip = new QueryWrapper<>(); nonVip.ne("level", "VIP"); List<User> nonVipUsers = userMapper.selectList(nonVip);得到两个列表后在 Java 里合并、去重。这个方案适用于 SQL 已经很难扩展、或者你想把查询逻辑拆开单独复用的情况。缺点也明显:多次数据库往返,数据量大时性能不佳,而且合并去重代码略显繁琐。
3.4 三种方案的适用场景对照
| 方案 | 核心写法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 方案一 | isNull().or().ne() | 写法最简单 | 前面有其他条件时 OR 会串味 | 单独一个 ne 条件,没有其他并列条件 |
| 方案二 | .and(w -> w.isNull().or().ne()) | 结构严谨,不受其他条件干扰 | 稍微多一层嵌套 | 多条件组合查询,最推荐日常使用 |
| 方案三 | 分两次查询再合并 | 代码可读性最强,逻辑清晰 | 多次 DB 交互,可能有性能损耗 | 查询条件复杂的场景,或想复用子查询 |
如果是新项目,我建议直接默认采用方案二。它既能保证 SQL 语义正确,又兼顾了可读性,属于标准的“带括号”写法,后续接需求也容易扩展。
这里额外提一个 SQL 层的小知识:MySQL 里也有安全等于运算符<=>,它能对NULL做相等比较,expr <=> NULL等价于expr IS NULL。不过 MyBatis Plus 的 QueryWrapper 没有直接暴露这个运算符,一般用.apply("expr <=> NULL")也能拼出来,但不如isNull()来得干净,知道有这么回事就行。
4. 同类隐患清单:QueryWrapper 里其他与 NULL 纠缠的操作符
4.1 notIn:集合里混进 NULL 整条查询直接废掉
ne这个坑已经很隐蔽了,notIn的坑更狠——一旦条件集合里有NULL,结果可能是一行都查不出来。看这个例子:
List<Long> ids = Arrays.asList(1L, 2L, null); QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.notIn("id", ids);SQL 会生成:
WHERE id NOT IN (1, 2, NULL)那么这个条件对一条id = 3的记录来说意味着什么?它等价于id != 1 AND id != 2 AND id != NULL。前两项是TRUE,但id != NULL是UNKNOWN,整个 AND 的结果就是UNKNOWN。WHERE 只放行TRUE,所以id = 3的记录照样被丢弃。
结论就是:IN列表里只要混进一个NULL,NOT IN的查询结果会变成空集。排查这种情况时,先在代码里把集合中的NULL过滤掉,或者在 SQL 层面用NOT EXISTS搭配子查询改写,才是稳妥做法。
4.2 排序的 NULL 位置:ASC/DESC 默认行为带来的错觉
排序问题没有查询条件那么致命,但同样容易让结果“看起来不对劲”。用 QueryWrapper 做排序:
wrapper.orderByDesc("create_time");MySQL 对NULL的排序默认行为是:ASC时NULL排在最前面,DESC时NULL排在最后面。很多人做“按创建时间倒序”时,潜意识希望最新记录在最上面,但如果某些记录create_time为NULL,它们会被排到列表最底部——这在某些分页展示场景里其实是合理的。反过来做某些需要先把未填写时间的记录优先展示的业务,就得额外处理:
wrapper.last("ORDER BY create_time IS NULL DESC, create_time DESC");create_time IS NULL对NULL行计算结果是TRUE(即1),非NULL行是FALSE(即0),所以NULL行会被排到最前面。这种写法很多人没用过,但它能精准控制NULL的排序位置。
4.3 更新场景:想把字段置为 NULL 却被 MyBatis Plus 默认策略拦住
和查询无关,但和NULL的“特殊待遇”一脉相承的坑,是更新字段为NULL时完全不生效。MyBatis Plus 默认的字段更新策略是不更新NULL字段,这意味着你用updateById传一个level为null的实体对象,生成的 SQL 根本没有level = NULL这个 set 子句,数据库里的值纹丝不动。
如果确实需要把某个字段置为NULL,正确做法是用UpdateWrapper显式指定:
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("id", 1) .set("level", null); userMapper.update(null, updateWrapper);或者在实体字段上加注解@TableField(updateStrategy = FieldStrategy.IGNORED),告诉框架这个字段允许被更新为NULL。这两种方式才是“主动置 NULL”的正确姿势,而不是依赖实体的set方法。
5. 排查方法论:以后遇“查询不对”我都是这么验 SQL 的
5.1 第一步:把日志里的 SQL 单独拉出来跑
遇到查询结果不对,第一件事永远是把 MyBatis Plus 实际生成的 SQL 拿到手里,不要只看 Java 代码。Java 代码里一个ne("level", "VIP")看不出问题,SQL 里的level != 'VIP'才是决定数据生死的真实条件。
在项目的application.yml里加上这段配置,把 SQL 日志打印出来:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl重启后执行查询,控制台会输出 Preparing 和 Parameters 两行。把 Preparing 后面的 SQL 配上 Parameters 里的实际参数,粘贴到 Navicat、DBeaver 或 MySQL 命令行里单独执行一遍,先确认问题是否在数据库层面就存在。
5.2 第二步:给每条条件手动标 NULL 行为
拿到 SQL 之后,别急着改代码。拿张纸或者直接在脑子里,把 SQL 里每一个 WHERE 条件拎出来,问自己三个问题:
- 这个字段在数据库里有没有可能存在
NULL? - 如果存在,这个条件下
NULL会被放行还是被丢弃? - 业务上,
NULL到底应该算“满足条件”还是“不满足条件”?
以WHERE level != 'VIP'为例,按这个思路走一遍,你立刻就能发现NULL行被丢弃、但业务上应该被算作非 VIP,逻辑缺口就显形了。把 WHERE 里的每个条件写下来,在旁边标注TRUE/FALSE/UNKNOWN三种结果,再组合出最终判定。这个过程虽然费点脑子,但也是最靠谱的。
5.3 第三步:用 COUNT 对比核验行数
最后一步是用COUNT做定量验证。当你对问题有个初步假设后,用两条 SQL 对比行数:
SELECT COUNT(*) FROM user WHERE level != 'VIP'; SELECT COUNT(*) FROM user WHERE level IS NULL OR level != 'VIP';两条结果一对比,差值就是被NULL悄悄吞掉的行数。这个数字如果和你的预期缺口对得上,那问题的根因就百分百锁定了。
这套三步排查法我后来一直沿用:拿真实 SQL、手动走三值逻辑、用 COUNT 定量验证。不管问题出在ne、notIn还是orderBy,都能快速定位,不用瞎猜。
写在最后的个人体会
踩过这次ne漏 NULL 的坑之后,我养成了一个习惯:凡是写“不等于、不包含、排除”这类否定型条件,第一反应就是问自己一句,这个字段允许 NULL 吗?允许的话,就在条件里把IS NULL或者OR分支补上。这也是为什么我一直建议团队在写 QueryWrapper 时,尽量用and(w -> ...)把 OR 条件包起来——它逼着你想清楚括号里的整体语义。
如果你也曾被我上面的坑折磨过,我猜你八成是盯着 Java 代码看了很久,没去扒 SQL 层面的行为。下次再遇到查询结果不对,先打印 SQL,再手动跑一遍,你会发现很多“MyBatis Plus 的 Bug”其实就是 SQL 三值逻辑给业务开的一个小玩笑。