数据库里最容易被小瞧的单词——IN、NOT IN 和 NULL 这三个放在一起,不是简单背个语法就能写明白的。我在生产环境里排查过太多类似的慢查询和返回空结果的问题,十次里有八次都出在这几个关键词的组合逻辑上。很多人写 SQL 的时候根本没意识到,NULL 不是“没有值”那么简单,它在数据库里代表“未知”,而“未知”一旦参与比较,整个过滤结果都会跟着变味。这篇文章就围绕 IN、NOT IN 和 NULL 三者的组合展开,讲清楚它们的语义、适用场景、常见误区和替代写法,最后给出一些我在实际项目里验证过的最稳方案。无论你是刚学 SQL 的新手,还是天天写业务查询的老手,只要你的表里允许空值,这篇文章都值得完整看一遍。
1. 内容整体设计与思路拆解
1.1 这不是“简单语法”问题,而是一套逻辑体系
很多初学者刚接触 IN 和 NOT IN 的时候,觉得它们无非就是 = 或 <> 的批量简化版。比方说:
sql复制SELECT * FROM user WHERE id IN (1, 2, 3);
直觉上等价于:
sql复制SELECT * FROM user WHERE id = 1 OR id = 2 OR id = 3;
而看到 NOT IN 的时候呢,又直觉地把它当作:
sql复制SELECT * FROM user WHERE id NOT IN (1, 2, 3);
-- 等价于
SELECT * FROM user WHERE id <> 1 AND id <> 2 AND id <> 3;
依赖这个等价关系写代码,在绝大多数数据正常的情况下确实没问题。所以很多人一直这么写,直到有一天线上报表数据不对了、下拉框选项神秘消失了,一查发现结果全是空的,才意识到问题没有那么简单。
问题就出在 NULL 上。SQL 里的比较逻辑不是二值的(true/false),而是三值的,多了个 UNKNOWN。NULL 参与任何比较运算,结果都是 UNKNOWN,既不是真也不是假。而 WHERE 子句只保留判断结果为 true 的行,UNKNOWN 的结果一律被过滤掉。
所以,id NOT IN (1, 2, NULL) 从逻辑展开来看是这样的:
sql复制id <> 1 AND id <> 2 AND id <> NULL
中间任何一个条件变成 UNKNOWN,整个 AND 表达式的结果就不是 true,而是 UNKNOWN 或 false。也就是说,只要 NOT IN 的列表里出现一个 NULL,整条记录都会被排除,不管 id 是多少。这个行为符合 SQL 标准,但绝不符合业务直觉。
1.2 从业务场景反向推导出写作重心
我写这篇文章的思路是:先不急着甩解决方案,而是梳理出实际业务里最容易触发这类问题的几个典型场景,然后逐个拆解。高频率踩坑的场景大概有这四类:
- 用子查询配合
IN和NOT IN做集合判断,比如查“没有下过单的用户”; - 在
IN列表里混入了可空字段,比如配置表里某个条件的值允许为空; - 用
NOT IN排除少量指定值时,没考虑到排除项里有NULL; - 用
NOT IN代替<>做不等值过滤,结果把NULL一起排除了。
后面所有分享都会围绕这四类场景展开,确保你看完能直接对照自己的代码定位问题。
1.3 为什么我优先推荐用 EXISTS 而不是 IN
从实际排查和性能优化角度来说,我个人的偏好顺序是:能用 EXISTS / NOT EXISTS 就优先用它,其次再考虑 IN / NOT IN。
原因不只是 NULL 这个坑。EXISTS 是存在性判断,它不关心子查询返回多少行、返回什么列,只关心“有没有”,所以在很多数据库优化器里能走更高效的执行计划。同时 NOT EXISTS 在处理 NULL 上面的语义更自然:它对子查询里返回 NULL 的记录不会产生“排他误伤”。这一点我会在后面的实操部分写得很细。
但这不意味着 IN 就不能用了。如果确定列表里没有 NULL,IN 语句写起来简洁清晰,性能在多数场景下也没问题。关键是——你需要有一双能提前识别 NULL 的眼睛。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 三值逻辑到底是什么
很多人听到“三值逻辑”觉得是理论课里的东西,和写 SQL 没关系。其实关系非常大。SQL 标准里,任何比较运算的结果都可能是三种之一:TRUE、FALSE、UNKNOWN。
1 = 1结果是TRUE1 = 2结果是FALSE1 = NULL结果是UNKNOWNNULL = NULL结果也是UNKNOWN
这个设计是符合真实世界认知的。数据库里的 NULL 表示“未知”,那“未知等于未知”吗?答案是“不知道”。就好比我问你“一个没填年龄的人和一个同样没填年龄的人,他们年龄相等吗?”,正常人不会回答“相等”,只能回答“不知道”。
好,有了这个底层认知,再去看 WHERE 子句的行为:
sql复制SELECT * FROM user WHERE age = NULL;
这条语句永远查不到任何记录,因为 age = NULL 的结果是 UNKNOWN。在 MySQL、Oracle、SQL Server、PostgreSQL 里全部如此。你必须写 WHERE age IS NULL。
数据库里判断 NULL 的唯一正确姿势就是 IS NULL 和 IS NOT NULL。
2.2 IN 遇到 NULL 的行为剖析
关于 IN 和 NULL,有个常见的认知分歧:有人说“in 里面带 NULL 不会报错,只是查不到”,其实这个说法不全对。
IN 的本质是连续的 OR 判断。看这个例子:
sql复制SELECT * FROM user WHERE id IN (1, 2, NULL);
等价于:
sql复制SELECT * FROM user WHERE id = 1 OR id = 2 OR id = NULL;
OR 的特点是:只要有一个条件为 TRUE,结果就是 TRUE。所以这个查询能正常返回 id 为 1 和 2 的记录,因为 id = 1、id = 2 已经保证了 TRUE。如果 id 是 3,三个条件全是 FALSE 或 UNKNOWN,结果就是 UNKNOWN,记录会被丢弃。也就是说,这个写法虽然能查到想要的数据,但在语义上是有瑕疵的——因为 NULL 那项其实根本没起作用。
看起来问题不大?如果子查询里混入了 NULL,影响确实有限。但如果是下面这种写法:
sql复制SELECT * FROM user WHERE id NOT IN (1, 2, NULL);
结果就是天壤之别。等价展开为:
sql复制SELECT * FROM user WHERE id <> 1 AND id <> 2 AND id <> NULL;
AND 的特点是:只要有一个条件不是 TRUE,整个结果就不是 TRUE。id 不管是 1、2、3 还是 100,id <> NULL 这一项永远为 UNKNOWN,所以整条表达式的结果永远不是 TRUE。最终结果永远是空集。
NULL 在 NOT IN 里比在 IN 里危险得多。
2.3 子查询返回 NULL 的隐藏雷区
最危险的情况不是手写一个带 NULL 的列表,而是你用子查询动态生成集合的时候,子查询结果里悄悄带了 NULL。
举个例子,业务上要查“没有下过有效订单的用户”:
sql复制SELECT * FROM user
WHERE id NOT IN (
SELECT user_id FROM order WHERE order_status = 1
);
看起来没毛病对吧?但如果 order 表里存在 user_id 为 NULL 的记录,哪怕只有一条,这个查询的结果就永远是空。逻辑上和上面一样:集合里包含 NULL,NOT IN 直接失效。
这类问题之所以难排查,是因为“数据是动态的”。你今天查可能没问题,明天业务表里多了一条异常数据,报表就跑空了。等到业务方来投诉的时候,你排查半天才发现问题出在一条 user_id = NULL 的脏数据上。
所以我的原则是:凡是用到 NOT IN 去接子查询结果的场景,一律保持警惕。该加条件就把 NULL 数据过滤掉:
sql复制SELECT * FROM user
WHERE id NOT IN (
SELECT user_id FROM order
WHERE order_status = 1 AND user_id IS NOT NULL
);
但是,这种写法要记住在子查询里加 IS NOT NULL 过滤。一旦忘了,代码评审时就要被点名了。
2.4 COUNT、GROUP BY、ORDER BY 里那些“关联陷阱”
NULL 的坑其实不止在 IN 和 NOT IN 里。一些看起来毫不相关的操作,一旦遇到 NULL,行为也很反直觉,而且这些坑经常和 IN/NOT IN 组合出现,形成组合拳。
COUNT(*)会计数所有行,包括全 NULL 的行;COUNT(col)只会统计该列不为 NULL 的行。GROUP BY会将所有 NULL 分到同一组。所以如果你按一个可空字段分组,结果里会多出一组“NULL 组”。ORDER BY默认排序时,MySQL 里 NULL 排最前,Oracle 里 NULL 排最后。同一个 SQL 在两个数据库里跑出完全不同的排序结果,这很容易引发线上问题。
建议在你写聚合查询、分组报表的时候,先问自己一句:“这张表里这个字段会有 NULL 吗?如果有,我的 SQL 会怎么表现?”提前把 NULL 拦截在业务查询之外,是最省心的法门。比如设计表的时候就给字段设置 NOT NULL DEFAULT 默认值,从源头减少 NULL 的侵入。
3. 实操过程与核心环节实现
3.1 准备测试数据,亲手复现这三种情况
为了让你能直接复现“空结果”的现象,我准备了一张简单的表和一些示例数据。你可以直接在任意主流数据库上跑,SQL 基本都是标准的。
sql复制CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50)
);
CREATE TABLE user_order (
id INT PRIMARY KEY,
user_id INT,
amount DECIMAL(10, 2)
);
INSERT INTO user (id, name) VALUES
(1, '张三'),
(2, '李四'),
(3, '王五'),
(4, '赵六');
INSERT INTO user_order (id, user_id, amount) VALUES
(101, 1, 100.00),
(102, 2, 200.00),
(103, NULL, 999.00);
关键就是第三条订单——user_id 为 NULL。这在真实环境里很常见,比如“游客订单”或“匿名用户的订单”。现在我们分别跑几种查询。
场景一:NOT IN 子查询,碰到 NULL
sql复制SELECT * FROM user
WHERE id NOT IN (
SELECT user_id FROM user_order
);
直觉上,两个用户下了单(1 和 2),所以没下单的用户应该是 3 和 4。但实际结果呢?空集。因为 user_order.user_id 列表中出现了 NULL,整个 NOT IN 被拦截了。
场景二:加条件过滤掉 NULL 之后
sql复制SELECT * FROM user
WHERE id NOT IN (
SELECT user_id FROM user_order
WHERE user_id IS NOT NULL
);
这次结果正常了,返回 3 和 4。
场景三:用 NOT EXISTS 重写
sql复制SELECT * FROM user u
WHERE NOT EXISTS (
SELECT 1 FROM user_order o
WHERE o.user_id = u.id
);
这种写法的好处是:o.user_id = u.id 的匹配逻辑只针对具体行的值做比较,不涉及“集合内是否包含 NULL”的全局判断。对每个用户来说,user_order 里有没有对应的行,一目了然。把输出打印出来看,结果是 3 和 4,没有坑。
3.2 对比 IN 和 EXISTS 在不同数据量的执行计划
“既然 NOT EXISTS 这么稳,那 IN 是不是就不用学了?”当然不是。小数据量下,IN 和 EXISTS 在绝大多数数据库优化器里都能产生相近的执行计划。但在数据量上来以后,情况会变得复杂。
以 MySQL 为例,优化器会尝试把 IN 子查询改写成半连接(semi-join)或物化(materialization),实际上很多写法是能互相转换的。而 NOT IN 子查询在某些版本里会被改写成 NOT EXISTS,但改写的条件和成本取决于优化器的判断。不同版本、不同统计信息、不同索引情况,性能表现都会有差异。
我个人的经验是:
- 小表驱动大表、子查询结果集很小时,
IN写起来最直观。 - 子查询结果集可能很大、或者里面可能有 NULL 时,优先
EXISTS/NOT EXISTS。 - 永远不要想当然认为“IN 一定比 EXISTS 快”或“EXISTS 一定比 IN 快”。在支持
EXPLAIN的数据库里,把两条 SQL 都跑一遍,看执行计划再下结论。
3.3 判空工具函数的使用边界
很多人为了解决 NULL 导致结果不对的问题,喜欢用函数“兜底”,比如 COALESCE、IFNULL、ISNULL。这些函数确实有用,但要注意使用边界。
sql复制SELECT * FROM user
WHERE id NOT IN (
SELECT COALESCE(user_id, -1) FROM user_order
);
这样写,理论上可避免 NULL 坑,因为 COALESCE 把 NULL 转成了 -1,而 -1 肯定不是合法 user id。但这种写法的隐忧是:如果子查询结果集特别大,在 user_id 上套函数会导致索引失效,查询变慢。
另外,用“哨兵值”方案时要格外小心:你选择的哨兵值(比如 -1、0、'EMPTY')必须确保永远不会和真实数据冲突。我在实际项目中就见过有人用 0 当哨兵值,结果业务表里真有 id=0 的数据,逻辑直接崩了。
我的建议是:
- 如果子查询返回的集合小,
COALESCE兜底可以接受。 - 如果集合大、命中频繁,优先考虑
NOT EXISTS,让优化器直接走索引。 - 如果业务强依赖 NOT IN 的写法,那就在表设计阶段就给外键字段加上
NOT NULL约束,从根上避免这类问题。
3.4 在复杂业务查询中的综合应用示例
来看一个更贴合实际业务的例子。假设有一个“商品推荐活动”,要找出所有“从未购买过任何商品,但加入过购物车”的用户。两张表:purchase 和 cart。
sql复制SELECT DISTINCT c.user_id
FROM cart c
WHERE c.user_id NOT IN (
SELECT p.user_id FROM purchase p
WHERE p.user_id IS NOT NULL
);
如果 purchase.user_id 本身是 NOT NULL 约束,那 IS NOT NULL 可省略。但如果这个字段是全表里唯一可标识用户的字段,就一定要防一手。
另一种更稳的写法:
sql复制SELECT DISTINCT c.user_id
FROM cart c
WHERE NOT EXISTS (
SELECT 1 FROM purchase p
WHERE p.user_id = c.user_id
);
这个写法在逻辑上更贴合自然语言:“购物车里有记录,且购买记录里没有匹配的用户”。而且因为 EXISTS 判断的是“有没有”,优化器处理起来更灵活,往往能更快返回结果。
4. 常见问题与排查技巧实录
4.1 典型问题:接口返回空列表,数据库却明明有数据
我接过的排查工单里,超过一半的 SQL 相关“灵异事件”最后都指向这个问题。
现象:后端接口查“未分配的用户”列表,返回空数组;但 DBA 在数据库客户端里直接执行同一条 SQL,发现能查到数据。
排查路径分三步:
- 检查接口传参是否真的进入了 SQL(比如参数被当成 NULL 传了进来)。
- 在数据库中手工替换参数,模拟线上实际取值,看 SQL 结果。
- 检查
EXPLAIN和执行日志,确认走了哪条执行计划。
很多时候走完第一步就能发现问题:ORM 框架里某个字段没赋值,默认传了 NULL,然后你的 SQL 是 WHERE status != ?,并且 status 列里本身也有 NULL。这时 NULL != NULL 结果为 UNKNOWN,记录直接丢了。
4.2 排查技巧:用 EXPLAIN、执行日志和最小化复现法
排查这类问题,最重要的不是背文档,而是掌握一套“最小化复现”的方法论。步骤如下:
- 先把复杂 SQL 拆成最小单元,比如单独执行子查询,看它返回了哪些值。
- 把子查询结果列表手写出来,加上或去掉 NULL,对比外层查询结果的变化。
- 再用
EXPLAIN看执行计划,确认没有因为隐式转换、函数包装导致的索引失效。
我举个具体的排查片段。原来的 SQL 长这样:
sql复制SELECT * FROM product
WHERE category_id NOT IN (
SELECT category_id FROM category_blacklist
);
排查时先单独跑:
sql复制SELECT category_id FROM category_blacklist;
结果里出现 NULL。再跑:
sql复制SELECT * FROM product WHERE category_id = NULL;
结果为空。这就破案了。
这个案例告诉我们:排查 NULL 引起的问题,不需要高深工具,会拆 SQL、会跑中间结果,就能定位。另外一个实用习惯是:写任何子查询时,先在脑中过一遍“这个子查询会不会有 NULL,有的话外层会怎样”。
4.3 面试和代码评审中常见考察点
这个问题也是面试官特别爱问的题目,问法有很多变体:
- “请说明 SQL 中 IN 和 EXISTS 的区别?”
- “NOT IN 子查询返回 NULL 会怎样?”
- “如何避免 NOT IN 陷阱?”
- “COUNT(*) 和 COUNT(列名) 的区别?”
其实这些问题的核心答案就一条:NULL 参与比较时结果是 UNKNOWN,NOT IN 遇到 NULL 会全军覆没,EXISTS / NOT EXISTS 不受影响。
在代码评审中,我一般会重点关注三类写法:
- 子查询直接作为
IN/NOT IN的输入,且源字段没有 NOT NULL 约束。 - 用
NOT IN排除少量固定值,但值列表是动态拼接的,没法保证里有没有 NULL。 - 用
OR连接多个等值条件,比如WHERE city = '北京' OR city = NULL。
这三种都是高危模式,建议在团队规范里直接禁止或要求强制改写成 EXISTS / NOT EXISTS 或加 IS NOT NULL 过滤。
4.4 从源头控制:建表阶段规避 NULL 的方案
与其在查询阶段反复救火,不如从表结构设计阶段就做好约束。下面是我在建表时的几个实践心得:
- 业务上必须存在的字段,一律加
NOT NULL,并给一个语义明确的默认值。比如用户状态status TINYINT NOT NULL DEFAULT 0。 - 数值字段如果可能为空,但又不想让 NULL 干扰统计,就用默认值 0 代替。前提是业务上能接受 0 作为“无”的语义。
- 日期字段也一样,能设默认值就设默认值,比如
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP。 - 如果字段本身确实允许“未知”语义,那就保留 NULL,但所有查询 SQL 里对引用该字段的地方都要做 NULL 防御。
表设计阶段的“NULL 最小化”策略,能显著减少后续 IN、NOT IN、GROUP BY、ORDER BY 里的各种异常行为。不要等到上线半年后,冷不丁冒出一条脏数据把报表搞挂,那就太被动了。
5. 更安全的替代写法与统一规范建议
5.1 常见的可直接套用的安全写法
为了让大家在写代码时直接套用,我整理了一份“对照选型表”。假设要从表 A 中筛选出那些“在表 B 中不存在对应记录”的行,有以下几种靠谱写法:
| 写法 | SQL 示例 | 是否受 NULL 影响 | 适用场景 |
|---|---|---|---|
| NOT EXISTS | SELECT * FROM A WHERE NOT EXISTS (SELECT 1 FROM B WHERE B.a_id = A.id) |
否 | 通用,推荐优先使用 |
| LEFT JOIN + IS NULL | SELECT A.* FROM A LEFT JOIN B ON B.a_id = A.id WHERE B.id IS NULL |
否 | 适合需要展示关联列、或大数据量下驱动优化时 |
| NOT IN + 子查询过滤 NULL | SELECT * FROM A WHERE id NOT IN (SELECT a_id FROM B WHERE a_id IS NOT NULL) |
是(已做防御) | 数据量小,且想保留 IN 语义时 |
| NOT IN + 手写值列表(无 NULL) | SELECT * FROM A WHERE id NOT IN (1, 2, 3) |
否(前提是列表无 NULL) | 排除少量固定值 |
这个表我建议保存在团队的 SQL 规范文档里。每次代码评审时拿出来对照,速度会快很多。尤其是 LEFT JOIN + IS NULL 这种写法,很多人没意识到它天然规避了 NULL 问题,还能顺便取到关联表的其他信息,值得好好掌握。
5.2 OR 逻辑和 IN 逻辑的转换注意事项
有时候我们需要写这样的查询:“查所有姓张或姓李的用户”,有人会写成:
sql复制SELECT * FROM user WHERE name = '张三' OR name = '李四';
如果换成 IN 写法:
sql复制SELECT * FROM user WHERE name IN ('张三', '李四');
这两种写法在语义上等价,但 IN 一定比 OR 更安全、更清晰。原因很简单:如果条件里有可空列,用 OR 连接很容易写出这种bug:
sql复制SELECT * FROM user WHERE name = '张三' OR name != '李四' OR name = NULL;
最后的 name = NULL 会产生 UNKNOWN,影响整个结果。写代码时尽量用 IN 代替连续的 OR,是降低逻辑出错概率的好习惯。但要记住,IN 列表里不能有 NULL,否则就会回到“NOT IN 被 NULL 拦截”的另一种变形中。
5.3 使用 NOT EXISTS 重写 NOT IN 的通用模板
如果你手头有大量 NOT IN 代码,想改造但不想把所有业务逻辑都反推一遍,我提供一个通用模板:
sql复制-- 原写法(有风险)
SELECT A.*
FROM A
WHERE A.id NOT IN (SELECT B.a_id FROM B);
-- 改造后(推荐)
SELECT A.*
FROM A
WHERE NOT EXISTS (
SELECT 1
FROM B
WHERE B.a_id = A.id
);
-- 展示关联数据时(推荐)
SELECT A.*
FROM A
LEFT JOIN B ON B.a_id = A.id
WHERE B.id IS NULL;
改造完后,建议对同一份数据跑一遍对比测试,确认结果集完全一致(在排除脏数据的前提下)。我一般会在测试环境跑一个数据一致性校验脚本,随机抽几条主键明细做对比,确认没有逻辑偏差。
5.4 对 ORM 和框架代码的影响
现在很多项目都用 MyBatis、Hibernate 这类 ORM 框架。它们生成的 SQL 虽然能自动规避一部分坑,但如果你在 XML 或注解里手写了动态 SQL,那 NULL 问题照样会原样复现。
举个 MyBatis 里的常见错误:
xml复制<select id="findUsers" resultType="User">
SELECT * FROM user
WHERE id NOT IN
<foreach collection="excludeIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
如果传进来的 excludeIds 集合里包含一个 null 元素,SQL 就变成了 NOT IN (1, 2, null),结果直接空集。而且这种错误在测试阶段往往不会被发现,因为测试数据里没有 null 项。
规范的做法是在代码里先过滤掉 null 元素,或者在 SQL 里加一个 id IS NOT NULL 的过滤条件:
xml复制SELECT * FROM user
WHERE id NOT IN (
SELECT t.id FROM (
<foreach collection="excludeIds" item="id" separator=" UNION ALL ">
SELECT #{id} AS id
</foreach>
) t
WHERE t.id IS NOT NULL
)
这个写法稍显繁琐,但对于动态列表来说,是稳妥的。更简单的方式是:如果是固定业务场景,直接用 NOT EXISTS 并传入数组,然后在 SQL 中用 JOIN 或 LEFT JOIN 实现,能够彻底绕开动态拼接带来的 NULL 风险。
6. 复盘:我从大量故障中学到的 SQL 安全观
写到这里,很多人可能会问:“既然 NOT IN 有这么多坑,那数据库干脆不支持它不就好了?”这话不对。NOT IN 本身不是不能用,而是要清楚它的使用前提:输入集合里绝对不能有 NULL。只要这个前提被满足,NOT IN 就是简洁高效的工具。
我更想说的是,SQL 里很多“反直觉”行为都来自对数据状态的盲目假设。你写下 NOT IN 的一瞬间,其实是在对数据集做一次隐式断言:“这个集合是干净的,没有未知值。”但生产环境的数据从来不会自动保持干净。它可能来自外部导入、历史迁移、人工补录、多系统同步……任何一环没处理好,就会混入 NULL。
所以我在实际工作中总结了一条经验:写 SQL 之前,先做数据画像。这个字段允许 NULL 吗?有多少比例是 NULL?在 WHERE 里用这个字段做过滤时,NULL 会导致什么结果?这些问题想清楚了,写出来的查询才真正“稳定”。
另外,我强烈建议每个团队都建立自己的“SQL 反模式清单”。把这类问题整理成条目,比如:
- 禁止在未加
IS NOT NULL过滤的情况下,用NOT IN承接子查询结果。 - 禁止用
= NULL或<> NULL判断字段。 - 禁止在
IN列表中动态拼接可能为 NULL 的变量。 - 建议用
NOT EXISTS代替NOT IN,除非你能证明集合无 NULL。 - 建议所有可空字段的查询场景都单独验证一次空值表现。
有了清单之后,新人上手效率会高很多,代码评审也会更有据可依。毕竟,数据库不会像编译器一样给出详细报错,它只会悄悄返回一个让你崩溃的空集。
最后分享一个我在项目中一直用的“SQL 自检习惯”:每次写完一段查询,我都会手动构造一组包含 NULL 的极端数据跑一遍,看看结果是否和预期一致。这个习惯帮我拦截过至少 5 次线上事故。你也可以试试,成本很低,收益很高。
