一条 WHERE status != '已退订' 的查询,漏掉了三万个该发邮件的用户:我被 SQL 里 NULL 的三值逻辑坑到背锅的那次群发事故
这是一次让我被运营同事追着问"为什么这些用户没收到"的尴尬事故。我们要做一次营销邮件群发,规则很简单:给"所有没有退订"的用户发。我写了一条 SQL,从用户表里筛出 status 不等于 '已退订' 的人,信心满满地把名单交了上去。群发跑完,运营一核对,脸色不太对:明明库里有几十万用户、退订的只是一小部分,可我筛出来的名单,却比预期少了整整三万人。这三万个本该收到邮件的用户,被我的 SQL,莫名其妙地漏掉了。
我赶紧排查,把那条 SQL 反复看了无数遍——WHERE status != '已退订',逻辑上明明白白:不是已退订的,不就该被选出来吗?可数据就是对不上。直到我把那三万个"被漏掉"的用户单独拉出来一看,发现他们有一个共同点:他们的 status 字段,值是 NULL(那些从老系统迁移过来、从没设置过 status 的用户)。那一刻我才惊觉,自己栽进了 SQL 里一个极其隐蔽、却又极其经典的坑:NULL,在 SQL 里,不是一个"普通的值",而是代表"未知(unknown)";而任何拿 NULL 去做的比较(包括 !=),其结果都不是 true 或 false,而是第三种值——UNKNOWN;而 WHERE 子句,只会保留那些结果为 true 的行。于是,status != '已退订' 对那些 status 为 NULL 的行,结果是 UNKNOWN,它们就被默默地、无声地过滤掉了。
故障现场:不等于"已退订"的,却没包含 NULL
我把这个反直觉的现象,在数据库里复现给你看:
-- 假设 user 表里有这些数据:
-- id | status
-- 1 | 正常
-- 2 | 已退订
-- 3 | NULL ← 从老系统迁移来的, 从没设置过 status
-- 4 | NULL
-- 我的查询: 选出"不是已退订"的用户
SELECT * FROM user WHERE status != '已退订';
-- 我以为会选出: id 1, 3, 4 (所有不是"已退订"的)
-- 实际只选出: id 1 !!! id 3 和 4 (status 为 NULL 的) 被漏掉了!
-- 为什么? 因为对 NULL 做比较, 结果是 UNKNOWN, 不是 true:
SELECT NULL != '已退订'; -- 结果: NULL (即 UNKNOWN), 不是 true!
SELECT NULL = '已退订'; -- 结果: NULL (即 UNKNOWN), 也不是 false!
SELECT NULL = NULL; -- 结果: NULL ! 连"NULL 等于 NULL"都是 UNKNOWN!
-- WHERE 子句的规则: 只保留"条件结果为 TRUE"的行。
-- status=NULL 时, status != '已退订' 的结果是 UNKNOWN (既非 true 也非 false),
-- → WHERE 不保留它 → 这一行被过滤掉了!
看着 SELECT NULL != '已退订' 的结果竟然是 NULL(而非我以为的 true),我才算明白了这三万人是怎么"消失"的。问题的核心是:在 SQL 里,NULL 代表的不是"空字符串"、也不是"零",而是"未知"——这个值,我们不知道它是什么。而当你拿一个"未知"的东西,去和 '已退订' 比较"是否相等"或"是否不等"时,数据库给出的诚实答案是:"我不知道(UNKNOWN)"——因为我连这个值是什么都不知道,我怎么知道它等不等于、不等于'已退订'呢?于是,NULL != '已退订' 的结果,既不是 true 也不是 false,而是第三种逻辑值 UNKNOWN。而 WHERE 子句有一条铁律:它只保留那些"筛选条件结果为 TRUE"的行;对于结果为 FALSE 或 UNKNOWN 的行,它一律不保留。所以,那三万个 status 为 NULL 的用户,因为 status != '已退订' 对他们的结果是 UNKNOWN(而非 TRUE),就被 WHERE 子句,悄无声息地、一个不剩地,过滤掉了。我那条"逻辑上明明没错"的 SQL,正是栽在了这个"NULL 不参与正常比较"的、隐蔽的三值逻辑上。
第一件事:搞懂 SQL 的"三值逻辑"和 NULL 的特殊性
定位到根源,我必须把 SQL 里这套关于 NULL 的逻辑,彻底搞明白:大多数编程语言里,逻辑只有两个值:true 和 false(二值逻辑)。但 SQL 不一样,它用的是"三值逻辑(three-valued logic)"——除了 TRUE 和 FALSE,还有第三个值 UNKNOWN。而这个 UNKNOWN,正是由 NULL 参与运算时产生的。
-- SQL 的三值逻辑: TRUE, FALSE, UNKNOWN
-- 核心规则: 任何值和 NULL 做比较运算(=, !=, <, >, ...), 结果都是 UNKNOWN
SELECT 5 = NULL; -- UNKNOWN (不是 false!)
SELECT 5 != NULL; -- UNKNOWN
SELECT 5 > NULL; -- UNKNOWN
SELECT NULL = NULL; -- UNKNOWN (这个最反直觉! NULL 不"等于"NULL!)
SELECT 'a' = NULL; -- UNKNOWN
-- 为什么? 因为 NULL 是"未知"。
-- "一个未知的值, 等于 5 吗?" → 我不知道 (UNKNOWN)
-- "一个未知的值, 等于 另一个未知的值 吗?" → 我更不知道 (UNKNOWN)
-- WHERE 子句只保留结果为 TRUE 的行:
-- WHERE 条件 = TRUE → 保留
-- WHERE 条件 = FALSE → 不保留
-- WHERE 条件 = UNKNOWN → 不保留! (这就是 NULL 行被漏掉的原因)
-- 三值逻辑的 AND/OR, 也有特殊规则:
SELECT TRUE AND NULL; -- UNKNOWN
SELECT FALSE AND NULL; -- FALSE (有一个 false, 整体就 false)
SELECT TRUE OR NULL; -- TRUE (有一个 true, 整体就 true)
SELECT NULL OR NULL; -- UNKNOWN
-- 所以判断 NULL, 不能用 =/!=, 必须用专门的 IS NULL / IS NOT NULL:
SELECT * FROM user WHERE status IS NULL; -- ✓ 正确判断是不是 NULL
SELECT * FROM user WHERE status IS NOT NULL; -- ✓ 正确判断不是 NULL
原理终于清晰了。SQL 用的是"三值逻辑":TRUE、FALSE、外加一个由 NULL 引入的 UNKNOWN。它的核心规则是:任何值(包括 NULL 自己)和 NULL 做比较运算(=、!=、<、> 等),结果永远是 UNKNOWN。这其中最反直觉的一条是:连 NULL = NULL,结果都是 UNKNOWN,而不是 true!——因为 NULL 是"未知",而"一个未知的值,等于另一个未知的值吗?"答案当然是"不知道"。而 WHERE 子句"只保留结果为 TRUE 的行"这条规则,和这套三值逻辑一结合,就解释了我的坑:任何对 NULL 的 =/!= 比较,结果都是 UNKNOWN,而 UNKNOWN 不会被 WHERE 保留,所以含 NULL 的行,就在各种比较条件下,被默默漏掉了。正因为 NULL 不能用普通的 =/!= 来判断,SQL 才专门提供了 IS NULL 和 IS NOT NULL 这两个运算符——它们是唯一能正确判断"一个值是不是 NULL"的方式。我那条 SQL 的错误,正是用了 != 这个"对 NULL 无效"的比较,去筛选一个可能含 NULL 的字段。
第二件事:正解——显式地把 NULL 考虑进去
搞懂了根因——"对 NULL 的比较结果是 UNKNOWN,会被 WHERE 漏掉"——正解就明确了:当一个字段可能为 NULL、而你的筛选条件又希望"把 NULL 也算进来"时,必须显式地把 NULL 这种情况,单独处理掉,不能指望 !=/= 帮你照顾到它。
-- 正解1: 用 OR ... IS NULL, 把 NULL 的情况显式补上
SELECT * FROM user
WHERE status != '已退订' OR status IS NULL; -- ← 明确地: 不等于'已退订' 或者 是NULL
-- 现在 id 1, 3, 4 都被选出来了! ✓
-- 正解2: 用 COALESCE / IFNULL 把 NULL "替换"成一个默认值, 再比较
SELECT * FROM user
WHERE COALESCE(status, '未知') != '已退订'; -- 把 NULL 当成 '未知', 它 != '已退订' 为 true
-- COALESCE(status, '未知'): 如果 status 是 NULL, 就返回 '未知', 否则返回 status
-- 正解3: 从数据设计上根治 —— 给字段加 NOT NULL 约束 + 默认值
-- 如果业务上 status 就不该为 NULL, 那一开始就该:
-- status VARCHAR(20) NOT NULL DEFAULT '正常'
-- 这样表里根本不会有 NULL, 也就没有这个坑了。
-- 对比反例(我踩的坑):
SELECT * FROM user WHERE status != '已退订'; -- ✗ 漏掉 status 为 NULL 的行!
-- 顺便: NOT IN 遇到含 NULL 的子查询, 会"全军覆没"返回空, 更坑!
SELECT * FROM user WHERE id NOT IN (SELECT ref_id FROM blacklist);
-- 如果 blacklist.ref_id 里有一个 NULL, 这条查询会返回【空结果】! 巨坑!
-- 正解: 用 NOT EXISTS, 或在子查询里过滤掉 NULL
这几个正解,从不同层面把 NULL 这个"漏网之鱼"给堵住了。正解1(OR ... IS NULL)是最直接的:既然 != 照顾不到 NULL,那我就用 OR status IS NULL,把"status 是 NULL"这种情况,显式地、明确地补进筛选条件里——这样,不等于"已退订"的、以及是 NULL 的,就都被选出来了。正解2(COALESCE/IFNULL)则换了个思路:在比较之前,先用 COALESCE(status, '未知') 把 NULL "替换"成一个具体的默认值(比如 '未知'),这样它就变成了一个普通的值,!= 比较就能正常工作了。正解3(数据设计层面)是最根本的:如果业务上 status 本就不应该为 NULL,那一开始建表时,就该给它加上 NOT NULL DEFAULT '正常' 约束——从源头上杜绝 NULL 的产生,这个坑自然就不存在了。我还顺带发现了一个比我这次更坑的"NULL 地雷":NOT IN 一个含 NULL 的子查询,会让整条查询返回空结果!——如果子查询返回的集合里,哪怕只有一个 NULL,NOT IN 就会"全军覆没",一行都返回不了,而且毫无报错,极其隐蔽。这个坑,通常要用 NOT EXISTS 来替代,或在子查询里先把 NULL 过滤掉。
下面这张图,展示了"忽略 NULL"和"显式处理 NULL"两条路径:
这张图的对比很清楚:左边红色那条,条件里没考虑 NULL,对 NULL 行的比较结果是 UNKNOWN、被 WHERE 默默漏掉,导致数据不全;右边绿色那条,用 OR IS NULL 或 COALESCE 显式地把 NULL 的情况覆盖进来,该选的行一个不漏;而最根本的,是建表时就用 NOT NULL 约束,从源头杜绝 NULL。两条路的分野,在于你有没有"把 NULL 这种特殊情况,显式地放在心上"。
第三件事:NULL 的坑,在 SQL 里遍地都是
填平了 WHERE 这个坑,我深入排查后发现,NULL 那"未知"的特性,会在 SQL 的各个角落,以各种意想不到的方式,影响你的查询结果:
-- NULL 在 SQL 各处的"坑":
-- 坑1: 聚合函数(COUNT/SUM/AVG)会"忽略"NULL
SELECT COUNT(status) FROM user; -- 只数 status 不为 NULL 的行! (漏掉 NULL 行)
SELECT COUNT(*) FROM user; -- 数所有行 (包括 NULL 行) —— 两者结果可能不同!
SELECT AVG(score) FROM exam; -- 算平均时, NULL 的行被跳过(分母也不算它)
-- 坑2: NULL 参与算术运算, 结果是 NULL (会"传染")
SELECT 100 + NULL; -- NULL ! (不是 100)
SELECT price * quantity FROM item; -- 任一为 NULL, 整个结果就 NULL
-- 坑3: 字符串拼接遇 NULL (不同数据库行为不同)
SELECT CONCAT('你好', NULL); -- MySQL: '你好'(忽略NULL); 但 || 在某些库里得 NULL
-- 坑4: GROUP BY 会把所有 NULL 归为"一组"
SELECT status, COUNT(*) FROM user GROUP BY status;
-- 所有 status 为 NULL 的行, 会被分到同一组(NULL 组)
-- 坑5: ORDER BY 时 NULL 的排序位置, 各数据库不一(有的排最前, 有的排最后)
SELECT * FROM user ORDER BY score; -- NULL 排哪? MySQL默认最前, 可用 NULLS LAST 控制
-- 坑6: 唯一索引允许多个 NULL (因为 NULL != NULL, 不算"重复")
-- 一个 UNIQUE 列, 可以有多行都是 NULL! (它们互不"相等")
这一排查,让我对 NULL 这个"未知"在 SQL 里的"杀伤范围",有了全面的认识。它的"未知"特性,像一根看不见的线,牵动着 SQL 的方方面面。坑1(聚合忽略 NULL):COUNT(列名) 会跳过 NULL 行,所以它和 COUNT(*) 的结果可能不同,SUM/AVG 同样会忽略 NULL——这在统计时极易算错。坑2(算术传染):NULL 参与任何算术运算,结果都是 NULL,像"传染"一样,一个 NULL 能让整个计算结果变成 NULL。坑3、4、5(拼接、分组、排序):NULL 在字符串拼接、GROUP BY 分组、ORDER BY 排序时,都有各自特殊、且不同数据库还可能行为不一的处理方式。坑6(唯一索引)更是反直觉:因为 NULL != NULL,所以一个 UNIQUE 唯一索引的列,竟然可以有多行都是 NULL(它们互相不算"重复")。这些坑共同说明:NULL 在 SQL 里,绝不是一个'可以无视的空值',而是一个有着独特'三值逻辑'语义、会以各种隐蔽方式影响查询结果的'特殊公民'。任何一个可能含 NULL 的字段,在你对它做查询、聚合、运算、排序时,你都必须时刻把'它可能是 NULL,而 NULL 的行为很特殊'这件事,放在心上。
第四件事:NULL 到底该不该用?——一场经典的设计争论
被 NULL 坑过之后,我去研究了"数据库里到底该不该用 NULL"这个话题,发现这竟是一场由来已久、连数据库理论大师都参与的经典争论。理解这场争论的两面,让我对"何时用 NULL、何时避免"有了更成熟的判断:
-- "该不该用 NULL" 的两派观点:
-- 反对派(如关系模型大师 C.J.Date): 尽量避免 NULL!
-- 理由: NULL 的三值逻辑, 让查询变复杂、易出错(就像我这次的坑),
-- 它破坏了二值逻辑的简洁性, 是"bug 的温床"。
-- 主张: 用 NOT NULL + 合理默认值, 或用专门的"标记值"代替 NULL。
-- 支持派(务实角度): NULL 有它不可替代的语义价值!
-- 理由: NULL 能真实地表达"未知"或"不适用"这两种重要的语义,
-- 而这两种语义, 用任何"默认值"都无法准确替代。
-- 例子:
-- - 一个用户还没填生日 → birthday 是 NULL (表示"未知", 不是 1970-01-01!)
-- - 一个商品没有"折扣价" → discount 是 NULL (表示"不适用", 不是 0!)
-- 如果硬用默认值代替, 反而会引入"语义错误":
-- 用 0 代替"未知的折扣" → 算平均折扣时, 这个 0 会拉低真实平均值! (错!)
-- 用 NULL → AVG 会正确地忽略它
-- 务实的中间立场:
-- 1. 如果一个字段"逻辑上必须有值"(如状态、数量) → 用 NOT NULL + 默认值
-- 2. 如果一个字段"确实可能'未知'或'不适用'" → 该用 NULL, 但查询时小心处理
-- 3. 别用"魔法默认值"(如 -1、空串、1970)硬替代 NULL —— 那会埋下更隐蔽的语义坑
这场争论,让我对 NULL 的认识,从"一个坑人的东西"上升到了"一个有取舍的设计选择"。反对派(以关系模型理论家为代表)主张尽量避免 NULL——因为它的三值逻辑确实让查询变复杂、易出错(我这次就是受害者),是"bug 的温床"。而支持派则从务实角度指出:NULL 有它不可替代的语义价值——它能真实地表达"未知"和"不适用"这两种重要的现实语义,而这两种语义,是任何"默认值"都无法准确替代的。比如,一个用户还没填生日,他的 birthday 就该是 NULL(表示"未知"),你绝不能用 1970-01-01 去代替——那会让他凭空"被出生"在 1970 年;一个商品没有折扣价,discount 就该是 NULL(表示"不适用"),你也不能用 0 代替——那在算"平均折扣"时,这个假的 0 会错误地拉低真实的平均值。所以,务实的中间立场是:逻辑上'必须有值'的字段(状态、数量),用 NOT NULL + 默认值;确实'可能未知或不适用'的字段,就该用 NULL,但查询时务必小心处理;而最该避免的,是用 -1、空串、1970 这类"魔法默认值"去硬替代 NULL——那不仅没解决问题,反而埋下了一个更隐蔽的'语义错误'的坑。把"该不该用 NULL"的判断整理成一张表:
| 字段情况 | 该用 | 原因 |
|---|---|---|
| 逻辑上必有值(状态/数量) | NOT NULL + 默认值 | 避免 NULL 坑, 语义也对 |
| 可能"未知"(生日/手机) | NULL | NULL 真实表达"未知" |
| 可能"不适用"(折扣价) | NULL | NULL 真实表达"不适用" |
| 想用 -1/空串/1970 代替 NULL | 别这么干 | 埋下语义错误的隐坑 |
| 外键/关联可能缺失 | NULL(或不建该行) | 看业务语义 |
第五件事:把"和 NULL 打交道"的注意点固化成清单
这次踩坑,让我把"写 SQL 时,该如何小心地对待 NULL"沉淀成了一份清单,以后写涉及可能含 NULL 字段的查询,照着自查:
-- 和 NULL 打交道的检查清单:
-- 1. 判断是不是 NULL, 永远用 IS NULL / IS NOT NULL, 绝不用 = NULL / != NULL
WHERE col IS NULL -- ✓
-- WHERE col = NULL -- ✗ 永远是 UNKNOWN, 选不出任何行!
-- 2. 用 !=/<> 筛选时, 想想"NULL 行要不要算进来", 要就显式 OR col IS NULL
WHERE status != '已退订' OR status IS NULL
-- 3. NOT IN 子查询, 警惕子查询含 NULL → 改用 NOT EXISTS
WHERE NOT EXISTS (SELECT 1 FROM blacklist b WHERE b.ref_id = user.id)
-- 4. 聚合统计时, 分清 COUNT(*) (含NULL行) 和 COUNT(列) (不含NULL)
-- 5. 算术/拼接涉及可能为 NULL 的列, 用 COALESCE 兜底
SELECT price * COALESCE(quantity, 0) FROM item;
-- 6. 排序时若在意 NULL 位置, 显式指定 NULLS FIRST / NULLS LAST (或用表达式)
-- 7. 建表时, 主动决定每个字段"该不该允许 NULL", 别让它默认可空
-- 能 NOT NULL 的就 NOT NULL, 给个合理默认值
-- 核心: 对每一个"可能为 NULL"的字段, 在查询/统计/运算它时,
-- 都主动问一句: "如果它是 NULL, 这里的行为是我想要的吗?"
这份清单的灵魂,是一个需要养成的习惯:对每一个"可能为 NULL"的字段,在你对它做查询、筛选、统计、运算的时候,都主动地、有意识地多问自己一句——"如果这个字段是 NULL,那么我这里写的逻辑,它的行为,会是我想要的吗?"我这次栽跟头,根子上就是缺了这一问:我写 status != '已退订' 时,脑子里完全没有"status 可能是 NULL,而 NULL 不会被 != 选中"这根弦。而清单里的每一条,都是这个"多问一句"在具体场景下的落实:判断 NULL 用 IS NULL(而非 = NULL),筛选时显式覆盖 NULL,NOT IN 警惕含 NULL 的子查询,聚合分清 COUNT(*) 和 COUNT(列),运算用 COALESCE 兜底,排序指定 NULL 位置,建表主动决定可空性。把这些落到一个根本动作上,就是:把 NULL 当成一个'需要被特别对待的特殊公民',而非一个'可以无视的普通空值'。把这份清单的要点和它防范的坑汇总成一张表:
| 操作 | 注意点 | 不注意的后果 |
|---|---|---|
| 判断 NULL | 用 IS NULL | = NULL 选不出任何行 |
| != 筛选 | 显式 OR col IS NULL | 漏掉 NULL 行 |
| NOT IN 子查询 | 用 NOT EXISTS | 含 NULL 时返回空 |
| 聚合统计 | 分清 COUNT(*) 与 COUNT(列) | 统计数字错 |
| 算术/拼接 | COALESCE 兜底 | 结果被传染成 NULL |
| 建表 | 主动定 NOT NULL/默认值 | 埋下 NULL 隐患 |
一张"查询涉及可能为 NULL 的字段该怎么办"的决策图
把这次踩坑沉淀成一张图。每当你写的 SQL 涉及一个可能为 NULL 的字段时,照着它走:
这张图的核心判断:先问"这个字段可能为 NULL 吗",可能,就根据你对它做的操作,采取对应的显式处理——判断用 IS NULL,筛选要含 NULL 就 OR ... IS NULL,聚合算术用 COALESCE,NOT IN 换 NOT EXISTS。把"涉及可空字段,先想想 NULL"变成写 SQL 的本能,那场"漏掉三万人"的事故就再也不会重演。
我立下的几条 NULL 处理规矩
这次"WHERE != 漏掉三万 NULL 用户"的事故后,我给自己立了几条规矩:
- 判断 NULL 用 IS NULL:永远用
IS NULL/IS NOT NULL,绝不用= NULL/!= NULL(永远是 UNKNOWN)。 - != 筛选想着 NULL:用
!=筛可能含 NULL 的字段,想清楚 NULL 行要不要算进来,要就显式OR col IS NULL。 - NOT IN 警惕含 NULL:
NOT IN子查询可能含 NULL 时改用NOT EXISTS,避免"全军覆没"返回空。 - 聚合分清 COUNT:统计时分清
COUNT(*)(含 NULL 行)和COUNT(列)(不含),别算错。 - 算术用 COALESCE 兜底:涉及可能为 NULL 的列做算术/拼接,用
COALESCE给个默认值,防"传染"。 - 建表主动定可空性:建表时主动决定每个字段该不该允许 NULL,能
NOT NULL就加约束 + 合理默认值。 - 别用魔法值替 NULL:别用
-1/空串/1970硬替代 NULL,那会埋下更隐蔽的语义坑。
这几条里,第一条和第二条是直接根治这次事故的核心。而贯穿所有规矩的那条主线,是对"未知"这一特殊状态的认真对待。我这次栽这么大跟头,根子上是我把 NULL 当成了一个"普通的值"——以为它就像空字符串、就像 0,可以用普通的 =/!= 去比较。可 NULL 在 SQL 里,代表的是一种特殊的状态——"未知";而"未知"这个状态,有它自己一套独特的、不同于普通值的逻辑(三值逻辑)。我用'对待普通值的方式',去对待一个'代表未知的特殊状态',这种'把特殊当普通'的疏忽,正是那三万人被漏掉的根源。认真地对待'未知'这种特殊状态、理解并顺应它特殊的逻辑,是写出正确 SQL 的一个关键。
写在最后:认真对待"未知",是一种重要的严谨
这次被 SQL 的 NULL 坑到的经历,给我一个超越数据库本身的、颇有意味的启示:"未知",是这个世界里一种真实存在、且需要被认真对待的状态;而我们处理问题时,常常会下意识地、图省事地,把"未知"和"已知"混为一谈,或者干脆假装"未知"不存在——可恰恰是这种对"未知"的轻慢,会让我们在那些"未知"真实存在的地方,栽下跟头。SQL 用一个专门的 NULL 和一套专门的三值逻辑,郑重其事地为"未知"这个状态,在它的逻辑体系里,留了一个独立的位置——这本身,就是一种值得我们学习的、对"未知"的尊重与严谨。而我之所以踩坑,正是因为我不够严谨,我把"未知(NULL)"和"一个确定的值"混为一谈,用处理后者的方式,去处理了前者。
想通这一点,我对"区分'已知'与'未知'"这种思维上的严谨,有了更深的体会。在很多事情上,'我知道它是 X'、'我知道它不是 X'、和'我不知道它是不是 X'——这是三种截然不同的状态,绝不能混为一谈。SQL 的三值逻辑(TRUE/FALSE/UNKNOWN),恰恰是把这三种状态,清清楚楚地区分了开来。而现实中,很多判断的失误、很多逻辑的漏洞,都源于我们把第三种状态('不知道')错误地、想当然地,归并到了前两种里去——要么当成了'是',要么当成了'不是',却忘了它其实是'不知道'。一个思维严谨的人,会清醒地为"未知"保留一个独立的位置:他不会因为"不知道一个东西是不是坏的",就想当然地认为"它是好的";也不会因为"无法证明它存在",就断言"它不存在"。这种'认真区分已知与未知、为未知保留独立位置'的严谨,是清晰、可靠的思考的一块基石。
所以,如果你也想让自己的代码、乃至自己的思考,更严谨、更少漏洞,我想把这次踩坑最想说的话送给你:请认真地对待"未知"这种状态,别图省事地把它和"已知"混为一谈。写 SQL 时,认真对待可能为 NULL 的字段;写代码时,认真对待一个值可能为 null/undefined/空的情况;做判断时,认真区分"我知道是""我知道不是"和"我不知道"这三种不同的状态。因为"未知",是真实存在的;无视它、或把它草率地当成"已知",并不会让它消失,只会让它在某个你没防备的地方,变成一个漏洞、一个坑、一次像'漏掉三万人'那样的事故。而认真地为'未知'保留一席之地、用恰当的方式去处理它,正是一种朴素却深刻的、通往严谨与可靠的思维习惯。那三万个因 NULL 而被漏掉的用户,最终教给我的,正是这份对"未知"的尊重与严谨——它让我懂得,无论是写代码还是思考问题,真正的严谨,不只在于把"已知"的部分处理对,更在于,认真地、不含糊地,对待好每一处"未知"。
—— 别看了 · 2026