刷题平台数据库题库里有一道编号 1435 的题,翻译过来叫“制作会话柱状图”。我第一眼看到这题觉得是送分题:把 session 的时长按分钟分个组,count 一下不就完事了?结果第一次提交就被几个测试用例教育了——当某个时间桶里压根没有会话时,我的 SQL 不是输出 0,而是直接少了那一行。
这道题真正的考点,并不在 CASE WHEN 本身,而在于一个很多人踩过的经典陷阱:固定区间统计时,如何把“缺失的类别”补回来。这篇文章我会把题目先拆干净,再给出三种可提交的写法,然后重点说说排序、边界值和 COUNT 字段这些细节,最后聊聊这个套路在真实报表里的迁移。适合刚开始准备 SQL 面试的同学,也会对日常写统计查询的开发者有帮助。
1. 题面拆解:输入两个字段,输出四行固定桶
1.1 输入表只有 session_id 和 duration,但单位是秒
题目给的Sessions表结构非常简单:
CREATE TABLE Sessions ( session_id INT, duration INT );duration的单位是秒。这一点看着不起眼,却是很多人踩坑的第一步:如果心里默认按分钟算,后面边界全乱。
题目要求按 5 分钟一个桶,把会话时长划分成四段:
| 桶名 | 对应时长区间 | 换算成秒 |
|---|---|---|
| [0-5> | 0 分钟 ≤ x < 5 分钟 | 0 ≤ x < 300 |
| [5-10> | 5 分钟 ≤ x < 10 分钟 | 300 ≤ x < 600 |
| [10-15> | 10 分钟 ≤ x < 15 分钟 | 600 ≤ x < 900 |
| 15 or more | x ≥ 15 分钟 | x ≥ 900 |
这里要注意桶名的写法:[0-5>是左闭右开,表示包含 0 分钟、不包含 5 分钟;15 or more没有上限,表示 15 分钟及以上都算。
1.2 输出要求里藏着一个“题眼”
我拿一组示例数据来演示一下:
| session_id | duration(秒) |
|---|---|
| 1 | 120 |
| 2 | 300 |
| 3 | 599 |
| 4 | 600 |
| 5 | 1200 |
换算一下:120 秒 = 2 分钟,落在[0-5>;300 秒 = 5 分钟整,落在[5-10>(因为[0-5>不包含 5 分钟);599 秒在 9 分 59 秒,落在[5-10>;600 秒 = 10 分钟整,落在[10-15>;1200 秒 = 20 分钟,落在15 or more。
正确的输出长这样:
| bin | total |
|---|---|
| [0-5> | 1 |
| [5-10> | 2 |
| [10-15> | 1 |
| 15 or more | 1 |
这个例子每个桶都有数据,看起来平平无奇。真正让人抓狂的是另一种输入,比如表里所有 session 都在 3 分钟以内:
| session_id | duration |
|---|---|
| 1 | 30 |
| 2 | 120 |
| 3 | 200 |
按照题目要求,输出必须是:
| bin | total |
|---|---|
| [0-5> | 3 |
| [5-10> | 0 |
| [10-15> | 0 |
| 15 or more | 0 |
注意,[5-10>、[10-15>、15 or more这三行必须出现,total 用 0 填。“没有会话的桶也要返回 0”,这句话就是全题最核心的约束,也是很多解法挂掉的原因。
2. 最直觉的写法:GROUP BY + CASE,为什么会把空桶弄丢
2.1 第一版代码通常长这样
看到分桶统计,大多数人的第一反应是先给每条记录算一个桶名,再按桶名分组:
SELECT CASE WHEN duration / 60 < 5 THEN '[0-5>' WHEN duration / 60 < 10 THEN '[5-10>' WHEN duration / 60 < 15 THEN '[10-15>' ELSE '15 or more' END AS bin, COUNT(*) AS total FROM Sessions GROUP BY bin;在“每个桶恰好都有数据”的时候,这版结果是对的。但一旦某个桶没数据,问题就出现了。
2.2 漏桶的本质:GROUP BY 只对被查询到的值分组
GROUP BY 的工作方式,是先把满足 WHERE 条件的行筛出来,再拿着这些行里的值去分组。它并不会像字典一样先声明“我要这四个桶”,再往里填充。
打个比方:老师点名只念“今天到场的同学”,缺勤的同学根本不会进入点名册,所以名单里直接没有这个人,而不是在名字旁写“0”。同理,如果表里没有一条记录落在[5-10>,这条桶的记录就不会出现在 GROUP BY 的结果里。
这一点在 LeetCode 判题时尤其阴险:它给的第一个示例往往每个桶都有数据,你一跑,结果正确,信心满满地提交,然后被第二个“空桶用例”一击致命。我自己就在这摔过一跤,所以现在看到“固定区间 + 分组统计”,第一反应永远是:某个区间可能没有数据,空桶需要主动补出来。
3. 三种能正确补 0 的解法
3.1 解法一:UNION ALL,四个区间各查一次,暴力但稳
这个思路最简单:我不指望 GROUP BY 帮我生成桶,而是自己把四个桶写死,每个桶单独 COUNT。因为没有桶标签是空字符串,即使某个桶计数为 0,SELECT 本身也会返回一行。
SELECT '[0-5>' AS bin, COUNT(*) AS total FROM Sessions WHERE duration < 300 UNION ALL SELECT '[5-10>', COUNT(*) FROM Sessions WHERE duration >= 300 AND duration < 600 UNION ALL SELECT '[10-15>', COUNT(*) FROM Sessions WHERE duration >= 600 AND duration < 900 UNION ALL SELECT '15 or more', COUNT(*) FROM Sessions WHERE duration >= 900;这段代码有四个独立的子查询,分别统计四个区间。每个子查询都固定输出一行,所以空桶也一定会出现。比如表里只有 0 到 5 分钟的数据时,后面三段各自查到 0 行,COUNT(*)返回 0,这样四行结果就齐了。
这里有几个小细节值得说:
- 用
UNION ALL而不是UNION。虽然四个子查询的桶名各不相同,永远不会重复,但UNION会多做一次去重和排序,完全没必要。数据量大了以后,UNION ALL是稳妥的性能选择。 - 四个区间之间用“左闭右开”的写法:
duration >= 300 AND duration < 600。边界值不会重复计入两个桶,也不会漏掉任何一条。 - 每个子查询里没有把
bin作为条件写进 WHERE,因为桶名是固定的,只要区间条件正确,对应的就是正确的桶。
这个解法的缺点也很明显:如果桶的数量是几十个,代码会变得异常啰嗦。但对于这道题只有 4 个桶的场景,它是最不容易出错的答案,也完全不需要考虑排序问题——如果判题只看集合内容的话。
3.2 解法二:CTE 虚拟桶 + LEFT JOIN,把“缺失类别”明着补
如果不想写四段重复代码,可以用一个虚拟的桶表,然后 LEFT JOIN 原表。这是处理“补缺失类别”最通用的思路:
WITH bins AS ( SELECT '[0-5>' AS bin UNION ALL SELECT '[5-10>' UNION ALL SELECT '[10-15>' UNION ALL SELECT '15 or more' ) SELECT b.bin, COUNT(s.session_id) AS total FROM bins b LEFT JOIN Sessions s ON b.bin = CASE WHEN s.duration < 300 THEN '[0-5>' WHEN s.duration < 600 THEN '[5-10>' WHEN s.duration < 900 THEN '[10-15>' ELSE '15 or more' END GROUP BY b.bin;理解这段代码,核心在 LEFT JOIN 的行为:
bins表固定有 4 行。LEFT JOIN 保证左边表(bins)的每一行都能保留下来。右边Sessions表没有匹配的行时,右边所有字段都是 NULL。于是空桶就变成了“右边全是 NULL 的一行”,而不是“消失的一行”。
这里有一个非常关键的坑:外层 SELECT 里不能用COUNT(*),必须用COUNT(s.session_id)。
原因在于,LEFT JOIN 之后,空桶的左边确实有一行 bins 记录。如果统计COUNT(*),它会把这一行也数进去,结果空桶的 total 会变成 1,而不是 0。而COUNT(s.session_id)只统计右边session_id不为 NULL 的行,空桶右边是 NULL,自然会计成 0。
这个坑我在第一次写 LEFT JOIN 补 0 报表时也踩过,当时查了半天数据,怎么都想不明白为什么明明没有用户,统计出来却是 1。后来才反应过来:COUNT(*)统计的是“输出行的数量”,而我们要的是“右边主表匹配行的数量”。
3.3 解法三:把桶边界放进表里,JOIN 时直接比区间
再看一个稍微进阶的写法:既然桶的边界是确定的,为什么不把上下界也存起来,让 JOIN 条件直接比较大小?这样连 CASE WHEN 都省了,排序也更自然:
WITH bucket_ranges AS ( SELECT '[0-5>' AS bin, 0 AS min_sec, 300 AS max_sec UNION ALL SELECT '[5-10>', 300, 600 UNION ALL SELECT '[10-15>', 600, 900 UNION ALL SELECT '15 or more', 900, 2147483647 ) SELECT r.bin, COUNT(s.session_id) AS total FROM bucket_ranges r LEFT JOIN Sessions s ON s.duration >= r.min_sec AND s.duration < r.max_sec GROUP BY r.bin, r.min_sec ORDER BY r.min_sec;这个写法把“桶”抽象成了一张带有上下界的配置表。JOIN 条件直接落到秒数上了,没有 CASE WHEN 的字符串比较,也不容易记错边界。最后一桶的上界用了2147483647,就是 MySQL 的 INT 最大值,相当于“无穷大”。如果你不喜欢这么大个数字,用999999999也完全够用。
GROUP BY 里需要把r.bin和r.min_sec都带上,因为min_sec参与了排序和分组逻辑;只按bin分组在 MySQL 的 ONLY_FULL_GROUP_BY 模式下会报错。ORDER BYr.min_sec则天然保证了桶的顺序是 0-5、5-10、10-15、15+,不用再手工排序。
这三种写法并不冲突:
| 解法 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| UNION ALL 四段 | 逻辑最直白,不需要理解 COUNT 坑 | 桶多时代码冗长 | 桶数量少且固定 |
| CTE + CASE LEFT JOIN | 桶列表集中,代码整洁 | 需要理解 LEFT JOIN 补 0 和 COUNT(NULL) | 通用补类别报表 |
| 桶边界驱动表 | 边界清晰,排序自然,无 CASE | 稍绕,需要理解哨兵上界 | 动态区间或需要维护多个桶表 |
4. 排序、边界值和 COUNT 字段:真正让你扣分的三个细节
4.1 直接 ORDER BY bin,结果顺序会出乎意料
如果你在做真实报表,而不是只在刷题平台提交答案,输出顺序往往比内容更让人头疼。bin字段是字符串,字符串排序按字符的字典序来。问题在于:
15 or more第一个字符是1[0-5>第一个字符是[
在常见字符集里,数字1排在字符[前面。所以如果直接ORDER BY bin,第一个跑出来的桶多半是15 or more,紧接着才是[0-5>、[10-15>、[5-10>,完全看不出“从小到大”的自然顺序。
解决办法一般有三种:
-- 办法一:CASE 手工映射序号 ORDER BY CASE bin WHEN '[0-5>' THEN 1 WHEN '[5-10>' THEN 2 WHEN '[10-15>' THEN 3 ELSE 4 END; -- 办法二:MySQL 的 FIELD 函数 ORDER BY FIELD(bin, '[0-5>', '[5-10>', '[10-15>', '15 or more');不过最舒服的还是解法三里的思路:直接按桶的下界字段排序。数据里有了min_sec,排序问题直接消失,这也是我强烈建议在真实业务里用“桶边界表”的原因。
4.2 边界条件:300、600、900 这三个秒数,向左还是向右?
这道题对边界的要求非常严格:
duration = 300,也就是 5 分钟整,到底算哪个桶?duration = 600,也就是 10 分钟整,到底算哪个桶?duration = 900,也就是 15 分钟整,到底算哪个桶?
题目的桶名[0-5>、[5-10>、[10-15>是左闭右开区间,所以答案很清楚:300 秒落在[5-10>,600 秒落在[10-15>,900 秒落在15 or more。
反过来说,如果你写的条件是duration <= 300归到[0-5>,那 300 秒就被多算了一次,同时[5-10>少算了一行数据,两边都错。牢记一条规则:区间统计用左闭右开,边界值写在下一个区间的起始条件里。
另外,秒到分钟的换算最好直接用秒比较。比如duration / 60 < 5在语法上没问题,但如果某个数据库的除法结果存在浮点误差,或者你忘了加分母,逻辑就会模糊。直接用duration < 300,一眼能看出 5 分钟等于 300 秒,不容易翻车。
4.3 COUNT(*) 还是 COUNT(字段)?在 LEFT JOIN 场景下真的不一样
很多同学写 SQL 用得顺手了,觉得COUNT(1)、COUNT(*)、COUNT(session_id)差不多,在普通 GROUP BY 查询里确实差不多。但一旦出现 LEFT JOIN,这三者的含义就开始分化了:
COUNT(*):统计输出结果集的行数,不管字段是否 NULL。COUNT(1):等价于COUNT(*),也是行数。COUNT(session_id):只统计session_id不为 NULL 的行数。
在“左表驱动补空桶”的查询里,空桶左侧有行、右侧字段全是 NULL,COUNT(*)会把左侧那行也算进去,结果变成 1。因此,只要想统计“右侧主表匹配到了多少行”,就必须用COUNT(s.session_id)或者COUNT(s.duration)这类字段计数。
如果觉得这个规则太绕,还有另一个写法能绕开:用标量子查询。
SELECT b.bin, (SELECT COUNT(*) FROM Sessions s WHERE b.bin = CASE WHEN s.duration < 300 THEN '[0-5>' WHEN s.duration < 600 THEN '[5-10>' WHEN s.duration < 900 THEN '[10-15>' ELSE '15 or more' END) AS total FROM bins b;子查询里没有 LEFT JOIN,COUNT(*)统计的纯粹是 Sessions 表的匹配行数,空桶自然返回 0。这个写法对初学者更友好,代价是每一行都要执行一次子查询,桶多时会慢一些。
5. 从刷题到业务:分桶补 0 的通用套路
5.1 订单金额分层,空桶就是“没人买的高价区间”
真实业务里的经典场景是订单金额分层。比如运营想看客单价的分布:
| 金额区间 | 订单数 |
|---|---|
| 0-100 元 | 1540 |
| 100-500 元 | 823 |
| 500-1000 元 | 106 |
| 1000 元以上 | 8 |
如果直接用 GROUP BY,恰好某个月没有 500-1000 元的订单,这一行就会消失。运营同学看报表时很可能根本注意不到“高价位区间无人购买”,而补 0 之后,0 这个数字本身就会立刻引起注意:高价区间没人买,是需要调整选品,还是流量没匹配对?缺失的本义是“无记录”,而 0 的本义是“存在但为零”,两者传递的信息完全不同。
用秒桶那题练出的模板,拿过来直接改字段名就行:
WITH amount_buckets AS ( SELECT '[0-100)' AS bucket, 0 AS low, 100 AS high UNION ALL SELECT '[100-500)', 100, 500 UNION ALL SELECT '[500-1000)', 500, 1000 UNION ALL SELECT '1000+', 1000, 999999 ) SELECT b.bucket, COUNT(o.order_id) AS order_cnt FROM amount_buckets b LEFT JOIN orders o ON o.amount >= b.low AND o.amount < b.high GROUP BY b.bucket, b.low ORDER BY b.low;这套 SQL 几乎就是 1435 题的翻版。会做这题的人,迁移到订单金额分层、用户活跃天数分层、页面停留时长分桶时,都不会有障碍。
5.2 接口延迟分桶,比平均数更诚实
做后端性能分析时,也经常需要看接口响应时间的分布。比如把耗时分成 <100ms、100-300ms、300-1000ms、>1000ms 四档,然后统计每个档位的调用次数。
这种场景比平均数更诚实:平均耗时 200ms 的背后,可能是 90% 请求都在 60ms,但有一个请求把平均值拉到了 2000ms。只有分桶直方图能让你一眼看出“尾部延迟有多长”。同理,如果 >1000ms 的桶没有数据,用 GROUP BY 会导致这一行直接不见,而补 0 后,性能报告里那个 0 才是真正的健康信号。
5.3 不同数据库方言下的写法差异
虽然刷题平台主要用 MySQL,实际工作中数据库可能各不相同,这套逻辑在不同方言里都有对应写法:
| 数据库 | 推荐写法 |
|---|---|
| MySQL 8+ | CTE + LEFT JOIN,或用桶边界表 |
| MySQL 5.7 | 不支持 CTE,把 CTE 改成派生表FROM (...) b即可 |
| PostgreSQL / SQL Server | 用VALUES构造桶表,或使用COUNT(*) FILTER (WHERE ...) |
PostgreSQL 的VALUES写法非常简洁,特别适合构造这种固定桶:
SELECT b.bin, COUNT(s.session_id) AS total FROM ( VALUES ('[0-5>', 0, 300), ('[5-10>', 300, 600), ('[10-15>', 600, 900), ('15 or more', 900, NULL) ) AS b(bin, lo, hi) LEFT JOIN Sessions s ON s.duration >= b.lo AND (b.hi IS NULL OR s.duration < b.hi) GROUP BY b.bin, b.lo ORDER BY b.lo;这个写法的妙处在于,hi为 NULL 就表示“没有上限”,专门用来表达 15 or more 这种开放性区间,不需要硬塞一个 INT 最大值。如果你在工作中能用 PostgreSQL,我很推荐这种方式。
5.4 我把这套题的做题模板总结成了四步
做完 1435 之后,我把“固定区间统计 + 缺失补 0”的查询总结成四个步骤,分享给你,后面遇到同类问题直接套用:
- 先把桶的边界写在纸上。单位、开闭区间、边界值归属,这三件事在写 SQL 前就要定死。比如秒、左闭右开、300 进下一个桶。
- 用某种方式构造桶表。可以是一串 UNION ALL,也可以是 CTE,还可以是 VALUES。核心是“桶有固定行”。
- LEFT JOIN 原始表,JOIN 条件用区间判断。左表是桶,右表是业务表,匹配条件写区间范围。建议用边界值字段,而不是字符串 CASE。
- COUNT 右侧主键,按桶下界排序。这步解决两个坑:空桶被算成 1 的问题,以及字符串排序乱序的问题。
我在实际写这类查询时,发现只要把第二步和第三步做好,后面几乎不用回头改。反过来,如果写 SQL 时一开始就图省事用 GROUP BY + CASE,等到发现少桶了,改动成本远比分步规划高得多。
最后再分享一个个人习惯
我最早做这道题时,也迷信过 GROUP BY + CASE 的“短小精悍”,结果被空桶用例打脸。后来养成了一个习惯:写任何分桶统计之前,先自问一句“这个查询的输出行,是不是要固定包含一些类别?”。只要答案是“是”,我就默认走“桶表 + LEFT JOIN”的路子,不再走 GROUP BY 的老路。
这个小习惯帮我少踩了很多线上报表的坑。也希望你在看这篇题解的时候,不只是背下三个 SQL,而是记住那个最核心的认知:缺失的类别不是不存在,只是你没把它从结果里“声明”出来。把桶变成一张表,把统计变成 JOIN,这道 1435 的考点就彻底拿下了。