做数据分析的人,应该对“按区间分桶统计”这类需求不陌生。拿到一张用户会话表,要统计每个时长区间里有多少记录,还要把没数据的区间也补成0,在SQL里确实能绊倒不少人。LeetCode的Database第1435题“制作会话柱状图”,就是专门考这个点的经典题目。
这道题的场景是这样的:有一张会话表Sessions,里面记录每次会话的唯一ID和持续时长duration(单位是秒)。现在需要把会话时长分成四个档次统计数量:[0-5>、[5-10>、[10-15>、15 or more,分别对应不足5分钟、5到10分钟、10到15分钟、15分钟及以上。重点在于,如果某一档一个会话都没有,这一档依然要出现在结果里,数量记0。
听起来不复杂,但你上手一写就会发现,直接把duration做CASE WHEN再GROUP BY,大概率会丢行。具体为什么会丢,用什么写法才能保证四行齐全,就是这篇文章要聊的重点。无论你是准备数据分析岗位的笔试题,还是工作中经常要做“按区间看分布”的统计,这个套路都值得收藏。
1. 题目解读与核心考点:这道题到底在考什么
1.1 先把表结构和分桶规则吃透
Sessions表结构非常简单,就两个字段。
| 字段名 | 类型 | 含义 |
|---|---|---|
| session_id | int | 会话ID,主键,唯一标识一次会话 |
| duration | int | 会话持续时长,单位是秒 |
示例数据长这样:
| session_id | duration |
|---|---|
| 1 | 120 |
| 2 | 180 |
| 3 | 300 |
| 4 | 450 |
| 5 | 900 |
| 6 | 1200 |
| 7 | 15 |
| 8 | 720 |
按分钟看,120秒是2分钟,300秒是5分钟,720秒是12分钟。分桶规则是左闭右开的区间:
[0-5>:大于等于0分钟,小于5分钟[5-10>:大于等于5分钟,小于10分钟[10-15>:大于等于10分钟,小于15分钟15 or more:大于等于15分钟
注意一个很容易看漏的细节:正好5分钟,也就是duration等于300秒时,属于第二档[5-10>而不是第一档;10分钟、15分钟同理,分别落进第三档、第四档。左闭右开这个规则一旦踩错,整个统计表跟着错。
期望输出是这样的:
| bin | total |
|---|---|
| [0-5> | 3 |
| [5-10> | 2 |
| [10-15> | 1 |
| 15 or more | 2 |
这四行是对着上面8条示例数据手工统计出来的:15秒、120秒、180秒落在[0-5>,300秒、450秒落在[5-10>,720秒落在[10-15>,900秒、1200秒落在15 or more。如果某一档没有会话,比如删掉720秒那条,[10-15>这一档依然要输出,数量为0。这就是题里埋的最大陷阱。
1.2 考点拆解:四个关键点
这道题在LeetCode数据库题库里难度不算高,但它同时考了四个非常基础又实用的点,组合起来就变精致了。
第一个是CASE WHEN条件分支。要把duration从秒换算成分钟,再映射到桶名称,同时必须处理好区间边界。
第二个是GROUP BY聚合与计数。根据桶名分组,数出每个桶里有多少条会话记录。只做到这一步远远不够,这是大多数人的第一反应,也是翻车的起点。
第三个是固定维度补全。桶列表是题目写死的四个区间,不是从数据里自然出现的值。只要数据里某个桶没有记录,纯GROUP BY就不会输出那一行。你必须额外造一张“桶全量表”,再通过连接把它带出来。这是题目真正想考的东西。
第四个是结果排序与NULL处理。输出四行的顺序是指定的,不是字典序;LEFT JOIN之后,没有数据的桶会得到NULL,还要安全地变成0。
这四个点单独拎出来都不难,但合到同一道题里,对SQL的综合能力就有要求了。尤其是第三个“固定维度补全”,在真实业务里极其常见——报表要求无论有没有数据都保留固定维度,没处理过这种需求的人,很容易被测试用例打回。
2. 解题思路对比:为什么直接GROUP BY会翻车
2.1 第一直觉:CASE WHEN + GROUP BY
大部分人上手的第一版代码长这样:
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;这个写法把duration换算成分钟,按区间打标,再按标签聚合。逻辑没毛病,跑起来也确实能算出数。问题在于,它返回的行数完全取决于数据里有几种桶标签。假如[10-15>这个区间恰好一个会话都没有,结果里就不会出现这一行。
还是用上面的8条示例数据做实验,把720秒那条删掉,数据库里只剩7条记录。上述SQL的输出会变成三行:
| bin | total |
|---|---|
| [0-5> | 3 |
| [5-10> | 2 |
| 15 or more | 2 |
期望输出是四行,缺的正是[10-15> 0这一行。题目要求所有桶为0也要输出,直接GROUP BY做不到。
2.2 为什么GROUP BY天然不适合这种场景
要理解这个问题,先说清楚GROUP BY的工作机制。SQL执行SELECT ... FROM Sessions GROUP BY bin时,会先按bin把真实存在的行分成一组一组,再对每组做聚合。分组键的取值来自数据本身,数据里没有的值,就不存在对应的组。
这和“固定维度报表”的需求正好相反。报表要求先有一张完整的维度表,比如四个桶的名称,再拿它和事实表碰撞,碰撞不到的地方填0。本质是“维度外连接事实”,而GROUP BY是“基于事实做切片”。后者天然会丢掉稀疏维度。
这个道理放到业务里很好懂:你有一张销售表,要按12个月统计销售额。如果6月一单都没有,GROUP BY月份的结果就只有11行。但报表必须显示12行,6月写0。做数据看板的人几乎每个月都会遇到这件事。
2.3 三种补全方案对比
既然纯GROUP BY不行,那就想办法把缺失的桶补上。常见思路有三种。
第一种是LEFT JOIN固定桶表。先用UNION ALL构造包含四个桶名的临时表,再把它作为左表,与分组统计结果做左连接。优点是逻辑清晰、性能好、通用性强,缺点是需要多写几行造桶SQL。
第二种是UNION ALL手动补零。先按GROUP BY统计,找出结果里缺了哪些桶,再用SELECT '[10-15>' AS bin, 0 AS total UNION ALL ...补进去。优点是直观,缺点是桶一多就变成体力活,而且你得提前知道数据里缺了哪些桶,程序化处理很别扭。
第三种是递归CTE生成桶。如果桶的规则是连续的数值区间,可以用递归CTE从0生成到某个上限,再拼接标签。优点是灵活可扩展,缺点是语法复杂,老版本MySQL还不支持递归。
从可维护性、通用性以及面试官的偏好来看,第一种都是首选。下面给出完整实现,这也是我认为最值得记住的一套模板。
3. 核心SQL实现与细节剖析:完整可跑的参考代码
3.1 参考SQL(MySQL 8.0+)
先把测试环境准备好:
CREATE TABLE Sessions ( session_id INT PRIMARY KEY, duration INT NOT NULL ); INSERT INTO Sessions (session_id, duration) VALUES (1, 120), (2, 180), (3, 300), (4, 450), (5, 900), (6, 1200), (7, 15), (8, 720);然后是这个解题SQL:
WITH bin_table AS ( SELECT '[0-5>' AS bin UNION ALL SELECT '[5-10>' UNION ALL SELECT '[10-15>' UNION ALL SELECT '15 or more' ), sessions_with_bin AS ( SELECT session_id, 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 FROM Sessions ) SELECT b.bin, COUNT(s.session_id) AS total FROM bin_table b LEFT JOIN sessions_with_bin s ON b.bin = s.bin GROUP BY b.bin ORDER BY FIELD(b.bin, '[0-5>', '[5-10>', '[10-15>', '15 or more');执行结果:
| bin | total |
|---|---|
| [0-5> | 3 |
| [5-10> | 2 |
| [10-15> | 1 |
| 15 or more | 2 |
如果把720秒那条删掉,也就是[10-15>区间没有会话,结果是这样:
| bin | total |
|---|---|
| [0-5> | 3 |
| [5-10> | 2 |
| [10-15> | 0 |
| 15 or more | 2 |
四行齐全,空桶正确补0,这就是我们要的效果。
3.2 逐段拆解:每段代码为什么这么写
bin_table这段是“维度表”。它用四个UNION ALL把题目要求的桶名一行行列出来。UNION ALL和UNION的区别在于前者不去重,这里四个字符串本身就互不相同,用哪个都一样,但UNION ALL性能略好,也是大家更习惯的写法。
sessions_with_bin这段是“事实表打标”。CASE WHEN从最小的区间开始判断,满足条件就返回桶名,否则继续向下走。由于CASE WHEN是短路求值,判断顺序绝对不能乱,把< 5和< 10写反了,会让5分钟的会话错误落桶。
为什么用duration / 60 < 5而不是duration < 300?两者数学上完全等价,但除以60更直白,读者一眼就能看出是在按分钟比较。如果换成duration < 300,建议加注释说明“300秒=5分钟”,否则过两个月再回来看代码容易懵。
主查询这段是核心。bin_table作为左表LEFT JOIN打标结果,左边四行无论如何都会保留;右边能匹配上就带出session_id,匹配不上就是NULL。COUNT(s.session_id)统计的是非NULL的session_id个数,所以空桶得到的结果不是1而是0。这是整条SQL里最精妙也最容易写错的一步——如果手滑写成COUNT(*),空桶会输出1,因为LEFT JOIN之后那一行在物理上依然存在,只是右表字段全是NULL。初次接触这个写法的人,十个有九个会在这个地方踩坑。
最后的ORDER BY FIELD指定输出顺序。FIELD的第一个参数是待排序字段,后面按优先级列出一串候选值,MySQL会把排在前面的值放在结果集前面。它保证了输出永远是[0-5>、[5-10>、[10-15>、15 or more这个顺序。别小看这一步,如果不加排序,数据库可能返回多种顺序,很可能被判错。
3.3 其他数据库的写法适配
如果你用的是PostgreSQL,整体思路完全一样,只有两个小差异:一是把ORDER BY FIELD(...)改成ORDER BY CASE b.bin WHEN '[0-5>' THEN 1 WHEN '[5-10>' THEN 2 WHEN '[10-15>' THEN 3 ELSE 4 END;二是如果需要处理NULL,应使用COALESCE而不是IFNULL。但因为这里用的是COUNT(session_id),其实用不到NULL转换函数。其余CTE、UNION ALL、LEFT JOIN的语法完全通用。
如果是SQL Server,写法也几乎一致,把IFNULL换成ISNULL、FIELD换成CASE排序即可。如果数据库不支持CTE,例如老版本MySQL 5.6,可以把两个CTE整体改写成子查询,逻辑不变:
SELECT bins.bin, COUNT(s.session_id) AS total FROM ( SELECT '[0-5>' AS bin UNION ALL SELECT '[5-10>' UNION ALL SELECT '[10-15>' UNION ALL SELECT '15 or more' ) bins LEFT JOIN ( SELECT session_id, 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 FROM Sessions ) s ON s.bin = bins.bin GROUP BY bins.bin ORDER BY FIELD(bins.bin, '[0-5>', '[5-10>', '[10-15>', '15 or more');这一版的兼容性最好,也最容易迁移到不同SQL方言。如果你需要在一个老项目里跑类似逻辑,直接用这个就行。
4. 实操中的坑与排查技巧:这些细节最容易出错
4.1 边界值:5分钟到底属于哪个桶
题目给出的桶是左闭右开区间,[0-5>包含0分钟但排除5分钟。duration恰好等于300秒时,应该落在[5-10>而不是[0-5>。
对应CASE WHEN的写法必须是连续小于:
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'如果手抖写成<=,比如第一句变成duration / 60 <= 5,那么5分钟会被分进第一个桶,后面的判断根本轮不到它。这种错误在数据上看起来只是“这里多个1、那里少个1”,但口径一旦错了,下游不管怎么用都是错的。
排查建议:构造一批正好落在300秒、600秒、900秒的测试数据,肉眼核对分桶结果。这三个点是三处边界,每个都必须单独验证一遍。
4.2 空桶的NULL与0陷阱
LEFT JOIN造出的空桶行,用COUNT(s.session_id)得到0,这是正确结果。但如果有人把函数改成COUNT(*),结果立刻变成1。这个细节我在实际排查中见过不止一次,最后定位到的问题都是COUNT写错。
顺带说一句,如果需求改成“没有会话的桶显示NULL而不是0”,SQL反而更简单:直接把COUNT(s.session_id)去掉,改成SUM(CASE WHEN s.session_id IS NULL THEN 0 ELSE 1 END),甚至直接不聚合、用字段把NULL带出来。但本题明确要求total为0,所以必须用COUNT来聚合并清掉NULL。
另外,如果duration字段本身允许NULL,需要在CASE WHEN里先处理。因为SQL里NULL参与比较会得到NULL而不是TRUE,每条duration为NULL的记录都会落入ELSE,被算成15 or more。稳妥的做法先单独过滤:CASE WHEN duration IS NULL THEN '未知' ...。这属于扩展思考,面试官追问时用得上。
4.3 分组与排序的兼容性
MySQL对GROUP BY的宽容度很高,允许select列表里出现没有参与分组或聚合的字段。但其他主流数据库的默认配置会直接报错,比如PostgreSQL。最严谨的写法是分组字段、排序字段、select字段尽量保持一致。我在上面的CTE版本里用GROUP BY b.bin,排序用ORDER BY FIELD(b.bin, ...),字段来源一致,就不会有歧义。
另一个容易忽略的坑是字符串排序。如果忘了ORDER BY,数据库很可能按字典序输出[0-5>、[10-15>、[5-10>、15 or more,和题目要求的顺序完全不一致。排序这一步绝不是可选项。
5. 从题目到业务:会话分桶统计模式化
5.1 一套模板打天下:固定标签表 + LEFT JOIN
把这道题的解法抽象一下,就是一套极其通用的SQL模式。
第一步,构造维度表,列出所有期望输出的分组标签。第二步,构造事实表,把原始行映射到某个标签上。第三步,LEFT JOIN加分组计数。第四步,按业务顺序排序。
这套模式可以套到大量需求上:你有一张用户表,要统计各年龄段的用户数量;有一张订单表,要统计各价格区间的订单笔数;有一张接口日志表,要统计各耗时区间的请求次数。全都长一个样子,差别只在第二步的CASE WHEN条件和第一步的标签枚举。
5.2 我实际用过的几个场景
有一回做产品埋点分析,要统计用户单次会话时长分布,判断用户在App里停留多久会出现明显流失。当时的表结构和这道题几乎一模一样,只是桶的边界改成了“0-1分钟、1-3分钟、3-5分钟、5-10分钟、10分钟以上”。我直接套模板,不到二十分钟就把结果交出去了。在此之前,我第一次做同类统计时用的是UNION ALL手动补零,只有五六个桶还能忍受,后面桶数增加到十几个,手动补零真的写到怀疑人生。
还有一次统计接口响应耗时的档位分布,要输出固定几个档位的请求量作为性能看板的数据源。需求方明确要求:某个档位即使0请求也必须保留,因为前端柱状图的x轴不能缺柱子。这就属于产品视角的固定维度,数据侧必须配合。固定标签表加LEFT JOIN是唯一干净的做法。
5.3 如果桶多了怎么扩展
实际工作里的分桶规则往往不止四个,可能是十个、二十个。UNION ALL一长串看起来特别笨重。如果桶的起点和终点是连续的,可以用递归CTE或数字表生成标签。比如生成0到15分钟、每分钟一个桶:
WITH RECURSIVE minute_range AS ( SELECT 0 AS minute UNION ALL SELECT minute + 1 FROM minute_range WHERE minute < 14 ) SELECT CONCAT('[', minute, '-', minute + 1, '>') AS bin FROM minute_range;再和事实表LEFT JOIN,后面的逻辑完全不变。这就是前面提到的第三种方案,平时用不上,但当你需要快速扩展桶数量时,它会省下大量重复的UNION ALL代码。
另外,如果业务上还要按日期或渠道等第二维度同时分组,只需要把第二维度字段加进GROUP BY和SELECT,模板一样成立。唯一要注意的是维度表也要跟着扩展成“日期×桶”的笛卡尔积,别漏了组合。
最后分享一点个人体会。这类问题我在实际项目里遇到的频率,远高于LeetCode上很多所谓的难题。关键不在于背下这道题的答案,而是理解“固定维度必须显式造出来,再LEFT JOIN上去”这个思维模型。用熟之后,不管是四档、十二档还是几十档,不管是在MySQL还是PostgreSQL里,都能在几分钟之内把架子搭好。
建议看到这里的读者,把示例数据复制到本地环境里跑一遍,然后故意删掉一个区间的数据,看看输出是不是依然四行齐全。做完这个实验,这个知识点才算真正长在身上。