最危险的 SQL 不报错
一句话总结
SQL 中真正可怕的不是语法错误,而是三个不会报错、却会返回貌似合理的错误结果的陷阱。
为什么需要它
查询语法错误会立即被指出。真正危险的是能够执行的查询。如果你在客户面前读出一个数字,后来却发现它是错的,那么此后这个项目给出的所有数字都会受到怀疑。
FDE 尤其容易暴露在这种风险下,因为面对的是别人的模式。你不知道哪些列允许 NULL、哪些关系是 1:N,也不知道哪个值代表逻辑删除,却必须写出查询。
它如何运作
记住三种不会报错却会算错的典型情况,就能避开大多数问题。
**第一,NULL 与 NOT IN。**假设我们要找出从未下过订单的客户。
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
只要 orders.customer_id 中哪怕有一条 NULL,这个查询就会返回 0 行。既不报错,也不警告。编写报告的人随后会原样写下一个在业务上似乎合理的结论:“没有任何客户从未下过订单。”
原因是三值逻辑。id NOT IN (1, 2, NULL) 的结果不是真,而是未知,未知无法通过 WHERE。解决办法是 NOT EXISTS。它只判断是否返回行,因此不会被 NULL 绊倒。养成使用 NOT EXISTS 的习惯更安全。
**第二,COUNT(*) 与 COUNT(열)。**两者并不相同。COUNT(*) 统计行数,而 COUNT(열) 只统计该列不是 NULL 的行。
这种差异最常出现在 LEFT JOIN 中。没有任何工单的客户,在连接结果中会表现为一行工单侧各列均为 NULL 的记录。此时 COUNT(t.id) 得到 0,而 COUNT(*) 得到 1。使用后者,就会把没有工单的客户统计成拥有一张工单。
同理,COUNT(closed_at) 只统计已关闭的工单。如果全部工单有 80 张,而这个值是 60,就表示仍有 20 张未关闭。理解后使用,它是便利的惯用法;不理解就使用,它会悄无声息地给出错误答案。
**第三,附加在 LEFT JOIN 后的 WHERE。**本来为了保留左表全部数据而使用 LEFT JOIN,却在 WHERE 子句中加入右表列的条件,那么没有匹配项的行会立刻全部被淘汰。因为 NULL 与任何值比较都不为真。结果便等同于 INNER JOIN。针对右表的条件不应放在 WHERE 中,而应该放进 ON 子句。
在实际工作中会遇到的情况
这里再补充一个 sqlite 特有的陷阱。用 .import 导入 CSV 时,**所有值都会以 TEXT 形式进入。**于是 WHERE id = 1001 会返回 0 行,因为保存的是字符串 '1001'。连接也会以同样方式悄无声息地返回 0 行。
如果导入 CSV 后连接结果空空如也,原因几乎总在这里。应当先用 CREATE TABLE 定义类型再导入,或者查询时使用 CAST。
最后还有一个实务习惯。把聚合查询展示给客户之前,要检查分母。总共有多少条,其中多少条符合条件。只报告比例,就无法知道是五条中的一条,还是五万条中的一万条,而这两种情况的含义完全不同。
给出数字之前要做的检查
即使避开了前述三个陷阱,如果这个数字要在客户面前引用,还要再做一步: 你将如何证明这个数字正确?
**换一种方法再数一次。**用连接算出的合计,也用不连接的子查询再算一遍, 确认两个值是否相同。两种方法得出相同答案,出错的可能性会大幅降低; 如果不同,差异本身也会告诉你遗漏了什么。连接 1:N 关系后再汇总,导致一侧数据膨胀的事故会在这里被发现。
**同时写出分母和分子。**这与前面的建议相同,但写进报告时应更进一步。 不要只写“转化率 20%”,而要写“5 条中 1 条(20%)”, 这样读者可以自行判断这个数字有多可信。
写明时间范围和基准时刻。“上个月销售额”每个人可能会有不同理解。 必须写清按哪一列划分(下单时间还是付款时间)、采用哪个时区, 以及是否包含边界,后续人员才能再次得到同一个数字。
**写明排除项。**如果排除了测试账号、已取消订单或内部员工请求, 就同时记录这一事实及其数量。排除本身通常没有问题,但若不注明, 与别人给出的数字不一致时,往往要花几个小时寻找原因。
最后,**保留查询本身。**如果只交付结果,几周后这个数字就无法复现。 把查询和执行时刻一起留下,之后有人问“这个数是怎么得出的”时, 几秒钟就能回答;更重要的是,自己再次查看时也知道当初采用了什么假设。
后续实验要做什么
把客户支持系统的快照导入 sqlite,从客户数、已确认收入、收入最高的客户等普通问题开始,再回答需要 NULL 聚合和 LEFT JOIN 的问题。最后一个问题的答案可能会与你预想的不同。