news 2026/9/24 6:10:23

答案跟我差 1,是我错了:一次 NULL 引发的 SQL 连环坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
答案跟我差 1,是我错了:一次 NULL 引发的 SQL 连环坑

答案跟我差 1,是我错了:一次 NULL 引发的 SQL 连环坑

一道会员留存率练习题,我的结果比标准答案各多 1 个人。
一开始我怀疑答案错了——毕竟第三个数字完全对得上,只有前两个偏。
查到最后发现是我错了,而且错在一个我"自以为加了保险"的地方。

这篇记录了完整排查过程,以及COUNT(*)COUNT(列)背后那条我真正该记住的规则。


一、先说数据

用的是一个便利店销售数据集(4 家门店、2026 年 6–8 月、1248 笔订单)。两张关键表:

说明行数
fact_order订单事实表order_id(主键)、store_idmember_id(可为 NULL)、order_timeorder_amount1248
dim_member会员维度表member_id(主键)、member_namelevelcity200

最需要留意的特征fact_order.member_id里有 260 笔是 NULL —— 这些是散客订单,不是会员消费。

实验环境:MySQL 8.0.44。下文所有数字均为实际执行结果。


二、题目与两个答案

题目:计算 7→8 月的会员留存率。

输出:七月活跃会员、八月活跃会员、八月仍活跃会员、留存率。

标准答案:

七月活跃会员 | 八月活跃会员 | 八月仍活跃会员 | 留存率 144 | 149 | 122 | 84.72

我跑出来的:

七月活跃会员 | 八月活跃会员 | 八月仍活跃会员 | 留存率 145 | 150 | 122 | 84.14

前两个数各多 1,第三个数一模一样。

而且我确实有一个"看起来合理"的理由:这张订单表里有非会员订单,我担心把散客也算成会员,所以特意加了一个非空判断

结果一验证,错的是我。


三、抓真凶:守卫条件写在了主键上

我最初的 SQL 是这样:

WITHjulyAS(SELECTDISTINCTmember_idFROMfact_orderWHEREorder_idISNOTNULL-- ← 问题在这ANDorder_time>='2026-07-01'ANDorder_time<'2026-08-01'),augAS(SELECTDISTINCTmember_idFROMfact_orderWHEREorder_idISNOTNULL-- ← 和这ANDorder_time>='2026-08-01'ANDorder_time<'2026-09-01'),joinedAS(SELECTa.member_idFROMjuly jJOINaug aONj.member_id=a.member_id)SELECT(SELECTCOUNT(*)FROMjuly)AS七月活跃会员,(SELECTCOUNT(*)FROMaug)AS八月活跃会员,(SELECTCOUNT(*)FROMjoined)AS八月仍活跃会员,ROUND(100.0*(SELECTCOUNT(*)FROMjoined)/(SELECTCOUNT(*)FROMjuly),2)AS留存率;

问题就在order_id IS NOT NULL

order_idfact_order的主键,永远不可能为 NULL。验证一下:

SELECTCOUNT(*)FROMfact_orderWHEREorder_idISNULL;-- 结果:0

0 行。这个"守卫条件"从头到尾是个空操作。我以为自己加了一道保险,其实什么都没做。

我真正想写的是member_id IS NOT NULL—— 这个才能筛掉那 260 笔散客订单:

条件保留行数
order_id IS NOT NULL1248(等于没筛)
member_id IS NOT NULL988(真正筛掉了散客单)

四、那 +1 是怎么来的

三个事实叠在一起:

事实一order_id IS NOT NULL不筛任何行。

事实二SELECT DISTINCT member_id不会剔除 NULL。NULL 在去重结果里会作为**一个独立的"值"**保留下来。

事实三COUNT(*)数的是行数。那行 NULL 也是行,于是被算成了"一个会员"。

三者叠加,就多出 1 个。实测对照:

写法7 月结果
COUNT(DISTINCT member_id)144(跳过 NULL)
COUNT(*)套在去重结果上145(NULL 被算成一行)

那第三个数字(交集 122)为什么没错?

因为NULL = NULL在 JOIN 里恒为假 —— 那行 NULL 配不上任何行,被 JOIN 自然过滤掉了。只有两个分母被污染了。


五、更深的坑:我担心的群体,和坑我的群体不是同一批

这是我这次最大的认知修正。

我加非空判断的初衷是:“有些会员只注册没下过单,不能把他们算进来。”

这个担忧本身是对的。但我搞混了两个完全不同的群体。实测数据:

从未下单的会员散客订单
数量20 个260 笔
住在哪张表只在dim_member只在fact_order
特征在订单表里一行都没有是真实订单,只是没绑会员
验证M0001 订单数实测 =0 笔
dim_member ── 200 个会员 ├─ 180 个下过单 └─ 20 个从未下单 ← 只存在于这张表,fact_order 里一行都没有 fact_order ── 1248 笔订单 ├─ 988 笔会员订单 └─ 260 笔散客订单 ← 只存在于这张表,member_id = NULL

关键在于:我的查询是FROM fact_order。没下过单的会员没有订单行,根本不在结果集里。他们连被筛的机会都没有。

我担心的风险,在这条查询里本来就不存在。而真正坑我的,是那 260 笔散客订单。

结论:风险方向由FROM哪张表决定。

从哪张表出发风险群体表现
fact_order(事实表)散客订单COUNT(*)把 NULL 数成 1 个人
dim_member(维度表)从未下单的会员LEFT JOIN补位行被COUNT(*)数成 1 笔

六、镜像陷阱:反过来写,会"少 20 个人"

想通上面这层之后,我顺手验证了反方向:从会员表出发,找"从未下单的会员"。

