对账少了一批单,SQL没报错:一行NULL吃掉整个结果

简介: 对账少了一批订单,SQL 不报错,结果却是空的。一行 NULL 让 NOT IN 变成 UNKNOWN,讲清三值逻辑与三种改法:NOT EXISTS、IS NOT NULL、表约束。

大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。

月初对账,结算表比支付表少了一批订单。我第一反应是同步漏了,先把同步任务翻了一遍。日志正常,延迟也正常,数据一条没丢。再跑对账 SQL,结果直接是空的。SQL 没报错,条件也没写错,问题出在子查询里的一行 NULL。

一、 结果少了,先别怀疑数据

对账少了数,很多人的第一反应是同步丢数据。这个方向要查,但别只查这个。同步链路有问题,通常是少几行、错几列,或者延迟越来越大。整批结果直接为空,更像是查询条件把行全过滤了。

那天就是这样。支付表里有单,退款表里有记录,左右都能对上。对账 SQL 一跑,输出零行。我换了 LEFT JOIN 去核,数据又都在。同一批数据,换个写法结果不同。问题基本就落在 SQL 语义上,不在存储里。

确认也简单。把子查询单独拎出来,数一下空值有几行。再把 NOT IN 换成 NOT EXISTS 跑一遍。两次结果一对比,是不是 NULL 的问题,几分钟就能定。别急着翻同步日志。

二、SQL 是三值逻辑

SQL 的逻辑不是两值,是三值。TRUE、FALSE,再加一个 UNKNOWN。NULL 参与比较,结果就是 UNKNOWN,不是 FALSE。WHERE 只保留 TRUE,FALSE 和 UNKNOWN 都被丢掉。这一条规则,是所有 NULL 陷阱的根。

所以 NULL = NULL 不成立,结果还是 UNKNOWN。NULL != 1 也不成立,结果也是 UNKNOWN。判断空值只能用 IS NULL 和 IS NOT NULL。写成 = NULL,永远查不到东西,也不报错。这类写法我在代码评审里见过不止一次。

NULL 还会传染。NULL + 1 是 NULL,CONCAT('a', NULL) 也是 NULL。整条表达式只要碰到 NULL,结果就废了。写报表时,先用 IFNULL 或 COALESCE 兜底。否则一个空字段,能让整列指标变成空。

三、NOT IN 为什么会全军覆没

NOT IN 的坑,来自它展开的方式。x NOT IN (1, 2) 等价于 x <> 1 AND x <> 2。列表里加一个 NULL,就变成 x <> 1 AND x <> 2 AND x <> NULL。最后这一项永远是 UNKNOWN。AND 里只要有一项 UNKNOWN,整个条件就不可能是 TRUE。

结果就是整条查询返回空。不是少几行,是一行都不剩。哪怕左表的 order_no 每一行都合法,也救不回来。子查询里只要有一行 NULL,整个 NOT IN 就废了。这一点和 IN 不一样,IN 碰到 NULL 一般不会改变非空值的匹配结果。

如果左边这一列本身也可能是 NULL,情况一样。NULL NOT IN (1, 2) 也是 UNKNOWN。所以判断空值,别用 NOT IN 去兜。要么先判 IS NOT NULL,要么换一种写法。把 NULL 从比较里拿出去,逻辑才回到两值。

-- 一行 NULL,足以让整条 NOT IN 返回空
SELECT 1 NOT IN (2, NULL);   -- 结果是 NULL

SELECT o.order_no
FROM t_order o
WHERE o.pay_date = '2026-10-25'
  AND o.status = 'PAID'
  AND o.order_no NOT IN (
      SELECT r.order_no FROM t_refund r
  );

那天退款表里正好有一条历史数据,order_no 是空的。导入时字段允许 NULL,没人注意。就这一行,让整个对账脚本静默返回空。没有报错,没有告警,只有一张对不上的报表。

四、聚合和唯一索引也在悄悄改数

NULL 还会在聚合里悄悄改数。COUNT(*) 数行,COUNT(col) 只数非空。两者差多少,就是这一列有多少 NULL。SUM 和 AVG 也会跳过 NULL,AVG 的分母变小,均值就偏高。对账时用 AVG 对金额,差一点都可能是 NULL 造成的。

唯一索引也拦不住 NULL。InnoDB 里判断唯一,用的是等值比较。NULL 和 NULL 比较是 UNKNOWN,不算重复。所以一个唯一索引列,可以插进多个 NULL。联合唯一索引也一样,只要有一列是 NULL,这一行就能重复。想让业务字段真正唯一,先把列改成 NOT NULL。

COUNT(DISTINCT col) 同样跳过 NULL。做去重统计时,这个数会比实际少。ON DUPLICATE KEY UPDATE 也受它影响。唯一键里带 NULL,冲突判断不成立,语句会走插入。业务想要幂等,唯一键就别留可空列。

SELECT COUNT(*) AS rows_all,
       COUNT(coupon_id) AS rows_not_null
FROM t_order;

-- 唯一索引列可以插入多个 NULL
CREATE UNIQUE INDEX uk_code ON t_user(code);
INSERT INTO t_user(code) VALUES (NULL), (NULL);

五、 索引、排序与分组

NULL 在索引里是有位置的。IS NULL 能走索引,执行计划里常见 ref_or_null。但 NULL 的选择性通常很差,一查就是一大片。它也不参与等值匹配,col = 1 不会带出 NULL。做统计时,NULL 的比例要单独看,别混进平均值里。

排序和分组也有自己的规则。ORDER BY 升序时,NULL 排在最前面。GROUP BY 会把所有 NULL 归成一组,DISTINCT 也把多个 NULL 当成一个。这些行为不报错,但和很多人的直觉不一样。报表里数字对不上,先想想有没有 NULL 被合并了。

