首页 / 资讯中心 / 文章详情

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

答案跟我差 1,是我错了:一次 NULL 引发的 SQL 连环坑 ★ FEATURED ARTICLE
答案跟我差 1是我错了一次 NULL 引发的 SQL 连环坑一道会员留存率练习题我的结果比标准答案各多 1 个人。一开始我怀疑答案错了——毕竟第三个数字完全对得上只有前两个偏。查到最后发现是我错了而且错在一个我自以为加了保险的地方。这篇记录了完整排查过程以及COUNT(*)与COUNT(列)背后那条我真正该记住的规则。一、先说数据用的是一个便利店销售数据集4 家门店、2026 年 6–8 月、1248 笔订单。两张关键表表说明行数fact_order订单事实表order_id(主键)、store_id、member_id(可为 NULL)、order_time、order_amount1248dim_member会员维度表member_id(主键)、member_name、level、city200最需要留意的特征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_time2026-07-01ANDorder_time2026-08-01),augAS(SELECTDISTINCTmember_idFROMfact_orderWHEREorder_idISNOTNULL-- ← 和这ANDorder_time2026-08-01ANDorder_time2026-09-01),joinedAS(SELECTa.member_idFROMjuly jJOINaug aONj.member_ida.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 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跳过 NULLCOUNT(*)套在去重结果上145NULL 被算成一行那第三个数字交集 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_idm.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 行的三个来源LEFT JOIN给没匹配上的行补 NULLDISTINCT保留 NULL 作为独立值事实表本身的业务性 NULL如散客订单的member_id这三种场景看起来毫不相关但坑人的机制完全相同。八、修正后的 SQLWITHjulyAS(SELECTDISTINCTmember_idFROMfact_orderWHEREmember_idISNOTNULL-- ← 改成这个ANDorder_time2026-07-01ANDorder_time2026-08-01),augAS(SELECTDISTINCTmember_idFROMfact_orderWHEREmember_idISNOTNULL-- ← 和这个ANDorder_time2026-08-01ANDorder_time2026-09-01),joinedAS(SELECTa.member_idFROMjuly jJOINaug aONj.member_ida.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 的差距往往不是语法差距。
阅读完成 · 觉得有帮助?
咨询建站