-- 看起来没问题SELECTm.member_idFROMdim_member mLEFTJOINfact_order oONo.member_id=m.member_idGROUPBYm.member_idHAVINGCOUNT(*)=0;

返回 0 行。但正确答案应该是 20 行。

原因还是COUNT(*)LEFT JOIN会给没有订单的会员补一行 NULL,所以COUNT(*)数到的是 1,不是 0。这个查询永远数不出 0,而且不报错。

换成COUNT(o.order_id)才能数到那 20 个人:

写法返回行数对错
HAVING COUNT(*) = 00 行✗ 完全找不到
HAVING COUNT(o.order_id) = 020 行

这和我这题的 bug 是同一件事的镜像:一个多算 1,一个少算到 0。根都是COUNT(*)数了补位的 NULL 行。


七、一条统一规则

把三种场景放在一起看,规律就出来了:

COUNT(*)数「行」,COUNT(列)数「非空值」。

只要结果集里存在补位的 NULL 行COUNT(*)就会骗你。

补位 NULL 行的三个来源:

  1. LEFT JOIN给没匹配上的行补 NULL
  2. DISTINCT保留 NULL 作为独立值
  3. 事实表本身的业务性 NULL(如散客订单的member_id

这三种场景看起来毫不相关,但坑人的机制完全相同。


八、修正后的 SQL

WITHjulyAS(SELECTDISTINCTmember_idFROMfact_orderWHEREmember_idISNOTNULL-- ← 改成这个ANDorder_time>='2026-07-01'ANDorder_time<'2026-08-01'),augAS(SELECTDISTINCTmember_idFROMfact_orderWHEREmember_idISNOTNULL-- ← 和这个ANDorder_time>='2026-08-01'ANDorder_time<'2026-09-01'),joinedAS(SELECTa.member_idFROMjuly jJOINaug aONj.member_id=a.member_id)SELECT(SELECTCOUNT(*)FROMjuly)AS七月活跃会员,-- 144(SELECTCOUNT(*)FROMaug)AS八月活跃会员,-- 149(SELECTCOUNT(*)FROMjoined)AS八月仍活跃会员,-- 122ROUND(100.0*(SELECTCOUNT(*)FROMjoined)/(SELECTCOUNT(*)FROMjuly),2)AS留存率;-- 84.72

输出144 / 149 / 122 / 84.72,与标准答案一致。

另一种等价修法是不加过滤,但把计数从COUNT(*)换成COUNT(member_id)

(SELECTCOUNT(member_id)FROMjuly)-- 自动跳过 NULL,同样得 144

两种都能得到正确结果。但我更推荐前一种——在源头挡掉脏数据,比在每个出口单独防更稳。后一种写法下 CTE 里仍残留那行 NULL,后续如果拿它做别的运算(再 JOIN、再聚合),随时会咬人。


九、带走的三个习惯

一、写完守卫条件,先验证它真的在筛。

IS NOT NULL这种条件最容易写成空操作。加不加它跑一次COUNT(*)对比——数字没变化就是白写。

加之前:1248 行 加之后:1248 行 ← 一眼可辨,这个条件是废的

一条SELECT COUNT(*)的检验成本,远低于事后排查。

二、数"实体"就用COUNT(DISTINCT 列)

数人、数单、数门店,一律用COUNT(DISTINCT 列名),不要COUNT(*)套在去重结果上。前者自动跳过 NULL。

三、写FROM的时候,先问"这张表里有没有业务性 NULL"。

fact_order.member_id可为 NULL 这件事,早就写在我的数据字典里,但我是在踩坑之后才真正理解它的含义。

知道"某字段可为空"不等于知道"它会在哪里咬你"。前一步是看文档,后一步得想清楚:从这张表出发,NULL 会出现在什么位置,而我又会用什么函数去数它。


结语

一次"多算 1 个人"的错误,背后牵出三块知识:

  • NULLDISTINCTJOIN、聚合函数里各有什么语义
  • COUNT(*)COUNT(列)的本质区别
  • 事实表与维度表的分工,如何决定 NULL 风险的方向

一行 SQL 的差距,往往不是语法差距。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 6:10:06

从 Prompt Engineering 到 Context Engineering:如何构建稳定的大模型输入输出

AI 基础概念 05&#xff5c;从指令设计、上下文组装到结构化输出与结果校验 上一篇&#xff0c;我们沿着预训练、SFT、偏好优化、LoRA 和量化&#xff0c;看清模型本身可以怎样被改变。但在多数 AI 应用里&#xff0c;团队并不会先训练一个模型&#xff0c;而是先通过 API 使用…

作者头像 李华
网站建设 2026/9/24 6:07:31

AI性能测评方法论:从跑分思维到场景化工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 6:04:38

铁道部信客票系统设计(三)

最近只是一时兴起&#xff0c;觉得无聊&#xff0c;正好要到买票的时候&#xff0c;写了这个一系列文章&#xff0c;首先是对自己这些年来的工作经验的总结&#xff0c;其次是把分布式事务性系统的设计思想进行分析和整理&#xff0c;最后也就是和想集大家的智慧&#xff0c;讨…

作者头像 李华
网站建设 2026/9/24 5:54:50

企业专利对外宣传的法律合规风险与话术规范

摘要专利是制造企业对外宣传中的常见卖点&#xff0c;但专利宣传并非“想怎么说就怎么说”&#xff0c;而是受到《广告法》《反不正当竞争法》等法律法规的严格约束。本文系统梳理《广告法》关于专利宣传的三条硬性规定&#xff0c;分析五类高风险话术的法律风险点&#xff0c;…

作者头像 李华
网站建设 2026/9/24 5:52:40

老主板BIOS魔改实战:微代码替换、VT-d与CR3校验绕过指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华