ORDER BY 降序时,NULL 排在最后。分页查询如果按可空列排序,前后两页可能对不上。GROUP BY 的 NULL 组也容易被漏看。做报表时,先把 NULL 转成业务上的默认值。比如空城市统一写成未知,分组才不会漂。

六、三种改法

改法有三条,按场景选。子查询里的列如果能保证非空,NOT IN 本身没问题,但要靠约束保证。拿不准的话,在子查询里加 WHERE col IS NOT NULL,先把 NULL 过滤掉。更稳的写法是 NOT EXISTS,它只看有没有匹配行,不受三值逻辑影响。

-- 写法一:先把子查询里的 NULL 过滤掉
SELECT o.order_no
FROM t_order o
WHERE o.pay_date = '2026-10-25'
  AND o.status = 'PAID'
  AND o.order_no NOT IN (
      SELECT r.order_no FROM t_refund r
      WHERE r.order_no IS NOT NULL
  );

-- 写法二:NOT EXISTS,不依赖三值逻辑
SELECT o.order_no
FROM t_order o
WHERE o.pay_date = '2026-10-25'
  AND o.status = 'PAID'
  AND NOT EXISTS (
      SELECT 1 FROM t_refund r
      WHERE r.order_no = o.order_no
  );

NOT EXISTS 还有一个好处,执行计划更容易走反连接。NOT IN 遇到可空列,优化器不敢直接转反连接。它得先处理 NULL 语义,往往退化成物化子查询。数据量一大,差的就是几倍时间。写法上的一个小改动,能同时修正确性和性能。

根本的办法还是约束。业务上不允许空的列,建表时就写 NOT NULL,再给默认值。已经上线的表,先补数据,再改约束,顺序不能反。直接加 NOT NULL,老数据里的 NULL 会让变更失败。这一步要排窗口,别在业务高峰做。

改约束前,先跑一遍空值统计。SELECT COUNT(*) FROM t WHERE col IS NULL。有数据就先补,补完再改。大表别直接 ALTER,会锁表或吃满 IO。用在线改表工具,或者分批补、最后切约束。顺序反了,变更会卡在门槛上。

默认值也别乱给。NOT NULL DEFAULT '' 会把空字符串塞进业务里。后面统计时,空串和真实值混在一起。用 0 还是 '',要和业务确认。默认值填错,等于把 NULL 换了个名字。问题没消失,只是更难查。

七、避坑清单

结果少了,先看条件里的 NULL,别先怀疑数据丢了。NOT IN 的子查询里,只要有一行 NULL,整条查询就会返回空。先过滤 NULL,或者直接换 NOT EXISTS。IN 相对安全,但别把两者混着记。

聚合的分母要看清。COUNT(*) 和 COUNT(col) 不是一回事,AVG 会跳过 NULL,均值会被抬高。唯一索引也不保证 NULL 唯一,多个 NULL 能同时存在。建约束之前,先把这些数核对一遍。

最后一条是我自己踩的。对账脚本里的 NOT IN 跑了半年没事,因为退款表当初没有 NULL。后来加了线下退款导入,order_no 允许空,脚本一夜之间全空。我第一反应是查数据,查了两小时才回头看 SQL。现在子查询只要可能为空,我一律加 IS NOT NULL 或改 NOT EXISTS。约束也会先补上。

写在最后

NULL 最麻烦的地方,是它不报错。条件变成 UNKNOWN,行就悄悄没了。你以为数据丢了,其实是被 SQL 过滤掉了。查错方向,越查越慌。

结果少了,先看条件里的 NULL。再看聚合的分母,最后看唯一约束。这三处都干净,再去怀疑同步和存储。顺序对了,能省很多时间。

你们被 NULL 坑过吗?是 NOT IN 查空,还是 COUNT 少算?当时是怎么定位的?评论区聊聊。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关文章
|
4天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
|
云安全 安全 网络安全
|
4天前
|
iOS开发 Docker MacOS
【2026实测】Docker Desktop下载、安装、汉化、配置教程(附官网安装包)
Docker Desktop 是 Docker 官方推出的桌面容器管理工具,集成引擎、CLI、图形界面与 Kubernetes,支持 Win/macOS/Linux。2026 最新版 4.88.1 强化 ARM 原生运行、镜像加速及资源可视化,附中文汉化与国内镜像配置指南。
808 1
|
17天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2933 49
|
11天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
2097 2
|
5天前
|
安全 算法 定位技术
《高性价比人生指南》完整原版:普通人用最少的资源,过好这一生的底层逻辑~How To Live Better
📖《高性价比人生指南》是一份基于实证、拒绝焦虑的生存手册,倡导以“人生时薪”为视角,重新计算时间、金钱与精力的成本与收益。它不承诺逆袭,只教人算清账:省下的钱若耗掉时间便亏;买体验比买物品更值;睡眠是最高杠杆的健康投资;关系贵在情绪价值而非表面热络;学习重在改变行为而非自我安慰。核心理念:用低成本、高回报的微小行动,换取长期的选择权——不是成为谁,而是掌控自己人生的主动权。
|
9天前
|
人工智能 JSON Linux
【保姆级】ComfyUI部署教程(下载+安装+配置+工作流,2026最新)
ComfyUI 是一款开源节点式AI生成工具,支持图像、视频、音频及3D内容创作。通过拖拽连线组合模型加载、提示词、采样等节点,构建可复用、易修改的工作流,兼容SD1.5、SDXL、Flux、Qwen Image等主流模型。
|
4天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)