答案跟我差 1,是我错了:一次 NULL 引发的 SQL 连环坑
一道会员留存率练习题,我的结果比标准答案各多 1 个人。
一开始我怀疑答案错了——毕竟第三个数字完全对得上,只有前两个偏。
查到最后发现是我错了,而且错在一个我"自以为加了保险"的地方。这篇记录了完整排查过程,以及
COUNT(*)与COUNT(列)背后那条我真正该记住的规则。
一、先说数据
用的是一个便利店销售数据集(4 家门店、2026 年 6–8 月、1248 笔订单)。两张关键表:
| 表 | 说明 | 行数 |
|---|---|---|
fact_order订单事实表 | order_id(主键)、store_id、member_id(可为 NULL)、order_time、order_amount | 1248 |
dim_member会员维度表 | member_id(主键)、member_name、level、city | 200 |
最需要留意的特征: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_id是fact_order的主键,永远不可能为 NULL。验证一下:
SELECTCOUNT(*)FROMfact_orderWHEREorder_idISNULL;-- 结果:00 行。这个"守卫条件"从头到尾是个空操作。我以为自己加了一道保险,其实什么都没做。
我真正想写的是member_id IS NOT NULL—— 这个才能筛掉那 260 笔散客订单:
| 条件 | 保留行数 |
|---|---|
order_id IS NOT NULL | 1248(等于没筛) |
member_id IS NOT NULL | 988(真正筛掉了散客单) |
四、那 +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(*) = 0 | 0 行 | ✗ 完全找不到 |
HAVING COUNT(o.order_id) = 0 | 20 行 | ✓ |
这和我这题的 bug 是同一件事的镜像:一个多算 1,一个少算到 0。根都是COUNT(*)数了补位的 NULL 行。
七、一条统一规则
把三种场景放在一起看,规律就出来了:
COUNT(*)数「行」,COUNT(列)数「非空值」。只要结果集里存在补位的 NULL 行,
COUNT(*)就会骗你。
补位 NULL 行的三个来源:
LEFT JOIN给没匹配上的行补 NULLDISTINCT保留 NULL 作为独立值- 事实表本身的业务性 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 个人"的错误,背后牵出三块知识:
NULL在DISTINCT、JOIN、聚合函数里各有什么语义COUNT(*)与COUNT(列)的本质区别- 事实表与维度表的分工,如何决定 NULL 风险的方向
一行 SQL 的差距,往往不是语法差距。